Files
codegraph/src/mcp/early-ppid.ts
T
c9f8c0ebaf fix(mcp): reap the server when its launcher is killed during startup (#1185) (#1199)
An MCP host that kills the launcher chain within the server's first ~100ms
while keeping the stdio pipes open (config probe, cancelled request, startup
timeout; Rust hosts that kill a child without dropping its stdio handles) left
the server orphaned: it booted already reparented to init, so the PPID
watchdog's "ppid changed" baseline was captured as 1 and could never fire, and
stdin never EOF'd. The process lingered — idle, ~30MB — until the host itself
exited, accumulating one per abandoned launch (the pile-up reported in #1185).
Reproduced on released 1.2.0/macOS: SIGKILL the launcher at +50ms → permanent
orphan; at +150ms the old late baseline had already run and reaped it.

Three-part fix:
- Capture process.ppid at the earliest line of the CLI entry (early-ppid.ts)
  and use it as every watchdog baseline, shrinking the blind window to the few
  ms before our first JS runs.
- Thread the real host pid down the bundled path: the npm shim and the
  standalone sh launcher set CODEGRAPH_HOST_PPID (an outer launcher's value
  wins), so the watchdog polls the host directly. Previously only the
  --liftoff-only relaunch set it, leaving the entire npm/standalone install
  base with hostPpid=null.
- Never-initialized backstop (startup-handshake.ts): a serve --mcp that
  receives no MCP traffic for CODEGRAPH_STARTUP_HANDSHAKE_TIMEOUT_MS (default
  15min, 0 disables) shuts down — the catch-all for a kill landing in the
  residual pre-JS window. Disarmed on the first byte, so a quiet-but-live
  session is never touched.

Also scrub CODEGRAPH_HOST_PPID from the detached daemon's env — it has no host,
and a stale pid must not leak into anything it spawns.

Validated end-to-end on the built bundle: the +50ms early-kill orphan is now
reaped while the host still holds the pipes open, and all six normal
lifecycle paths (clean close, SIGTERM/SIGKILL child, host exit/SIGKILL,
fd-holding adversarial host) stay clean. New coverage in
startup-handshake.test.ts, mcp-startup-orphan.test.ts, and npm-shim.test.ts.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 08:45:12 -05:00

26 lines
1.4 KiB
TypeScript

/**
* Parent-pid baseline captured as early as possible in process life (#1185).
*
* The PPID watchdog's POSIX signal is "`process.ppid` CHANGED since startup" —
* but a launcher killed within the first ~100ms of our boot (an MCP host's
* config probe, an instant user cancel, an initialize-timeout teardown) can
* reparent this process to init BEFORE the serve/proxy code captured its
* baseline. The baseline then reads `1`, never diverges, and the watchdog is
* permanently blind — the orphaned-server accumulation reported in #1185.
* Reproduced on macOS: SIGKILL the launcher 50ms after spawn while the host
* holds the stdio pipes open, and the server survived indefinitely; at 150ms
* the old capture had already run and the watchdog reaped it.
*
* The CLI entry imports this module before anything else, so the capture runs
* within the first few ms of JS execution — the earliest a Node process can
* observe its parent. A kill landing in the remaining pre-JS window (process
* spawn → first require) still captures `1`; that residual case is covered by
* the startup-handshake timeout (see ./startup-handshake.ts), which reaps a
* server that never receives any MCP traffic.
*
* Library consumers don't load the CLI entry; for them the capture runs at
* first import of the MCP layer — no worse than the previous per-call-site
* capture, and identical once the module cache warms.
*/
export const EARLY_PPID: number = process.ppid;