fix(daemon): keep a session alive when its daemon is restarted under it (#662) (#713)

When an MCP host (opencode and others) SIGTERM's the shared daemon as a new
session starts, the existing session's proxy used to exit on the dropped socket
— silently losing CodeGraph for that session, and hanging any request in flight
at the drop. The SIGTERM originates in the host's process-tree teardown, not in
CodeGraph (nothing here signals another process), so the fix is proxy
resilience, not chasing the signal.

The local-handshake proxy now treats a daemon disconnect as recoverable rather
than terminal: it falls back to its in-process engine for the rest of the
session (the same path used when no daemon is reachable at startup, and what
CODEGRAPH_NO_DAEMON does) and re-serves any requests that were in flight to the
dead daemon, so the host never hangs. The proxy still exits when the HOST goes
away (stdin close / PPID watchdog) — only daemon loss is now non-fatal.

Also replaces the over-the-wire liveness-sweep test added in #712 — which was
flaky under heavy parallel load (a raced raw-socket connect) — with a
deterministic Daemon.reapDeadClients unit test. The client-hello round-trip is
still exercised by every daemon test (the real proxy now sends it).

Validated with a reproduction (proxy stays alive, in-flight request answered,
post-drop request recovers) and a regression test in mcp-daemon.test.ts.
Confirmed on macOS (full suite green) and a Windows 11 VM.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-06-06 16:43:02 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 80358a84d9
commit 471084dd6e
4 changed files with 123 additions and 57 deletions
+45 -5
View File
@@ -191,6 +191,19 @@ export async function runLocalHandshakeProxy(deps: LocalHandshakeDeps): Promise<
let engine: MCPEngine | null = null;
let engineReady: Promise<void> | null = null;
let shuttingDown = false;
// Requests forwarded to the daemon and not yet answered, keyed by JSON-RPC id.
// If the daemon dies mid-session (#662 — e.g. an MCP host SIGTERM's it when a
// new session starts), these would otherwise hang forever; we re-serve them
// in-process so the host always gets a reply.
const inflight = new Map<unknown, string>();
const trackInflight = (line: string): void => {
try {
const m = JSON.parse(line) as JsonRpc;
if (m && m.id !== undefined && typeof m.method === 'string' && m.method !== 'initialize') {
inflight.set(m.id, line);
}
} catch { /* unparseable — nothing we could re-serve anyway */ }
};
const writeClient = (obj: JsonRpc | string): void => {
try { process.stdout.write((typeof obj === 'string' ? obj : JSON.stringify(obj)) + '\n'); } catch { /* host gone */ }
@@ -221,11 +234,16 @@ export async function runLocalHandshakeProxy(deps: LocalHandshakeDeps): Promise<
}
} else if (msg.method === 'ping' && id !== undefined) {
writeClient({ jsonrpc: '2.0', id, result: {} });
} else if (id !== undefined && msg.method !== 'initialize') {
// A request we can't serve in-process (and the daemon is gone) — answer
// with an error rather than let the host hang on a reply that won't come.
writeClient({ jsonrpc: '2.0', id, error: { code: -32603, message: 'CodeGraph daemon unavailable' } });
}
// initialize already answered locally; notifications (initialized) need no reply.
};
const routeToDaemon = (line: string): void => {
if (daemonStatus === 'ready' && daemonSocket) {
trackInflight(line);
try { daemonSocket.write(line.endsWith('\n') ? line : line + '\n'); } catch { /* close path */ }
} else if (daemonStatus === 'failed') {
void handleLocally(line);
@@ -284,15 +302,37 @@ export async function runLocalHandshakeProxy(deps: LocalHandshakeDeps): Promise<
const line = sockBuf.slice(0, idx);
sockBuf = sockBuf.slice(idx + 1);
if (!line.trim()) continue;
if (clientInitId !== undefined) {
try { const m = JSON.parse(line) as JsonRpc; if (m.id === clientInitId && ('result' in m || 'error' in m)) continue; } catch { /* relay */ }
let resp: JsonRpc | null = null;
try { resp = JSON.parse(line) as JsonRpc; } catch { /* not JSON — relay verbatim */ }
if (resp && resp.id !== undefined && ('result' in resp || 'error' in resp)) {
inflight.delete(resp.id); // answered — no longer in flight
// Suppress the daemon's reply to the initialize we forwarded to prime it
// (the client already got the local handshake response).
if (clientInitId !== undefined && resp.id === clientInitId) continue;
}
writeClient(line);
}
});
socket.on('close', shutdown);
socket.on('error', shutdown);
for (const line of pending) { try { socket.write(line + '\n'); } catch { /* ignore */ } }
// The daemon going away does NOT end the session (#662). An MCP host can
// SIGTERM the shared daemon when another session starts; if we exited here,
// this host would silently lose CodeGraph and any in-flight request would
// hang. Instead, fall back to the in-process engine for the rest of the
// session and re-serve whatever the dead daemon never answered.
const onDaemonLost = (): void => {
if (shuttingDown || daemonStatus !== 'ready') return; // host teardown, or already handled
daemonStatus = 'failed';
try { daemonSocket?.destroy(); } catch { /* ignore */ }
daemonSocket = null;
process.stderr.write(
`[CodeGraph MCP] Shared daemon connection lost; serving this session in-process (degraded), re-serving ${inflight.size} in-flight request(s).\n`
);
const orphaned = [...inflight.values()];
inflight.clear();
for (const line of orphaned) void handleLocally(line);
};
socket.on('close', onDaemonLost);
socket.on('error', onDaemonLost);
for (const line of pending) { trackInflight(line); try { socket.write(line + '\n'); } catch { /* ignore */ } }
pending.length = 0;
} else if (!shuttingDown) {
daemonStatus = 'failed';