Uncovered while fixing the settings.cache cluster of #304. Filing separately because it is a distinct defect that the crash was masking, and it is not fully resolved.
Symptom
src/__tests__/session-persistence-integration.test.ts → "GC tick drops on-disk expired entries while leaving live in-memory buckets intact" fails intermittently:
650 | expect(lost).toBeDefined();
error: expect(received).toBeDefined()
Measured rate: 0/12 failures on an idle machine, 2/8 on a busy one. Load-sensitive, not deterministic.
Root cause (partially addressed)
The test drove a GC pass and then sampled the captured audit array with a bare events.find(...):
await plugin._runGCTickForTests();
const gcAudit = events.find((e) => e.event === "mcp_session_gc");
const lost = events.find((e) => e.event === "mcp_session_lost_on_restart" && ...);
Audits reach that array asynchronously — _audit() (src/plugins/mcp-multiplexer/session-persistence.ts:114) calls getMcpBridge().audit(), and the test's capture wrapper pushes on that call. Awaiting the GC tick does not guarantee the audit has landed, so the sample races.
Confirmed by instrumenting: on a failing run the event is eventually present (["mcp_session_lost_on_restart","ttl_expired","pty-stale"]) — the assertion just ran too early.
The rest of the file already uses a polling waitForAudit helper for exactly this reason; the GC assertions were the ones that didn't.
What was done
The #304 cluster-1 PR converts both GC lookups to awaited polling, adding a waitForAuditMatch predicate variant (the existing helper matches on event name only, and lost needs to match on reason + ptyId too).
What remains
That removed a genuine race and reduced the failure rate, but did not eliminate it under load — still ~2/8 with other work running on the machine. The remaining suspicion is the 2000ms polling budget being exceeded under contention, but that is not proven.
Options, roughly in order of preference:
- Give the GC path a deterministic completion signal — have
_runGCTickForTests() resolve only after its audits have been dispatched, so no polling is needed at all. Best fix; removes the timing assumption rather than widening it.
- Raise the polling budget for this assertion specifically. Cheap, but keeps a wall-clock assumption in a test that will run on shared CI runners.
- Make the audit capture synchronous in tests.
Why it matters
#304 widens the CI test scope toward the full suite. A test that fails ~25% of the time under load turns CI red at random once this file is in scope, which is exactly the failure mode #304 exists to remove. This should be closed before src/__tests__/ is added to the CI path list.
Related: #304 (test hygiene), #324 (no typecheck step).
Uncovered while fixing the
settings.cachecluster of #304. Filing separately because it is a distinct defect that the crash was masking, and it is not fully resolved.Symptom
src/__tests__/session-persistence-integration.test.ts→ "GC tick drops on-disk expired entries while leaving live in-memory buckets intact" fails intermittently:Measured rate: 0/12 failures on an idle machine, 2/8 on a busy one. Load-sensitive, not deterministic.
Root cause (partially addressed)
The test drove a GC pass and then sampled the captured audit array with a bare
events.find(...):Audits reach that array asynchronously —
_audit()(src/plugins/mcp-multiplexer/session-persistence.ts:114) callsgetMcpBridge().audit(), and the test's capture wrapper pushes on that call. Awaiting the GC tick does not guarantee the audit has landed, so the sample races.Confirmed by instrumenting: on a failing run the event is eventually present (
["mcp_session_lost_on_restart","ttl_expired","pty-stale"]) — the assertion just ran too early.The rest of the file already uses a polling
waitForAudithelper for exactly this reason; the GC assertions were the ones that didn't.What was done
The #304 cluster-1 PR converts both GC lookups to awaited polling, adding a
waitForAuditMatchpredicate variant (the existing helper matches on event name only, andlostneeds to match onreason+ptyIdtoo).What remains
That removed a genuine race and reduced the failure rate, but did not eliminate it under load — still ~2/8 with other work running on the machine. The remaining suspicion is the 2000ms polling budget being exceeded under contention, but that is not proven.
Options, roughly in order of preference:
_runGCTickForTests()resolve only after its audits have been dispatched, so no polling is needed at all. Best fix; removes the timing assumption rather than widening it.Why it matters
#304 widens the CI test scope toward the full suite. A test that fails ~25% of the time under load turns CI red at random once this file is in scope, which is exactly the failure mode #304 exists to remove. This should be closed before
src/__tests__/is added to the CI path list.Related: #304 (test hygiene), #324 (no typecheck step).