Skip to content

Commit 16d13d9

Browse files
authored
fix: hooks should respect maxConcurrency (#9653)
1 parent bb20389 commit 16d13d9

File tree

15 files changed

+1338
-151
lines changed

15 files changed

+1338
-151
lines changed

docs/config/maxconcurrency.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,6 @@ outline: deep
99
- **Default**: `5`
1010
- **CLI**: `--max-concurrency=10`, `--maxConcurrency=10`
1111

12-
A number of tests that are allowed to run at the same time marked with `test.concurrent`.
12+
The maximum number of tests and hooks that can run at the same time when using `test.concurrent` or `describe.concurrent`.
1313

14-
Test above this limit will be queued to run when available slot appears.
14+
The hook execution order within a single group is also controlled by [`sequence.hooks`](/config/sequence#sequence-hooks). With `sequence.hooks: 'parallel'`, the execution is bounded by the same limit of [`maxConcurrency`](/config/maxconcurrency).

docs/config/sequence.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ Changes the order in which hooks are executed.
145145

146146
- `stack` will order "after" hooks in reverse order, "before" hooks will run in the order they were defined
147147
- `list` will order all hooks in the order they are defined
148-
- `parallel` will run hooks in a single group in parallel (hooks in parent suites will still run before the current suite's hooks)
148+
- `parallel` runs hooks in a single group in parallel (hooks in parent suites still run before the current suite's hooks). The actual number of simultaneously running hooks is limited by [`maxConcurrency`](/config/maxconcurrency).
149149

150150
::: tip
151151
This option doesn't affect [`onTestFinished`](/api/hooks#ontestfinished). It is always called in reverse order.

docs/guide/cli-generated.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -781,7 +781,7 @@ Default timeout of a teardown function in milliseconds (default: `10000`)
781781
- **CLI:** `--maxConcurrency <number>`
782782
- **Config:** [maxConcurrency](/config/maxconcurrency)
783783

784-
Maximum number of concurrent tests in a suite (default: `5`)
784+
Maximum number of concurrent tests and suites during test file execution (default: `5`)
785785

786786
### expect.requireAssertions
787787

docs/guide/parallelism.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ Unlike _test files_, Vitest runs _tests_ in sequence. This means that tests insi
2222

2323
Vitest supports the [`concurrent`](/api/test#test-concurrent) option to run tests together. If this option is set, Vitest will group concurrent tests in the same _file_ (the number of simultaneously running tests depends on the [`maxConcurrency`](/config/maxconcurrency) option) and run them with [`Promise.all`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all).
2424

25+
The hook execution order within a single group is also controlled by [`sequence.hooks`](/config/sequence#sequence-hooks). With `sequence.hooks: 'parallel'`, the execution is bounded by the same limit of [`maxConcurrency`](/config/maxconcurrency).
26+
2527
Vitest doesn't perform any smart analysis and doesn't create additional workers to run these tests. This means that the performance of your tests will improve only if you rely heavily on asynchronous operations. For example, these tests will still run one after another even though the `concurrent` option is specified. This is because they are synchronous:
2628

2729
```ts

packages/runner/src/run.ts

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type {
1818
TestContext,
1919
WriteableTestContext,
2020
} from './types/tasks'
21+
import type { ConcurrencyLimiter } from './utils/limit-concurrency'
2122
import { processError } from '@vitest/utils/error' // TODO: load dynamically
2223
import { shuffle } from '@vitest/utils/helpers'
2324
import { getSafeTimers } from '@vitest/utils/timers'
@@ -35,6 +36,7 @@ import { hasFailed, hasTests } from './utils/tasks'
3536
const now = globalThis.performance ? globalThis.performance.now.bind(globalThis.performance) : Date.now
3637
const unixNow = Date.now
3738
const { clearTimeout, setTimeout } = getSafeTimers()
39+
let limitMaxConcurrency: ConcurrencyLimiter
3840

3941
/**
4042
* Normalizes retry configuration to extract individual values.
@@ -141,7 +143,7 @@ async function callTestHooks(
141143

142144
if (sequence === 'parallel') {
143145
try {
144-
await Promise.all(hooks.map(fn => fn(test.context)))
146+
await Promise.all(hooks.map(fn => limitMaxConcurrency(() => fn(test.context))))
145147
}
146148
catch (e) {
147149
failTask(test.result!, e, runner.config.diffOptions)
@@ -150,7 +152,7 @@ async function callTestHooks(
150152
else {
151153
for (const fn of hooks) {
152154
try {
153-
await fn(test.context)
155+
await limitMaxConcurrency(() => fn(test.context))
154156
}
155157
catch (e) {
156158
failTask(test.result!, e, runner.config.diffOptions)
@@ -188,11 +190,13 @@ export async function callSuiteHook<T extends keyof SuiteHooks>(
188190
}
189191

190192
async function runHook(hook: Function) {
191-
return getBeforeHookCleanupCallback(
192-
hook,
193-
await hook(...args),
194-
name === 'beforeEach' ? args[0] as TestContext : undefined,
195-
)
193+
return limitMaxConcurrency(async () => {
194+
return getBeforeHookCleanupCallback(
195+
hook,
196+
await hook(...args),
197+
name === 'beforeEach' ? args[0] as TestContext : undefined,
198+
)
199+
})
196200
}
197201

198202
if (sequence === 'parallel') {
@@ -311,6 +315,8 @@ async function callAroundHooks<THook extends Function>(
311315
let useCalled = false
312316
let setupTimeout: ReturnType<typeof createTimeoutPromise>
313317
let teardownTimeout: ReturnType<typeof createTimeoutPromise> | undefined
318+
let setupLimitConcurrencyRelease: (() => void) | undefined
319+
let teardownLimitConcurrencyRelease: (() => void) | undefined
314320

315321
// Promise that resolves when use() is called (setup phase complete)
316322
let resolveUseCalled!: () => void
@@ -352,17 +358,22 @@ async function callAroundHooks<THook extends Function>(
352358

353359
// Setup phase completed - clear setup timer
354360
setupTimeout.clear()
361+
setupLimitConcurrencyRelease?.()
355362

356363
// Run inner hooks - don't time this against our teardown timeout
357364
await runNextHook(index + 1).catch(e => hookErrors.push(e))
358365

366+
teardownLimitConcurrencyRelease = await limitMaxConcurrency.acquire()
367+
359368
// Start teardown timer after inner hooks complete - only times this hook's teardown code
360369
teardownTimeout = createTimeoutPromise(timeout, 'teardown', stackTraceError)
361370

362371
// Signal that use() is returning (teardown phase starting)
363372
resolveUseReturned()
364373
}
365374

375+
setupLimitConcurrencyRelease = await limitMaxConcurrency.acquire()
376+
366377
// Start setup timeout
367378
setupTimeout = createTimeoutPromise(timeout, 'setup', stackTraceError)
368379

@@ -381,6 +392,10 @@ async function callAroundHooks<THook extends Function>(
381392
catch (error) {
382393
rejectHookComplete(error as Error)
383394
}
395+
finally {
396+
setupLimitConcurrencyRelease?.()
397+
teardownLimitConcurrencyRelease?.()
398+
}
384399
})()
385400

386401
// Wait for either: use() to be called OR hook to complete (error) OR setup timeout
@@ -392,6 +407,7 @@ async function callAroundHooks<THook extends Function>(
392407
])
393408
}
394409
finally {
410+
setupLimitConcurrencyRelease?.()
395411
setupTimeout.clear()
396412
}
397413

@@ -410,6 +426,7 @@ async function callAroundHooks<THook extends Function>(
410426
])
411427
}
412428
finally {
429+
teardownLimitConcurrencyRelease?.()
413430
teardownTimeout?.clear()
414431
}
415432
}
@@ -524,7 +541,7 @@ async function callCleanupHooks(runner: VitestRunner, cleanups: unknown[]) {
524541
if (typeof fn !== 'function') {
525542
return
526543
}
527-
await fn()
544+
await limitMaxConcurrency(() => fn())
528545
}),
529546
)
530547
}
@@ -533,7 +550,7 @@ async function callCleanupHooks(runner: VitestRunner, cleanups: unknown[]) {
533550
if (typeof fn !== 'function') {
534551
continue
535552
}
536-
await fn()
553+
await limitMaxConcurrency(() => fn())
537554
}
538555
}
539556
}
@@ -623,7 +640,7 @@ export async function runTest(test: Test, runner: VitestRunner): Promise<void> {
623640
))
624641

625642
if (runner.runTask) {
626-
await $('test.callback', () => runner.runTask!(test))
643+
await $('test.callback', () => limitMaxConcurrency(() => runner.runTask!(test)))
627644
}
628645
else {
629646
const fn = getFn(test)
@@ -632,7 +649,7 @@ export async function runTest(test: Test, runner: VitestRunner): Promise<void> {
632649
'Test function is not found. Did you add it using `setFn`?',
633650
)
634651
}
635-
await $('test.callback', () => fn())
652+
await $('test.callback', () => limitMaxConcurrency(() => fn()))
636653
}
637654

638655
await runner.onAfterTryTask?.(test, {
@@ -940,12 +957,10 @@ export async function runSuite(suite: Suite, runner: VitestRunner): Promise<void
940957
}
941958
}
942959

943-
let limitMaxConcurrency: ReturnType<typeof limitConcurrency>
944-
945960
async function runSuiteChild(c: Task, runner: VitestRunner) {
946961
const $ = runner.trace!
947962
if (c.type === 'test') {
948-
return limitMaxConcurrency(() => $(
963+
return $(
949964
'run.test',
950965
{
951966
'vitest.test.id': c.id,
@@ -957,7 +972,7 @@ async function runSuiteChild(c: Task, runner: VitestRunner) {
957972
'code.column.number': c.location?.column,
958973
},
959974
() => runTest(c, runner),
960-
))
975+
)
961976
}
962977
else if (c.type === 'suite') {
963978
return $(
Lines changed: 46 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
// A compact (code-wise, probably not memory-wise) singly linked list node.
22
type QueueNode<T> = [value: T, next?: QueueNode<T>]
33

4+
export interface ConcurrencyLimiter extends ConcurrencyLimiterFn {
5+
acquire: () => (() => void) | Promise<() => void>
6+
}
7+
8+
type ConcurrencyLimiterFn = <Args extends unknown[], T>(func: (...args: Args) => PromiseLike<T> | T, ...args: Args) => Promise<T>
9+
410
/**
511
* Return a function for running multiple async operations with limited concurrency.
612
*/
7-
export function limitConcurrency(concurrency: number = Infinity): <Args extends unknown[], T>(func: (...args: Args) => PromiseLike<T> | T, ...args: Args) => Promise<T> {
13+
export function limitConcurrency(concurrency: number = Infinity): ConcurrencyLimiter {
814
// The number of currently active + pending tasks.
915
let count = 0
1016

@@ -30,28 +36,50 @@ export function limitConcurrency(concurrency: number = Infinity): <Args extends
3036
}
3137
}
3238

33-
return (func, ...args) => {
34-
// Create a promise chain that:
35-
// 1. Waits for its turn in the task queue (if necessary).
36-
// 2. Runs the task.
37-
// 3. Allows the next pending task (if any) to run.
38-
return new Promise<void>((resolve) => {
39-
if (count++ < concurrency) {
40-
// No need to queue if fewer than maxConcurrency tasks are running.
41-
resolve()
39+
const acquire = () => {
40+
let released = false
41+
const release = () => {
42+
if (!released) {
43+
released = true
44+
finish()
4245
}
43-
else if (tail) {
46+
}
47+
48+
if (count++ < concurrency) {
49+
return release
50+
}
51+
52+
return new Promise<() => void>((resolve) => {
53+
if (tail) {
4454
// There are pending tasks, so append to the queue.
45-
tail = tail[1] = [resolve]
55+
tail = tail[1] = [() => resolve(release)]
4656
}
4757
else {
4858
// No other pending tasks, initialize the queue with a new tail and head.
49-
head = tail = [resolve]
59+
head = tail = [() => resolve(release)]
5060
}
51-
}).then(() => {
52-
// Running func here ensures that even a non-thenable result or an
53-
// immediately thrown error gets wrapped into a Promise.
54-
return func(...args)
55-
}).finally(finish)
61+
})
5662
}
63+
64+
const limiterFn: ConcurrencyLimiterFn = (func, ...args) => {
65+
function run(release: () => void) {
66+
try {
67+
const result = func(...args)
68+
if (result instanceof Promise) {
69+
return result.finally(release)
70+
}
71+
release()
72+
return Promise.resolve(result)
73+
}
74+
catch (error) {
75+
release()
76+
return Promise.reject(error)
77+
}
78+
}
79+
80+
const release = acquire()
81+
return release instanceof Promise ? release.then(run) : run(release)
82+
}
83+
84+
return Object.assign(limiterFn, { acquire })
5785
}

packages/vitest/src/node/cli/cli-config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -733,7 +733,7 @@ export const cliOptionsConfig: VitestCLIOptions = {
733733
},
734734
},
735735
maxConcurrency: {
736-
description: 'Maximum number of concurrent tests in a suite (default: `5`)',
736+
description: 'Maximum number of concurrent tests and suites during test file execution (default: `5`)',
737737
argument: '<number>',
738738
},
739739
expect: {

test/cli/fixtures/fails/concurrent-suite-deadlock.test.ts

Lines changed: 0 additions & 44 deletions
This file was deleted.

test/cli/fixtures/fails/concurrent-test-deadlock.test.ts

Lines changed: 0 additions & 40 deletions
This file was deleted.

0 commit comments

Comments
 (0)