fix(windows): reap orphaned MCP processes when their parent exits (#692, #576, #680) (#711)

On Windows the PPID watchdog could never fire: orphans aren't reparented, so
`process.ppid` stays constant after the parent dies (defeating the ppid-change
check), and the standalone bundle pre-bakes `--liftoff-only`, skipping the
relaunch that sets `CODEGRAPH_HOST_PPID` (defeating the host-liveness check).
With neither signal available, an orphaned proxy / direct server ran forever,
the shared daemon never saw the client disconnect, and its idle timer never
armed — node processes accumulated until CPU saturated.

Add a win32-only signal: poll the original parent's liveness directly, since
ppid is stable there. Gated to Windows so POSIX double-fork cases keep relying
on the ppid-change signal (a dead original parent is not proof of orphaning on
POSIX). The decision is extracted into a pure, unit-tested helper shared by all
three watchdog sites (proxy socket, proxy local-handshake, direct mode).

Validated on a real Windows 11 VM: in the exact bundle scenario (direct mode,
no HOST_PPID) an orphaned server now exits within one watchdog poll via the new
path; the POSIX reparent path is unchanged and its integration test still passes.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-06-06 15:05:35 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 4e5cf2de56
commit 565eb20e26
5 changed files with 226 additions and 16 deletions
+16 -9
View File
@@ -22,6 +22,7 @@ import * as fs from 'fs';
import * as net from 'net';
import { HOST_PPID_ENV } from '../extraction/wasm-runtime-flags';
import { DaemonHello, MAX_HELLO_LINE_BYTES } from './daemon';
import { supervisionLostReason } from './ppid-watchdog';
import { CodeGraphPackageVersion } from './version';
import { SERVER_INFO, PROTOCOL_VERSION } from './session';
import { SERVER_INSTRUCTIONS } from './server-instructions';
@@ -292,8 +293,14 @@ function startPpidWatchdogNoSocket(onDeath: () => void): void {
const originalPpid = process.ppid;
const hostPpid = parseHostPpid(process.env[HOST_PPID_ENV]);
const timer = setInterval(() => {
if (process.ppid !== originalPpid || (hostPpid !== null && !isProcessAliveLocal(hostPpid))) {
process.stderr.write('[CodeGraph MCP] Parent process exited; shutting down.\n');
const reason = supervisionLostReason({
originalPpid,
currentPpid: process.ppid,
hostPpid,
isAlive: isProcessAliveLocal,
});
if (reason) {
process.stderr.write(`[CodeGraph MCP] Parent process exited (${reason}); shutting down.\n`);
onDeath();
}
}, pollMs);
@@ -408,13 +415,13 @@ function startPpidWatchdog(socket: net.Socket): void {
const originalPpid = process.ppid;
const hostPpid = parseHostPpid(process.env[HOST_PPID_ENV]);
const timer = setInterval(() => {
const current = process.ppid;
const ppidChanged = current !== originalPpid;
const hostGone = hostPpid !== null && !isProcessAliveLocal(hostPpid);
if (ppidChanged || hostGone) {
const reason = ppidChanged
? `ppid ${originalPpid} -> ${current}`
: `host pid ${hostPpid} exited`;
const reason = supervisionLostReason({
originalPpid,
currentPpid: process.ppid,
hostPpid,
isAlive: isProcessAliveLocal,
});
if (reason) {
process.stderr.write(`[CodeGraph MCP] Parent process exited (${reason}); shutting down.\n`);
try { socket.destroy(); } catch { /* ignore */ }
process.exit(0);