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:
@@ -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;
|
||||
|
||||
@@ -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 (`<childPid>.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<void> => 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<void> => {
|
||||
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<boolean> => {
|
||||
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 };
|
||||
};
|
||||
Reference in New Issue
Block a user