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}:
reapDeadClients skips it — peerIsDead is false when peers.pid === null (daemon.ts:474, 484-489).
armIdleTimer's zero-client exit (daemon.ts:389, this.clients.size === 0) never fires; handleConnection already disarmIdleTimer()'d, and only dropClient re-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.
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 anMCPSessionfor that already-closed socket and adds it tothis.clients, but its close handler never fires — so the session is a permanent phantom. It is never reaped (peer pid is null), and it keepsclients.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 (realDaemon, real sockets — no timing race).Root cause
readClientHelloresolves via the socket'sclose/errorevent (daemon.ts:852onEnd), so the.thenruns after the socket is destroyed.SocketTransport.start()then doessocket.on('close', handleSocketClose)on an already-closed socket, and Node does not replaycloseto a listener added afterward — sohandleSocketClose→ theonClosehandler →dropClientnever runs. The session is stuck inclientswith{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.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
handleConnectionis mid-readClientHello), then drop without sending a client-hello. Control arm = a clean connect+close.Observed:
With
CODEGRAPH_MCP_DEBUG=1the daemon logsclientHello finish pid=nullthentransport attached flowing=false, confirming the transport attached to the already-closed socket and never saw aclose.(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 whilereadClientHellois 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, package1.4.1, Node 22.23.1 (Linux). Reproduces on a cleannpm ci && npm run build.