···12121313const WORKER_START_TIMEOUT = 90_000
14141515+// escape hatch while the adaptive worker scaling is validated across platforms
1616+const isAdaptiveScalingEnabled = process.env.VITEST_POOL_ADAPTIVE !== '0'
1717+1518interface Options {
1619 distPath: string
1720 teardownTimeout: number
···3841 private exitPromises: Promise<void>[] = []
3942 private _isCancelling: boolean = false
40434444+ // Workers start one at a time while the queue justifies another one
4545+ // (mirrors the browser pool's adaptive session scaling): every worker pays
4646+ // a spawn + runtime bring-up that competes with the already-running workers
4747+ // for the same CPUs and Vite server, so fast suites finish sooner with
4848+ // fewer workers, while suites with slower files still scale up to
4949+ // `maxWorkers`.
5050+ private _startingCount = 0
5151+ // refined with the measured duration after every worker start
5252+ private _spawnCost = 150
5353+ // EMA of how long a single task takes; undefined until the first signal,
5454+ // which means "keep starting workers like before". A reused worker's first
5555+ // task pays the environment bring-up on top of the test, so it only counts
5656+ // when the worker cannot be reused (then it is the true per-task cost).
5757+ private _taskCostEma: number | undefined
5858+4159 constructor(private options: Options, private logger: Logger) {}
42604361 setMaxWorkers(maxWorkers: number): void {
···4664 this.workerIds = new Map(
4765 Array.from({ length: maxWorkers }).fill(0).map((_, i) => [i + 1, true]),
4866 )
6767+6868+ // a new task group can have a completely different per-task cost
6969+ // (different pool, environment or project — e.g. typecheck tasks after
7070+ // unit tests), so its scaling starts from a clean signal
7171+ this._taskCostEma = undefined
4972 }
50735174 async run(task: PoolTask, method: 'run' | 'collect'): Promise<void> {
···6992 return
7093 }
71949595+ if (isAdaptiveScalingEnabled && !this.canScheduleNext()) {
9696+ // re-evaluated when the in-flight worker start settles or a task
9797+ // finishes — both end with another `schedule()` call
9898+ return
9999+ }
100100+72101 const { task, resolver, method } = this.queue.shift()!
7310274103 try {
75104 let isMemoryLimitReached = false
76105 const runner = this.getPoolRunner(task, method)
106106+ const isFreshRunner = !runner.isStarted
7710778108 const poolId = runner.poolId ?? this.getConcurrencyId()
79109 runner.poolId = poolId
···121151 WORKER_START_TIMEOUT,
122152 )
123153154154+ this._startingCount++
155155+ const startedAt = performance.now()
156156+124157 await runner.start({ workerId: task.context.workerId })
125158 .catch(error =>
126159 resolver.reject(
127160 new Error(`[vitest-pool]: Failed to start ${task.worker} worker for test files ${formatFiles(task)}.`, { cause: error }),
128161 ),
129162 )
130130- .finally(() => clearTimeout(id))
163163+ .finally(() => {
164164+ clearTimeout(id)
165165+ this._startingCount--
166166+ })
167167+168168+ if (!resolver.isRejected) {
169169+ this._spawnCost = performance.now() - startedAt
170170+ }
171171+172172+ // the next worker can start while this one runs its task
173173+ void this.schedule()
131174 }
132175133176 let span: Span | undefined
177177+ const requestedAt = performance.now()
134178135179 if (!resolver.isRejected) {
136180 span = runner.startTracesSpan(`vitest.worker.${method}`)
···142186 await resolver.promise
143187 .catch(error => span?.recordException(error))
144188 .finally(() => span?.end())
189189+190190+ // the EMA only informs the scaling of reusable workers, and a fresh
191191+ // worker's first task pays the environment bring-up on top of the
192192+ // test itself — only steady-state tasks of reused workers count
193193+ if (!resolver.isRejected && task.isolate === false && !isFreshRunner) {
194194+ const taskCost = performance.now() - requestedAt
195195+ this._taskCostEma = this._taskCostEma == null
196196+ ? taskCost
197197+ : this._taskCostEma * 0.7 + taskCost * 0.3
198198+ }
145199146200 const index = this.activeTasks.indexOf(activeTask)
147201 if (index !== -1) {
···217271218272 async close(): Promise<void> {
219273 await this.cancel()
274274+ }
275275+276276+ private canScheduleNext(): boolean {
277277+ const { task } = this.queue[0]
278278+279279+ // isolated tasks pay a worker per task no matter what — there is no
280280+ // avoidable bring-up cost for the scaling to save, so they keep the
281281+ // unrestricted behavior
282282+ if (task.isolate !== false) {
283283+ return true
284284+ }
285285+286286+ // an idle reusable worker adds no bring-up cost
287287+ if (this.sharedRunners.some(runner => isEqualRunner(runner, task))) {
288288+ return true
289289+ }
290290+291291+ // fresh workers ramp up by doubling (in-flight starts never exceed the
292292+ // workers that already run) — the queue may no longer justify more
293293+ // workers by the time the current batch is up, while a long queue still
294294+ // reaches `maxWorkers` in logarithmic time instead of one by one
295295+ const startedWorkers = this.activeTasks.length - this._startingCount
296296+ if (this._startingCount >= Math.max(1, startedWorkers)) {
297297+ return false
298298+ }
299299+300300+ // the first worker always starts; without a per-task signal keep the old
301301+ // behavior of scaling straight up to `maxWorkers`
302302+ if (this.activeTasks.length === 0 || this._taskCostEma == null) {
303303+ return true
304304+ }
305305+306306+ // only pay for another worker when the remaining work, split across the
307307+ // workers we already have, still takes considerably longer than a worker
308308+ // start costs — a new worker does not just cost its own bring-up, it
309309+ // also competes with the running workers for the same CPUs
310310+ const projectedDrainMs
311311+ = (this.queue.length * this._taskCostEma) / this.activeTasks.length
312312+ return projectedDrainMs > Math.max(this._spawnCost, 50) * 2
220313 }
221314222315 private getPoolRunner(task: PoolTask, method: 'run' | 'collect'): PoolRunner {