Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
48 changes: 16 additions & 32 deletions packages/runner/src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ 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 @@ -36,7 +35,6 @@ 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 @@ -142,8 +140,9 @@ async function callTestHooks(
}

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

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

if (sequence === 'parallel') {
const limit = limitConcurrency(runner.config.maxConcurrency)
callbacks.push(
...(await Promise.all(hooks.map(hook => runHook(hook)))),
...(await Promise.all(hooks.map(hook => limit(() => runHook(hook))))),
)
}
else {
Expand Down Expand Up @@ -315,8 +313,6 @@ 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 @@ -358,22 +354,17 @@ 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 @@ -392,10 +383,6 @@ 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 @@ -407,7 +394,6 @@ async function callAroundHooks<THook extends Function>(
])
}
finally {
setupLimitConcurrencyRelease?.()
setupTimeout.clear()
}

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

if (runner.runTask) {
await $('test.callback', () => limitMaxConcurrency(() => runner.runTask!(test)))
await $('test.callback', () => runner.runTask!(test))
}
else {
const fn = getFn(test)
Expand All @@ -649,7 +634,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', () => limitMaxConcurrency(() => fn()))
await $('test.callback', () => fn())
}

await runner.onAfterTryTask?.(test, {
Expand Down Expand Up @@ -898,7 +883,8 @@ export async function runSuite(suite: Suite, runner: VitestRunner): Promise<void
else {
for (let tasksGroup of partitionSuiteChildren(suite)) {
if (tasksGroup[0].concurrent === true) {
await Promise.all(tasksGroup.map(c => runSuiteChild(c, runner)))
const groupLimiter = limitConcurrency(runner.config.maxConcurrency)
await Promise.all(tasksGroup.map(c => groupLimiter(() => runSuiteChild(c, runner))))
}
else {
const { sequence } = runner.config
Expand Down Expand Up @@ -1006,8 +992,6 @@ async function runSuiteChild(c: Task, runner: VitestRunner) {
}

export async function runFiles(files: File[], runner: VitestRunner): Promise<void> {
limitMaxConcurrency ??= limitConcurrency(runner.config.maxConcurrency)

for (const file of files) {
if (!file.tasks.length && !runner.config.passWithNoTests) {
if (!file.result?.errors?.length) {
Expand Down
15 changes: 15 additions & 0 deletions packages/runner/src/utils/limit-concurrency.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,18 @@
// Concurrency strategy: DFS with bounded parallelism
//
// Each concurrent fan-out in the task tree (concurrent suite children, parallel hooks)
// creates a short-lived local limiter instance scoped to that group's lifetime.
// Each child holds one slot for its entire subtree duration — released only when the
// subtree fully completes (all before/after hooks at every level inside it).
//
// Guarantee: at any concurrent group, at most `maxConcurrency` subtrees are
// simultaneously in-flight. This bounds resource ownership at every level of the tree,
// not just at leaf (test) level.
//
// Why per-group instances instead of one global: a global limiter held across entire
// subtrees would deadlock when nested concurrent groups try to acquire slots from the
// same exhausted pool. Per-group instances are independent across tree levels.

// A compact (code-wise, probably not memory-wise) singly linked list node.
type QueueNode<T> = [value: T, next?: QueueNode<T>]

Expand Down
4 changes: 2 additions & 2 deletions test/cli/test/around-each.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2332,7 +2332,7 @@ test('nested aroundEach setup error is not propagated to outer runTest catch', a
| ^
18| })
19|
❯ nested-around-each-setup-error.test.ts:7:11
❯ nested-around-each-setup-error.test.ts:7:17
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
Expand Down Expand Up @@ -2459,7 +2459,7 @@ test('nested aroundAll setup error is not propagated to outer runSuite catch', a
| ^
18| })
19|
❯ nested-around-all-setup-error.test.ts:7:11
❯ nested-around-all-setup-error.test.ts:7:17
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
Expand Down
8 changes: 2 additions & 6 deletions test/cli/test/concurrent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,9 @@ describe.concurrent('wrapper', () => {
defers[1].resolve()
await defers[2]
})
})
// })

describe('2nd suite', () => {
// describe('2nd suite', () => {
test('c', async () => {
expect(1).toBe(1)
await defers[1]
Expand Down Expand Up @@ -150,8 +150,6 @@ test('suite deadlocks with insufficient maxConcurrency', async () => {
"Test timed out in 500ms.
If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout".",
],
},
"2nd suite": {
"c": "passed",
},
},
Expand All @@ -175,8 +173,6 @@ test('suite passes when maxConcurrency is high enough', async () => {
"1st suite": {
"a": "passed",
"b": "passed",
},
"2nd suite": {
"c": "passed",
},
},
Expand Down
Loading