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
+38 -1
View File
@@ -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}`);