Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
8435041
fix: `beforeAll/afterAll` should respect `maxConcurrency`
hi-ogawa Feb 13, 2026
e202b06
docs: update
hi-ogawa Feb 13, 2026
4a709e7
docs: more
hi-ogawa Feb 13, 2026
bc91621
docs: clarify maxConcurrency includes concurrent hooks
hi-ogawa Feb 13, 2026
ab41161
docs: clarify maxConcurrency and sequence.hooks behavior
hi-ogawa Feb 13, 2026
f60f025
docs: simplify maxConcurrency wording and hook note
hi-ogawa Feb 13, 2026
87f6d32
wip
hi-ogawa Feb 13, 2026
5c32922
docs: tweak
hi-ogawa Feb 13, 2026
e5d6cdf
docs: tweak
hi-ogawa Feb 13, 2026
5b314fd
test: fix concurrent
hi-ogawa Feb 13, 2026
1e24af1
test: one
hi-ogawa Feb 13, 2026
e091de9
test: integration
hi-ogawa Feb 13, 2026
baa340e
test: remove old
hi-ogawa Feb 13, 2026
de530f2
test: add maxConcurrency deadlock coverage for concurrent hooks
hi-ogawa Feb 13, 2026
f0d08fd
test: add __suite_errors__ to errorTree
hi-ogawa Feb 13, 2026
7b64acd
Merge branch 'main' into 02-13-fix_beforeall_afterall_should_respect_…
hi-ogawa Feb 13, 2026
0e306b3
test: faster timeout
hi-ogawa Feb 13, 2026
5a30feb
fix(runner): respect maxConcurrency for around hooks
hi-ogawa Feb 13, 2026
6abaaa4
test: update concurrent
hi-ogawa Feb 13, 2026
fa19b20
test: more edge cases
hi-ogawa Feb 13, 2026
a02e84b
fix(runner): make around run callbacks non-throwing
hi-ogawa Feb 13, 2026
0a63a78
Merge branch 'main' into 02-13-fix_beforeall_afterall_should_respect_…
hi-ogawa Feb 14, 2026
a5a0f2d
Revert "fix(runner): make around run callbacks non-throwing"
hi-ogawa Feb 14, 2026
fbe6b32
refactor: simplify acquire/release
hi-ogawa Feb 14, 2026
34bf2df
chore: lint
hi-ogawa Feb 14, 2026
a9717bc
fix: release more
hi-ogawa Feb 14, 2026
c12ffc0
test: more
hi-ogawa Feb 14, 2026
d0a1adf
fix: preserve nested around hook failures and add regressions
hi-ogawa Feb 16, 2026
00d1ec4
fix: fix errors order
hi-ogawa Feb 16, 2026
6e679f8
Merge branch 'fix/issue-9669-around-hook-propagation' into 02-13-fix_…
hi-ogawa Feb 16, 2026
02b8277
test: update
hi-ogawa Feb 16, 2026
e53955c
perf: reduce microtask
hi-ogawa Feb 16, 2026
afb8207
refactor: limitConcurrency
hi-ogawa Feb 16, 2026
3895d7e
perf: reduce microtask
hi-ogawa Feb 16, 2026
6f7d4de
chore: naming
hi-ogawa Feb 16, 2026
bb1382d
Merge branch 'main' into 02-13-fix_beforeall_afterall_should_respect_…
hi-ogawa Feb 17, 2026
1fbdd6c
test: destructure { task }
hi-ogawa Feb 17, 2026
b64e8b5
Merge branch 'main' into 02-13-fix_beforeall_afterall_should_respect_…
hi-ogawa Feb 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/config/maxconcurrency.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@ outline: deep
- **Default**: `5`
- **CLI**: `--max-concurrency=10`, `--maxConcurrency=10`

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

Test above this limit will be queued to run when available slot appears.
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).
2 changes: 1 addition & 1 deletion docs/config/sequence.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ Changes the order in which hooks are executed.

