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,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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user