* fix(test-perf): the module tracer never flushed under Vitest 4, so a traced run wrote nothing
scripts/test-perf/trace-setup.ts flushed its counters only from process.on('exit') and a 15s
interval. Vitest's forks pool kills its workers rather than letting them exit, so the exit
handler never ran, and the README's own workflow - "trace one file at a time" - is a 5-10s run
that never reached the interval either.
The failure mode is the expensive kind: .test-perf/trace was never created at all, and
trace-report.mjs answered "no .test-perf/trace - run a traced suite first", which reads as
operator error rather than as a dead instrument. Since graph.mjs's model of what a worker loads
is validated against this tracer, the validation was unreproducible for as long as it was dead.
Flush from afterAll, which runs inside the worker before the pool can kill it. The exit handler
and the interval stay as backstops.
Verified on origin/main in a clean worktree: before the change the documented workflow creates no
trace directory; after it, trace-report.mjs prints a real report (1 worker snapshot, 45 distinct
modules).
The regression test spawns a real traced run and requires a snapshot naming the module the
fixture imported - asserting that this file contains the string "afterAll" would pass just as
happily with the hook registered somewhere it never fires. Both mutants die for their own
reason: reverting to the old exit-only flush fails on "expected 0 to be greater than 0", and a
flush that writes an empty snapshot fails on the module-name assertion. It runs in 1.7s.
TESTPERF_TRACE_DIR is new, so that test can redirect its snapshots instead of clobbering a trace
someone is reading.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CuaBZXbMVSzwPkpEtHhvxh
* fix(test-perf): name the traced project apart, or its dep cache eats the unit suite's
Audit round 1 on this PR found the regression test was deploy-blocking, and reproducing it
independently was worse than the report: 16 of 53 files red, not 5 of 6.
Vitest keys a project's dep-optimizer cache on sha1(projectName) and Vite's config hash includes
the plugin names, so the traced project - which spread the unit project and kept its name while
adding the tracer plugin - resolved to the SAME node_modules/.vite/vitest/<hash>/deps_ssr as the
normal unit suite and hashed differently. Vite responds by deleting and re-bundling that
directory, while unrelated workers in the same shard are importing chunks out of it:
"Cannot find module '.../deps_ssr/prom-client.js'". assert-shard-ran.mjs would not have caught
it either - losing ~400 of ~5539 tests stays inside its band.
Naming the traced project unit-trace gives it its own cache dir. Same 53-file selection:
53 passed, 0 deps_ssr errors, two cache dirs on disk.
Also from the audit:
- trace-report.mjs now honours TESTPERF_TRACE_DIR, which trace-setup.ts already read. Exporting
it used to reproduce this PR's own bug: snapshots on disk, report says "run a traced suite
first".
- The trace dir is cleared at the start of each traced run. The report SUMS every snapshot it
finds and snapshots outlive their run, so a second run silently reported roughly doubled
numbers - a wrong number rather than a missing one.
- Snapshots are keyed by a per-worker id rather than bare pid. forks gives one process per file
so pid was unique there, but threads puts every worker in one process (measured: 3 files gave
3 snapshots on forks, 1 on threads), and per-file flushing would have several workers writing
one path concurrently.
- The flush no longer swallows its error. A silently unwritten snapshot is the exact failure
this PR exists to close.
- The child run's interval backstop is pushed to an hour, so afterAll is the only path that can
satisfy the test. Otherwise a child that lives past 15s passes it through the timer with or
without the fix.
- README: the traced invocation is --project unit-trace, and the paragraph claiming a multi-file
traced run keeps only the last file's counters is no longer true.
Fixing the clear-at-start introduced its own regression, caught by re-running the battery: it
removed the caller-supplied directory rather than its snapshots, turning a clean assertion into
an ENOENT crash. It now unlinks *.json only.
Six mutants, six deaths, each on its own assertion: no afterAll -> expected 0 to be greater than
0; empty snapshot -> expected [] to include the fixture module; project renamed back to unit ->
startup error, no "1 passed"; clearing disabled -> expected 2 to be 1; bare pid -> filename shape.
The bare-pid case is killed by a filename-shape PROXY and is labelled as one in the test - the
real property needs a threads-pool child with several workers, which these tests do not build.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CuaBZXbMVSzwPkpEtHhvxh
* fix(test-perf): close the rename's own coverage hole, and stop the fix round's new sharp edges
Delta re-audit of the previous round. Its verdict was "safe to merge"; its findings were not
empty, and the findings are what decide.
The gap that mattered: every assertion named unit-trace, so renaming the project back to unit in
BOTH the config and the test passed green while fully restoring the cache collision that took 16
of 53 files red. The previous tip was literally that configuration and shipped green. There is
now a case that runs the traced config with --project unit and requires it to FAIL with "No
projects matched", so the two spellings can no longer be reverted together in silence.
The bare-pid guard was a filename-shape proxy, and the audit was right that the real property is
cheap to test: two more three-line fixtures and one --pool=threads child. It now asserts the DATA
survived - all three fixture modules present in the merged snapshot - rather than the filename.
Reverting to a bare pid loses two workers' counters to a clean last-wins overwrite and the case
goes red.
That mutant also exposed a coupling the audit did not see: the writer's filename format and the
clear's delete predicate were two copies of one rule, and with a bare pid the clear silently
stopped matching, so a second run summed on top of the first. Both now come from
trace-snapshot-name.ts.
Other audit findings:
- trace-report.mjs created no .test-perf/ before writing trace.json, so with TESTPERF_TRACE_DIR
set on a fresh clone the documented workflow crashed with a raw ENOENT after doing all the
merge work. mkdirSync first.
- TESTPERF_TRACE_INTERVAL_MS ran through bare Number(), so abc/empty/-5 all became a 1ms
synchronous whole-snapshot write loop inside every worker - measured 45 fires in 50ms - which
charges its own cost to the measurement. Validated with the same idiom vitest.config.mts uses.
- The snapshot clear deleted every top-level *.json in a caller-supplied directory. Scoped to
this tool's own filename shape, and wrapped: a throw there happens at config load and would
abort the whole run with a raw stack.
- trace-config.mts advertised a bench.mjs invocation that its own rename breaks (bench hardcodes
--project unit). Docstring corrected rather than widening bench's filter, which would drag
unit-native into every yardstick run.
- Two comments corrected against measurement: the flush fires once per test FILE, not per suite,
and the thread-pool failure is a silent last-wins overwrite, not a truncated read.
- README now discloses what the rename costs - a second 87MB dep bundle - and that under --watch
only the first re-run clears.
Seven mutants, six dead on their own assertion: no afterAll -> expected 0 to be greater than 0;
empty snapshot -> expected [] to include; clearing disabled and bare pid -> expected 2 to be 1;
full rename -> expected +0 not to be 0. M7, reverting the interval validation, SURVIVES: no test
supplies garbage to that knob, so it is an unpinned defensive guard and is called one here rather
than counted as covered.
🔴 One of the previous round's mutants did not actually apply - backtick escaping - and its
"survived" reading was a fact about the harness, not the code. Every mutation in this round
asserts its own target was found before the run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CuaBZXbMVSzwPkpEtHhvxh
* fix(test-perf): pin the two behaviours the last round asserted but never checked
Round 3 of the delta audit. Verdict was "ship"; it still found two guards that no test could see,
and both are now killed by a mutant.
- The clear's PROTECTIVE half was unpinned. Widening SNAPSHOT_FILE_RE back to /\.json$/ left every
test green while destroying a caller's other JSON - exactly what the README promises it will not
do, and the whole reason trace-snapshot-name.ts exists. Only the drift direction was covered.
Test 1 now writes important-notes.json into the trace dir and requires it to survive.
- The interval validation was called an unpinnable defensive guard. That was giving up early: the
audit pointed out this PR had just established the pattern for pinning it. resolveIntervalMs is
now a pure helper in the shared module with a nine-case table, no child process, and reverting
it turns seven cases red.
Fixing that found a real hole in my own guard: Number.parseInt reads '1e10' as 1, which is the
1ms flush loop the check exists to prevent, and a case bare Number() handled correctly. It now
uses Number with an n >= 1 bound.
Also from the audit:
- The clear caught its error around the whole loop, so one undeletable snapshot abandoned every
other stale file - and the survivors are summed into this run's numbers. Now per entry.
- The docstring said a typo'd --project unit "fails loudly". True, but not harmlessly: the clear
runs at config load, before the project filter, so that invocation empties the trace directory
first. Said so.
- The suffix comment claimed once per worker; measured once per test FILE under isolation.
- Fixture c's stated rationale was wrong - two files already discriminate, because the tracer
re-initialises per file rather than per worker, so a scheduler batching them cannot hide an
overwrite. Deleted it and the threads case runs on two.
Nine mutants, nine deaths, each on its own assertion: no afterAll / bare pid / writer filename
drift -> expected 0 to be greater than 0; empty snapshot -> expected [] to include; clearing
disabled -> expected 2 to be 1; full rename -> expected +0; interval validation reverted ->
expected NaN to be 15000; regex widened -> expected false to be true. Every mutation asserts its
target matched exactly once before the run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CuaBZXbMVSzwPkpEtHhvxh
* fix(test-perf): the interval guard had an upper bound problem, and the test asserted the broken value
Round 4 of the delta audit, and it caught my round-3 fix landing on the same pathology from the
other side.
resolveIntervalMs bounded the input below but not above. setInterval stores its delay in a 32-bit
signed int, so anything over TIMEOUT_MAX (2147483647) is clamped to 1ms - measured on Node
24.19.0, 1e10 fired 281 times in a 300ms window with "TimeoutOverflowWarning: Timeout duration was
set to 1", and it reproduces end to end in a real traced run. That is the ~900 writes/second
inside every worker that the function's own docstring exists to prevent, reachable from the "make
it huge so it never fires" direction. Worse, the it.each table I added asserted 1e10 -> 1e10 as
the correct answer, so the defect was enshrined as deliberate. Bounded at both ends now, with
cases at MIN-1, MIN, MAX, MAX+1 and 1e10.
Four behaviours the audit showed were unpinned or unmodelled:
- The per-entry catch in the clear was real and load-bearing - 0 survivors vs 5 - and no test
could tell it from the pre-fix shape. The clear is now an exported clearStaleSnapshots with a
synthetic-directory test including an EISDIR entry; reverting it to a rethrow goes red.
- mergeSnapshots in the test had stopped modelling trace-report.mjs: it filtered by the snapshot
regex while the real reader took any *.json. Running the real reader against the directory this
PR's own test creates gave "20 distinct modules | NaN executions | NaNs self time". The reader
now accepts snapshots by SHAPE rather than extension - it is a .mjs file and the shared
constants are TS, so a value check is what cannot drift - and a test spawns it against a foreign
file and requires no NaN.
- Dropping the regex's $ anchor survived green: it still matches 1234-abcd.json.bak and .json.swp,
i.e. editor and backup files next to a caller's data. The survives-set now includes one.
- The summing merge survived green too, because with clearing on there is only ever one snapshot.
It is now pinned where two genuinely coexist - the threads case - against the sum of a module
common to both files.
Also: TESTPERF_TRACE_DIR used ?? in all three readers, so an exported-but-empty value kept '',
mkdirSync('') threw, no snapshot was written and the report said "run a traced suite first" - the
dead-instrument failure this whole change exists to remove. Now || with a trim, single-sourced.
And trace-setup.ts's header rationale claimed globalThis makes counters survive across files;
measured, each file writes a disjoint snapshot on both pools and the totals are correct because
the report SUMS. The stale per-worker framing is gone from the report's label too - that count is
test files.
Thirteen mutants, thirteen deaths, each on its own assertion. Every mutation asserts its target
matched exactly once before the run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CuaBZXbMVSzwPkpEtHhvxh
* fix(test-perf): guard the tool, not the test's mirror of it — closing three named gaps
Round 5 of the delta audit named three fixes and a mechanical closing condition: re-run five
specific mutants rather than open a sixth round. All three are here and all five now die.
The pattern the audit named, which is what actually cost the last two rounds: the tests were
guarding their own mirror of the tool rather than the tool.
- The interval table derived every expectation from the constant it was testing, so it could no
longer see a WRONG CONSTANT. DEFAULT -> 1 and MAX -> 2**31 both survived a green suite, and both
are the 1ms flush loop the function exists to prevent - reachable on the default path a
developer hits by not setting the variable at all. That is round 3's defect one notch out,
introduced by round 3's own fix. Two literal rows now sit beside the derived ones.
- The shipped reader's sum was unpinned. mergeSnapshots in the test is a reimplementation, and it
was the only thing any mutant could see; the single test that ran the real reader had a
one-snapshot fixture, where summing and overwriting are identical. The fixture now holds two
snapshots naming one module, asserted against the totals the tool itself prints.
- The .mjs reader cannot import the shared TS resolver, so it carries its own copy of the
empty-string rule - the same drift the shared module exists to stop, one layer out where the
module cannot reach. Reverting it to ?? was green while reintroducing half the dead-instrument
failure this PR removes. A second spawn with TESTPERF_TRACE_DIR='' closes it.
Free corrections from the same round: trace-config.mts still documented one snapshot "per worker"
while trace-snapshot-name.ts three files away says that is not true; a test title and the README
carried the same stale framing; fixture b's comment counted three fixtures when there are two.
Closing condition, each mutation asserted applied and its mutant text confirmed on disk before the
run: DEFAULT->1 dies on `expected 1 to be 15000`; MAX->2**31 on `expected 2147483648 to be 15000`;
reader loads += -> = and selfMs += -> = both on the printed totals; .mjs ?? on the fallback path.
28 tests, 4.6s.
Not taken, deliberately: the report still exits with a raw ENOTDIR stack when TESTPERF_TRACE_DIR
names a regular file, and isSnapshot's guards are wider than the one test covering them. Both were
called optional, neither is a live defect, and adding more scaffolding to a gitignored dev tool is
the thing this ladder has been overdoing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CuaBZXbMVSzwPkpEtHhvxh
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
13 KiB
Unit-suite performance tooling
Measurement for the unit suite, and the dashboard that tracks the isolation migration. Everything
here writes to .test-perf/; nothing is hand-maintained.
⚠️ .test-perf/ is gitignored by this change — the entry is added in the same commit as these
scripts. Until it lands, run output sits untracked in git status, one broad git add away from
being committed by someone else. That has happened in this repo before, so if you are cherry-picking
these scripts rather than merging, take the .gitignore line with them.
Rebuild the dashboard
node scripts/test-perf/graph.mjs # static import graph + vi.mock inventory
node scripts/test-perf/dashboard.mjs # -> .test-perf/dashboard.html
graph.mjs writes inventory.json and closures.json. graphModules is what a worker really
loads: lazy import() is not followed except from a test file itself, a vi.mock factory
without importOriginal truncates the subtree behind it (the mocked module is still counted — vitest
transforms it), and an import type / import { type X } statement is erased before the graph sees
it. graphModulesRaw keeps the naive count for the bundler question.
The two differ by 75x on a page-render test, in the direction that puts the cheapest files at the
top of a ranking: four src/tests/pages/apps/** files counted 1,655–1,670 naively and load 13–26,
while their measured worker time ranks 202–572 of 1,065. Do not rank test cost by the raw count.
⚠️ The test-file exception is load-bearing, not a detail. dynamic(() => import(...)) in a page never
runs; await import('~/pages/...') in a test body is the point of the test. Treating them the same
makes those four files either the top of the ranking or 1 module each, and both readings are wrong.
Validated by diffing the model against a transform-hook trace as sets: 3 of 5 files exact with empty diffs both ways, 16 modules of symmetric error across 691 traced (2.32%). Counts alone agreed often enough to hide two of the four causes above — a count agreeing is not the rule agreeing. Ground truth exists for 5 files of 1,065; that is a sample, not a proof.
Open .test-perf/dashboard.html in a browser. It shows where the run's time goes, how far the
migration has got, which modules sit in the most test closures, and every run recorded so far.
Record a run
Any vitest invocation gains per-file timings by adding the reporter:
pnpm exec vitest run --project unit --reporter=default \
--reporter=./scripts/test-perf/reporter.mjs
Output lands at .test-perf/runs/<TESTPERF_LABEL>.perf.json, with per-file collect (import),
setup, duration and pass/fail counts.
🔴 TESTPERF_LABEL does not reach a run started through the dev-server test queue — the daemon
spawns with its own environment, so a queued run always writes run.perf.json. Rename it afterwards,
or run vitest directly when the label matters.
The yardstick
A full run costs minutes and has to be serialised against every other agent on the box, which makes it useless as an edit→measure loop. Use the fixed 90-file subset instead:
node scripts/test-perf/bench.mjs --label before --workers 4
node scripts/test-perf/bench.mjs --label after --workers 4
node scripts/test-perf/bench.mjs --label after-noiso --workers 4 --no-isolate
The subset is stratified across closure sizes (--make-subset regenerates it from the inventory), so
slimming one fat chain shows up rather than being averaged away.
🔴 The subset's job is to be STABLE, not to make measurements comparable across time. It fixes what is measured; it does nothing about when. Two yardstick runs hours apart are not comparable — see the noise floor below — so always pair a before and an after inside one window, and never quote a yardstick number against one from an earlier session.
⚠️ And a null from the yardstick is not evidence of no effect: it is 90 of 1,065 files, so a change concentrated outside the sample reads as flat. Two changes measured flat here were later shown by a full paired run to be real.
⚠️ Wall clock on a busy box swings up to ~68%. Always pair a measurement with a control run taken in the same window; never compare against a number from a different session. With several agents sharing the box the noise floor was measured at ±30% — the same configuration gave 53.3s and 76.6s in one session. Below ~20%, prefer phase numbers (collect/setup/test worker-seconds) over wall clock, or do not claim the win.
🔴 The yardstick systematically understates isolate: false, and cannot be used to judge it.
That flag amortises the registry build across the files a worker runs, so its win scales with
files-per-worker. 90 files at 16 workers is ~6 files each — a registry built once instead of six
times — and measured 1.65x. The full 1065-file suite at 8 workers is ~133 files each, and measured
16x on the same phase. Judge isolation on a full run or not at all.
🔴 No --no-isolate number is quotable without a per-file collected count beside it. Its damage
is not confined to failing assertions: files silently collect ZERO tests, and how many is
width-dependent — 9 of 90 at forks/4 workers, 14 of 90 at threads/4, 0 of 90 at threads/16, same
input. A summary line cannot show you this. reporter.mjs writes per-file passed/failed/skipped for
exactly this comparison.
Config sweep
node scripts/test-perf/sweep.mjs --workers 4,16 --repeat 2
Runs the subset across pool × isolation × worker count, back to back, and keeps the faster of each
repeat — contention only ever makes a run slower. Results in .test-perf/sweep.json.
Module tracer
The static graph cannot tell you what actually ran: a vi.mock factory stops the real module and its
whole subtree from executing. The tracer brackets every first-party module body, so it counts real
executions and separates a module's own cost from its imports'.
pnpm exec vitest run --project unit-trace --config scripts/test-perf/trace-config.mts \
--max-workers=1 path/to/one.test.ts
node scripts/test-perf/trace-report.mjs
🔴 The project is unit-trace, not unit, and the name is load-bearing: Vitest keys the
dep-optimizer cache on sha1(projectName) while Vite's config hash includes the plugin names, so a
traced project called unit points at the normal suite's deps_ssr and makes Vite delete and
re-bundle it. Measured while building this: a 53-file selection containing a traced child run went
16 files red with Cannot find module '…/deps_ssr/prom-client.js'.
The price of that separation, so it is not a surprise: the traced project mints a second full dep
bundle — measured at 87 MB / 44 files under sha1('unit-trace'), the same file count as the
normal suite's, built from one trivial fixture (cold 2.5s, warm 1.3s). It lands in the
node_modules/.vite cache the main branch saves. The alternative is corrupting concurrent runs,
so this is the right trade, but it is not free.
⚠️ bench.mjs cannot drive this config: it hardcodes --project unit. Invoke vitest directly, as
above.
⚠️ Under isolate: true, globalThis is reset between test files. That used to mean a multi-file
traced run kept only the last file's counters; it no longer does, because each worker now flushes
its own snapshot and trace-report.mjs sums them. The remaining reason to trace one file at a time
is attribution — the merged report cannot tell you which file loaded what.
The trace directory's snapshots are cleared at the start of every traced run (in
trace-config.mts), because the report sums every *.json it finds and stale snapshots from a
previous run would silently double the numbers. Only files matching this tool's own
<pid>-<suffix>.json shape are removed, so pointing TESTPERF_TRACE_DIR at a directory holding
other JSON does not destroy it. Two traced runs must not overlap; give one its own
TESTPERF_TRACE_DIR if they must. Both the tracer and the report honour that variable.
⚠️ The clear happens once per process, at config load — so under vitest --watch only the first
re-run clears, and later ones accumulate. Tracing is a vitest run workflow; if you watch, expect
inflated totals.
🔴 The counters flush from afterAll, and that is not a detail: forks KILLS its workers instead
of letting them exit, so the process.on('exit') flush this started life with never ran. Under
Vitest 4.1.11 the workflow above wrote nothing at all — no .test-perf/trace/, and
trace-report.mjs answering "run a traced suite first", which reads as operator error rather
than as a dead instrument. The 15s interval did not cover it either, because "trace one file at a
time" is a 5–10s run. scripts/test-perf/__tests__/trace-flush.test.ts spawns a real traced run
and fails if no snapshot lands, so it cannot go quiet again; TESTPERF_TRACE_DIR redirects the
snapshots so that test cannot clobber a trace you are reading.
This is the tracer the graph model is validated against (see "Rebuild the dashboard" above), so while it was dead that validation was unreproducible — treat any trace-vs-graph claim older than this note as unverified.
Per-worker union
node scripts/test-perf/graph.mjs # also writes .test-perf/closures.json
node scripts/test-perf/order.mjs
Under isolate: false a worker keeps one module registry for its whole lifetime, so its cost is the
union of what its files import, not the sum. order.mjs reports the mean per-worker union for
alphabetical order against a graph-affinity order, at several worker counts.
Measured 2026-08-15: affinity ordering is not worth having. At 31 workers the mean per-worker
union was 1084 modules alphabetically and 1139 by affinity — slightly worse. Alphabetical order
already groups by directory, and directory already correlates with the import graph. The sequencer
that applied the affinity order was deleted; order.mjs is kept because the union report is the
number that bounds what isolate: false can deliver.
🔴 Both union figures above predate the honest counts and must be re-run before either is quoted as a level. The conclusion is a ratio between two orderings under one counting convention, so it survives; 1084 and 1139 do not.
Two other things measured and found not to help, recorded so nobody spends the hour again:
NODE_COMPILE_CACHE— three yardstick runs under--no-isolate: cold 26.8s, warm 51.4s, warm again 33.9s. No signal, and the cache directory did fill (5.3 MB), so it was active. vite-node does not evaluate through the loader that cache covers.vmThreads— looked 1.16x faster thanforkswith a clean 90-file run, twice. It is a race: on the five files that executesharp, worker counts 2 and 3 crash or pass on identical input. CI's 4 vCPU resolves to ~3 workers, the width measured at 1-in-3 SIGSEGV.
What the numbers meant on 2026-08-15
🔴 There is no single "main baseline", and quoting one as a constant is a mistake. The same tree at
3863adcbb0 was measured three times on 2026-08-15 and the results are 36% apart on import:
| run | wall | import | tests | files / tests / failed |
|---|---|---|---|---|
| clean box, no reporter attached | 206.6s | 4565.5s | 541.1s | 1065 / 16784 / 16 |
baseline-full-uncapped.perf.json (shipped artifact) |
243.1s | 5476.3s | 563.7s | 1065 / 16784 / 16 |
| paired control for the integration run | 204.4s | 4491s | 541s | 1065 / 16784 / 16 |
Identical file, test and failure counts in all three — the tree did not change, the box did. That spread is the same size as several of the effects measured against it, which is exactly why every main-relative figure must name the run it was measured against, by label, and why a change is only credible when its control was taken in the same window.
Derived shares move with the row you pick: import is 81% of worker time on the first row and 84.3% on the second.
- Import is not module evaluation: traced at 1 worker, the module bodies of two of the heaviest
files totalled ~0.4s against a 25.4s import phase. Cost is linear in module count, not module
weight. ⚠️ The mechanism behind the remaining ~98% is inferred, not instrumented: an early
reading blamed vite-node's per-module IPC, and the pool sweep refutes it —
threadsbeatvmThreadswhile paying a cold fetch, which shipping module source cannot explain. The better-supported reading is compile-and-evaluate into a fresh registry, once per file. V8 compile time was never measured. - The closure distribution is bimodal: p50 66 modules, p75 1088. 411 files carry 96% of all module executions.
- 16 failures across 6 files is the known-good Windows baseline (
path.relative()backslashes against/literals); green on Linux CI.