Skip to content

Rare 20s timeout under coverage, unrelated to the mock-state leaks in #190 #214

Description

@Bccorb

Follows #190, which fixed three state leaks and took the flake rate from roughly
15% to one failure in about 57 runs. This is the residual, which looks like a
different mechanism.

The observation

The husky pre-commit hook runs npm run coverage. On one commit it failed:

 ❯ tests/integration/internal/internal.spec.ts (25 tests | 1 failed) 20122ms
      × returns 500 when the anomaly query fails 20010ms

 FAIL  tests/integration/internal/internal.spec.ts
   > GET /internal/security/anomalies (additional branches)
   > returns 500 when the anomaly query fails
 Error: Test timed out in 20000ms.

Not reproduced in 16 subsequent coverage runs (8 parallel, 8 sequential), nor in
40 runs of vitest run (20 of each). So it is rare, and it only has been seen
under coverage.

Why it is probably not the #190 mechanism

The test is a single request against a handler that cannot hang on its own logic:

it('returns 500 when the anomaly query fails', async () => {
  (AuthEvent.findAll as any).mockRejectedValue(new Error('boom'));

  const res = await request(app).get('/internal/security/anomalies');

  expect(res.status).toBe(500);
});

getSecurityAnomalies in src/controllers/internalSecurity.ts is one
AuthEvent.findAll inside a try, with a catch that returns
500 {"error":"Failed to detect anomalies"}. The route is auth: 'access' plus
requireAdmin('read'); the spec mocks attachAuthMiddleware locally and
requireAdmin is mocked globally to call next(), so neither middleware can
swallow the request. There is no await in that path that can fail to settle.

A 20 second wait for that is not an ordering problem, which is what #190 was. It
looks more like the worker being starved: under --coverage the suite takes
~34s sequential against ~2s for vitest run, all 93 files share one worker under
--fileParallelism=false, and v8 coverage instrumentation is on top of that.

Worth checking

  • Whether it only happens under --fileParallelism=false --coverage, which is
    what the pre-commit hook used at the time. The hook now runs the parallel
    coverage command, so reproducing may need the old flag explicitly.
  • Whether an unhandled rejection is involved. mockRejectedValue sets the
    default implementation, so any straggler call to AuthEvent.findAll from an
    earlier test's fire-and-forget path would also reject, and an unhandled
    rejection can take the worker with it.
  • Whether the 20s testTimeout is simply too tight for the slowest coverage runs
    on a loaded machine, in which case this is a threshold to raise rather than a
    bug. Worth measuring the p99 duration of the integration specs under coverage
    before assuming it is a hang.
  • tests/integration/internal/internal.spec.ts is the second heaviest
    mockResolvedValueOnce user in the suite (14 of them), behind
    organizations.spec.ts (22). The one pre-Test suite is intermittently flaky, including in the sequential coverage run #190 timeout observed was in
    organizations.spec.ts. That may be a coincidence now that the queues are
    drained between tests, or it may mean something is still off in those two files
    specifically.

Why it matters

The hook runs the full coverage suite on every commit, so a rare flake still
blocks a commit that is fine, and the fix is to retry. That is the habit #190
was about breaking.

Acceptance

  • The cause is identified, or the timeout is shown to be a threshold problem and
    adjusted with the measurement that justifies it
  • 20 consecutive npm run coverage runs pass

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions