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:
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user