You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Found while landing #2323 (PR #2334), and it is the same class that issue is
about — a number nobody chose — one layer down. #2323 fixed the wall-clock budgets; this is the concurrency that makes those budgets get spent.
The defect
No Vitest project in this repo sets maxWorkers — confirmed, nothing in any vitest.shared.mts, client config or poolOptions names it — so all six inherit
Vitest's own default.
That default is Math.max(availableParallelism() - 1, 1) for a non-watch run
(getDefaultThreadsCount, node_modules/vitest/dist/chunks/cli-api.BK8pd4xc.js:2354,
Vitest 4.1.10). On the machine this team works on (Apple M3, 4 performance + 4
efficiency cores, 8 logical) that is 7, not 8.
⚠️ Vitest's own CLI help text says default: os.availableParallelism(), which is
stale — the code subtracts one. Do not re-derive this number from --help.
Seven workers on eight logical cores still means only four of them can get a
performance core. The rest run the same workload on E-cores that are several
times slower, which is how a test that takes a second blows a 15-second budget.
That default assumes homogeneous cores. Every Apple Silicon machine violates that
assumption, and the whole team is on them.
One stage is already exempt: the browser pool (Storybook) self-caps at Math.min(12, numCpus - 1), so it is not governed by whatever is decided
here.
Measured
344-file web unit suite, runs sequential, same machine, nothing else of ours
running. npx vitest run --project=unit --maxWorkers=N:
maxWorkers
Wall
vs 8
CPU in tests
CPU in import
8 (explicit)
46s
—
138.7s
97.3s
6
56s
+22%
120.5s
80.2s
4
60s
+30%
99.0s
53.2s
3
70s
+52%
88.1s
44.9s
2
93s
+102%
82.6s
39.0s
⚠️The baseline row is not the default. It was run as an explicit --maxWorkers=8, one worker above the 7 this repo actually inherits. The shape of
the curve is very unlikely to change, but every percentage in the vs column is
measured against a configuration nothing runs. Re-measure with 7 as the
baseline before quoting any of these figures in a decision.
The CPU columns are the finding, not the wall column. Same 344 files and the
same assertions, but 8 workers burns 40% more CPU in tests and 2.5x more
in import than 2 workers. That is contention overhead — cache thrash plus work
landing on efficiency cores. The default is not buying eight cores of
parallelism; it is buying roughly four and paying for eight.
⚠️maxWorkers: 4 is not "one per performance core." macOS exposes no CPU
affinity API, and Vitest's workers are given no distinguishing QoS class, so the
scheduler is free to place any of them on an E-core — and with the main process
and v8 coverage instrumentation also runnable, it will. Capping reduces contention; it does not buy P-core residency. 4 is a chosen number, not a
derived one, and whatever value is committed has to be justified as a chosen one.
What the table actually argues
Worth stating plainly, because it shapes the decision: the default is the
fastest configuration measured. Every row below it is slower in wall clock. The
CPU columns only matter to the extent that something else wants those cycles —
and on this machine the only something else is another gate in another worktree.
So this is a politeness knob for the multi-session case, not a fix for a bad
single-session default. A developer running one gate alone pays the full wall-clock
cost for no benefit. That is a real trade to make deliberately; it is not a free
win, and the issue should not be closed as though it were one.
Why it matters beyond runtime
#2334's review took thirteen local:gate runs, of which roughly half went red —
every time on unrelated files with a bare Test timed out, a different set each
run, each passing in about a second in isolation. One showed 940 seconds of wall
clock against a 15-second budget, which is a starved worker rather than a slow
assertion. Non-causation was provable on the later ones: zero files under clients/ had changed since the last fully green run.
Not memory (55% free, zero swap) and not Docker (<1% CPU). Sampling during a gate
showed the spikes coming from the gate itself.
⚠️The flake reduction is a well-grounded hypothesis, not a measured result.
The mechanism fits exactly, but proving it needs many runs at each setting — hours
of machine time — and nobody should read the table above as having demonstrated
it. Measuring that is part of this issue, not a premise of it.
The design question, which is why this is not a one-liner
Capping workers is a pure loss on CI. GitHub runners are not oversubscribed
and no evidence from #2323 or #2334 implicates them — CI went 8 for 8 green across
that PR's entire review. A committed maxWorkers: 4 would slow every CI run to
fix a problem CI does not have.
So the options, in rough order of preference:
Set it in local:gate, as an env var read by vitest.shared.mts. CI never
runs local:gate — .github/workflows/main.yml invokes npm run validate
(line 55) and npm run coverage (line 175) directly — so the cap lives in the
one entry point that is local-only by construction. No CI cost, no CI
sniffing, no knob anyone has to discover, and it propagates to every child
script through the environment. verify:test-timeouts can assert both the
plumbing and the value, which is exactly its existing charter. The gap to name: a bare npm run validate in the fast inner loop would not
get the cap, since that script is shared with CI. Probably acceptable — the
long contended runs are gate runs — but say so rather than leaving it implicit.
Key it off CI detection.process.env.CI ? undefined : 4. Gets the benefit
by default locally at no CI cost, but makes the local gate and CI run
measurably different configurations. ⚠️ The original filing cited the pinned-CLI reasoning in AGENTS.md
against this. That citation was a poor fit — the pinned-CLI argument is about a different schema, one that can disagree about pass/fail on semantics, and maxWorkers changes nothing about what runs. The objection survives for a
different reason: the budgets this knob interacts with are wall-clock, so a
purely-scheduling difference can still flip an outcome. That is the whole
premise of this issue, and it is the reason to write down, not the other one.
Commit maxWorkers: 4 everywhere and accept the CI cost as the price of one
number that means the same thing in both places.
⚠️None of these compose across worktrees. Four sessions at maxWorkers: 4 is
still 16 workers on 8 cores — worse than one session at the inherited 7. This
narrows the self-inflicted share only — which the sampling suggests is the
larger one — and the cross-worktree lease #2323 raises and defers is the actual
fix for the multi-session case. Do not close that door by declaring this issue the
solution.
Work
Re-measure the baseline at 7, the value actually inherited, and recompute
the vs column against it. The existing table's reference point does not exist
in practice.
Re-run the sweep under v8 coverage (test:coverage), which is the heavier
and more flake-prone stage, and on the tui project. The unit-suite shape
should transfer but has not been checked.
Measure the flake rate, not just runtime: N full runs at the default and at
the candidate, counting Test timed out failures. That is the number this issue
actually turns on. ⚠️Decide up front whether this is affordable and whether it can be run
cleanly. It is hours of machine time on a box whose contention is the thing
under test, and a concurrent session in another worktree invalidates the sample
— the control-arm problem from the gate-queue work: control-green /
treatment-red is not a verdict if ambient load moved between the arms. If it
cannot be run cleanly, say so and fall back to deciding on the CPU-burn
evidence alone, with the flake claim recorded as a standing hypothesis.
Pick an option above and say why in the code, the way TIMEOUTS does.
If a value is committed, extend verify:test-timeouts to assert it — it already
owns "the concurrency and budget numbers are stated, not inherited".
Acceptance
maxWorkers is either a stated value with its reasoning, or explicitly recorded
here as "keep the default" with the measurement that justifies it.
The reasoning states the value as chosen, not as derived from the P-core
count, since it cannot be.
The flake-rate measurement is recorded — or its infeasibility is, with the
decision resting explicitly on the runtime and CPU evidence instead.
CI runtime impact is stated explicitly, whichever option is taken.
What #2339 means for this issue's ordering (from PR #2344)
The maxWorkers cap here and the #2339 lease are alternatives for the load half, and at two sessions the load half measured ~1.3x with no timeouts. With gates serialized, the oversubscription this issue exists to mitigate comes only from non-gate work (a bare npm run coverage in another session, Spotlight after an install). Measure this against a leased baseline before deciding it.
Found while landing #2323 (PR #2334), and it is the same class that issue is
about — a number nobody chose — one layer down. #2323 fixed the wall-clock
budgets; this is the concurrency that makes those budgets get spent.
The defect
No Vitest project in this repo sets
maxWorkers— confirmed, nothing in anyvitest.shared.mts, client config orpoolOptionsnames it — so all six inheritVitest's own default.
That default is
Math.max(availableParallelism() - 1, 1)for a non-watch run(
getDefaultThreadsCount,node_modules/vitest/dist/chunks/cli-api.BK8pd4xc.js:2354,Vitest 4.1.10). On the machine this team works on (Apple M3, 4 performance + 4
efficiency cores, 8 logical) that is 7, not 8.
default: os.availableParallelism(), which isstale — the code subtracts one. Do not re-derive this number from
--help.Seven workers on eight logical cores still means only four of them can get a
performance core. The rest run the same workload on E-cores that are several
times slower, which is how a test that takes a second blows a 15-second budget.
That default assumes homogeneous cores. Every Apple Silicon machine violates that
assumption, and the whole team is on them.
One stage is already exempt: the browser pool (Storybook) self-caps at
Math.min(12, numCpus - 1), so it is not governed by whatever is decidedhere.
Measured
344-file web
unitsuite, runs sequential, same machine, nothing else of oursrunning.
npx vitest run --project=unit --maxWorkers=N:maxWorkerstestsimport--maxWorkers=8, one worker above the 7 this repo actually inherits. The shape ofthe curve is very unlikely to change, but every percentage in the
vscolumn ismeasured against a configuration nothing runs. Re-measure with
7as thebaseline before quoting any of these figures in a decision.
The CPU columns are the finding, not the wall column. Same 344 files and the
same assertions, but 8 workers burns 40% more CPU in
testsand 2.5x morein
importthan 2 workers. That is contention overhead — cache thrash plus worklanding on efficiency cores. The default is not buying eight cores of
parallelism; it is buying roughly four and paying for eight.
maxWorkers: 4is not "one per performance core." macOS exposes no CPUaffinity API, and Vitest's workers are given no distinguishing QoS class, so the
scheduler is free to place any of them on an E-core — and with the main process
and v8 coverage instrumentation also runnable, it will. Capping reduces
contention; it does not buy P-core residency.
4is a chosen number, not aderived one, and whatever value is committed has to be justified as a chosen one.
What the table actually argues
Worth stating plainly, because it shapes the decision: the default is the
fastest configuration measured. Every row below it is slower in wall clock. The
CPU columns only matter to the extent that something else wants those cycles —
and on this machine the only something else is another gate in another worktree.
So this is a politeness knob for the multi-session case, not a fix for a bad
single-session default. A developer running one gate alone pays the full wall-clock
cost for no benefit. That is a real trade to make deliberately; it is not a free
win, and the issue should not be closed as though it were one.
Why it matters beyond runtime
#2334's review took thirteen
local:gateruns, of which roughly half went red —every time on unrelated files with a bare
Test timed out, a different set eachrun, each passing in about a second in isolation. One showed 940 seconds of wall
clock against a 15-second budget, which is a starved worker rather than a slow
assertion. Non-causation was provable on the later ones: zero files under
clients/had changed since the last fully green run.Not memory (55% free, zero swap) and not Docker (<1% CPU). Sampling during a gate
showed the spikes coming from the gate itself.
The mechanism fits exactly, but proving it needs many runs at each setting — hours
of machine time — and nobody should read the table above as having demonstrated
it. Measuring that is part of this issue, not a premise of it.
The design question, which is why this is not a one-liner
Capping workers is a pure loss on CI. GitHub runners are not oversubscribed
and no evidence from #2323 or #2334 implicates them — CI went 8 for 8 green across
that PR's entire review. A committed
maxWorkers: 4would slow every CI run tofix a problem CI does not have.
So the options, in rough order of preference:
local:gate, as an env var read byvitest.shared.mts. CI neverruns
local:gate—.github/workflows/main.ymlinvokesnpm run validate(line 55) and
npm run coverage(line 175) directly — so the cap lives in theone entry point that is local-only by construction. No CI cost, no
CIsniffing, no knob anyone has to discover, and it propagates to every child
script through the environment.
verify:test-timeoutscan assert both theplumbing and the value, which is exactly its existing charter.
The gap to name: a bare
npm run validatein the fast inner loop would notget the cap, since that script is shared with CI. Probably acceptable — the
long contended runs are gate runs — but say so rather than leaving it implicit.
TEST_MAX_WORKERSread invitest.shared.mtsnext toTIMEOUTS, defaulting to unset (library behavior)and set by developers who want it. Honest, but a knob everyone has to discover
is a knob nobody sets — the same objection Test-gate timeouts are still library defaults — the unit, tui and launcher projects and all 788 Testing Library waits fail correct tests under concurrent-worktree load #2323 raises against its own
deferred
INSPECTOR_TEST_TIMEOUT_SCALE. Option 1 is this option with thediscovery problem solved.
process.env.CI ? undefined : 4. Gets the benefitby default locally at no CI cost, but makes the local gate and CI run
measurably different configurations.
AGENTS.mdagainst this. That citation was a poor fit — the pinned-CLI argument is about a
different schema, one that can disagree about pass/fail on semantics, and
maxWorkerschanges nothing about what runs. The objection survives for adifferent reason: the budgets this knob interacts with are wall-clock, so a
purely-scheduling difference can still flip an outcome. That is the whole
premise of this issue, and it is the reason to write down, not the other one.
maxWorkers: 4everywhere and accept the CI cost as the price of onenumber that means the same thing in both places.
maxWorkers: 4isstill 16 workers on 8 cores — worse than one session at the inherited 7. This
narrows the self-inflicted share only — which the sampling suggests is the
larger one — and the cross-worktree lease #2323 raises and defers is the actual
fix for the multi-session case. Do not close that door by declaring this issue the
solution.
Work
7, the value actually inherited, and recomputethe
vscolumn against it. The existing table's reference point does not existin practice.
test:coverage), which is the heavierand more flake-prone stage, and on the
tuiproject. The unit-suite shapeshould transfer but has not been checked.
the candidate, counting
Test timed outfailures. That is the number this issueactually turns on.
cleanly. It is hours of machine time on a box whose contention is the thing
under test, and a concurrent session in another worktree invalidates the sample
— the control-arm problem from the gate-queue work: control-green /
treatment-red is not a verdict if ambient load moved between the arms. If it
cannot be run cleanly, say so and fall back to deciding on the CPU-burn
evidence alone, with the flake claim recorded as a standing hypothesis.
TIMEOUTSdoes.verify:test-timeoutsto assert it — it alreadyowns "the concurrency and budget numbers are stated, not inherited".
Acceptance
maxWorkersis either a stated value with its reasoning, or explicitly recordedhere as "keep the default" with the measurement that justifies it.
count, since it cannot be.
decision resting explicitly on the runtime and CPU evidence instead.
What #2339 means for this issue's ordering (from PR #2344)
The
maxWorkerscap here and the #2339 lease are alternatives for the load half, and at two sessions the load half measured ~1.3x with no timeouts. With gates serialized, the oversubscription this issue exists to mitigate comes only from non-gate work (a barenpm run coveragein another session, Spotlight after an install). Measure this against a leased baseline before deciding it.