- `stack` will order "after" hooks in reverse order, "before" hooks will run in the order they were defined
- `list` will order all hooks in the order they are defined
- `parallel` will run hooks in a single group in parallel (hooks in parent suites will still run before the current suite's hooks)
- `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).

::: tip
This option doesn't affect [`onTestFinished`](/api/hooks#ontestfinished). It is always called in reverse order.
Expand Down
2 changes: 1 addition & 1 deletion docs/guide/cli-generated.md
Original file line number Diff line number Diff line change
Expand Up @@ -781,7 +781,7 @@ Default timeout of a teardown function in milliseconds (default: `10000`)
- **CLI:** `--maxConcurrency <number>`
- **Config:** [maxConcurrency](/config/maxconcurrency)

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

### expect.requireAssertions

Expand Down
2 changes: 2 additions & 0 deletions docs/guide/parallelism.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ Unlike _test files_, Vitest runs _tests_ in sequence. This means that tests insi

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).

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).

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:

```ts
Expand Down
45 changes: 30 additions & 15 deletions packages/runner/src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
TestContext,
WriteableTestContext,
} from './types/tasks'
import type { ConcurrencyLimiter } from './utils/limit-concurrency'
import { processError } from '@vitest/utils/error' // TODO: load dynamically
import { shuffle } from '@vitest/utils/helpers'
import { getSafeTimers } from '@vitest/utils/timers'
Expand All @@ -35,6 +36,7 @@ import { hasFailed, hasTests } from './utils/tasks'
const now = globalThis.performance ? globalThis.performance.now.bind(globalThis.performance) : Date.now
const unixNow = Date.now
const { clearTimeout, setTimeout } = getSafeTimers()
let limitMaxConcurrency: ConcurrencyLimiter

