Files
codegraph/__tests__/daemon-client-liveness.test.ts
T
80358a84d9 fix(daemon): reap dead-peer clients + inactivity backstop so a daemon can't leak (#692) (#712)
Layer-2 defense-in-depth follow-up to the Windows PPID watchdog fix (#711).
That fix makes an orphaned proxy exit so its socket closes and the daemon
reaps via the refcount + idle timer. This adds two daemon-side safety nets for
the residual case where a socket close is never delivered (a Windows named-pipe
hazard) and a phantom client would otherwise pin the daemon forever:

  - Liveness sweep: a proxy now sends an optional client-hello carrying its pid
    (+ host pid) right after verifying the daemon hello; the daemon periodically
    drops any client whose peer process is dead, re-arming the idle timer.
    Fail-safe and version-pinned — a connection that never sends the hello just
    falls back to the socket-close lifecycle, and the daemon reads it before the
    transport so a non-hello first line is handed through untouched.
  - Inactivity backstop: the daemon exits after a generous no-traffic window
    (CODEGRAPH_DAEMON_MAX_IDLE_MS, default 30 min) even with clients attached, so
    a phantom client that sends nothing can't keep it alive.

Pure helpers (parseClientHelloLine, peerIsDead) are unit-tested; the full
handshake + sweep and the backstop are covered end-to-end in mcp-daemon.test.ts.
Validated on a real Windows 11 VM: the sweep reaps a dead-pid client over a
named pipe and the backstop fires with a client still connected.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:23:48 -04:00

70 lines
2.8 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 { 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);
});
});