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
+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();