/**
* Normalizes retry configuration to extract individual values.
Expand Down Expand Up @@ -141,7 +143,7 @@ async function callTestHooks(

if (sequence === 'parallel') {
try {
await Promise.all(hooks.map(fn => fn(test.context)))
await Promise.all(hooks.map(fn => limitMaxConcurrency(() => fn(test.context))))
}
catch (e) {
failTask(test.result!, e, runner.config.diffOptions)
Expand All @@ -150,7 +152,7 @@ async function callTestHooks(
else {
for (const fn of hooks) {
try {
await fn(test.context)
await limitMaxConcurrency(() => fn(test.context))
}
catch (e) {
failTask(test.result!, e, runner.config.diffOptions)
Expand Down Expand Up @@ -188,11 +190,13 @@ export async function callSuiteHook<T extends keyof SuiteHooks>(
}

async function runHook(hook: Function) {
return getBeforeHookCleanupCallback(
hook,
await hook(...args),
name === 'beforeEach' ? args[0] as TestContext : undefined,
)
return limitMaxConcurrency(async () => {
return getBeforeHookCleanupCallback(
hook,
await hook(...args),
name === 'beforeEach' ? args[0] as TestContext : undefined,
)
})
}

if (sequence === 'parallel') {
Expand Down Expand Up @@ -311,6 +315,8 @@ async function callAroundHooks<THook extends Function>(
let useCalled = false
let setupTimeout: ReturnType<typeof createTimeoutPromise>
let teardownTimeout: ReturnType<typeof createTimeoutPromise> | undefined
let setupLimitConcurrencyRelease: (() => void) | undefined
let teardownLimitConcurrencyRelease: (() => void) | undefined

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

// Setup phase completed - clear setup timer
setupTimeout.clear()
setupLimitConcurrencyRelease?.()

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

teardownLimitConcurrencyRelease = await limitMaxConcurrency.acquire()

// Start teardown timer after inner hooks complete - only times this hook's teardown code
teardownTimeout = createTimeoutPromise(timeout, 'teardown', stackTraceError)

// Signal that use() is returning (teardown phase starting)
resolveUseReturned()
}

setupLimitConcurrencyRelease = await limitMaxConcurrency.acquire()

// Start setup timeout
setupTimeout = createTimeoutPromise(timeout, 'setup', stackTraceError)

Expand All @@ -381,6 +392,10 @@ async function callAroundHooks<THook extends Function>(
catch (error) {
rejectHookComplete(error as Error)
}
finally {
setupLimitConcurrencyRelease?.()
teardownLimitConcurrencyRelease?.()
}
})()

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

Expand All @@ -410,6 +426,7 @@ async function callAroundHooks<THook extends Function>(
])
}
finally {
teardownLimitConcurrencyRelease?.()
teardownTimeout?.clear()
}
}
Expand Down Expand Up @@ -524,7 +541,7 @@ async function callCleanupHooks(runner: VitestRunner, cleanups: unknown[]) {
if (typeof fn !== 'function') {
return
}
await fn()
await limitMaxConcurrency(() => fn())
}),
)
}
Expand All @@ -533,7 +550,7 @@ async function callCleanupHooks(runner: VitestRunner, cleanups: unknown[]) {
if (typeof fn !== 'function') {
continue
}
await fn()
await limitMaxConcurrency(() => fn())
}
}
}
Expand Down Expand Up @@ -623,7 +640,7 @@ export async function runTest(test: Test, runner: VitestRunner): Promise<void> {
))

if (runner.runTask) {
await $('test.callback', () => runner.runTask!(test))
await $('test.callback', () => limitMaxConcurrency(() => runner.runTask!(test)))
}
else {
const fn = getFn(test)
Expand All @@ -632,7 +649,7 @@ export async function runTest(test: Test, runner: VitestRunner): Promise<void> {
'Test function is not found. Did you add it using `setFn`?',
)
}
await $('test.callback', () => fn())
await $('test.callback', () => limitMaxConcurrency(() => fn()))
}

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

let limitMaxConcurrency: ReturnType<typeof limitConcurrency>

async function runSuiteChild(c: Task, runner: VitestRunner) {
const $ = runner.trace!
if (c.type === 'test') {
return limitMaxConcurrency(() => $(
return $(
'run.test',
{
'vitest.test.id': c.id,
Expand All @@ -957,7 +972,7 @@ async function runSuiteChild(c: Task, runner: VitestRunner) {
'code.column.number': c.location?.column,
},
() => runTest(c, runner),
))
)
}
else if (c.type === 'suite') {
return $(
Expand Down
64 changes: 46 additions & 18 deletions packages/runner/src/utils/limit-concurrency.ts
Copy link
Copy Markdown
Collaborator Author

@hi-ogawa hi-ogawa Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I've put a lot more limitConcurrency in runner, I optimized this to reduce microtask as much as possible. This should cost only one microtask in the happy path (i.e. when not reaching limit) and two when limited. The previous implementation always paid three microtasks.

Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
// A compact (code-wise, probably not memory-wise) singly linked list node.
type QueueNode<T> = [value: T, next?: QueueNode<T>]

export interface ConcurrencyLimiter extends ConcurrencyLimiterFn {
acquire: () => (() => void) | Promise<() => void>
}

type ConcurrencyLimiterFn = <Args extends unknown[], T>(func: (...args: Args) => PromiseLike<T> | T, ...args: Args) => Promise<T>

/**
* Return a function for running multiple async operations with limited concurrency.
*/
export function limitConcurrency(concurrency: number = Infinity): <Args extends unknown[], T>(func: (...args: Args) => PromiseLike<T> | T, ...args: Args) => Promise<T> {
export function limitConcurrency(concurrency: number = Infinity): ConcurrencyLimiter {
// The number of currently active + pending tasks.
let count = 0

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

return (func, ...args) => {
// Create a promise chain that:
// 1. Waits for its turn in the task queue (if necessary).
// 2. Runs the task.
// 3. Allows the next pending task (if any) to run.
return new Promise<void>((resolve) => {
if (count++ < concurrency) {
// No need to queue if fewer than maxConcurrency tasks are running.
resolve()
const acquire = () => {
let released = false
const release = () => {
if (!released) {
released = true
finish()
}
else if (tail) {
}

if (count++ < concurrency) {
return release
}

return new Promise<() => void>((resolve) => {
if (tail) {
// There are pending tasks, so append to the queue.
tail = tail[1] = [resolve]
tail = tail[1] = [() => resolve(release)]
}
else {
// No other pending tasks, initialize the queue with a new tail and head.
head = tail = [resolve]
head = tail = [() => resolve(release)]
}
}).then(() => {
// Running func here ensures that even a non-thenable result or an
// immediately thrown error gets wrapped into a Promise.
return func(...args)
}).finally(finish)
})
}

const limiterFn: ConcurrencyLimiterFn = (func, ...args) => {
function run(release: () => void) {
try {
const result = func(...args)
if (result instanceof Promise) {
return result.finally(release)
}
release()
return Promise.resolve(result)
}
catch (error) {
release()
return Promise.reject(error)
}
}

const release = acquire()
return release instanceof Promise ? release.then(run) : run(release)
}

return Object.assign(limiterFn, { acquire })
}
2 changes: 1 addition & 1 deletion packages/vitest/src/node/cli/cli-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -733,7 +733,7 @@ export const cliOptionsConfig: VitestCLIOptions = {
},
},
maxConcurrency: {
description: 'Maximum number of concurrent tests in a suite (default: `5`)',
description: 'Maximum number of concurrent tests and suites during test file execution (default: `5`)',
argument: '<number>',
},
expect: {
Expand Down
44 changes: 0 additions & 44 deletions test/cli/fixtures/fails/concurrent-suite-deadlock.test.ts

This file was deleted.

40 changes: 0 additions & 40 deletions test/cli/fixtures/fails/concurrent-test-deadlock.test.ts

This file was deleted.

Loading
Loading