Files
codegraph/__tests__/daemon-client-liveness.test.ts
T
356f5f7659 fix(daemon): gate the inactivity backstop on client liveness (#1200) (#1201)
The shared daemon's inactivity backstop (#692) reaped the daemon after
maxIdleMs (default 30 min) of no inbound query bytes whenever a client was
still connected — without ever checking whether that client was actually
alive. lastActivityAt is fed only by inbound socket data and MCP has no
keepalive, so a genuinely-live session that just hadn't queried CodeGraph in
30 min tripped it. The daemon then exited, and the proxy's onDaemonLost
degrades that session (and every other session sharing the daemon) to an
in-process engine for the rest of its life. On one dev machine over a day the
backstop fired 20 times on live sessions (clients=1) and the liveness sweep
caught 0 real dead peers — net harm.

The backstop exists only to catch a phantom client (one counted but gone,
whose socket-close was never delivered). It now consults the peer pids the
daemon already tracks: after the inactivity window it sweeps provably-dead
peers, then reaps the daemon only if NO remaining client can be proven alive
(every one is an unknown-pid connection the sweep can't verify — the sole
phantom class it can't catch). One provably-alive client keeps the daemon up.

Extracted the decision into Daemon.backstopShouldExit(isAlive) so it's unit-
testable with an injected liveness probe, mirroring reapDeadClients. All #692
guarantees preserved; the only behavior change is that a provably-alive quiet
session is no longer reaped.

- daemon-client-liveness.test.ts: 7 new deterministic cases for
  backstopShouldExit (live kept, phantom reaped, mixed protects the live one,
  dead-peer swept-then-held, within-window, zero-client).
- mcp-daemon.test.ts: the integration test that asserted the backstop reaps a
  live connected client (it encoded the bug) now asserts the opposite — a
  live-but-quiet session survives several backstop windows with its lockfile
  intact and no backstop shutdown logged.

Validated end-to-end on the built bundle: a quiet session's daemon stayed up
across 4 backstop windows (maxIdle=3s), same pid throughout, zero backstop
fires. Found while fixing #1185.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 09:13:13 -05:00

182 lines
7.7 KiB
TypeScript

/**
* Unit coverage for the daemon-side client-liveness primitives (#692, Layer 2).
*
* These back the daemon's defense against a phantom client — one whose process
* died without the socket ever signalling close (a Windows named-pipe hazard).
* The wire parsing and the liveness decision are pure, so they're tested here;
* the full handshake + sweep is exercised end-to-end in `mcp-daemon.test.ts`.
*/
import { describe, it, expect } from 'vitest';
import { Daemon, parseClientHelloLine, peerIsDead } from '../src/mcp/daemon';
describe('parseClientHelloLine', () => {
it('parses a well-formed client-hello', () => {
expect(parseClientHelloLine('{"codegraph_client":1,"pid":1234,"hostPid":56}'))
.toEqual({ pid: 1234, hostPid: 56 });
});
it('accepts a null host pid and a missing host pid', () => {
expect(parseClientHelloLine('{"codegraph_client":1,"pid":1234,"hostPid":null}'))
.toEqual({ pid: 1234, hostPid: null });
expect(parseClientHelloLine('{"codegraph_client":1,"pid":1234}'))
.toEqual({ pid: 1234, hostPid: null });
});
it('returns null for a JSON-RPC message (no marker) so it is treated as data', () => {
expect(parseClientHelloLine('{"jsonrpc":"2.0","id":1,"method":"initialize"}')).toBeNull();
});
it('rejects a wrong-typed marker, a non-numeric pid, and a non-integer marker', () => {
expect(parseClientHelloLine('{"codegraph_client":true,"pid":1}')).toBeNull();
expect(parseClientHelloLine('{"codegraph_client":2,"pid":1}')).toBeNull();
expect(parseClientHelloLine('{"codegraph_client":1,"pid":"1"}')).toBeNull();
});
it('returns null for invalid / empty / non-object JSON', () => {
expect(parseClientHelloLine('not json')).toBeNull();
expect(parseClientHelloLine('')).toBeNull();
expect(parseClientHelloLine('42')).toBeNull();
expect(parseClientHelloLine('null')).toBeNull();
});
});
describe('peerIsDead', () => {
const aliveAll = () => true;
const deadAll = () => false;
const deadOnly = (...pids: number[]) => (pid: number) => !pids.includes(pid);
it('never reaps a client with an unknown pid (no client-hello)', () => {
expect(peerIsDead({ pid: null, hostPid: null }, deadAll)).toBe(false);
expect(peerIsDead({ pid: null, hostPid: 99 }, deadAll)).toBe(false);
});
it('keeps a client whose proxy is alive', () => {
expect(peerIsDead({ pid: 100, hostPid: null }, aliveAll)).toBe(false);
});
it('reaps a client whose proxy process is gone', () => {
expect(peerIsDead({ pid: 100, hostPid: null }, deadOnly(100))).toBe(true);
});
it('reaps when the proxy is alive but its host is gone', () => {
// proxy 100 alive, host 42 dead
expect(peerIsDead({ pid: 100, hostPid: 42 }, deadOnly(42))).toBe(true);
});
it('keeps a client when both proxy and host are alive', () => {
expect(peerIsDead({ pid: 100, hostPid: 42 }, aliveAll)).toBe(false);
});
});
describe('Daemon.reapDeadClients', () => {
// Construct with idleTimeoutMs:0 so dropping the last client doesn't arm a real
// idle timer. The constructor opens no sockets/DB, so this stays a fast unit test.
const makeDaemon = () => new Daemon('/tmp/codegraph-reap-unit-test', { idleTimeoutMs: 0 }) as any;
const fakeSession = () => ({ stopped: false, stop() { this.stopped = true; } });
it('drops clients with a dead peer and leaves live ones attached', () => {
const d = makeDaemon();
const dead = fakeSession();
const live = fakeSession();
d.clients.add(dead); d.clientPeers.set(dead, { pid: 111, hostPid: null });
d.clients.add(live); d.clientPeers.set(live, { pid: 222, hostPid: null });
const reaped = d.reapDeadClients((pid: number) => pid !== 111); // 111 dead, 222 alive
expect(reaped).toBe(1);
expect(dead.stopped).toBe(true);
expect(d.clients.has(dead)).toBe(false);
expect(d.clientPeers.has(dead)).toBe(false); // peer record cleaned up too
expect(d.clients.has(live)).toBe(true);
});
it('never reaps a client with an unknown pid (no client-hello)', () => {
const d = makeDaemon();
const s = fakeSession();
d.clients.add(s); d.clientPeers.set(s, { pid: null, hostPid: null });
expect(d.reapDeadClients(() => false)).toBe(0); // everything "dead", but pid unknown
expect(d.clients.has(s)).toBe(true);
});
it('reaps a client whose host pid is gone even if its proxy pid is alive', () => {
const d = makeDaemon();
const s = fakeSession();
d.clients.add(s); d.clientPeers.set(s, { pid: 100, hostPid: 42 });
expect(d.reapDeadClients((pid: number) => pid !== 42)).toBe(1); // proxy 100 alive, host 42 dead
expect(d.clients.has(s)).toBe(false);
});
});
// The inactivity backstop (#692) must reap a phantom daemon but NEVER a
// live-but-quiet session — reaping the latter silently degraded that session
// (and any others sharing the daemon) to an in-process engine, and on a real
// machine it fired far more often on live sessions than on actual phantoms.
describe('Daemon.backstopShouldExit', () => {
// maxIdleMs small; idleTimeoutMs:0 so a sweep that empties the set doesn't arm
// a real timer. Force the inactivity window open by backdating lastActivityAt.
const makeDaemon = () => {
const d = new Daemon('/tmp/codegraph-backstop-unit-test', { idleTimeoutMs: 0, maxIdleMs: 1000 }) as any;
d.lastActivityAt = Date.now() - 60_000; // long past the 1000ms window
return d;
};
const fakeSession = () => ({ stopped: false, stop() { this.stopped = true; } });
it('does NOT reap while a provably-alive client stays connected (the fix)', () => {
const d = makeDaemon();
const live = fakeSession();
d.clients.add(live); d.clientPeers.set(live, { pid: 222, hostPid: null });
expect(d.backstopShouldExit(() => true)).toBe(false); // 222 alive → keep the daemon
expect(d.clients.has(live)).toBe(true);
});
it('reaps when only an unknown-pid client remains (the phantom the sweep cannot catch)', () => {
const d = makeDaemon();
const phantom = fakeSession();
d.clients.add(phantom); d.clientPeers.set(phantom, { pid: null, hostPid: null });
// Unknown pid → the sweep leaves it, and after the window it's a probable phantom.
expect(d.backstopShouldExit(() => false)).toBe(true);
});
it('protects a live session even when a phantom is also connected', () => {
const d = makeDaemon();
const live = fakeSession();
const phantom = fakeSession();
d.clients.add(live); d.clientPeers.set(live, { pid: 222, hostPid: null });
d.clients.add(phantom); d.clientPeers.set(phantom, { pid: null, hostPid: null });
// 222 alive, phantom unknown → ANY alive keeps the daemon; the live one wins.
expect(d.backstopShouldExit((pid: number) => pid === 222)).toBe(false);
expect(d.clients.has(live)).toBe(true);
});
it('sweeps a dead-peer client first; if that empties the set it does not exit', () => {
const d = makeDaemon();
const dead = fakeSession();
d.clients.add(dead); d.clientPeers.set(dead, { pid: 111, hostPid: null });
// 111 dead → swept by backstopShouldExit; empty set → idle timer owns it, no backstop exit.
expect(d.backstopShouldExit(() => false)).toBe(false);
expect(d.clients.has(dead)).toBe(false);
expect(dead.stopped).toBe(true);
});
it('does not exit before the inactivity window elapses', () => {
const d = makeDaemon();
d.lastActivityAt = Date.now(); // fresh — inside the 1000ms window
const phantom = fakeSession();
d.clients.add(phantom); d.clientPeers.set(phantom, { pid: null, hostPid: null });
expect(d.backstopShouldExit(() => false)).toBe(false);
expect(d.clients.has(phantom)).toBe(true); // not even swept yet
});
it('does not exit with zero clients (the idle timer owns that case)', () => {
const d = makeDaemon();
expect(d.backstopShouldExit(() => false)).toBe(false);
});
});