# How Modly's PythonBridge Manages the FastAPI Backend Lifecycle and Crash Recovery

> Learn how Modly's PythonBridge manages FastAPI backend lifecycle with health polling and crash recovery. Discover its robust subprocess monitoring and restart capabilities for reliable applications.

- Repository: [lightningpixel/modly](https://github.com/lightningpixel/modly)
- Tags: internals
- Published: 2026-08-20

---

**Modly's PythonBridge ([`electron/main/python-bridge.ts`](https://github.com/lightningpixel/modly/blob/main/electron/main/python-bridge.ts)) uses a dedicated subprocess with health polling, intentional stop flags, and platform-specific process group termination to start, monitor, and restart the FastAPI backend while notifying the UI of unexpected crashes.**

The PythonBridge is the critical infrastructure layer in [lightningpixel/modly](https://github.com/lightningpixel/modly) that isolates the Electron renderer from the Python runtime. It handles environment resolution, port conflict safety, graceful shutdown, and automatic crash detection—enabling the UI to respond when the backend fails.

---

## FastAPI Backend Lifecycle Management

### Initialization and State Tracking

The bridge maintains three core pieces of state for lifecycle management, as seen in [`electron/main/python-bridge.ts`](https://github.com/lightningpixel/modly/blob/main/electron/main/python-bridge.ts) [lines 14‑19]:

```typescript
private process: ChildProcess | null = null;
private ready: boolean = false;
private windowGetter: (() => BrowserWindow | null) | null = null;

```

The `windowGetter` is a callback supplied via `setWindowGetter()` that allows the bridge to push IPC events to the renderer even when it lacks direct access to the window object.

### Starting the Server with Duplicate Guard

The public `start()` method prevents race conditions by caching the ongoing start promise [lines 25‑34]:

```typescript
async start(): Promise<void> {
  if (this.startPromise) return this.startPromise;
  if (this.process) throw new Error('Python bridge already started');
  
  this.startPromise = this._start();
  try {
    await this.startPromise;
  } finally {
    this.startPromise = undefined;
  }
}

```

This ensures that concurrent calls during app initialization collapse into a single startup sequence.

### Spawn Configuration in `_start()`

The private `_start()` method [lines 36‑50] performs several critical setup steps before launching uvicorn:

1. **Resolves paths**: Python executable via `resolvePythonExecutable()`, API directory via `resolveApiDir()`
2. **Port safety**: Calls `killProcessOnPort()` to terminate any process bound to port `8765`
3. **Environment construction**: Injects runtime directories as environment variables
4. **Process spawning**: Runs `uvicorn main:app --host 127.0.0.1 --port 8765`

The process is spawned **detached on POSIX systems** (not Windows) so the entire process group can be terminated later:

```typescript
const options: SpawnOptions = {
  cwd: apiDir,
  detached: process.platform !== 'win32',  // Critical for process-group killing
  env: this.cleanPythonEnv(),
  stdio: ['ignore', 'pipe', 'pipe']
};

```

---

## Health Monitoring and Readiness Detection

### Polling the `/health` Endpoint

After spawn, `waitUntilReady()` polls the FastAPI health endpoint with exponential backoff [lines 78‑90]:

```typescript
private async waitUntilReady(retries = 30, intervalMs = 500): Promise<void> {
  const url = `http://127.0.0.1:8765/health`;
  
  for (let i = 0; i < retries; i++) {
    try {
      const response = await fetch(url, { signal: AbortSignal.timeout(1000) });
      if (response.ok) {
        this.ready = true;
        return;
      }
    } catch {}
    await new Promise(r => setTimeout(r, intervalMs));
  }
  throw new Error('FastAPI health check failed');
}

```

The `ready` flag gates all downstream bridge operations and is exposed via `isReady()`.

### Log Streaming to the Renderer

All stdout and stderr lines are piped through `emitTqdmLog()` [lines 71‑84], which forwards them via the `python:log` IPC channel:

```typescript
this.process.stdout!.on('data', (data) => {
  const lines = data.toString().split('\n');
  lines.forEach((line: string) => {
    if (line.trim()) {
      this.emitTqdmLog(line);
      log.info('[Python]', line);
    }
  });
});

```

This gives users real-time visibility into model loading progress and backend activity.

---

## Crash Detection and Recovery Mechanisms

### Distinguishing Intentional vs. Unexpected Exits

The exit handler [lines 85‑99] implements the core crash detection logic:

```typescript
this.process.on('exit', (code, _signal) => {
  const wasReady = this.ready;
  this.ready = false;
  this.process = null;
  
  if (wasReady && !this.intentionalStop) {
    // Unexpected crash—notify the renderer
    this.emitToRenderer('python:crashed', { code });
    log.error(`Python backend crashed with exit code ${code}`);
  }
  
  this.intentionalStop = false;  // Reset for next cycle
});

```

The `intentionalStop` flag is set to `true` only during `stop()` or `restart()` calls, allowing precise classification of exit causes.

### UI Notification Flow

When a crash is detected, the bridge emits `python:crashed` with the exit code. Renderers listen via:

```typescript
import { ipcRenderer } from 'electron';

ipcRenderer.on('python:crashed', (_, { code }) => {
  // Display recovery UI to user
  console.error(`Backend crashed (code ${code})`);
});

```

---

## Graceful Shutdown and Process Termination

### Platform-Specific Kill Strategies

The `stop()` method [lines 104‑124] implements distinct termination logic per platform:

- **Windows**: Uses `taskkill /PID <pid> /T /F` to force-terminate the process tree
- **POSIX**: Sends `SIGKILL` to the negative PID (`-pid`) to terminate the entire process group

```typescript
if (process.platform === 'win32') {
  spawn('taskkill', ['/PID', pid.toString(), '/T', '/F']);
} else {
  // Negative PID kills the process group created by detached spawn
  process.kill(-pid, 'SIGKILL');
}

```

The detached spawn configuration in `_start()` is essential here—it ensures Python child processes (model workers, extensions) are included in the group and cannot outlive the parent.

### State Cleanup

`stop()` also clears internal state to prevent stale references:

```typescript
this.ready = false;
this.process = null;
this.intentionalStop = false;

```

---

## Restart and Memory Management

The `restart()` method [lines 127‑133] combines stop and start with proper intention flagging:

```typescript
async restart(): Promise<void> {
  this.intentionalStop = true;  // Prevents crash notification
  await this.stop();
  await this.start();
}

```

This is specifically used to **release GPU/Metal-wired memory** after heavy generation workloads, where Python's memory allocator may retain large tensors. A full process restart guarantees clean memory state without restarting the entire Electron application.

---

## Port Conflict Safety

Before any spawn, `killProcessOnPort()` [lines 150‑176] ensures port `8765` is available:

```typescript
private async killProcessOnPort(port: number): Promise<void> {
  if (process.platform === 'darwin' || process.platform === 'linux') {
    // lsof -i :8765 -t | xargs kill -9
    const { stdout } = await execAsync(`lsof -i :${port} -t`);
    if (stdout.trim()) {
      const pids = stdout.trim().split('\n');
      for (const pid of pids) {
        try { process.kill(Number(pid), 'SIGKILL'); } catch {}
      }
    }
  } else {
    // Windows: netstat -ano | findstr :8765
    // Parse and taskkill the PIDs
  }
}

```

This prevents "address already in use" failures when the previous session failed to clean up, or when orphaned uvicorn processes survive an unclean Electron exit.

---

## Practical Integration Example

Complete lifecycle integration in the main process:

```typescript
import { app, BrowserWindow, ipcMain } from 'electron';
import { PythonBridge } from './electron/main/python-bridge';

const bridge = new PythonBridge();

// 1. Provide window access for IPC
bridge.setWindowGetter(() => BrowserWindow.getAllWindows()[0] ?? null);

// 2. Start at app readiness
app.whenReady().then(async () => {
  try {
    await bridge.start();
    console.log('FastAPI ready on port 8765');
  } catch (err) {
    console.error('Failed to start backend:', err);
    app.quit();
  }
});

// 3. Expose restart to renderer
ipcMain.handle('python:restart', () => bridge.restart());

// 4. Clean shutdown
app.on('before-quit', async () => {
  await bridge.stop();
});

// 5. Forward crash to renderer's recovery handler
// (automatic via bridge.emitToRenderer('python:crashed', ...))

```

---

## Summary

- **Subprocess isolation**: FastAPI runs in a dedicated uvicorn process, detached on POSIX for group-level termination
- **Health-probed readiness**: `waitUntilReady()` polls `/health` before marking the bridge ready
- **Crash detection**: The `intentionalStop` flag distinguishes user-initiated restarts from unexpected failures
- **Platform-aware termination**: Windows uses `taskkill /T /F`; POSIX uses `SIGKILL` on the negative process group ID
- **UI notification**: Crashes emit `python:crashed` IPC events so renderers can display recovery options
- **Memory recovery**: `restart()` provides a clean Python runtime without full app restart

---

## Frequently Asked Questions

### What triggers the `python:crashed` event in Modly?

The `python:crashed` event fires when the FastAPI subprocess exits **while `ready === true` and `intentionalStop === false`**. This condition catches segfaults, unhandled Python exceptions, or external termination—any exit not explicitly requested via `stop()` or `restart()`. The exit code is included in the event payload for diagnostic logging.

### How does Modly prevent port binding conflicts on startup?

Before spawning uvicorn, `killProcessOnPort()` executes platform-specific commands to identify and terminate processes listening on port `8765`. On macOS/Linux it uses `lsof -i :8765 -t`; on Windows it parses `netstat -ano` output. This ensures a clean slate even when previous sessions left orphaned processes.

### Why does the PythonBridge use detached process spawning?

Detached spawning (`detached: process.platform !== 'win32'`) creates a new process group on POSIX systems. This allows `stop()` to send `SIGKILL` to `-pid` (the negative group ID), which reliably terminates the uvicorn parent, worker processes, and any extension subprocesses in a single operation. Without this, Python child processes could outlive the Electron app.

### Can the FastAPI backend be restarted without quitting Modly?

Yes. Call `bridge.restart()` from the main process, which sets `intentionalStop = true`, executes `stop()` with full process group termination, then `start()` with fresh environment resolution. This is exposed to renderers via IPC and is specifically used to release GPU memory after heavy inference workloads.