Skip to content

fix(test): waitForCondition can't enforce its budget, so slow polls fail opaquely #440

Description

@EricAndrechek

waitForCondition in tests/e2e/sdk/helpers.ts cannot enforce the budget it advertises. One slow fn() overruns it arbitrarily, so a latency spike surfaces as an opaque vitest Test timed out in 20000ms instead of a legible Condition not met after 6000ms.

This is a diagnosability defect, not a correctness one. The suite still passes on a machine with spare CPU, and on CI. What it costs is debugging time when something is slow — the failure names nothing, so the reader's first instinct is to suspect their own diff.

Re-scoped 2026-08-11. This issue was originally filed as "local make ci blocked" during #446. That framing was wrong: the frequency of those failures was environmental (CPU contention on one developer's machine — see "What this is not" below), and no repo change would have prevented them. The residue is the budget bug described here, which is machine-independent and worth fixing on its own merits. Priority dropped P2 → P3 to match.

The bug

export async function waitForCondition(fn, timeoutMs = 10_000, intervalMs = 250) {
  const start = Date.now();
  while (Date.now() - start < timeoutMs) {
    if (await fn()) return;          // ← unbounded: the deadline is checked
                                     //   BEFORE the call, never during it
    await new Promise((r) => setTimeout(r, intervalMs));
  }
  throw new Error(`Condition not met after ${timeoutMs}ms`);
}

The loop condition guards entry, not the call. Nothing interrupts fn() once it starts, so the effective ceiling is timeoutMs + (duration of the last fn()), unbounded.

Instrumented evidence from a real run:

poll#8 -> false (fn took   246ms, elapsed  4506ms)
poll#9 -> true  (fn took 12716ms, elapsed 17472ms)   # 6s budget, returned at 17.5s
poll#8 -> true  (fn took 23259ms, elapsed 28019ms)   # 10s budget, returned at 28.0s

A 10s budget ran 28s. Because that overshoots the caller's vitest testTimeout, the throw never happens — vitest kills the test first, and the resulting message says nothing about which condition failed or how long the poll actually waited.

chQuery (same file) has no timeout either, so it can be the slow call indefinitely.

Suggested fix

  1. Race fn() against the remaining budget rather than checking the clock only between polls, so the documented Condition not met after <n>ms actually fires at <n>.
  2. Give chQuery an AbortSignal.timeout(...) so a single stalled request fails as itself rather than as a mystery.

Neither makes anything faster. Both convert a silent wall into a failure that names its own cause.

Why it's worth doing anyway

The e2e suite's timing assumptions are tight enough that a merely busy machine can brush them, independent of any pathological process:

  • The 500-row test's own comment records 5104ms observed on APFS for the JetStream publish-fsync, against a 2500ms ack assertion elsewhere in the same file.
  • A healthy control run measured that fsync at 3763ms — real but not generous headroom.
  • CI infrastructure isn't immune to spikes either; fix(ci): cache Go modules once, not once per compile flavor #446's runs alone hit an artifact-service 403 and a coverage-directory collision.

When any of those fire, the current code turns a five-second diagnosis into a long one.

What this is not

SUPERSEDED 2026-08-11 — this section's conclusion was wrong. The failures were not
environmental. Root cause: undici 8.8.0-8.9.0 stalls for seconds before writing a request onto an
idle pooled socket (nodejs/undici#5600, fixed in 8.10.0); Node 26 bundles 8.9.0 while .nvmrc
pins Node 22 for CI. Local make test-e2e went 3-fail-in-5 to 0-fail-in-5 with a one-line change.
The measurements and reasoning below are left intact for the record, but see
this comment first.

Recorded so the next reader doesn't repeat the investigation. The original burst of failures was CPU contention on one developer's machine, not a repo defect:

Evidence Reading
Second developer, same hardware/OrbStack, fresh clone: suite green in 111.6s, the victim test at 5111ms Code fine
GitHub CI green — including 4 of 5 times the same tree failed locally Code fine
Idle ClickHouse: 1500 sequential queries p99 2ms; 600 fresh TCP connections p99 3ms Docker/network fine
Same queries during a loaded run: 2.5–17.8s, 14 stalls totalling 89.4s Contention
Load average 8.54 mean on a 14-core machine, one process averaging 82% of a core (203% peak) Cause

Ruled out with measurements, not reasoning: stale/reused containers (fresh testcontainer per run), Docker/OrbStack health, host disk pressure (295 GB free), the ingest path (single-row insert acked in 10ms; server logged it 519ms after the preceding batch), connection churn, and TCP SYN drops.

The moving target — a different test failing each run — is the signature of contention against the tightest-bounded assertion, not of a specific broken test. batching.test.ts was the most frequent victim only because its 20s ceiling is the tightest.

Reproduction

Not reliably reproducible on an idle machine, by design — that's the point. To see the overrun directly, instrument waitForCondition to log fn() duration per poll and run the suite under artificial CPU load.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/infraCI, build, deploy, Docker, releasebugSomething isn't working

    Type

    No type

    Projects

    Status
    Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions