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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
1b13d79d1d
commit
356f5f7659
+37
-7
@@ -418,9 +418,11 @@ export class Daemon {
|
||||
/**
|
||||
* Defense-in-depth against a daemon that outlives its clients (#692), for the
|
||||
* cases the refcount + idle timer miss because a socket close never arrives:
|
||||
* - **Inactivity backstop:** exit if no inbound traffic for `maxIdleMs` while
|
||||
* clients are still (nominally) connected. A phantom client sends nothing,
|
||||
* so it can't pin the daemon past this window.
|
||||
* - **Inactivity backstop:** after `maxIdleMs` with no inbound traffic, reap
|
||||
* the daemon — but ONLY if no connected client can be proven alive (see
|
||||
* {@link backstopShouldExit}). This is the sole phantom class the sweep
|
||||
* below can't catch: a client whose client-hello never arrived, so we have
|
||||
* no pid to check.
|
||||
* - **Liveness sweep:** drop any client whose peer process has died (per the
|
||||
* client-hello pids), which re-arms the idle timer once the last real
|
||||
* client is gone. Catches a dead peer within one sweep instead of waiting
|
||||
@@ -432,10 +434,7 @@ export class Daemon {
|
||||
if (this.maxIdleMs > 0) {
|
||||
const tick = Math.min(this.maxIdleMs, 60_000);
|
||||
this.maxIdleTimer = setInterval(() => {
|
||||
if (this.stopping || this.clients.size === 0) return; // idle timer owns the no-client case
|
||||
if (Date.now() - this.lastActivityAt >= this.maxIdleMs) {
|
||||
void this.stop('inactivity backstop');
|
||||
}
|
||||
if (this.backstopShouldExit(isProcessAlive)) void this.stop('inactivity backstop');
|
||||
}, tick);
|
||||
this.maxIdleTimer.unref?.();
|
||||
}
|
||||
@@ -446,6 +445,37 @@ export class Daemon {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether the inactivity backstop should reap the daemon right now.
|
||||
* Public + `isAlive`-injected for deterministic tests; the timer calls it each
|
||||
* tick with the real liveness probe.
|
||||
*
|
||||
* The backstop exists ONLY to catch a **phantom** client (#692) — one counted
|
||||
* but actually gone, whose socket-close was never delivered. It must never
|
||||
* reap a **live-but-quiet** session (connected, alive peer, just not querying):
|
||||
* doing so silently severed the shared daemon and degraded that session — and
|
||||
* any others sharing it — to an in-process engine. `lastActivityAt` only tracks
|
||||
* inbound query bytes, and MCP has no keepalive, so a genuinely-live session
|
||||
* trips the raw inactivity window after ~30 min of not being queried.
|
||||
*
|
||||
* So: once the inactivity window elapses, drop provably-dead peers (the same
|
||||
* check the periodic sweep runs), then reap the daemon only when NOT ONE
|
||||
* remaining client can be proven alive — i.e. every client left is an
|
||||
* unknown-pid connection the sweep can't verify. A single provably-alive
|
||||
* client keeps the daemon up. Has the sweep's side effect (drops dead peers).
|
||||
*/
|
||||
backstopShouldExit(isAlive: (pid: number) => boolean): boolean {
|
||||
if (this.stopping || this.clients.size === 0) return false; // idle timer owns the no-client case
|
||||
if (Date.now() - this.lastActivityAt < this.maxIdleMs) return false; // still within the window
|
||||
this.reapDeadClients(isAlive);
|
||||
if (this.clients.size === 0) return false; // sweep cleared them — idle timer takes over
|
||||
const anyProvablyAlive = [...this.clients].some((session) => {
|
||||
const peers = this.clientPeers.get(session);
|
||||
return peers != null && peers.pid !== null && !peerIsDead(peers, isAlive);
|
||||
});
|
||||
return !anyProvablyAlive;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop every connected client whose peer process is gone. Returns the count
|
||||
* reaped. `isAlive` is injected for testing. Clients with unknown pids (no
|
||||
|
||||
Reference in New Issue
Block a user