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
+42
View File
@@ -57,6 +57,15 @@ function writeLauncher(binDir: string): void {
fs.chmodSync(p, 0o755);
}
// A fake bundle launcher that echoes the threaded host pid, so we can prove the
// shim passed CODEGRAPH_HOST_PPID down to the server (#1185).
function writeHostPpidLauncher(binDir: string): void {
fs.mkdirSync(binDir, { recursive: true });
const p = path.join(binDir, 'codegraph');
fs.writeFileSync(p, '#!/bin/sh\necho "HOST_PPID=[${CODEGRAPH_HOST_PPID}]"\n');
fs.chmodSync(p, 0o755);
}
// Launch the shim with async spawn so the in-process HTTPS server can respond
// while it runs (spawnSync would block this event loop and deadlock).
function runShim(pkgDir: string, args: string[], env: Record<string, string>) {
@@ -160,6 +169,39 @@ describe.skipIf(isWindows)('npm-shim launcher', () => {
expect(r.stderr).toContain('--registry=https://registry.npmjs.org');
expect(r.stderr).toContain('install.sh');
});
// #1185: the shim threads the MCP host's pid (its own parent) down to the
// bundled server so the server's orphan watchdog can poll the host directly
// — the fix for a server left orphaned when the launcher is killed during its
// startup. The shim's own parent here is the vitest runner (a real live pid).
it('threads CODEGRAPH_HOST_PPID to the bundled server (#1185)', async () => {
const pkg = makePkg();
const platformPkg = path.join(pkg, 'node_modules', '@colbymchenry', `codegraph-${target}`);
writeHostPpidLauncher(path.join(platformPkg, 'bin'));
fs.writeFileSync(path.join(platformPkg, 'package.json'),
JSON.stringify({ name: `@colbymchenry/codegraph-${target}`, version: '9.9.9-test' }) + '\n');
const r = await runShim(pkg, [], { CODEGRAPH_INSTALL_DIR: mkTmp('cache') });
expect(r.status).toBe(0);
// Non-empty and numeric — the shim's parent pid was passed through.
const m = r.stdout.match(/HOST_PPID=\[(\d+)\]/);
expect(m, `expected a numeric HOST_PPID, got: ${r.stdout}`).not.toBeNull();
expect(Number(m![1])).toBeGreaterThan(0);
});
it('does not clobber an already-set CODEGRAPH_HOST_PPID (#1185)', async () => {
const pkg = makePkg();
const platformPkg = path.join(pkg, 'node_modules', '@colbymchenry', `codegraph-${target}`);
writeHostPpidLauncher(path.join(platformPkg, 'bin'));
fs.writeFileSync(path.join(platformPkg, 'package.json'),
JSON.stringify({ name: `@colbymchenry/codegraph-${target}`, version: '9.9.9-test' }) + '\n');
// An outer launcher already threaded the true host pid — it must win over
// the shim's own parent, or a chain of launchers would each overwrite it.
const r = await runShim(pkg, [], { CODEGRAPH_INSTALL_DIR: mkTmp('cache'), CODEGRAPH_HOST_PPID: '424242' });
expect(r.status).toBe(0);
expect(r.stdout).toContain('HOST_PPID=[424242]');
});
});
describe.skipIf(!CAN_NET)('npm-shim download fallback (local HTTPS)', () => {