diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index 98fcc87e..b706fcbc 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -285,6 +285,22 @@ const performConfirmedQuit = () => { app.exit(0); }; +// Hard-stop signals (`Ctrl+C` on `electron:dev`, an external `kill`/SIGTERM, +// terminal close) bypass the normal app-quit flow — which would orphan the +// in-process web server's managed OpenCode child. Run the same background +// teardown the quit path uses (which kills the sidecar), then exit. The startup +// reaper remains the backstop for an unhandled hard crash (SIGKILL). +for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) { + process.on(signal, () => { + try { + shutdownBackgroundServices(); + } catch (error) { + log.warn(`[electron] ${signal} shutdown failed:`, error); + } + app.exit(0); + }); +} + const requestQuitWithConfirmation = async () => { await refreshQuitRiskFlags(); diff --git a/packages/vscode/src/opencode.ts b/packages/vscode/src/opencode.ts index f1dd3631..efbaa374 100644 --- a/packages/vscode/src/opencode.ts +++ b/packages/vscode/src/opencode.ts @@ -9,6 +9,7 @@ import { spawn } from 'child_process'; import { randomBytes } from 'crypto'; import { normalizeWindowsDriveLetter } from './pathUtils'; import { resolveWorkingDirectoryChange } from './workingDirectoryChange'; +import { registerManagedProcess, unregisterManagedProcess, reapOrphanedProcesses } from './opencodeProcessRegistry'; const t = vscode.l10n.t; @@ -688,6 +689,9 @@ async function spawnManagedOpenCodeServer( child.on('error', onError); }); + // Record this child so a future run can reap it if we crash before teardown. + registerManagedProcess({ pid: child.pid, ownerPid: process.pid, port, binary, runtime: 'vscode' }); + return { url, close: () => { @@ -696,6 +700,7 @@ async function spawnManagedOpenCodeServer( } catch { // ignore } + unregisterManagedProcess(child.pid); }, }; } @@ -726,6 +731,7 @@ async function allocateManagedOpenCodePort(): Promise { export function createOpenCodeManager(context: vscode.ExtensionContext): OpenCodeManager { let server: { url: string; close: () => void } | null = null; + let reapedOrphansOnce = false; let managedApiUrlOverride: string | null = null; let managedPassword: string | null = null; let managedPasswordSource: 'user-env' | 'generated' | 'rotated' | null = null; @@ -866,6 +872,19 @@ export function createOpenCodeManager(context: vscode.ExtensionContext): OpenCod return; } + // Before spawning our own server, reap any OpenCode process WE spawned in a + // prior run that was orphaned by a crash/host-kill. Verified + scoped to our + // own pids, so it never touches a live instance's or the user's own server. + if (!reapedOrphansOnce) { + reapedOrphansOnce = true; + try { + const { reaped } = await reapOrphanedProcesses({ log: (msg) => console.log(msg) }); + if (reaped > 0) console.log(`[opencode] startup reaped ${reaped} orphaned process(es)`); + } catch (error) { + console.warn('[opencode] orphan reap failed:', error instanceof Error ? error.message : error); + } + } + setStatus('connecting'); cliMissing = false; cliPath = null; diff --git a/packages/vscode/src/opencodeProcessRegistry.ts b/packages/vscode/src/opencodeProcessRegistry.ts new file mode 100644 index 00000000..903198c7 --- /dev/null +++ b/packages/vscode/src/opencodeProcessRegistry.ts @@ -0,0 +1,236 @@ +// Managed OpenCode process registry + orphan reaper — VS Code parity copy. +// +// The VS Code extension does NOT bundle the web package, so it cannot import +// the web runtime's registry module. This is a parity implementation that +// reads/writes the SAME on-disk registry directory and uses the SAME algorithm, +// so a process spawned by any runtime (web, desktop, VS Code) can be reaped by +// any other. +// +// Storage is ONE FILE PER SPAWNED PROCESS (`.json`) in a registry +// directory — never a single shared JSON file — because multiple runtimes and +// windows run concurrently and a shared file would be clobbered by the +// read-modify-write race. Per-process files mean each instance only ever writes +// or deletes its OWN file. +// +// See packages/web/server/lib/opencode/managed-process-registry.js for the full +// rationale and safety model. In short: we only ever kill pids THIS product +// recorded, re-verified as a live `opencode serve`, and only when their spawner +// is provably gone (reparented to pid 1, or recorded owner pid dead). + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +type ManagedProcessEntry = { + pid: number; + ownerPid: number; + port: number | null; + binary: string | null; + runtime: string; + startedAt: string; +}; + +const resolveRegistryDir = (): string => { + const override = process.env.OPENCHAMBER_MANAGED_PROCESS_REGISTRY; + if (override && override.trim()) return override.trim(); + return path.join(os.homedir(), '.config', 'openchamber', 'managed-opencode'); +}; + +const entryFilePath = (pid: number): string => path.join(resolveRegistryDir(), `${pid}.json`); + +const writeEntryFile = (entry: ManagedProcessEntry): void => { + const dir = resolveRegistryDir(); + try { + fs.mkdirSync(dir, { recursive: true }); + const filePath = path.join(dir, `${entry.pid}.json`); + const tmp = `${filePath}.tmp-${process.pid}`; + fs.writeFileSync(tmp, JSON.stringify(entry, null, 2)); + fs.renameSync(tmp, filePath); + } catch { + // Best-effort: a failed registry write must never break spawn/shutdown. + } +}; + +const readAllEntries = (): Array<{ entry: ManagedProcessEntry; filePath: string }> => { + const dir = resolveRegistryDir(); + let names: string[] = []; + try { + names = fs.readdirSync(dir).filter((name) => name.endsWith('.json')); + } catch { + return []; + } + const out: Array<{ entry: ManagedProcessEntry; filePath: string }> = []; + for (const name of names) { + const filePath = path.join(dir, name); + try { + const entry = JSON.parse(fs.readFileSync(filePath, 'utf8')); + if (entry && Number.isInteger(entry.pid)) { + out.push({ entry: entry as ManagedProcessEntry, filePath }); + } else { + fs.rmSync(filePath, { force: true }); + } + } catch { + try { fs.rmSync(filePath, { force: true }); } catch { /* ignore */ } + } + } + return out; +}; + +export const registerManagedProcess = (input: { + pid: number | undefined; + ownerPid?: number; + port?: number | null; + binary?: string | null; + runtime?: string; +}): void => { + const pid = input.pid; + if (!Number.isInteger(pid)) return; + writeEntryFile({ + pid: pid as number, + ownerPid: Number.isInteger(input.ownerPid) ? (input.ownerPid as number) : process.pid, + port: Number.isInteger(input.port as number) ? (input.port as number) : null, + binary: typeof input.binary === 'string' ? input.binary : null, + runtime: typeof input.runtime === 'string' ? input.runtime : 'vscode', + startedAt: new Date().toISOString(), + }); +}; + +export const unregisterManagedProcess = (pid: number | undefined): void => { + if (!Number.isInteger(pid)) return; + try { + fs.rmSync(entryFilePath(pid as number), { force: true }); + } catch { + // ignore + } +}; + +const isPidAlive = (pid: number): boolean => { + if (!Number.isInteger(pid)) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException)?.code === 'EPERM'; + } +}; + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +const readUnixProcInfo = (pid: number): { ppid: number; command: string } | null => { + try { + const result = spawnSync('ps', ['-p', String(pid), '-o', 'ppid=,command='], { + encoding: 'utf8', + timeout: 3000, + windowsHide: true, + }); + const line = (result.stdout || '').trim(); + if (!line) return null; + const match = line.match(/^\s*(\d+)\s+(.*)$/); + if (!match) return null; + return { ppid: Number.parseInt(match[1], 10), command: match[2] }; + } catch { + return null; + } +}; + +const readWindowsImageName = (pid: number): string | null => { + try { + const result = spawnSync('tasklist', ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'], { + encoding: 'utf8', + timeout: 3000, + windowsHide: true, + }); + return (result.stdout || '').trim() || null; + } catch { + return null; + } +}; + +const commandIdentifiesOurServer = (command: string, entry: ManagedProcessEntry): boolean => { + if (typeof command !== 'string') return false; + const lower = command.toLowerCase(); + if (!lower.includes('opencode') || !lower.includes('serve')) return false; + if (Number.isInteger(entry.port) && !command.includes(String(entry.port))) return false; + return true; +}; + +const killOrphan = async (pid: number): Promise => { + if (process.platform === 'win32') { + try { + spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore', timeout: 5000, windowsHide: true }); + } catch { + // ignore + } + return; + } + + const signalTree = (signal: NodeJS.Signals) => { + try { process.kill(-pid, signal); } catch { /* ignore */ } + try { process.kill(pid, signal); } catch { /* ignore */ } + }; + + signalTree('SIGTERM'); + for (let waited = 0; waited < 1500 && isPidAlive(pid); waited += 150) { + await sleep(150); + } + if (isPidAlive(pid)) { + signalTree('SIGKILL'); + await sleep(300); + } +}; + +const processEntry = async ( + entry: ManagedProcessEntry, + log?: (message: string) => void, +): Promise => { + if (!isPidAlive(entry.pid)) return false; + + const ownerGone = Number.isInteger(entry.ownerPid) && !isPidAlive(entry.ownerPid); + + if (process.platform === 'win32') { + const image = readWindowsImageName(entry.pid); + const looksLikeOpencode = typeof image === 'string' && image.toLowerCase().includes('opencode'); + if (looksLikeOpencode && ownerGone) { + await killOrphan(entry.pid); + log?.(`[opencode] reaped orphaned process pid ${entry.pid} (owner ${entry.ownerPid} gone)`); + return true; + } + return false; + } + + const info = readUnixProcInfo(entry.pid); + if (!info || !commandIdentifiesOurServer(info.command, entry)) return false; + + const orphaned = info.ppid === 1 || ownerGone; + if (!orphaned) return false; + + await killOrphan(entry.pid); + log?.(`[opencode] reaped orphaned process pid ${entry.pid} (reparented/owner gone)`); + return true; +}; + +export const reapOrphanedProcesses = async ( + options: { log?: (message: string) => void } = {}, +): Promise<{ inspected: number; reaped: number }> => { + const { log } = options; + const records = readAllEntries(); + if (records.length === 0) return { inspected: 0, reaped: 0 }; + + let reaped = 0; + for (const { entry, filePath } of records) { + let drop = false; + try { + const wasReaped = await processEntry(entry, log); + if (wasReaped) reaped += 1; + drop = wasReaped || !isPidAlive(entry.pid); + } catch (error) { + log?.(`[opencode] reap check failed for pid ${entry.pid}: ${error instanceof Error ? error.message : error}`); + } + if (drop) { + try { fs.rmSync(filePath, { force: true }); } catch { /* ignore */ } + } + } + + return { inspected: records.length, reaped }; +}; diff --git a/packages/web/server/lib/opencode/lifecycle.js b/packages/web/server/lib/opencode/lifecycle.js index b03a8dae..ca1a82d5 100644 --- a/packages/web/server/lib/opencode/lifecycle.js +++ b/packages/web/server/lib/opencode/lifecycle.js @@ -1,5 +1,6 @@ import { spawn, spawnSync } from 'node:child_process'; import net from 'node:net'; +import { registerManagedProcess, unregisterManagedProcess, reapOrphanedProcesses } from './managed-process-registry.js'; const parsePositiveInt = (value, fallback) => { const parsed = Number.parseInt(String(value ?? ''), 10); @@ -140,7 +141,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => { }); }; - const closeManagedOpenCodeChild = async (child) => { + const terminateChildProcess = async (child) => { if (!child) { return; } @@ -212,6 +213,19 @@ export const createOpenCodeLifecycleRuntime = (deps) => { await waitForChildProcessClose(child, 1000); }; + const closeManagedOpenCodeChild = async (child) => { + const pid = child?.pid; + try { + await terminateChildProcess(child); + } finally { + // Drop it from the registry only once it has actually exited, so a child + // that survived teardown stays eligible for the next run's reaper. + if (Number.isInteger(pid) && hasChildProcessExited(child)) { + unregisterManagedProcess(pid); + } + } + }; + const formatCapturedOutput = ({ stdout, stderr }) => { const parts = []; if (stdout.trim()) { @@ -324,6 +338,19 @@ export const createOpenCodeLifecycleRuntime = (deps) => { child.on('error', onError); }); + // Record this child so a future run can reap it if we crash before teardown. + // The web-server lifecycle runs in-process inside multiple hosts, so tag the + // actual host (Electron sets OPENCHAMBER_RUNTIME='desktop'; the standalone + // web CLI leaves it unset → 'web'; SSH remote → 'ssh-remote') rather than a + // hardcoded label, matching the server's existing runtimeName convention. + registerManagedProcess({ + pid: child.pid, + ownerPid: process.pid, + port, + binary, + runtime: process.env.OPENCHAMBER_RUNTIME || 'web', + }); + return { url, pid: child.pid || null, @@ -747,6 +774,16 @@ export const createOpenCodeLifecycleRuntime = (deps) => { const bootstrapOpenCodeAtStartup = async () => { try { + // Before doing anything, reap any OpenCode process WE spawned in a prior + // run that was orphaned by a crash/hard-exit. Verified + scoped to our own + // pids, so it never touches a live instance's or the user's own server. + try { + const { reaped } = await reapOrphanedProcesses({ log: (msg) => console.log(msg) }); + if (reaped > 0) console.log(`[lifecycle] startup reaped ${reaped} orphaned OpenCode process(es)`); + } catch (error) { + console.warn('[lifecycle] orphan reap failed:', error?.message ?? error); + } + syncFromHmrState(); if (await isOpenCodeProcessHealthy()) { console.log(`[HMR] Reusing existing OpenCode process on port ${state.openCodePort}`); diff --git a/packages/web/server/lib/opencode/managed-process-registry.js b/packages/web/server/lib/opencode/managed-process-registry.js new file mode 100644 index 00000000..2e225bce --- /dev/null +++ b/packages/web/server/lib/opencode/managed-process-registry.js @@ -0,0 +1,251 @@ +// Managed OpenCode process registry + orphan reaper. +// +// OpenChamber spawns the OpenCode server as an EXTERNAL child binary (on Unix +// with `detached: true`, so it leads its own process group). That binary can +// therefore outlive its parent if the parent is hard-killed/crashes/`Ctrl+C`ed +// before graceful teardown runs — leaving an orphaned `opencode serve` that +// then contends on the shared SQLite DB and slows everything down. +// +// We cannot tie an arbitrary external binary to the parent's death portably +// (Electron's `utilityProcess` would, but it only runs JS entrypoints, not a +// standalone binary). So we use the same pattern OpenCode's own CLI daemon uses +// for its detached server: an on-disk record of the pids WE spawned, plus a +// startup reaper that kills ONLY our own, verified, genuinely-orphaned +// processes — never a process a live instance (another desktop window, a VS +// Code host, the user's standalone `opencode`) is actively using. +// +// Storage: ONE FILE PER SPAWNED PROCESS in a registry directory, named +// `.json`. Multiple runtimes (web/desktop/VS Code) and multiple +// windows all run concurrently; a single shared JSON file would be corrupted by +// the read-modify-write race (last writer wins, clobbering another instance's +// entry). Per-process files mean every instance only ever writes/deletes its +// OWN file, so there is no write contention at all. +// +// Safety model (why this never kills the wrong thing): +// 1. The reaper only ever considers pids THIS product recorded. The user's +// standalone CLI server, the official desktop app, and the TUI are never +// recorded, so they are never even candidates. +// 2. Before killing, it re-verifies the live pid is still an `opencode serve` +// matching the recorded port (guards against the OS recycling a dead pid +// onto an unrelated process). +// 3. It kills only when the spawning owner is provably gone — the child has +// been reparented to init/pid 1, or the recorded owner pid is dead. A +// child still owned by a live instance is left untouched. +// +// The VS Code extension cannot import this module (it does not bundle the web +// package); it carries a parity implementation that reads/writes the SAME dir. + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const resolveRegistryDir = () => { + const override = process.env.OPENCHAMBER_MANAGED_PROCESS_REGISTRY; + if (override && override.trim()) return override.trim(); + return path.join(os.homedir(), '.config', 'openchamber', 'managed-opencode'); +}; + +const entryFilePath = (pid) => path.join(resolveRegistryDir(), `${pid}.json`); + +const writeEntryFile = (entry) => { + const dir = resolveRegistryDir(); + try { + fs.mkdirSync(dir, { recursive: true }); + const filePath = path.join(dir, `${entry.pid}.json`); + const tmp = `${filePath}.tmp-${process.pid}`; + fs.writeFileSync(tmp, JSON.stringify(entry, null, 2)); + fs.renameSync(tmp, filePath); + } catch { + // Best-effort: a failed registry write must never break spawn/shutdown. + } +}; + +const readAllEntries = () => { + const dir = resolveRegistryDir(); + let names = []; + try { + names = fs.readdirSync(dir).filter((name) => name.endsWith('.json')); + } catch { + return []; + } + const out = []; + for (const name of names) { + const filePath = path.join(dir, name); + try { + const entry = JSON.parse(fs.readFileSync(filePath, 'utf8')); + if (entry && Number.isInteger(entry.pid)) { + out.push({ entry, filePath }); + } else { + fs.rmSync(filePath, { force: true }); + } + } catch { + // Corrupt/partial file — drop it. + try { fs.rmSync(filePath, { force: true }); } catch {} + } + } + return out; +}; + +/** Record an OpenCode process WE spawned so a future run can reap it if orphaned. */ +export const registerManagedProcess = ({ pid, ownerPid, port, binary, runtime } = {}) => { + if (!Number.isInteger(pid)) return; + writeEntryFile({ + pid, + ownerPid: Number.isInteger(ownerPid) ? ownerPid : process.pid, + port: Number.isInteger(port) ? port : null, + binary: typeof binary === 'string' ? binary : null, + runtime: typeof runtime === 'string' ? runtime : 'web', + startedAt: new Date().toISOString(), + }); +}; + +/** Drop a pid from the registry (after we have killed/closed it ourselves). */ +export const unregisterManagedProcess = (pid) => { + if (!Number.isInteger(pid)) return; + try { + fs.rmSync(entryFilePath(pid), { force: true }); + } catch { + } +}; + +const isPidAlive = (pid) => { + if (!Number.isInteger(pid)) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + // EPERM = process exists but we lack permission to signal it → still alive. + return error?.code === 'EPERM'; + } +}; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +// Returns { ppid, command } for a live pid on Unix, or null if it can't be read. +const readUnixProcInfo = (pid) => { + try { + const result = spawnSync('ps', ['-p', String(pid), '-o', 'ppid=,command='], { + encoding: 'utf8', + timeout: 3000, + windowsHide: true, + }); + const line = (result.stdout || '').trim(); + if (!line) return null; + const match = line.match(/^\s*(\d+)\s+(.*)$/); + if (!match) return null; + return { ppid: Number.parseInt(match[1], 10), command: match[2] }; + } catch { + return null; + } +}; + +// Windows image name for a pid (e.g. "opencode.exe"), or null. +const readWindowsImageName = (pid) => { + try { + const result = spawnSync('tasklist', ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'], { + encoding: 'utf8', + timeout: 3000, + windowsHide: true, + }); + return (result.stdout || '').trim() || null; + } catch { + return null; + } +}; + +const commandIdentifiesOurServer = (command, entry) => { + if (typeof command !== 'string') return false; + const lower = command.toLowerCase(); + if (!lower.includes('opencode') || !lower.includes('serve')) return false; + // Tie to the exact server we registered when we know its port, so a recycled + // pid running a *different* opencode server is never mistaken for ours. + if (Number.isInteger(entry.port) && !command.includes(String(entry.port))) return false; + return true; +}; + +const killOrphan = async (pid) => { + if (process.platform === 'win32') { + try { + spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore', timeout: 5000, windowsHide: true }); + } catch { + } + return; + } + + const signalTree = (signal) => { + try { process.kill(-pid, signal); } catch {} + try { process.kill(pid, signal); } catch {} + }; + + signalTree('SIGTERM'); + for (let waited = 0; waited < 1500 && isPidAlive(pid); waited += 150) { + await sleep(150); + } + if (isPidAlive(pid)) { + signalTree('SIGKILL'); + await sleep(300); + } +}; + +// Decide+act on a single registry entry. Returns true if it was reaped. +const processEntry = async (entry, { log }) => { + // Dead pid → nothing to do (caller drops the file). + if (!isPidAlive(entry.pid)) return false; + + const ownerGone = Number.isInteger(entry.ownerPid) && !isPidAlive(entry.ownerPid); + + if (process.platform === 'win32') { + const image = readWindowsImageName(entry.pid); + const looksLikeOpencode = typeof image === 'string' && image.toLowerCase().includes('opencode'); + // Windows lacks reliable reparent-to-1 semantics (job objects usually kill + // children with the parent), so we reap only when the owner is provably dead + // AND the image still looks like opencode. + if (looksLikeOpencode && ownerGone) { + await killOrphan(entry.pid); + log?.(`[lifecycle] reaped orphaned OpenCode pid ${entry.pid} (owner ${entry.ownerPid} gone)`); + return true; + } + return false; + } + + const info = readUnixProcInfo(entry.pid); + // Can't verify identity (or it's not our server) → leave it alone. + if (!info || !commandIdentifiesOurServer(info.command, entry)) return false; + + const orphaned = info.ppid === 1 || ownerGone; + if (!orphaned) return false; // still owned by a live instance + + await killOrphan(entry.pid); + log?.(`[lifecycle] reaped orphaned OpenCode pid ${entry.pid} (reparented/owner gone)`); + return true; +}; + +/** + * Kill any genuinely-orphaned OpenCode processes WE previously spawned, and + * prune their registry files. Safe to call at startup before spawning a new + * server. Returns { inspected, reaped }. + */ +export const reapOrphanedProcesses = async ({ log } = {}) => { + const records = readAllEntries(); + if (records.length === 0) return { inspected: 0, reaped: 0 }; + + let reaped = 0; + for (const { entry, filePath } of records) { + let drop = false; + try { + const wasReaped = await processEntry(entry, { log }); + if (wasReaped) reaped += 1; + // Drop the file when the process is gone (reaped now, or already dead); + // keep it only while the process is still alive and owned by a live owner. + drop = wasReaped || !isPidAlive(entry.pid); + } catch (error) { + log?.(`[lifecycle] reap check failed for pid ${entry.pid}: ${error?.message ?? error}`); + } + if (drop) { + try { fs.rmSync(filePath, { force: true }); } catch {} + } + } + + return { inspected: records.length, reaped }; +}; diff --git a/packages/web/server/lib/opencode/server-startup-runtime.js b/packages/web/server/lib/opencode/server-startup-runtime.js index 551badd5..e53a14ae 100644 --- a/packages/web/server/lib/opencode/server-startup-runtime.js +++ b/packages/web/server/lib/opencode/server-startup-runtime.js @@ -131,9 +131,15 @@ export const createServerStartupRuntime = (dependencies) => { const handleSignal = async () => { await gracefulShutdown(); }; + // Cover every signal a shell or dev harness may use to stop/restart us, so + // the managed OpenCode child is always torn down gracefully instead of + // orphaned: SIGINT/SIGQUIT (Ctrl+C/Ctrl+\), SIGTERM (kill/default), SIGHUP + // (terminal close), SIGUSR2 (nodemon restart for `dev:server:watch`). process.on('SIGTERM', handleSignal); process.on('SIGINT', handleSignal); process.on('SIGQUIT', handleSignal); + process.on('SIGHUP', handleSignal); + process.on('SIGUSR2', handleSignal); setSignalsAttached(true); syncToHmrState(); }