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