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.
237 lines
7.7 KiB
TypeScript
237 lines
7.7 KiB
TypeScript
// 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 };
|
|
};
|