Files
Justin Maier 7048aadfa1 perf(tests): split sharp-executing tests onto their own pool, pre-bundle five externals (#3960)
* perf(tests): route sharp-executing tests to their own forks project

sharp 0.32.6's addon is not context-aware, so a worker_threads worker that has
run a libvips operation segfaults at thread teardown - after the tests pass and
the summary prints. That takes the whole run down with an exit code and no
failing test.

It is a race, not a threshold. On the six affected files, three repeats per
width: vmThreads crashed 3/3 at 1 worker, 2/3 at 2, 1/3 at 3, 0/3 at 4; threads
crashed at every width. A green run is not evidence of safety.

Importing sharp is harmless; only executing an operation arms it. 100 test files
carry sharp in their static closure and exactly six call it. That set was
measured by aliasing sharp to a recording proxy and running all 100 under forks,
not by grepping and not by a crash-scan - with a race, "ran alone and didn't
crash" builds the list out of the files that got lucky.

- unit        -> the suite minus those six, pool unchanged (forks)
- unit-native -> pool: forks pinned, including only those six

unit deliberately does NOT move to threads. threads measured 1.04x at 4 workers
and 0.94x at 16 - no win - and it segfaults mid-run on the full suite at roughly
1 in 4, after completing hundreds of files cleanly and with no unit-native file
having run, so a second crasher exists that is not sharp and is not diagnosed.
What the split buys is that the sharp crash is deterministic and gone, so anyone
experimenting with --pool=threads no longer has to fight it too.

Excluded from unit rather than merely claimed by unit-native, so that a run
naming a sharp file under --project unit reports "No test files found" rather
than running it on a thread pool if one is ever selected. Shared settings are
hoisted into one object both projects spread, so they cannot drift apart.

Every selector moves to a unit* project pattern. Verified by file count rather
than by a green summary: vitest list over unit* is 1065 files and unit-native is
6, summing to the pre-split baseline.

no-sharp-outside-native-project.test.ts is the positive control, since nothing
else in the suite can notice this breaking: the realistic failure is a rename,
which stops matching unit-native's include AND unit's exclude. Mutation-tested,
not assumed green.

* perf(tests): pre-bundle five externals nothing mocks

Every test file gets a fresh module registry - under forks with isolate: true it
is literally a fresh child process per file (N files at maxWorkers=1 give N
distinct pids), so Node's module cache dies with it and each externalised
package is imported cold once per file that reaches it. Pre-bundling collapses a
package's many-hundred-file native load into one chunk, paid once per run.

Full suite, control then treatment in one window:

  control     wall 217.2s  collect 4730s  1066 files  16787 tests  17 failed
  treatment   wall 192.9s  collect 4082s  1066 files  16787 tests  17 failed

The failure SET is unchanged, diffed both directions - nothing appeared, nothing
cleared. This alters timing, not behaviour.

The effect tracks exposure, which is what separates it from ambient drift:

  reaches 0 of the 5   353 files  collect  113s ->  105s   -6.9%
  reaches 1            115 files  collect  150s ->  103s  -31.2%
  reaches 2            209 files  collect  529s ->  373s  -29.6%
  reaches 3             18 files  collect   69s ->   52s  -24.3%
  reaches 5            371 files  collect 3869s -> 3449s  -10.9%

Exposure 5 shows the smallest percentage and the largest absolute saving (420s
of 648s) because those are the heavyweight files: five packages are a small
share of a 1,300-module closure and a large share of a small one. Percentage
tracks share-of-closure, absolute tracks file weight.

A control-vs-control run to size run-to-run drift directly was attempted and
died to an unrelated crash, so the residual drift term is unmeasured and the
figure above should be read as an upper bound.

The list is confined to packages nothing mocks, and that is load-bearing.
Pre-bundling wraps a package as a CJS-interop chunk, so a vi.mock factory
returning only named exports stops satisfying its consumers - adding redis and
the mocked aws-sdk clients takes four mock-holding files from 92 tests passing
to 7 collected. The importOriginal form does not protect against this. Those
three are worth ~275s more and need a default export added to six mock
factories first; that is a separate change.

The treatment paid its cold optimize pass inside the measured run - the shared
.vite cache was not cleared - so the number is not flattered by a warm cache,
and a fresh CI runner pays the same thing.

* test(perf): pin the native project's pool independently of unit's

The guard asserted unit ran on threads, which was the state the split shipped in
for about ten minutes. It caught its own config change, which is the behaviour
wanted, but the assertion was aimed at the wrong invariant: what must hold is
that unit-native stays on a process-based pool whatever unit is pointed at, not
that unit is on any particular one.

* test(perf): acceptance harness for the six external-mock factories

The change that brings redis and the aws-sdk clients into the pre-bundling
safelist cannot be verified on a tree that does not enable the optimizer:
without pre-bundling the package is not wrapped as a CJS-interop chunk, the
missing default export never bites, and a green run proves only that the old
config still works. This runs the six affected files under a config with all
three candidates pre-bundled.

Compares per-file collected counts rather than the total. s3-utils is 66 of the
106, so a sum of 40 could be one file collecting zero and still read as a
partial pass.

Negative control on the unchanged tree: 5 of 6 files collect 0, and the harness
exits 1 naming each one.

* docs(test-perf): record the measurement envelope this box imposes

Two identical full runs, back to back, nothing changed between them, came out
+20.5% apart on collect. That pair was contaminated, so it is not a drift figure
- but it demonstrates the box can move further than most of the effects measured
today, which means any comparison assembled from two windows is unreadable.

Collects the methodology that follows: quote in-pair controls rather than
cross-window deltas; a control group must be comparable in cost and not merely
in count; a dose-response on an axis confounded with file cost is suggestive
rather than conclusive; a crashed run's wall clock is not a fast run; and check
for the workload rather than for the runtime when deciding the box is quiet.

A clean drift pair still has not been taken and is the denominator for
everything else here.

* docs(test-perf): scout Bun and node:test as vitest replacements

Recommendation is to stay on vitest, but the measurement overturns the cost
model we spent the day optimising against.

Same 84-module first-party graph: vitest collect 5298ms, bun 3.3ms, node+tsx
8.5ms. Whole-suite arithmetic gives vitest 10.2ms per static module-instance
against 0.04ms for bun. The cost is the module runner, not the modules - which
matches this morning's tracer result (569 module bodies in ~0.4s against a 25.4s
import phase) and locates the time in vite-node's per-module fetch/instantiate
rather than in compile-and-evaluate.

