Skip to content

flaky: session-persistence GC test races the async audit capture (~25% under load) #326

Description

@TerrysPOV

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:

  1. 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.
  2. 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.
  3. 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions