Repository navigation
Daemon leaks a permanent phantom client when a peer dies mid-handshake (after daemon-hello, before client-hello), defeating the idle-exit reaper — verified deterministically #1356
Description
Summary
When a client connects to the shared daemon and dies after reading the daemon hello but before writing its own client-hello line (the per-host proxy's PPID watchdog firing, a SIGKILL'd host, the startup-handshake timeout → process.exit), the daemon builds an MCPSession for that already-closed socket and adds it to this.clients, but its close handler never fires — so the session is a permanent phantom. It is never reaped (peer pid is null), and it keeps clients.size > 0, so the idle-exit timer's zero-client condition never fires and the daemon never idle-exits. Verified end-to-end with a deterministic harness (real Daemon, real sockets — no timing race).
Root cause
// src/mcp/daemon.ts:369-383 (handleConnection)
void readClientHello(socket).then((peers) => {
const transport = new SocketTransport(socket);
const session = new MCPSession(transport, this.engine, { explicitProjectPath: this.projectRoot });
transport.onClose(() => this.dropClient(session)); // registered on an already-closed socket
this.clients.add(session);
this.clientPeers.set(session, peers); // peers = { pid: null, hostPid: null }
this.disarmIdleTimer();
session.start(); // transport.start() attaches socket.on('close', ...)
...
});readClientHello resolves via the socket's close/error event (daemon.ts:852 onEnd), so the .then runs after the socket is destroyed. SocketTransport.start() then does socket.on('close', handleSocketClose) on an already-closed socket, and Node does not replay close to a listener added afterward — so handleSocketClose → the onClose handler → dropClient never runs. The session is stuck in clients with {pid:null}:
reapDeadClientsskips it —peerIsDeadis false whenpeers.pid === null(daemon.ts:474,484-489).armIdleTimer's zero-client exit (daemon.ts:389,this.clients.size === 0) never fires;handleConnectionalreadydisarmIdleTimer()'d, and onlydropClientre-arms it.- The 30-min inactivity backstop only helps if the daemon goes fully silent for the whole window; other real sessions' traffic keeps resetting
lastActivityAt.
Net: one mid-handshake client death pins the daemon (and its engine/socket/session) for the rest of its life, defeating the idle-exit anti-leak mechanism.
Repro (deterministic harness — real Daemon, real sockets)
Connect, wait for the daemon hello (so handleConnection is mid-readClientHello), then drop without sending a client-hello. Control arm = a clean connect+close.
const daemon = new Daemon(dir, { idleTimeoutMs: 500 });
await daemon.start();
// CONTROL: connect, send a proper client-hello, then close
// FAULT: connect, read daemon hello, then socket.destroy() (no client-hello)Observed:
CONTROL (clean connect+close): clients=0 idleTimerArmed=true
FAULT (drop mid-handshake): clients=1 idleTimerArmed=false
After 1300ms (idle timeout 500ms), both clients long gone:
clients = 1 (should be 0)
idle timer armed = false (⇒ no idle-exit scheduled)
liveness sweep reaped = 0 (⇒ can't reap; peer pid null)
daemon still alive = true (did NOT idle-exit)
With CODEGRAPH_MCP_DEBUG=1 the daemon logs clientHello finish pid=null then transport attached flowing=false, confirming the transport attached to the already-closed socket and never saw a close.
(Note: if the client is destroyed before the daemon even runs handleConnection, no session is created and there is no leak — the leak needs the drop to land while readClientHello is pending, which is the realistic proxy-death window.)
Suggested direction
Guard the continuation against a socket that closed during the hello read, e.g. if (socket.destroyed) return; before building the transport/session; or have the liveness sweep drop any client whose underlying socket is already destroyed, independent of peer pid.
Environment
main @ 5955d04, package 1.4.1, Node 22.23.1 (Linux). Reproduces on a clean npm ci && npm run build.
Activity
inth3shadows commentedon Jul 18, 2026
Suggested label for triage (I lack permission to apply it myself):
bug.fix(mcp): stop pending connections and serve read-only fallbacks (#1963…
colbymchenry commentedon Sep 27, 2026
Thanks for the detailed report, @inth3shadows, and thanks for using CodeGraph! This is fixed on
mainby #2042, which builds on @danusha2345's #1979 (thank you!): a peer that disconnects after the daemon's hello but before its own no longer registers a phantom client, so the idle-exit reaper keeps working. The fix will be in the next release.