How Modly's PythonBridge Manages the FastAPI Backend Lifecycle and Crash Recovery
Modly's PythonBridge (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 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 [lines 14‑19]:
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]:
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:
- Resolves paths: Python executable via
resolvePythonExecutable(), API directory viaresolveApiDir() - Port safety: Calls
killProcessOnPort()to terminate any process bound to port8765 - Environment construction: Injects runtime directories as environment variables
- 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:
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]:
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:
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:
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:
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 /Fto force-terminate the process tree - POSIX: Sends
SIGKILLto the negative PID (-pid) to terminate the entire process group
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:
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:
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:
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:
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/healthbefore marking the bridge ready - Crash detection: The
intentionalStopflag distinguishes user-initiated restarts from unexpected failures - Platform-aware termination: Windows uses
taskkill /T /F; POSIX usesSIGKILLon the negative process group ID - UI notification: Crashes emit
python:crashedIPC 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.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →