testing
All 2,592 tests passed and the job failed: a vitest sharding playbook
Three rounds of CI failures where every test was green and the job was red — vitest's worker→main reporter RPC timing out under file-count pressure. The ~110-file floor we split on (set below the lowest count we watched fail), the matrix design that contains it, and the playbook for the next regrowth.
By the X-GIS team 5 min read
Three times in six weeks, a CI run on this repo ended like this: every test passed, the job exited red, and the only error line was
Error: [vitest-worker]: Timeout calling "onTaskUpdate"Run 27242663060 is representative: 2,592/2,592 tests green, job failed — twice in a row. The first red read like a flaky test, so the instinct was to re-run — and it came back red identically: same green count, same reporter error line. A flake that reproduces to the byte on re-run is not a flake, and that is what turned attention from the tests to the channel reporting them. This post is the diagnosis, the numbers we measured, and the playbook that has kept it contained since.
The mechanism
vitest workers report progress to the main thread over an RPC channel
(birpc); onTaskUpdate is the call that streams task state back per test.
The call has a timeout on the order of 60 s [1][2]. Under load the main
thread — which is also running the reporter, aggregating output, and
coordinating pools — falls behind on servicing those calls. RPC state
accumulates as a run progresses, so the failure probability rises with the
number of test files a single worker pool processes: the last files of a big
run are the ones that time out. The symptom is exactly what we saw, and what
others report upstream: all tests pass, the run fails on the reporter
channel [1][3].
Two mitigations don’t require touching the pool at all:
--silent=true— per-test console chatter keeps the main thread busy exactly when workers are waiting on it; failure output is unaffected. (Spell the value explicitly: bare--silent <path>makes the CLI parser eat the path filter as the flag’s value.)- Fresh pools — separate vitest invocations each start a new worker pool: a new worker carries zero accumulated birpc backlog, so the timeout budget effectively resets at every leg boundary.
But the load-bearing variable is files-per-pool.
The measured threshold: ~110 files per pool
We never found a documented limit; we found ours empirically, three times:
| Date | Trigger | Files in the leg | Outcome | Action |
|---|---|---|---|---|
| baseline | one combined run | ~590 | timeouts | split into 5 path-filtered invocations |
| 2026-06-10 | test growth 226 → 262 | 262 | green tests, red job ×2 (run 27242663060) | split runtime into two ~130-file legs |
| 2026-06-16 | bug-hunt sweep | 156 + 156 | flaked again (run 27581620678) | four legs, each < 110 files |
| 2026-06-26 | demo-QA sweep | engine-render at 122 | green tests, red job (run 28186666263) | --shard=1/2 → two pools of ~61 |
~110 is a chosen floor, not a measured ceiling — and the table is honest about the gap. The lowest count that has actually flaked here is 122 (the 2026-06-26 engine-render leg), yet the two ~130-file runtime legs from 2026-06-10 held until later growth pushed them past it. So there is no clean line: the real edge drifts with runner speed and how much RPC a run has already accumulated (slow CI hits it sooner; locally these all pass). We set the tripwire at ~110 — deliberately below the 122 we have watched fail — and split on approach rather than probing for the exact edge on someone else’s PR. Treat it as a conservative budget, not a spec.
Splitting without silently losing coverage
Two splitting mechanisms, used for different reasons:
- Path partitions (
runtime/src/engine/projection runtime/src/engine/text,--excludefor the remainder) — readable legs with stable names, but you must prove the partition is complete: the excludes of one leg must be the includes of the others. vitest --shard=1/2[4] — when one directory alone exceeds the threshold. Deterministic, disjoint, and complete by construction: no glob arithmetic to get wrong.
The failure mode to fear is not a red job — it’s a silently green one.
This matrix shards by directory, so when a package moves (our map/ content
was extracted from runtime/), its tests stop matching every existing leg
and simply stop running, with nothing turning red. Every extraction now ships
with its own matrix leg, and the config carries the warning for the next one.
The matrix around the shards
- Each leg is a matrix entry with a distinct check name (
test (engine-render-a)),fail-fast: falseso one red shard doesn’t mask the others. - A single
test-resultaggregator withif: always()is the only name branch protection should ever require — leg names change every re-split; the aggregator treatssuccessandskippedboth as a pass, so path-filtered skips [5] never strand a PR. - The cost of parallel legs is real: the workspace build runs once per leg. Wall-clock ≈ the slowest leg (~84 s) instead of the serial ~189 s; the trade is runner-minutes for latency.
The playbook
Written into the workflow file itself, next to the matrix:
- A leg flakes
onTaskUpdateafter growing past ~110 files → split that leg (path partition if a directory boundary exists,--shard=1/Nif not). - Never merge legs back to “rebalance” — the splits exist for the RPC ceiling, not for tidiness, and a merge reintroduces the flake months later on someone else’s PR.
- New package or extracted directory → new leg, in the same commit.
The general lesson: when a test job fails but every test passed, suspect the reporting channel before the tests — and treat its capacity as a budget that test-suite growth spends.
References
- vitest-dev/vitest, issue #6479 —
[vitest-worker]: Timeout calling "onTaskUpdate"on CI while all tests pass. - vitest-dev/vitest, issue #8164 — the 60 s birpc default behind the timeout.
- vitest-dev/vitest, issue #4497 —
[birpc] timeout on calling "onTaskUpdate". - vitest, CLI
--sharddocumentation. - dorny/paths-filter, conditional job execution by changed paths.
Read next
testing
8 min read
A gate you have never watched fail is decoration
We broke instanced batching on purpose to watch a brand-new CI gate go red — 100,000 draw calls — before trusting its green. Why new gates need a demonstrated failure, and why this one asserts dc(10k) === dc(100k) instead of pinning an exact count.
testing
4 min read
What a software GPU can and cannot verify
Our CI runs real shaders on SwiftShader — but only some render claims survive a CPU rasterizer. The dividing line we use (compute parity and shader compilation: yes; full-pipeline pixel parity: no), and the red-baseline incident that taught us to blame the software GPU last.
testing
4 min read
Offline tile mirrors for deterministic map e2e
Our CI container's browser can't reach the internet, but the final render gate needs a real 117-layer basemap. On mirroring styles, tiles, glyphs and sprites locally — and the traps: SPA fallback serving HTML as tiles, unencoded fontstacks, and settling on signals instead of sleeps.