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>
This commit is contained in:
Colby Mchenry
2026-07-07 08:45:12 -05:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 6ea65246a5
commit c9f8c0ebaf
12 changed files with 421 additions and 9 deletions
+5
View File
@@ -23,6 +23,11 @@
* codegraph upgrade [version] Update CodeGraph to the latest release
*/
// FIRST import, before anything else loads: capture process.ppid while our
// launcher is (almost certainly) still alive. A launcher killed mid-startup
// otherwise blinds the PPID watchdog forever (#1185) — see early-ppid.ts.
import '../mcp/early-ppid';
import { Command } from 'commander';
import * as path from 'path';
import * as fs from 'fs';
+4 -1
View File
@@ -31,6 +31,7 @@
import { installMainThreadWatchdog } 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 {
@@ -52,7 +53,9 @@ export function installCommandSupervision(label: string): CommandSupervision {
// 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 originalPpid = process.ppid;
// 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);
let ppidTimer: ReturnType<typeof setInterval> | null = null;
+25
View File
@@ -0,0 +1,25 @@
/**
* 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;
+23 -4
View File
@@ -50,8 +50,10 @@ import {
import { connectWithHello, runLocalHandshakeProxy } from './proxy';
import { getDaemonSocketCandidates } from './daemon-paths';
import { getTelemetry } from '../telemetry';
import { EARLY_PPID } from './early-ppid';
import { supervisionLostReason, parsePpidPollMs, parseHostPpid } from './ppid-watchdog';
import { installMainThreadWatchdog, WatchdogHandle } from './liveness-watchdog';
import { armStartupHandshakeTimeout } from './startup-handshake';
import { treatStdinFailureAsShutdown } from './stdin-teardown';
import { HOST_PPID_ENV } from '../extraction/wasm-runtime-flags';
@@ -148,6 +150,11 @@ function spawnDetachedDaemon(root: string): void {
stdio = 'ignore'; // no log file — discard daemon output rather than fail
}
try {
// The daemon has no host: scrub the threaded host pid so it can't leak
// into the daemon's env (and from there into anything the daemon spawns),
// where a long-dead session's host pid would trigger spurious shutdowns.
const env: NodeJS.ProcessEnv = { ...process.env, [DAEMON_INTERNAL_ENV]: '1' };
delete env[HOST_PPID_ENV];
const child = spawn(
process.execPath,
[...process.execArgv, scriptPath, 'serve', '--mcp', '--path', root],
@@ -155,7 +162,7 @@ function spawnDetachedDaemon(root: string): void {
detached: true,
stdio,
windowsHide: true,
env: { ...process.env, [DAEMON_INTERNAL_ENV]: '1' },
env,
},
);
child.unref();
@@ -189,9 +196,10 @@ export class MCPServer {
// Worker-thread liveness watchdog (#850). Long-lived modes only; SIGKILLs the
// process if the main thread wedges in a non-yielding sync loop.
private livenessWatchdog: WatchdogHandle | null = null;
// PPID watchdog baseline — captured at construction so we always have a
// baseline, even if start() runs after a fork-style reparent.
private originalPpid: number = process.ppid;
// PPID watchdog baseline — from the CLI entry's earliest-possible capture
// (early-ppid.ts). Capturing here (construction) already lost the race when
// the launcher was killed during module loading (#1185).
private originalPpid: number = EARLY_PPID;
private hostPpid: number | null = parseHostPpid(process.env[HOST_PPID_ENV]);
// Idempotency guard for stop().
private stopped = false;
@@ -314,6 +322,17 @@ export class MCPServer {
// ECONNRESET/hangup instead of a clean close) as shutdown, and destroy the
// stream so a hung fd can't busy-spin the event loop (#799).
treatStdinFailureAsShutdown(() => this.stop());
// Backstop for a launch abandoned during startup (#1185): launcher killed
// before EARLY_PPID could see it + host holding our pipes open. A server
// that never receives a byte of MCP traffic isn't serving anyone. Armed
// after session.start() attached the real stdin consumer.
armStartupHandshakeTimeout(() => {
process.stderr.write(
'[CodeGraph MCP] No MCP traffic since startup; assuming an abandoned launch and shutting down (#1185). ' +
'Tune with CODEGRAPH_STARTUP_HANDSHAKE_TIMEOUT_MS (0 disables).\n'
);
this.stop();
});
this.mode = 'direct';
this.installSignalHandlers();
+23 -3
View File
@@ -22,7 +22,9 @@ import * as fs from 'fs';
import * as net from 'net';
import { HOST_PPID_ENV } from '../extraction/wasm-runtime-flags';
import { DaemonClientHello, DaemonHello, MAX_HELLO_LINE_BYTES } from './daemon';
import { EARLY_PPID } from './early-ppid';
import { supervisionLostReason } from './ppid-watchdog';
import { armStartupHandshakeTimeout } from './startup-handshake';
import { treatStdinFailureAsShutdown } from './stdin-teardown';
import { CodeGraphPackageVersion } from './version';
import { SERVER_INFO, PROTOCOL_VERSION } from './session';
@@ -178,7 +180,7 @@ function sendClientHello(socket: net.Socket): void {
const clientHello: DaemonClientHello = {
codegraph_client: 1,
pid: process.pid,
hostPid: parseHostPpid(process.env[HOST_PPID_ENV]) ?? process.ppid,
hostPid: parseHostPpid(process.env[HOST_PPID_ENV]) ?? EARLY_PPID,
};
try { socket.write(JSON.stringify(clientHello) + '\n'); } catch { /* best-effort */ }
}
@@ -328,6 +330,18 @@ export async function runLocalHandshakeProxy(deps: LocalHandshakeDeps): Promise<
// busy-spinning the event loop (#799).
treatStdinFailureAsShutdown(shutdown);
startPpidWatchdogNoSocket(shutdown);
// Backstop for a launch abandoned before any of the above can see it: killed
// launcher + held-open pipes + reparent that beat the EARLY_PPID capture
// (#1185). A server that never receives a single byte isn't serving anyone.
// Armed after the stdin 'data' consumer above so no bytes are emitted while
// only the backstop's listener exists.
armStartupHandshakeTimeout(() => {
process.stderr.write(
'[CodeGraph MCP] No MCP traffic since startup; assuming an abandoned launch and shutting down (#1185). ' +
'Tune with CODEGRAPH_STARTUP_HANDSHAKE_TIMEOUT_MS (0 disables).\n'
);
shutdown();
});
// ---- daemon connection (background) ----
let socket: net.Socket | null = null;
@@ -396,7 +410,10 @@ export async function runLocalHandshakeProxy(deps: LocalHandshakeDeps): Promise<
function startPpidWatchdogNoSocket(onDeath: () => void): void {
const pollMs = parsePollMs(process.env.CODEGRAPH_PPID_POLL_MS);
if (pollMs <= 0) return;
const originalPpid = process.ppid;
// Baseline from the CLI entry's earliest capture, not process.ppid here —
// a launcher killed during our first ~100ms would otherwise leave the
// baseline at 1 and blind the divergence check forever (#1185).
const originalPpid = EARLY_PPID;
const hostPpid = parseHostPpid(process.env[HOST_PPID_ENV]);
const timer = setInterval(() => {
const reason = supervisionLostReason({
@@ -524,7 +541,10 @@ function pipeUntilClose(socket: net.Socket): Promise<void> {
function startPpidWatchdog(socket: net.Socket): void {
const pollMs = parsePollMs(process.env.CODEGRAPH_PPID_POLL_MS);
if (pollMs <= 0) return;
const originalPpid = process.ppid;
// Baseline from the CLI entry's earliest capture, not process.ppid here —
// a launcher killed during our first ~100ms would otherwise leave the
// baseline at 1 and blind the divergence check forever (#1185).
const originalPpid = EARLY_PPID;
const hostPpid = parseHostPpid(process.env[HOST_PPID_ENV]);
const timer = setInterval(() => {
const reason = supervisionLostReason({
+71
View File
@@ -0,0 +1,71 @@
/**
* Never-initialized backstop for `serve --mcp` (#1185).
*
* Every real MCP host sends `initialize` immediately after spawning a server.
* A server that has received NO bytes at all for many minutes is not serving
* anyone — it is the residue of an abandoned launch: the host killed the
* launcher chain during startup (config probe, instant cancel, initialize
* timeout) but kept our stdio pipe fds open, so stdin never EOFs. If the kill
* landed before {@link ../mcp/early-ppid} could observe the real parent, the
* PPID watchdog is blind too (baseline `1`), and — pre-#1185 — the orphan
* lived until the HOST process exited, accumulating one ~30MB node process
* per occurrence.
*
* This backstop closes that last hole: arm a one-shot timer at serve start
* and disarm it on the first byte of client traffic. If the timer fires, the
* caller shuts the server down. The default is deliberately generous (15
* minutes) — hosts initialize within milliseconds, so the only processes this
* ever reaps are ones nobody is talking to. It never affects a session that
* spoke even once: after the first byte the timer is gone for good (a
* quiet-but-live session is the PPID watchdog's / stdin teardown's job).
*
* IMPORTANT (callers): attaching a `'data'` listener switches the stream into
* flowing mode. Arm this AFTER the real stdin consumer is attached, in the
* same synchronous block, so no early bytes are emitted while only our
* listener exists. The detached daemon must never arm this — its stdin is
* `'ignore'` and its lifecycle is refcount/idle-based.
*
* Tune with `CODEGRAPH_STARTUP_HANDSHAKE_TIMEOUT_MS`; `0` disables.
*/
/** Default wait for the first byte of MCP traffic before assuming orphaned. */
export const DEFAULT_STARTUP_HANDSHAKE_TIMEOUT_MS = 900_000; // 15 min
export const STARTUP_HANDSHAKE_TIMEOUT_ENV = 'CODEGRAPH_STARTUP_HANDSHAKE_TIMEOUT_MS';
/**
* Parse the timeout env override. Missing/invalid → default; `<= 0` → `0`
* (disabled), the same disable convention as `CODEGRAPH_PPID_POLL_MS`.
*/
export function parseStartupHandshakeTimeoutMs(raw: string | undefined): number {
if (raw === undefined || raw === '') return DEFAULT_STARTUP_HANDSHAKE_TIMEOUT_MS;
const parsed = Number(raw);
if (!Number.isFinite(parsed)) return DEFAULT_STARTUP_HANDSHAKE_TIMEOUT_MS;
if (parsed <= 0) return 0;
return Math.floor(parsed);
}
/**
* Arm the backstop. `onAbandoned` runs at most once, only if no `'data'` event
* arrives on `stream` within the timeout. Returns a disarm function (idempotent;
* also detaches the listener). `stream`/`timeoutMs` are injectable for tests.
*/
export function armStartupHandshakeTimeout(
onAbandoned: () => void,
stream: NodeJS.ReadableStream = process.stdin,
timeoutMs: number = parseStartupHandshakeTimeoutMs(process.env[STARTUP_HANDSHAKE_TIMEOUT_ENV]),
): () => void {
if (timeoutMs <= 0) return () => { /* disabled */ };
const onFirstData = (): void => { clearTimeout(timer); };
const timer = setTimeout(() => {
stream.removeListener('data', onFirstData);
onAbandoned();
}, timeoutMs);
// Never let the backstop itself keep an otherwise-finished process alive.
timer.unref?.();
stream.once('data', onFirstData);
return (): void => {
stream.removeListener('data', onFirstData);
clearTimeout(timer);
};
}