/** * Process supervision for long-running CLI commands (`index` / `init --index`). * * Indexing a large repo can run for a while on the main thread, and #999 * surfaced two ways that goes wrong when nothing is watching it: * * 1. **Orphaned worker.** `index` runs in a child re-exec'd with * `--liftoff-only` (the WASM-flag relaunch). Its parent blocks in * `spawnSync`, so when the parent shim is killed it cannot forward the * signal — the child keeps running, now orphaned, pinning a core. The PPID * watchdog (#277) notices the parent/host went away and exits the child. * 2. **Wedged indexer.** The `#850` main-thread liveness watchdog — which * SIGKILLs a process whose event loop stops turning — was wired only into * the MCP `serve` path, so a wedged `index`/`init` was never auto-killed. * * Both reuse the exact mechanisms `serve` already uses; this just makes them * available to a one-shot command. Best-effort and self-disabling: a missing * watchdog never blocks the command from running. Both honour the same env * switches as `serve` (`CODEGRAPH_NO_WATCHDOG`, `CODEGRAPH_PPID_POLL_MS=0`). * * Unlike the daemon — whose main thread only does fast, bounded work — the * `index`/`init` path runs reference resolution and dynamic-edge synthesis * SYNCHRONOUSLY on this thread, and on a large repo that is legitimately many * seconds of work. So those spans yield cooperatively to the event loop * (`src/resolution/cooperative-yield.ts`) to keep the heartbeat alive; without * that the watchdog would SIGKILL a valid, in-progress index (#1091). The * distinction it must preserve — kill a TRUE wedge, spare slow-but-progressing * work — is exactly what cooperative yielding buys: a genuinely stuck span never * reaches its next yield, so it still trips the timeout. */ import { installMainThreadWatchdog, WatchdogOptions } from '../mcp/liveness-watchdog'; import { supervisionLostReason, parsePpidPollMs, parseHostPpid } from '../mcp/ppid-watchdog'; import { isProcessAlive } from '../mcp/daemon-registry'; import { EARLY_PPID } from '../mcp/early-ppid'; import { HOST_PPID_ENV } from '../extraction/wasm-runtime-flags'; export interface CommandSupervision { /** Tear down both watchdogs. Idempotent; call when the command finishes. */ stop(): void; } /** * Install the liveness + PPID watchdogs for the duration of a CLI command. * `label` is used in the shutdown notice (e.g. `"index"`). Returns a handle * whose `stop()` must be called when the command completes so neither watchdog * outlives it. * * Pass `watchdog.progressPaths` (the project's SQLite DB + `-wal`) so the * liveness watchdog can tell a slow-but-progressing store on degraded storage * (files advancing) from a true wedge (they aren't) — one long synchronous * SQLite statement on a 150-IOPS disk otherwise gets a healthy index * SIGKILLed (#1231). */ export function installCommandSupervision(label: string, watchdog: WatchdogOptions = {}): CommandSupervision { // Liveness watchdog: a separate process that SIGKILLs us if our event loop // stops turning for too long (a wedged synchronous loop). Self-disables on // CODEGRAPH_NO_WATCHDOG. const liveness = installMainThreadWatchdog(watchdog); // PPID watchdog: detect that the parent (or the host threaded past the // relaunch shim) died and we've been orphaned, then exit instead of leaking. const stopParentWatch = watchParent((reason) => { try { process.stderr.write(`[CodeGraph ${label}] Parent process exited (${reason}); aborting.\n`); } catch { /* stderr gone with the parent — exit anyway */ } process.exit(1); }); let stopped = false; return { stop(): void { if (stopped) return; stopped = true; stopParentWatch(); liveness?.stop(); }, }; } /** * Call `onLost` once, when the parent — or the host threaded past the * relaunch shim — goes away. The shim blocks in `spawnSync` and cannot forward * a signal, so this is how a re-exec'd child learns it was orphaned. Returns a * stop function. `CODEGRAPH_PPID_POLL_MS=0` disables it. */ export function watchParent(onLost: (reason: string) => void): () => void { // Baseline from the CLI entry's earliest-possible capture — reading // process.ppid here would miss a launcher killed during startup (#1185). const originalPpid = EARLY_PPID; const hostPpid = parseHostPpid(process.env[HOST_PPID_ENV]); const pollMs = parsePpidPollMs(process.env.CODEGRAPH_PPID_POLL_MS); if (pollMs <= 0) return () => {}; const timer = setInterval(() => { const reason = supervisionLostReason({ originalPpid, currentPpid: process.ppid, hostPpid, isAlive: isProcessAlive, }); if (reason) { clearInterval(timer); onLost(reason); } }, pollMs); // Never let the watchdog itself keep the process alive past its real work. timer.unref(); return () => clearInterval(timer); }