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:
co-authored by
Claude Opus 4.8
parent
4e5cf2de56
commit
565eb20e26
+8
-7
@@ -49,6 +49,7 @@ import {
|
||||
} from './daemon';
|
||||
import { connectWithHello, runLocalHandshakeProxy } from './proxy';
|
||||
import { getDaemonSocketPath } from './daemon-paths';
|
||||
import { supervisionLostReason } from './ppid-watchdog';
|
||||
import { HOST_PPID_ENV } from '../extraction/wasm-runtime-flags';
|
||||
|
||||
/**
|
||||
@@ -423,13 +424,13 @@ export class MCPServer {
|
||||
const pollMs = parsePpidPollMs(process.env.CODEGRAPH_PPID_POLL_MS);
|
||||
if (pollMs <= 0) return;
|
||||
this.ppidWatchdog = setInterval(() => {
|
||||
const current = process.ppid;
|
||||
const ppidChanged = current !== this.originalPpid;
|
||||
const hostGone = this.hostPpid !== null && !isProcessAlive(this.hostPpid);
|
||||
if (ppidChanged || hostGone) {
|
||||
const reason = ppidChanged
|
||||
? `ppid ${this.originalPpid} -> ${current}`
|
||||
: `host pid ${this.hostPpid} exited`;
|
||||
const reason = supervisionLostReason({
|
||||
originalPpid: this.originalPpid,
|
||||
currentPpid: process.ppid,
|
||||
hostPpid: this.hostPpid,
|
||||
isAlive: isProcessAlive,
|
||||
});
|
||||
if (reason) {
|
||||
process.stderr.write(
|
||||
`[CodeGraph MCP] Parent process exited (${reason}); shutting down.\n`
|
||||
);
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Shared decision logic for the PPID watchdog (#277, #692).
|
||||
*
|
||||
* The watchdog's job: notice that the process we depend on — our parent, or the
|
||||
* MCP host reached past an intermediate launcher — has died, so an orphaned
|
||||
* proxy / direct server shuts itself down instead of leaking forever.
|
||||
*
|
||||
* Parent death surfaces differently per OS, and getting this wrong is what
|
||||
* caused the unbounded daemon/proxy leak on Windows (#692, #576):
|
||||
*
|
||||
* - **POSIX** reparents an orphan to init (pid 1), so `process.ppid` *changes*
|
||||
* the instant the parent dies. That divergence is the classic #277 signal.
|
||||
* - **Windows** never reparents: `process.ppid` keeps reporting the original
|
||||
* (now-dead) parent forever, so the change-check can never fire. There we
|
||||
* must poll the original parent's *liveness* instead.
|
||||
*
|
||||
* The liveness fallback is deliberately gated to Windows. On POSIX a
|
||||
* double-forked grandparent can legitimately outlive the reparent, so a dead
|
||||
* `originalPpid` is not proof of orphaning there — the change-check is the
|
||||
* correct and sufficient POSIX signal, and using liveness too would risk a
|
||||
* false-positive shutdown.
|
||||
*/
|
||||
export interface SupervisionState {
|
||||
/** `process.ppid` captured at startup. */
|
||||
originalPpid: number;
|
||||
/** `process.ppid` right now. */
|
||||
currentPpid: number;
|
||||
/**
|
||||
* The MCP host pid threaded past an intermediate launcher
|
||||
* (`CODEGRAPH_HOST_PPID`), or null when unknown — e.g. the standalone bundle,
|
||||
* which pre-bakes `--liftoff-only` and so never runs the relaunch that sets it.
|
||||
*/
|
||||
hostPpid: number | null;
|
||||
/** Liveness probe — `process.kill(pid, 0)` in production, stubbed in tests. */
|
||||
isAlive: (pid: number) => boolean;
|
||||
/** Defaults to `process.platform`. */
|
||||
platform?: NodeJS.Platform;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a human-readable reason string when the process has lost its
|
||||
* supervisor and should shut down, or null while it is still supervised.
|
||||
*/
|
||||
export function supervisionLostReason(state: SupervisionState): string | null {
|
||||
const { originalPpid, currentPpid, hostPpid, isAlive } = state;
|
||||
const platform = state.platform ?? process.platform;
|
||||
|
||||
// POSIX: the parent dying reparents us, so ppid diverges. (Never on Windows.)
|
||||
if (currentPpid !== originalPpid) {
|
||||
return `ppid ${originalPpid} -> ${currentPpid}`;
|
||||
}
|
||||
// Windows: ppid is stable across parent death, so detect it by liveness.
|
||||
// Skip pid 0/1 — "unknown" and init are never a real Windows parent, and a
|
||||
// bogus liveness probe there must not trigger a shutdown.
|
||||
if (platform === 'win32' && originalPpid > 1 && !isAlive(originalPpid)) {
|
||||
return `parent pid ${originalPpid} exited`;
|
||||
}
|
||||
// Either platform: the host pid threaded past a launcher shim is gone.
|
||||
if (hostPpid !== null && !isAlive(hostPpid)) {
|
||||
return `host pid ${hostPpid} exited`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
+16
-9
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user