Before: launch up to the hard ceiling
min(maxWorkers, files.length) pages open concurrently. Even trivial files can each pay for a context, page, and browser module graph.
Vitest treats maxWorkers as a hard ceiling, opens browser sessions incrementally, and dynamically caps the batch when existing sessions appear sufficient.
min(maxWorkers, files.length) pages open concurrently. Even trivial files can each pay for a context, page, and browser module graph.
Pages open sequentially until the measured queue appears cheap enough for the current session count. Expensive queues can still grow toward maxWorkers.
Hypothetical four-file run. All four pages ask the shared Vite server to bring up their module graphs at once.
The old pool calls every openPage before awaiting Promise.all. In this illustration, contention stretches the shared startup phase to 800 ms.
Same four files. S1 executes files sequentially while S2 opens; the queue drains before S3 is useful.
The bars use illustrative fixed timings: 300 ms per staged open and 100 ms per file. They show the intended contention model, not benchmark measurements.
queueLen × fileCostEMA ÷ openSessions ≤ 2 × max(openCost, 100 ms)
Stop opening sessions; otherwise continue toward hard maxWorkers.
openCost is replaced after every completed open, so the latest browser worker startup controls the next decision.
fileCostEMA = EMA * 0.7 + duration * 0.3. Each session's first file is excluded because it also pays tester bootstrap cost.
Without contention, the natural cap begins when estimated drain is no longer than observed startup. The implementation's 2× broadens that cap region: equivalently, it discounts projected drain by half or treats opening as twice as expensive. The 100 ms floor avoids treating an unusually tiny startup sample as free.
S1 and S2 are running files. The queue contains eight more unassigned files.
82200 ms500 msS1 and S2 are running files. The queue contains twelve more unassigned files.
122200 ms500 msPurple history left of “decision now” shows the already-paid sequential opens for S1 and S2. S1 completes two 200 ms files while S2 opens; the first is excluded as bootstrap and the second seeds the estimate. At the decision, current files f3 and f4 are omitted because the formula counts only the unassigned queue, shown from f5 onward. Actual assignment depends on which session asks for work next.
After S1 shifts the first file, the queue remains non-empty and the EMA is still undefined, so S2 starts opening immediately when maxWorkers > 1.
Until one session completes its second file, shouldOpenAnotherSession returns true and the pool can reach maxWorkers.
When many tabs are useful, each new open waits behind the previous one. The benefit depends on reduced server contention outweighing this serialization.
As remaining work grows, projected drain exceeds any finite startup threshold and the dynamic cap rises toward the configured ceiling.
scaleSessionsopenPage().runNextTest| Field and role | Fresh poolfields + constructor | runTestsenqueue + reuse |
Session openingscaleSessions |
File runningrunNextTest |
First file donecompletion callback | Later file doneEMA update | Run completecheckCompletion |
Next batchrunTests again |
|---|---|---|---|---|---|---|---|---|
_queuework not yet assigned |
[] |
[f1 ... fN]files appended |
N filesnothing shifts until a page opens |
shrinkingeach session shifts one |
shrinkingnext file shifts immediately |
shrinking |
[] |
[new files] |
_promisewhole-run completion |
undefined |
pending DeferPromise |
same promise |
same promise |
same promise |
same promise |
resolve; undefined |
new DeferPromise |
_scalingone opener loop + completion latch |
undefined |
Promise<void>loop starts |
pendingprevents early completion |
pendingmay open another page |
pending or settled |
undefinedonce the dynamic cap is chosen |
undefined |
new promise if capacity exists |
_fileCostEmasteady per-file estimate |
undefined |
undefined |
undefinedno signal: keep opening |
undefined |
undefinedfirst file excluded |
seed or 70/30 update |
retained |
reusedaffects initial scaling |
_sessionOpenCostlatest page startup cost |
250 ms |
250 ms |
replace after await openPage |
latest measurement |
latest measurement |
latest measurement |
retained |
reused |
_warmedUpSessionssessions past bootstrap file |
{} |
{} |
{} |
{} |
add(sessionId) |
already presentduration enters EMA |
retained |
reused sessions stay warm |
orchestratorsconnected session map in project state |
size 0 |
existing sessions retainedsets available capacity |
size + 1 after connectionopenPage waits for ready |
all connected sessionsbounds scaling at maxWorkers |
unchangedunless a WebSocket closes |
unchangeda disconnect removes its entry |
all live sessions retained |
same sessions reusednew pages fill remaining capacity |
readySessionsopen sessions with no assigned work |
all 0 live sessionsempty set is complete |
full set -> remove allocated IDsunallocated idle sessions remain |
new session absentit is not idle yet |
running IDs absent |
stay absent if another file shifts |
add when queue is emptyfinishSession |
full set againsize === orchestrators.size |
full set -> remove allocated IDs |
This shows a fresh pool's first batch. Exact queue counts and overlap depend on timing. The final column is important: the EMA, open cost, warmed-session set, and ready-session set survive completion and influence the next batch.
!_scaling && readySessions.size === orchestrators.size
The run cannot resolve while a page is still opening. Both the final session and the scaling promise's finally path recheck this invariant, which covers either completion order. Source
S1's early files establish a 200 ms EMA, but the eight files still queued actually take 500 ms each.
82200 ms500 msThis is a linear time scale: the 500 ms opening and queued-file blocks have equal width, each 2.5 times wider than a 200 ms sample. Scroll horizontally to inspect the full 3.5-second run.
8 × 500 ÷ 2 = 2000 ms, so paying 500 ms for S3 would be worthwhile. The low EMA comes from files that finished early enough to be observed while sessions were opening. Once that estimate caps the pool, this batch never invokes scaleSessions again, even when later completions raise the EMA.Use historical per-file durations when available and sum estimates for the actual queued files instead of multiplying by one global EMA.
Allow new cost samples to trigger another scaling decision while queued work remains. This helps, although a slow sample may arrive late.
Do not carry cheap collection or stale watch-run timings directly into a different execution phase or substantially changed batch.
Apply a low cap only with enough representative evidence, and consider a minimum-session floor or opt-out when explicit concurrency matters.