feat(opencode): never leave orphaned OpenCode server processes

OpenChamber spawns the OpenCode server as an external child binary (detached
on Unix), so a hard crash, SIGKILL, or Ctrl+C of the host before graceful
teardown could leave it running. Orphaned servers then accumulate and contend
on the shared SQLite DB, causing severe startup slowdowns.

Add a per-process registry plus a startup reaper, mirroring the pattern
OpenCode's own CLI daemon uses for its detached server:

- One file per spawned process at
  ~/.config/openchamber/managed-opencode/<pid>.json. Per-process files avoid
  the read-modify-write clobber race between concurrent runtimes/windows that a
  single shared file would suffer.
- On spawn, record the child (pid, owner pid, port, binary, host runtime).
- On graceful close/restart, delete the record.
- On startup, reap only our own, verified, genuinely-orphaned processes:
  recorded by us AND still a live `opencode serve` on the recorded port AND
  whose spawner is provably gone (reparented to pid 1, or recorded owner dead).
  It never touches a process a live instance is using, the user's standalone
  server, the official desktop app, or the TUI.

Wire it into every runtime that spawns the server:

- web/desktop via the OpenCode lifecycle (register on spawn, unregister on
  close/restart, reap at startup). The restart-for-config-change flow inherits
  this automatically through the same kill/spawn paths.
- VS Code carries a parity implementation (it does not bundle the web package)
  that reads/writes the same registry directory and uses the same algorithm.
- Tag the actual host runtime (desktop/web/ssh-remote/vscode) for observability.

Also tighten teardown so the registry stays accurate and orphans die promptly
instead of only on the next start:

- The web server now also handles SIGHUP and SIGUSR2 (terminal close and the
  nodemon restart used by dev:server:watch / dev:web:hmr).
- Electron now installs SIGINT/SIGTERM/SIGHUP handlers that run the same
  background teardown as a normal quit, covering Ctrl+C on electron:dev.

External OpenCode servers (OPENCODE_SKIP_START) are intentionally excluded: we
never manage or kill processes we did not spawn.
This commit is contained in:
Bohdan Triapitsyn
2026-06-24 16:51:17 +03:00
parent a9dfd32347
commit 2ff5428c69
6 changed files with 566 additions and 1 deletions
+19
View File
@@ -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<number> {
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;