Skip to content

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

@inth3shadows

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions