Jelajahi Sumber

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>
Colby Mchenry 2 bulan lalu
induk
melakukan
c9f8c0ebaf

+ 1 - 0
CHANGELOG.md

@@ -36,6 +36,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 - `codegraph init` and `codegraph index` no longer get killed by the safety watchdog at the "Resolving refs" step on large method-name-heavy codebases (big Java/enterprise monorepos were the main victims, especially on slower machines). Resolution used to come up for air only every 500 references, so a dense stretch of expensive ones could starve the watchdog long enough for it to assume the process was stuck and kill a perfectly healthy index. Resolution now checkpoints after every reference, and two of the expensive steps got much cheaper: repeated method lookups on the same type are now cached, and source files are no longer re-split line-by-line for every call being resolved — indexing such repos is several times faster as a result. Generated or minified single-line files are also skipped during receiver-type inference instead of being scanned per call. Thanks @UchihaYong and @wangmeng-95 for the reports. (#1122)
 - An index left incomplete by an interrupted run now heals itself on the next sync instead of silently staying wrong forever. If indexing died partway through resolving references (a crash, Ctrl-C, or the watchdog kill fixed above), the affected files still looked indexed but their caller/impact edges were missing — a too-small blast radius clustering by package or module, e.g. a Spring `@Resource`-injected method reporting 3 of its 10 real caller files — and because incremental syncs only re-resolve files that changed, the damage was permanent until a full re-index. Any sync (a watched file change, or a bare `codegraph sync`) now detects the leftover references and finishes resolving them, `codegraph status` warns when an index is in that state instead of passing it off as healthy, and a rare early-stop that could abandon resolution on repos whose first files reference only external libraries is fixed too. Thanks @KnifeOfLife for the report and the package-correlation observation that pinned it down. (#1187)
+- CodeGraph's background server no longer leaves a lingering Node process behind when your editor or agent kills its launcher during startup. If the app that started CodeGraph (Codex, Claude Code, or another MCP client) was killed within the server's first fraction of a second — a config probe, a cancelled request, a startup timeout — while keeping the connection's pipes open, the server could be handed off to the system before its orphan-detection watchdog had captured a reference point, leaving it running (idle, ~30 MB) until the launching app itself exited; over a long day of repeated launches these accumulated. The server now records its parent at the earliest possible moment, and the npm and standalone launchers pass the real host's process id down to it so it can watch the host directly instead of only the launcher that may already be gone. As a final backstop, a server that never receives a single request after starting now shuts itself down instead of waiting for the host to exit — tunable with `CODEGRAPH_STARTUP_HANDSHAKE_TIMEOUT_MS` (0 disables). Thanks @ruslan33321 for the report. (#1185)
 - The automatic context hook for Claude Code now fires for structural questions asked in nearly thirty languages — French, Spanish, Portuguese, German, Italian, Dutch, Polish, Czech, Romanian, Hungarian, Greek, Swedish, Danish, Norwegian, Finnish, Russian, Ukrainian, Turkish, Indonesian, Vietnamese, Thai, Hindi, Arabic, Farsi, Hebrew, Japanese, Korean, and both simplified and traditional Chinese — instead of just English and simplified Chinese. Previously a natural question like "comment marche la state machine des commandes ?" injected nothing unless it happened to contain a code-shaped symbol name, making the hook look broken for non-English teams. English questions phrased with derived word forms ("explain the architecture…", "what are the dependencies…") now fire too, and prompts in any other language still fire when they name a symbol from the index. Thanks @anthonyle-roy-lgtm for the report. (#1126)
 - Lua and Luau method calls with capitalized names (`obj:Method()` — the standard Roblox convention) now link to the right method. Because Lua's method-call syntax looks identical to a Luau type annotation, a capitalized call like `lg:Log()` was misread as declaring the variable's type, so whenever two or more classes shared a method name (`Init`, `Update`, `Destroy`, …) the call was silently dropped from callers, impact/blast-radius, and flow traces. Lowercase method names were unaffected. Thanks @inth3shadows for the precise root-cause analysis and repro. (#1124)
 - Removed dead code left behind by the discontinued managed-reasoning feature. Its `codegraph login` flow was unplugged before ever shipping in a release, but the unused module still shipped inside the platform bundles, and a security review flagged its Windows browser-open step (it routed the login URL through `cmd`, which would have been unsafe had the flow ever been wired back up). The leftover module and its tests are now fully deleted. Thanks @inth3shadows for the report. (#1114)

+ 106 - 0
__tests__/mcp-startup-orphan.test.ts

@@ -0,0 +1,106 @@
+/**
+ * Startup-orphan regression tests (#1185) — spawn-level.
+ *
+ * Reproduced bug: an MCP host kills the launcher chain within the server's
+ * first ~100ms while keeping the stdio pipes open (config probe, instant
+ * cancel, initialize-timeout teardown; Rust hosts that kill a child without
+ * dropping its stdio handles hold pipes exactly like this). The server booted
+ * already reparented, so its PPID-watchdog baseline read 1 (blind forever),
+ * stdin never EOF'd, and the process lived until the HOST exited — one ~30MB
+ * node process leaked per occurrence.
+ *
+ * These tests exercise the last-resort defense end-to-end on the real built
+ * binary: a server that receives no MCP traffic shuts itself down when the
+ * startup-handshake timeout lapses, and a server that got even one message
+ * is never touched by it.
+ *
+ * POSIX-only: the blind spot is a POSIX reparenting artifact (Windows never
+ * reparents, so its liveness-based check keeps working with a late baseline),
+ * and the suite avoids the known Windows EPERM teardown quirk of spawned
+ * `serve --mcp` children holding the temp cwd open.
+ */
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { spawn, ChildProcessWithoutNullStreams } from 'child_process';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+
+const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
+
+function spawnServer(cwd: string, handshakeTimeoutMs: number): ChildProcessWithoutNullStreams {
+  return spawn(process.execPath, [BIN, 'serve', '--mcp'], {
+    cwd,
+    stdio: ['pipe', 'pipe', 'pipe'],
+    env: {
+      ...process.env,
+      // Direct mode: hermetic (no detached daemon to leak from the suite).
+      // The backstop is armed identically on the proxy path.
+      CODEGRAPH_NO_DAEMON: '1',
+      // Single process (skip the --liftoff-only re-exec) so exit-code and
+      // liveness assertions observe the server itself.
+      CODEGRAPH_WASM_RELAUNCHED: '1',
+      // One less helper child; the liveness watchdog is not under test.
+      CODEGRAPH_NO_WATCHDOG: '1',
+      CODEGRAPH_TELEMETRY: '0',
+      DO_NOT_TRACK: '1',
+      CODEGRAPH_STARTUP_HANDSHAKE_TIMEOUT_MS: String(handshakeTimeoutMs),
+    },
+  }) as ChildProcessWithoutNullStreams;
+}
+
+function waitForExit(child: ChildProcessWithoutNullStreams, timeoutMs: number): Promise<number | null> {
+  return new Promise((resolve, reject) => {
+    if (child.exitCode !== null) { resolve(child.exitCode); return; }
+    const timer = setTimeout(
+      () => reject(new Error(`server did not exit within ${timeoutMs}ms`)),
+      timeoutMs
+    );
+    child.on('exit', (code) => { clearTimeout(timer); resolve(code); });
+  });
+}
+
+const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
+
+describe.skipIf(process.platform === 'win32')('startup-orphan backstop (#1185)', () => {
+  let dir: string;
+  let child: ChildProcessWithoutNullStreams | null = null;
+
+  beforeEach(() => {
+    dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-orphan-'));
+  });
+
+  afterEach(() => {
+    if (child && child.exitCode === null) child.kill('SIGKILL');
+    child = null;
+    fs.rmSync(dir, { recursive: true, force: true });
+  });
+
+  it('a server that never receives MCP traffic shuts itself down', async () => {
+    child = spawnServer(dir, 1000);
+    let stderr = '';
+    child.stderr.on('data', (c) => { stderr += c.toString(); });
+
+    // Keep our pipe ends open the whole time — the abandoned-launch shape:
+    // no stdin EOF ever arrives; only the backstop can reap the server.
+    const code = await waitForExit(child, 15_000);
+    expect(code).toBe(0);
+    expect(stderr).toContain('No MCP traffic since startup');
+  }, 20_000);
+
+  it('a server that got an initialize is never reaped by the backstop', async () => {
+    child = spawnServer(dir, 1000);
+    child.stdin.write(JSON.stringify({
+      jsonrpc: '2.0', id: 1, method: 'initialize',
+      params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 't', version: '0' } },
+    }) + '\n');
+
+    // Well past the 1s backstop window: the first byte disarmed it for good.
+    await sleep(3000);
+    expect(child.exitCode).toBeNull();
+
+    // Normal lifecycle still intact: closing stdin ends the session.
+    child.stdin.end();
+    const code = await waitForExit(child, 10_000);
+    expect(code).toBe(0);
+  }, 20_000);
+});

+ 42 - 0
__tests__/npm-shim.test.ts

@@ -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)', () => {

+ 108 - 0
__tests__/startup-handshake.test.ts

@@ -0,0 +1,108 @@
+/**
+ * Never-initialized backstop + early ppid capture (#1185).
+ *
+ * The orphan these guard against: an MCP host kills the launcher chain within
+ * the server's first ~100ms and keeps the stdio pipes open. The server boots
+ * already reparented (ppid baseline reads 1 → the divergence watchdog is
+ * blind), stdin never EOFs, and pre-#1185 the process lived until the host
+ * itself exited. The backstop reaps any server that never receives a single
+ * byte of MCP traffic; early-ppid.ts shrinks the blind window itself.
+ */
+import { describe, it, expect } from 'vitest';
+import { PassThrough } from 'stream';
+import {
+  DEFAULT_STARTUP_HANDSHAKE_TIMEOUT_MS,
+  armStartupHandshakeTimeout,
+  parseStartupHandshakeTimeoutMs,
+} from '../src/mcp/startup-handshake';
+import { EARLY_PPID } from '../src/mcp/early-ppid';
+
+const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
+
+describe('parseStartupHandshakeTimeoutMs', () => {
+  it('defaults when unset or empty', () => {
+    expect(parseStartupHandshakeTimeoutMs(undefined)).toBe(DEFAULT_STARTUP_HANDSHAKE_TIMEOUT_MS);
+    expect(parseStartupHandshakeTimeoutMs('')).toBe(DEFAULT_STARTUP_HANDSHAKE_TIMEOUT_MS);
+  });
+
+  it('defaults on non-numeric garbage', () => {
+    expect(parseStartupHandshakeTimeoutMs('abc')).toBe(DEFAULT_STARTUP_HANDSHAKE_TIMEOUT_MS);
+    expect(parseStartupHandshakeTimeoutMs('NaN')).toBe(DEFAULT_STARTUP_HANDSHAKE_TIMEOUT_MS);
+  });
+
+  it('treats 0 and negatives as disabled', () => {
+    expect(parseStartupHandshakeTimeoutMs('0')).toBe(0);
+    expect(parseStartupHandshakeTimeoutMs('-5')).toBe(0);
+  });
+
+  it('floors fractional values', () => {
+    expect(parseStartupHandshakeTimeoutMs('2500.7')).toBe(2500);
+  });
+});
+
+describe('armStartupHandshakeTimeout', () => {
+  it('fires exactly once when no data ever arrives', async () => {
+    const stream = new PassThrough();
+    let fired = 0;
+    armStartupHandshakeTimeout(() => { fired++; }, stream, 40);
+    await sleep(140);
+    expect(fired).toBe(1);
+  });
+
+  it('does not fire once any traffic arrives', async () => {
+    const stream = new PassThrough();
+    let fired = 0;
+    armStartupHandshakeTimeout(() => { fired++; }, stream, 40);
+    stream.write('{"jsonrpc":"2.0","id":1,"method":"initialize"}\n');
+    await sleep(140);
+    expect(fired).toBe(0);
+  });
+
+  it('a single early byte disarms it for good', async () => {
+    const stream = new PassThrough();
+    let fired = 0;
+    armStartupHandshakeTimeout(() => { fired++; }, stream, 40);
+    stream.write('x');
+    await sleep(140); // well past the 40ms window, with no further traffic
+    expect(fired).toBe(0);
+  });
+
+  it('the returned disarm function cancels it', async () => {
+    const stream = new PassThrough();
+    let fired = 0;
+    const disarm = armStartupHandshakeTimeout(() => { fired++; }, stream, 40);
+    disarm();
+    disarm(); // idempotent
+    await sleep(140);
+    expect(fired).toBe(0);
+  });
+
+  it('timeout 0 disables (env convention shared with CODEGRAPH_PPID_POLL_MS)', async () => {
+    const stream = new PassThrough();
+    let fired = 0;
+    const disarm = armStartupHandshakeTimeout(() => { fired++; }, stream, 0);
+    await sleep(80);
+    expect(fired).toBe(0);
+    disarm(); // still callable
+  });
+
+  it('does not steal data from the real consumer', async () => {
+    // The backstop attaches its own once('data') listener; the actual MCP
+    // consumer on the same stream must still see every byte.
+    const stream = new PassThrough();
+    let seen = '';
+    stream.on('data', (c: Buffer) => { seen += c.toString(); });
+    armStartupHandshakeTimeout(() => { /* no-op */ }, stream, 1000);
+    stream.write('hello');
+    stream.write(' world');
+    await sleep(20);
+    expect(seen).toBe('hello world');
+  });
+});
+
+describe('EARLY_PPID', () => {
+  it('captured a plausible parent pid at module load', () => {
+    expect(Number.isInteger(EARLY_PPID)).toBe(true);
+    expect(EARLY_PPID).toBeGreaterThan(0);
+  });
+});

+ 5 - 0
scripts/build-bundle.sh

@@ -98,6 +98,11 @@ while [ -L "$SELF" ]; do
   esac
 done
 DIR="$(cd "$(dirname "$SELF")/.." && pwd)"
+# Thread the MCP host's pid to the server's orphan watchdog (issue #1185).
+# $PPID is our parent — the host itself when it launched this script directly;
+# an already-threaded value (the npm shim sets the true host pid) wins.
+CODEGRAPH_HOST_PPID="${CODEGRAPH_HOST_PPID:-$PPID}"
+export CODEGRAPH_HOST_PPID
 # --liftoff-only: avoid the V8 turboshaft WASM Zone OOM (issues #293/#298).
 exec "$DIR/node" --liftoff-only "$DIR/lib/dist/bin/codegraph.js" "$@"
 LAUNCH

+ 8 - 1
scripts/npm-shim.js

@@ -45,7 +45,14 @@ async function main() {
   // Happy path: the npm-installed optional dependency. Fall back to a download
   // when the registry didn't deliver it.
   var resolved = resolveInstalledBundle() || (await selfHealBundle());
-  var res = childProcess.spawnSync(resolved.command, resolved.args, { stdio: 'inherit', windowsHide: true });
+  // Thread the MCP host's pid (our parent) down to the bundled server so its
+  // orphan watchdog can poll the host directly. Without this, the server can
+  // only watch THIS shim — and a shim killed during the server's first ~100ms
+  // of startup used to leave the server orphaned forever (issue #1185). An
+  // already-set value (an outer launcher) wins.
+  var env = Object.assign({}, process.env);
+  if (!env.CODEGRAPH_HOST_PPID) env.CODEGRAPH_HOST_PPID = String(process.ppid);
+  var res = childProcess.spawnSync(resolved.command, resolved.args, { stdio: 'inherit', windowsHide: true, env: env });
   if (res.error) {
     process.stderr.write('codegraph: ' + res.error.message + '\n');
     process.exit(1);

+ 5 - 0
src/bin/codegraph.ts

@@ -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
src/bin/command-supervision.ts

@@ -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 - 0
src/mcp/early-ppid.ts

@@ -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
src/mcp/index.ts

@@ -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
src/mcp/proxy.ts

@@ -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 - 0
src/mcp/startup-handshake.ts

@@ -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);
+  };
+}