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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
565eb20e26
commit
80358a84d9
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
@@ -143,6 +143,16 @@ function readLockPid(root: string): number | null {
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/** The socket path the daemon actually bound, as it recorded in its lockfile —
|
||||
* robust on Windows where a recomputed pipe path can differ from the daemon's. */
|
||||
function readLockSocketPath(root: string): string | null {
|
||||
try {
|
||||
const raw = fs.readFileSync(path.join(root, '.codegraph', 'daemon.pid'), 'utf8');
|
||||
const info = JSON.parse(raw);
|
||||
return typeof info.socketPath === 'string' ? info.socketPath : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function readDaemonLog(root: string): string {
|
||||
try { return fs.readFileSync(path.join(root, '.codegraph', 'daemon.log'), 'utf8'); }
|
||||
catch { return ''; }
|
||||
@@ -359,6 +369,66 @@ describe('Shared MCP daemon (issue #411)', () => {
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
it('reaps a client whose process died without the socket closing (liveness sweep, #692)', async () => {
|
||||
const net = await import('net');
|
||||
// Bring a daemon up via a real proxy (a live client), sweep fast.
|
||||
const env = { CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '30000', CODEGRAPH_DAEMON_CLIENT_SWEEP_MS: '300' };
|
||||
const server = spawnServer(tempDir, env);
|
||||
servers.push(server);
|
||||
sendInitialize(server.child, `file://${tempDir}`, 1);
|
||||
await waitFor(() => findResponse(server.stdout, 1), 10000);
|
||||
await waitFor(() => (readLockPid(realRoot) ?? 0) > 0, 8000);
|
||||
|
||||
// Connect a RAW client that announces a dead pid and then never closes its
|
||||
// socket — the exact phantom-client shape the sweep exists to catch. Use the
|
||||
// socket path the daemon recorded in its lockfile (robust on Windows, where
|
||||
// a recomputed named-pipe path can differ from the one the daemon bound).
|
||||
const sockPath = await waitFor(() => readLockSocketPath(realRoot), 8000);
|
||||
const raw = net.createConnection(sockPath);
|
||||
raw.on('error', () => { /* ignore — we destroy it ourselves */ });
|
||||
try {
|
||||
// Consume the daemon hello (one line), then send our client-hello.
|
||||
// Generous timeouts: the unref'd sweep interval can stretch under a busy
|
||||
// event loop (engine init / a loaded CI box), so don't race it tight.
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let buf = '';
|
||||
const to = setTimeout(() => reject(new Error('no daemon hello within 15s')), 15000);
|
||||
raw.on('data', (c: Buffer) => {
|
||||
buf += c.toString('utf8');
|
||||
if (buf.includes('\n')) { clearTimeout(to); resolve(); }
|
||||
});
|
||||
});
|
||||
raw.write(JSON.stringify({ codegraph_client: 1, pid: 999_999, hostPid: null }) + '\n');
|
||||
|
||||
// The sweep should detect pid 999999 is dead and reap that client.
|
||||
await waitFor(
|
||||
() => readDaemonLog(realRoot).includes('Reaping client with dead peer (pid 999999'),
|
||||
15000,
|
||||
);
|
||||
} finally {
|
||||
raw.destroy();
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
it('exits on the inactivity backstop even while a client stays connected (#692)', async () => {
|
||||
// Backstop short, idle timeout long: with a client connected the idle timer
|
||||
// never arms, so only the inactivity backstop can take the daemon down.
|
||||
const env = { CODEGRAPH_DAEMON_MAX_IDLE_MS: '1500', CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '60000' };
|
||||
const server = spawnServer(tempDir, env);
|
||||
servers.push(server);
|
||||
sendInitialize(server.child, `file://${tempDir}`, 1);
|
||||
await waitFor(() => findResponse(server.stdout, 1), 10000);
|
||||
await waitFor(() => (readLockPid(realRoot) ?? 0) > 0, 8000);
|
||||
const daemonPid = readLockPid(realRoot)!;
|
||||
expect(isAlive(daemonPid)).toBe(true);
|
||||
|
||||
// Send nothing further — the client stays connected but idle. The backstop
|
||||
// should fire and the daemon should exit and clean up its lockfile.
|
||||
expect(await waitProcessExit(daemonPid, 12000)).toBe(true);
|
||||
expect(readDaemonLog(realRoot)).toContain('inactivity backstop');
|
||||
expect(fs.existsSync(path.join(realRoot, '.codegraph', 'daemon.pid'))).toBe(false);
|
||||
}, 30000);
|
||||
|
||||
it('daemon idle-times-out after the last client disconnects', async () => {
|
||||
const env = { CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '800', CODEGRAPH_PPID_POLL_MS: '200' };
|
||||
const server = spawnServer(tempDir, env);
|
||||
|
||||
Reference in New Issue
Block a user