mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-26 07:13:13 +08:00
`codegraph ui` re-execs itself with `--liftoff-only`; the shim the user started blocks in spawnSync and cannot forward a signal. Killing it by pid — a process manager, an IDE task, `kill` — left the re-exec'd server serving its port forever (found during a sweep: servers from hours earlier were still answering on ports 4801–4808 against deleted indexes, holding their stdout pipes so the harness that started them never exited). Ctrl+C was unaffected (the signal reaches the whole process group). `index`/`init` already guard this with the #277 PPID watchdog. Factor it out of installCommandSupervision as `watchParent(onLost)` and use it in `ui` with the same shutdown as SIGINT/SIGTERM (close the index, then the socket). The liveness watchdog is deliberately not installed for `ui`: a long Steps computation can legitimately hold the event loop. Test: POSIX end-to-end through the real relaunch (CODEGRAPH_WASM_RELAUNCHED unset, 200ms poll) — SIGKILL the shim, the port stops answering; confirmed failing without the fix. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
109 lines
4.9 KiB
TypeScript
109 lines
4.9 KiB
TypeScript
/**
|
|
* 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);
|
|
}
|