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:
co-authored by
Claude Opus 4.8
parent
6ea65246a5
commit
c9f8c0ebaf
+23
-4
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user