Unrealisable, though. Bun cannot load any graph reaching the React/Next side -
it dies resolving use-sidecar's package exports - so the numbers are measured on
the light stratum only, which is the flattering-slice trap: 383 of 1065 files
have no infra dependency and the largest of those is an 86-module closure.
Module-scope env aborts the import under both runtimes, and cache-helpers hung
past 300s under bun after the env gate was satisfied.

The mock surface is the wall: 1053 of 1065 files import from vitest, 3883
vi.mock sites across 651 files, plus 8053 vi.fn and the fake-timer, spy and
importActual surface. The canonical mock system, its guard, the allowlist
ratchet, reporter.mjs, the dashboard and the queue integration are all
vitest-shaped as well.

Retarget rather than switch: if per-module cost is vite-node overhead, shrinking
the graph attacks a term worth ~0.04ms of real work per module, and the leverage
is in how many times a module is INSTANTIATED - which is what isolate:false
removes.

* docs(test-perf): retract the per-module ratio in the runner scouting

It divided collect by inventory.json's static module counts, and that artifact
was wrong by up to 75x and selectively so - it followed lazy dynamic import
edges that never execute and ignored vi.mock factories. Honest suite union is
1321, not 3230.

The per-file wall clock the recommendation rests on needs no denominator and is
unaffected: same 84-module closure, vitest collect 5298ms against bun 3.3ms and
node+tsx 8.5ms. So is the tracer result behind it - 569 module bodies executing
in ~0.4s against a 25.4s import phase, measured with no static count at all.

No counterpart figure is quoted for bun, because its denominator came from the
same artifact.

* docs(test-perf): scrub the remaining per-module claims from the runner scout

Two survivors of the retraction: an 'orders of magnitude per module' headline
and a stratum characterisation quoting closure sizes, both resting on the same
broken counts. Restated against the per-file wall clock, which needs no
denominator, and the observed hard failure, which is not a count.

