fix(daemon): reap dead-peer clients + inactivity backstop so a daemon can't leak (#692) (#712)

Layer-2 defense-in-depth follow-up to the Windows PPID watchdog fix (#711).
That fix makes an orphaned proxy exit so its socket closes and the daemon
reaps via the refcount + idle timer. This adds two daemon-side safety nets for
the residual case where a socket close is never delivered (a Windows named-pipe
hazard) and a phantom client would otherwise pin the daemon forever:

  - Liveness sweep: a proxy now sends an optional client-hello carrying its pid
    (+ host pid) right after verifying the daemon hello; the daemon periodically
    drops any client whose peer process is dead, re-arming the idle timer.
    Fail-safe and version-pinned — a connection that never sends the hello just
    falls back to the socket-close lifecycle, and the daemon reads it before the
    transport so a non-hello first line is handed through untouched.
  - Inactivity backstop: the daemon exits after a generous no-traffic window
    (CODEGRAPH_DAEMON_MAX_IDLE_MS, default 30 min) even with clients attached, so
    a phantom client that sends nothing can't keep it alive.

Pure helpers (parseClientHelloLine, peerIsDead) are unit-tested; the full
handshake + sweep and the backstop are covered end-to-end in mcp-daemon.test.ts.
Validated on a real Windows 11 VM: the sweep reaps a dead-pid client over a
named pipe and the backstop fires with a client still connected.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-06-06 16:23:48 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 565eb20e26
commit 80358a84d9
5 changed files with 390 additions and 9 deletions
+21 -1
View File
@@ -21,7 +21,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 { DaemonClientHello, DaemonHello, MAX_HELLO_LINE_BYTES } from './daemon';
import { supervisionLostReason } from './ppid-watchdog';
import { CodeGraphPackageVersion } from './version';
import { SERVER_INFO, PROTOCOL_VERSION } from './session';
@@ -93,6 +93,7 @@ export async function runProxy(
`[CodeGraph MCP] Attached to shared daemon on ${socketPath} (pid ${hello.pid}, v${hello.codegraph}).\n`
);
sendClientHello(socket);
startPpidWatchdog(socket);
await pipeUntilClose(socket);
// Host disconnected (or the daemon went away). The proxy's only job is the
@@ -132,9 +133,28 @@ export async function connectWithHello(
process.stderr.write(
`[CodeGraph MCP] Attached to shared daemon on ${socketPath} (pid ${hello.pid}, v${hello.codegraph}).\n`
);
sendClientHello(socket);
return socket;
}
/**
* Tell the daemon our pids right after we verify its hello, so its liveness
* sweep can reap this client if our process dies without the socket ever
* signalling close (the Windows named-pipe hazard behind #692). Best-effort:
* sent before any piped bytes so it's always the daemon's first line from us,
* and a write failure here is harmless (the daemon just falls back to the
* socket-close lifecycle). `hostPid` mirrors the PPID watchdog: the threaded
* host pid if set, else our own parent (the host, on a no-relaunch bundle).
*/
function sendClientHello(socket: net.Socket): void {
const clientHello: DaemonClientHello = {
codegraph_client: 1,
pid: process.pid,
hostPid: parseHostPpid(process.env[HOST_PPID_ENV]) ?? process.ppid,
};
try { socket.write(JSON.stringify(clientHello) + '\n'); } catch { /* best-effort */ }
}
type JsonRpc = Record<string, unknown>;
/** Dependencies the local-handshake proxy needs, injected by MCPServer (which