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:
@@ -285,6 +285,22 @@ const performConfirmedQuit = () => {
|
|||||||
app.exit(0);
|
app.exit(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Hard-stop signals (`Ctrl+C` on `electron:dev`, an external `kill`/SIGTERM,
|
||||||
|
// terminal close) bypass the normal app-quit flow — which would orphan the
|
||||||
|
// in-process web server's managed OpenCode child. Run the same background
|
||||||
|
// teardown the quit path uses (which kills the sidecar), then exit. The startup
|
||||||
|
// reaper remains the backstop for an unhandled hard crash (SIGKILL).
|
||||||
|
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
||||||
|
process.on(signal, () => {
|
||||||
|
try {
|
||||||
|
shutdownBackgroundServices();
|
||||||
|
} catch (error) {
|
||||||
|
log.warn(`[electron] ${signal} shutdown failed:`, error);
|
||||||
|
}
|
||||||
|
app.exit(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const requestQuitWithConfirmation = async () => {
|
const requestQuitWithConfirmation = async () => {
|
||||||
await refreshQuitRiskFlags();
|
await refreshQuitRiskFlags();
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { spawn } from 'child_process';
|
|||||||
import { randomBytes } from 'crypto';
|
import { randomBytes } from 'crypto';
|
||||||
import { normalizeWindowsDriveLetter } from './pathUtils';
|
import { normalizeWindowsDriveLetter } from './pathUtils';
|
||||||
import { resolveWorkingDirectoryChange } from './workingDirectoryChange';
|
import { resolveWorkingDirectoryChange } from './workingDirectoryChange';
|
||||||
|
import { registerManagedProcess, unregisterManagedProcess, reapOrphanedProcesses } from './opencodeProcessRegistry';
|
||||||
|
|
||||||
const t = vscode.l10n.t;
|
const t = vscode.l10n.t;
|
||||||
|
|
||||||
@@ -688,6 +689,9 @@ async function spawnManagedOpenCodeServer(
|
|||||||
child.on('error', onError);
|
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 {
|
return {
|
||||||
url,
|
url,
|
||||||
close: () => {
|
close: () => {
|
||||||
@@ -696,6 +700,7 @@ async function spawnManagedOpenCodeServer(
|
|||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
unregisterManagedProcess(child.pid);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -726,6 +731,7 @@ async function allocateManagedOpenCodePort(): Promise<number> {
|
|||||||
|
|
||||||
export function createOpenCodeManager(context: vscode.ExtensionContext): OpenCodeManager {
|
export function createOpenCodeManager(context: vscode.ExtensionContext): OpenCodeManager {
|
||||||
let server: { url: string; close: () => void } | null = null;
|
let server: { url: string; close: () => void } | null = null;
|
||||||
|
let reapedOrphansOnce = false;
|
||||||
let managedApiUrlOverride: string | null = null;
|
let managedApiUrlOverride: string | null = null;
|
||||||
let managedPassword: string | null = null;
|
let managedPassword: string | null = null;
|
||||||
let managedPasswordSource: 'user-env' | 'generated' | 'rotated' | null = null;
|
let managedPasswordSource: 'user-env' | 'generated' | 'rotated' | null = null;
|
||||||
@@ -866,6 +872,19 @@ export function createOpenCodeManager(context: vscode.ExtensionContext): OpenCod
|
|||||||
return;
|
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');
|
setStatus('connecting');
|
||||||
cliMissing = false;
|
cliMissing = false;
|
||||||
cliPath = null;
|
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 };
|
||||||
|
};
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { spawn, spawnSync } from 'node:child_process';
|
import { spawn, spawnSync } from 'node:child_process';
|
||||||
import net from 'node:net';
|
import net from 'node:net';
|
||||||
|
import { registerManagedProcess, unregisterManagedProcess, reapOrphanedProcesses } from './managed-process-registry.js';
|
||||||
|
|
||||||
const parsePositiveInt = (value, fallback) => {
|
const parsePositiveInt = (value, fallback) => {
|
||||||
const parsed = Number.parseInt(String(value ?? ''), 10);
|
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) {
|
if (!child) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -212,6 +213,19 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
|||||||
await waitForChildProcessClose(child, 1000);
|
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 formatCapturedOutput = ({ stdout, stderr }) => {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
if (stdout.trim()) {
|
if (stdout.trim()) {
|
||||||
@@ -324,6 +338,19 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
|||||||
child.on('error', onError);
|
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 {
|
return {
|
||||||
url,
|
url,
|
||||||
pid: child.pid || null,
|
pid: child.pid || null,
|
||||||
@@ -747,6 +774,16 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
|||||||
|
|
||||||
const bootstrapOpenCodeAtStartup = async () => {
|
const bootstrapOpenCodeAtStartup = async () => {
|
||||||
try {
|
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();
|
syncFromHmrState();
|
||||||
if (await isOpenCodeProcessHealthy()) {
|
if (await isOpenCodeProcessHealthy()) {
|
||||||
console.log(`[HMR] Reusing existing OpenCode process on port ${state.openCodePort}`);
|
console.log(`[HMR] Reusing existing OpenCode process on port ${state.openCodePort}`);
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
// Managed OpenCode process registry + orphan reaper.
|
||||||
|
//
|
||||||
|
// OpenChamber spawns the OpenCode server as an EXTERNAL child binary (on Unix
|
||||||
|
// with `detached: true`, so it leads its own process group). That binary can
|
||||||
|
// therefore outlive its parent if the parent is hard-killed/crashes/`Ctrl+C`ed
|
||||||
|
// before graceful teardown runs — leaving an orphaned `opencode serve` that
|
||||||
|
// then contends on the shared SQLite DB and slows everything down.
|
||||||
|
//
|
||||||
|
// We cannot tie an arbitrary external binary to the parent's death portably
|
||||||
|
// (Electron's `utilityProcess` would, but it only runs JS entrypoints, not a
|
||||||
|
// standalone binary). So we use the same pattern OpenCode's own CLI daemon uses
|
||||||
|
// for its detached server: an on-disk record of the pids WE spawned, plus a
|
||||||
|
// startup reaper that kills ONLY our own, verified, genuinely-orphaned
|
||||||
|
// processes — never a process a live instance (another desktop window, a VS
|
||||||
|
// Code host, the user's standalone `opencode`) is actively using.
|
||||||
|
//
|
||||||
|
// Storage: ONE FILE PER SPAWNED PROCESS in a registry directory, named
|
||||||
|
// `<childPid>.json`. Multiple runtimes (web/desktop/VS Code) and multiple
|
||||||
|
// windows all run concurrently; a single shared JSON file would be corrupted by
|
||||||
|
// the read-modify-write race (last writer wins, clobbering another instance's
|
||||||
|
// entry). Per-process files mean every instance only ever writes/deletes its
|
||||||
|
// OWN file, so there is no write contention at all.
|
||||||
|
//
|
||||||
|
// Safety model (why this never kills the wrong thing):
|
||||||
|
// 1. The reaper only ever considers pids THIS product recorded. The user's
|
||||||
|
// standalone CLI server, the official desktop app, and the TUI are never
|
||||||
|
// recorded, so they are never even candidates.
|
||||||
|
// 2. Before killing, it re-verifies the live pid is still an `opencode serve`
|
||||||
|
// matching the recorded port (guards against the OS recycling a dead pid
|
||||||
|
// onto an unrelated process).
|
||||||
|
// 3. It kills only when the spawning owner is provably gone — the child has
|
||||||
|
// been reparented to init/pid 1, or the recorded owner pid is dead. A
|
||||||
|
// child still owned by a live instance is left untouched.
|
||||||
|
//
|
||||||
|
// The VS Code extension cannot import this module (it does not bundle the web
|
||||||
|
// package); it carries a parity implementation that reads/writes the SAME dir.
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
|
||||||
|
const resolveRegistryDir = () => {
|
||||||
|
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) => path.join(resolveRegistryDir(), `${pid}.json`);
|
||||||
|
|
||||||
|
const writeEntryFile = (entry) => {
|
||||||
|
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 = () => {
|
||||||
|
const dir = resolveRegistryDir();
|
||||||
|
let names = [];
|
||||||
|
try {
|
||||||
|
names = fs.readdirSync(dir).filter((name) => name.endsWith('.json'));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const out = [];
|
||||||
|
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, filePath });
|
||||||
|
} else {
|
||||||
|
fs.rmSync(filePath, { force: true });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Corrupt/partial file — drop it.
|
||||||
|
try { fs.rmSync(filePath, { force: true }); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Record an OpenCode process WE spawned so a future run can reap it if orphaned. */
|
||||||
|
export const registerManagedProcess = ({ pid, ownerPid, port, binary, runtime } = {}) => {
|
||||||
|
if (!Number.isInteger(pid)) return;
|
||||||
|
writeEntryFile({
|
||||||
|
pid,
|
||||||
|
ownerPid: Number.isInteger(ownerPid) ? ownerPid : process.pid,
|
||||||
|
port: Number.isInteger(port) ? port : null,
|
||||||
|
binary: typeof binary === 'string' ? binary : null,
|
||||||
|
runtime: typeof runtime === 'string' ? runtime : 'web',
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Drop a pid from the registry (after we have killed/closed it ourselves). */
|
||||||
|
export const unregisterManagedProcess = (pid) => {
|
||||||
|
if (!Number.isInteger(pid)) return;
|
||||||
|
try {
|
||||||
|
fs.rmSync(entryFilePath(pid), { force: true });
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPidAlive = (pid) => {
|
||||||
|
if (!Number.isInteger(pid)) return false;
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
// EPERM = process exists but we lack permission to signal it → still alive.
|
||||||
|
return error?.code === 'EPERM';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
// Returns { ppid, command } for a live pid on Unix, or null if it can't be read.
|
||||||
|
const readUnixProcInfo = (pid) => {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Windows image name for a pid (e.g. "opencode.exe"), or null.
|
||||||
|
const readWindowsImageName = (pid) => {
|
||||||
|
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, entry) => {
|
||||||
|
if (typeof command !== 'string') return false;
|
||||||
|
const lower = command.toLowerCase();
|
||||||
|
if (!lower.includes('opencode') || !lower.includes('serve')) return false;
|
||||||
|
// Tie to the exact server we registered when we know its port, so a recycled
|
||||||
|
// pid running a *different* opencode server is never mistaken for ours.
|
||||||
|
if (Number.isInteger(entry.port) && !command.includes(String(entry.port))) return false;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const killOrphan = async (pid) => {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
try {
|
||||||
|
spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore', timeout: 5000, windowsHide: true });
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const signalTree = (signal) => {
|
||||||
|
try { process.kill(-pid, signal); } catch {}
|
||||||
|
try { process.kill(pid, signal); } catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
signalTree('SIGTERM');
|
||||||
|
for (let waited = 0; waited < 1500 && isPidAlive(pid); waited += 150) {
|
||||||
|
await sleep(150);
|
||||||
|
}
|
||||||
|
if (isPidAlive(pid)) {
|
||||||
|
signalTree('SIGKILL');
|
||||||
|
await sleep(300);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Decide+act on a single registry entry. Returns true if it was reaped.
|
||||||
|
const processEntry = async (entry, { log }) => {
|
||||||
|
// Dead pid → nothing to do (caller drops the file).
|
||||||
|
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');
|
||||||
|
// Windows lacks reliable reparent-to-1 semantics (job objects usually kill
|
||||||
|
// children with the parent), so we reap only when the owner is provably dead
|
||||||
|
// AND the image still looks like opencode.
|
||||||
|
if (looksLikeOpencode && ownerGone) {
|
||||||
|
await killOrphan(entry.pid);
|
||||||
|
log?.(`[lifecycle] reaped orphaned OpenCode pid ${entry.pid} (owner ${entry.ownerPid} gone)`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = readUnixProcInfo(entry.pid);
|
||||||
|
// Can't verify identity (or it's not our server) → leave it alone.
|
||||||
|
if (!info || !commandIdentifiesOurServer(info.command, entry)) return false;
|
||||||
|
|
||||||
|
const orphaned = info.ppid === 1 || ownerGone;
|
||||||
|
if (!orphaned) return false; // still owned by a live instance
|
||||||
|
|
||||||
|
await killOrphan(entry.pid);
|
||||||
|
log?.(`[lifecycle] reaped orphaned OpenCode pid ${entry.pid} (reparented/owner gone)`);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kill any genuinely-orphaned OpenCode processes WE previously spawned, and
|
||||||
|
* prune their registry files. Safe to call at startup before spawning a new
|
||||||
|
* server. Returns { inspected, reaped }.
|
||||||
|
*/
|
||||||
|
export const reapOrphanedProcesses = async ({ log } = {}) => {
|
||||||
|
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 the file when the process is gone (reaped now, or already dead);
|
||||||
|
// keep it only while the process is still alive and owned by a live owner.
|
||||||
|
drop = wasReaped || !isPidAlive(entry.pid);
|
||||||
|
} catch (error) {
|
||||||
|
log?.(`[lifecycle] reap check failed for pid ${entry.pid}: ${error?.message ?? error}`);
|
||||||
|
}
|
||||||
|
if (drop) {
|
||||||
|
try { fs.rmSync(filePath, { force: true }); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { inspected: records.length, reaped };
|
||||||
|
};
|
||||||
@@ -131,9 +131,15 @@ export const createServerStartupRuntime = (dependencies) => {
|
|||||||
const handleSignal = async () => {
|
const handleSignal = async () => {
|
||||||
await gracefulShutdown();
|
await gracefulShutdown();
|
||||||
};
|
};
|
||||||
|
// Cover every signal a shell or dev harness may use to stop/restart us, so
|
||||||
|
// the managed OpenCode child is always torn down gracefully instead of
|
||||||
|
// orphaned: SIGINT/SIGQUIT (Ctrl+C/Ctrl+\), SIGTERM (kill/default), SIGHUP
|
||||||
|
// (terminal close), SIGUSR2 (nodemon restart for `dev:server:watch`).
|
||||||
process.on('SIGTERM', handleSignal);
|
process.on('SIGTERM', handleSignal);
|
||||||
process.on('SIGINT', handleSignal);
|
process.on('SIGINT', handleSignal);
|
||||||
process.on('SIGQUIT', handleSignal);
|
process.on('SIGQUIT', handleSignal);
|
||||||
|
process.on('SIGHUP', handleSignal);
|
||||||
|
process.on('SIGUSR2', handleSignal);
|
||||||
setSignalsAttached(true);
|
setSignalsAttached(true);
|
||||||
syncToHmrState();
|
syncToHmrState();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user