Environment
main at 5a06163d. Found while adding the smoke suite in #483.
This issue was filed with the wrong diagnosis and has been rewritten. The original text blamed an unbounded per-worker leak in vitest and called the cause unknown. It is not a vitest defect; it is one line in a spec added by #480. The bisection and the fix are below. The wrong version is kept in the edit history because the way it was wrong is the interesting part — see the last section.
The defect
test/utils/indistinguishable-snapshots.spec.ts (added in #480) collects every .snap file under test/:
readdirSync(root, { recursive: true, encoding: 'utf8' })
.filter(f => f.endsWith('.snap'))
{ recursive: true } has no ignore option. It walks everything and returns every path in a single array — and test/ contains installed dependency trees: test/nuxt/node_modules, plus test/smoke/fixture/node_modules once #483 adds that workspace package. The array is hundreds of thousands of strings, built to find 212 files.
Measured on a 4-core machine:
|
wall time |
result |
readdirSync('test', { recursive: true }) |
> 120 s, killed |
never returned |
| pruned walk |
2 ms, 46 MB RSS |
the same 212 files, the same two directories |
The worker died at the heap limit. Vitest reported it as:
FATAL ERROR: Ineffective mark-compacts near heap limit
Error: [vitest-pool]: Worker forks emitted error.
Caused by: Error: Worker exited unexpectedly
with no file name, which is what made it look like a suite-wide condition rather than one spec.
Why it looked like it was everywhere
| run |
files |
result |
main @ 6f0e71ef |
314 |
green |
| #483, first push |
315 |
OOM |
| #483, after moving the module suite out |
314 |
OOM |
--project vue alone, on main |
153 |
152 passed, 1 error |
--project vue, --max-old-space-size=1024 |
153 |
152 passed, 1 error |
The last row is the one that named it. If memory accumulated across files, an eight-times-smaller heap would have died eight times sooner. It died at exactly the same point, so exactly one file was responsible, and --reporter=verbose diffed against the collected list said which.
main was green because the cost scales with what is installed under test/: test/nuxt/node_modules alone stayed (barely) under the runner's 4 GB. #483 added a second dependency tree there and pushed it over — which is why the failure arrived with a pull request that never touched the guard.
Fix
Shipped in #483. snapshotFiles() is a hand-rolled walk that prunes as it descends:
const PRUNED = new Set(['node_modules', '.nuxt', '.output', '.data', '.cache', 'dist'])
Snapshots never live in any of those, so nothing is lost. A regression test builds a temp tree containing node_modules/pkg/__snapshots__/Vendored.spec.ts.snap and .output/server/Built.spec.ts.snap and asserts neither is returned.
What to take from it
The guard was reviewed six times in #480 — by /review and five reviewers — and every one of them, including the two who ran it, saw { recursive: true } and read it as "walks the tree". It does. The cost of walking this tree was invisible because nothing about the code says how big the tree is, and the spec passed everywhere it was run.
Two things would have caught it earlier and neither existed:
- the failure names nothing.
Worker exited unexpectedly with no file is the whole reason this took an afternoon. Vitest cannot do better on a SIGKILL-class death, but a CI step that ran each project separately would at least have narrowed it to vue in one run.
- the smaller-cap bisect is cheap and was not tried first.
NODE_OPTIONS=--max-old-space-size=1024 turns "eight minutes, then a mystery" into "the same failure, four times faster, and now it is one file". Worth writing into the testing notes as the first move on any OOM here.
The second is a real gap in .github/contributing/testing.md and is worth a follow-up on its own.
Environment
mainat5a06163d. Found while adding the smoke suite in #483.The defect
test/utils/indistinguishable-snapshots.spec.ts(added in #480) collects every.snapfile undertest/:{ recursive: true }has no ignore option. It walks everything and returns every path in a single array — andtest/contains installed dependency trees:test/nuxt/node_modules, plustest/smoke/fixture/node_modulesonce #483 adds that workspace package. The array is hundreds of thousands of strings, built to find 212 files.Measured on a 4-core machine:
readdirSync('test', { recursive: true })The worker died at the heap limit. Vitest reported it as:
with no file name, which is what made it look like a suite-wide condition rather than one spec.
Why it looked like it was everywhere
main@6f0e71ef--project vuealone, onmain--project vue,--max-old-space-size=1024The last row is the one that named it. If memory accumulated across files, an eight-times-smaller heap would have died eight times sooner. It died at exactly the same point, so exactly one file was responsible, and
--reporter=verbosediffed against the collected list said which.mainwas green because the cost scales with what is installed undertest/:test/nuxt/node_modulesalone stayed (barely) under the runner's 4 GB. #483 added a second dependency tree there and pushed it over — which is why the failure arrived with a pull request that never touched the guard.Fix
Shipped in #483.
snapshotFiles()is a hand-rolled walk that prunes as it descends:Snapshots never live in any of those, so nothing is lost. A regression test builds a temp tree containing
node_modules/pkg/__snapshots__/Vendored.spec.ts.snapand.output/server/Built.spec.ts.snapand asserts neither is returned.What to take from it
The guard was reviewed six times in #480 — by
/reviewand five reviewers — and every one of them, including the two who ran it, saw{ recursive: true }and read it as "walks the tree". It does. The cost of walking this tree was invisible because nothing about the code says how big the tree is, and the spec passed everywhere it was run.Two things would have caught it earlier and neither existed:
Worker exited unexpectedlywith no file is the whole reason this took an afternoon. Vitest cannot do better on aSIGKILL-class death, but a CI step that ran each project separately would at least have narrowed it tovuein one run.NODE_OPTIONS=--max-old-space-size=1024turns "eight minutes, then a mystery" into "the same failure, four times faster, and now it is one file". Worth writing into the testing notes as the first move on any OOM here.The second is a real gap in
.github/contributing/testing.mdand is worth a follow-up on its own.