* docs(test-perf): correct the runner comparison to like-for-like

The headline compared vitest's collect for a TEST FILE against a probe importing
only the SOURCE module underneath it - a different and much smaller graph. That
is where '~1600x' came from.

Like-for-like, on the same 82-module test-file closure: vitest collect 5298ms,
bun 259ms (median of 5, 250-262). ~20x, not three orders of magnitude. node+tsx
cannot import a test file at all - 'Vitest cannot be imported in a CommonJS
module using require()'.

Per-module refit against aidan's honest closures.json (mode: 'real') joined to
the pre-ctl full run: 1065 files, 104797 real module-instances, collect 4729s ->
vitest 45.1 ms/module, independently agreeing with aidan's 43.6. bun 3.2
ms/module on the file both can load.

The recommendation is unchanged and the mechanism finding is unchanged; the size
of the gap was overstated.

* docs(vitest): say why the unit projects set no per-project maxWorkers

Per-project maxWorkers does apply at runtime, but two projects with different
counts need different sequence.groupOrder values, and different groups run
serially. For a 1059/6 split that trades the concurrency between them for a
knob nobody needs - the six-file project would gate the other 1059 instead of
filling spare capacity beside it.

Currently reads as an omission, so a future reader adds one and loses
concurrency without knowing they traded for it.

* docs(test-perf): final form of the runner scouting result

Leads with both corrections stated in place rather than silently edited out, and
records that neither changed the recommendation.

Promotes the cross-validation to a finding of its own: 45.1 ms per
module-instance here against aidan's independent 43.6, from a different artifact
by a different route. Two wrong denominators would not have agreed, so the pair
is what licenses everything downstream that divides by a module count.

Names what both errors had in common - each a denominator error producing a
number right about the thing it measured and wrong about what that thing was.
Checking two runtimes are comparable is not checking the two quantities are.

* fix(test): pin unit-native's pool against a CLI --pool, and correct the project selector

`unit-native`'s static `pool: 'forks'` loses to a CLI `--pool=threads`: resolveProjects
builds cliOverrides from a list that includes `pool` and spreads it after options.test,
so the flag wins. The six sharp-executing files would then follow `unit` onto a thread
pool and segfault AFTER printing a green summary.

configureVitest hooks run after resolveProjects(cliOptions), and getFilePoolName --
`browser.enabled ? 'browser' : project.config.pool` -- is what stamps each spec's pool,
so re-asserting there outranks the flag. The other two readers of project.config.pool
populate task metadata from the same field and cannot disagree with it.

The comment already claimed this guarantee; without the plugin it was false, and false
in the reassuring direction.

Also corrects CLAUDE.md: the unit suite is two projects now, so `--project unit` silently
runs 1059 of 1065 files and exits 0. Select it as `--project 'unit*'`, which is what
package.json's own scripts already do.

Bound: the pin covers `pool` and nothing else. isolate, fileParallelism, sequence,
testTimeout and retry are on the same cliOverrides list and remain overridable.

Not verified by a run -- the mechanism was read from vitest 4.0.18's cli-api chunk twice,
independently, by two readers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UtTG4QQR29eWf7kjM6HiLU

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 17:07:44 -06:00

29 lines
1.1 KiB
TypeScript

/**
* The root config with `sharp` aliased to scripts/test-perf/sharp-usage-probe.mjs, so a run
* records which test files execute a libvips operation. Runs under `forks`, which survives.
*
* Derived from the real config rather than restating it — the `unit` project's alias list is
* load-bearing (nine workspace packages that fail to COLLECT without it), and a hand-copied
* version would rot silently.
*/
import path from 'path';
import base from '../../vitest.config.mts';
const sharpAlias = {
find: /^sharp$/,
replacement: path.resolve(import.meta.dirname, 'sharp-usage-probe.mjs'),
};
const config = base as any;
const projects = config.test.projects.filter(
(p: any) => typeof p === 'object' && p.test?.name === 'unit'
);
if (projects.length !== 1) {
throw new Error(`expected exactly one 'unit' project, found ${projects.length}`);
}
// Must precede the `~` entry so it wins, same ordering rule the componentAlias follows.
projects[0].resolve.alias = [sharpAlias, ...projects[0].resolve.alias];
config.test.projects = projects;
export default config;