Skip to content

maxConcurrency no longer bounds in-flight tests in describe.concurrent, causing per-test resource leaks (regression from v3?) #10097

@dbousamra

Description

@dbousamra

Describe the bug

Summary

In vitest 3, maxConcurrency bounded the number of tests that were in flight at any moment - that is, the number of tests whose beforeEach/body/afterEach cycle had started but not yet completed. After #9653, the limiter was moved from runTest-level to leaf-level (individual hook/body function calls). This was done to fix #8367, where sibling beforeAll hooks weren't being throttled. But it removed a guarantee that resource-owning test suites were relying on: that resources allocated in beforeEach and freed in afterEach would only ever exist maxConcurrency at a time.

In v4, with describe.concurrent and an expensive beforeEach, the runtime now fires every test's beforeEach to completion before any test body runs, because the FIFO limiter releases between leaf calls and the queued bodies are appended after every queued beforeEach. The result is that all N concurrent tests own their per-test resources simultaneously, regardless of maxConcurrency.

Impact

This affects test suites that:

  • Use describe.concurrent (or concurrent: true via config)
  • Allocate per-test resources in beforeEach (DB pools, app instances, file handles, network sockets, cloned databases)
  • Free those resources in afterEach

Just to give an example scenario, inside each of our tests, we create and connect to a database. In v3, we would have max 5 of those connections active at any one time. In v4 we have 400 (we have 400 tests), even though only 5 tests happening at once. The 395 sit there connected, waiting for a test slot. Every concurrent test holds its a DB conn for the entire duration of all sibling tests' beforeEach calls.

The new behaviour is internally consistent and the PR description (#9653) is reasonable: maxConcurrency now means "max user functions executing at any instant", which is a sensible interpretation of the option name. But it leaves no built-in way to express "max tests that own resources at any instant". These were the same number in v3 (as a side effect of where the limiter wrapped); they're decoupled in v4.

The available knobs in v4 don't fill the gap:

  • maxConcurrency: now leaf-level, doesn't bound resource ownership
  • maxWorkers / fileParallelism: worker-level, doesn't help inside a single concurrent file
  • pool/isolate: orthogonal
  • singleFork/singleThread: collapses parallelism entirely, defeats the purpose of describe.concurrent

The only fix I can see is to add a paired semaphore in user-space (acquire in beforeEach, release in afterEach) or implement a custom test runner that overrides onBeforeRunTask/onAfterRunTask. Both work, but they require users to know that the v4 behaviour exists, and effectively re-implement the v3 guarantee outside of vitest. Workaround I've got going atm (per-worker semaphore in the test setup file):

import { limitConcurrency } from '@vitest/runner/utils'

const limiter = limitConcurrency(5)
const releases = new WeakMap<object, () => void>()

beforeEach(async (context) => {
  const release = await limiter.acquire()
  releases.set(context.task, release)
  // ... real setup that allocates resources
})

afterEach(async (context) => {
  try {
    // ... real teardown that frees resources
  } finally {
    releases.get(context.task)?.()
    releases.delete(context.task)
  }
})

What I'd like to see:

  1. A new option along the lines of maxInFlightTests (or sequence.maxInFlightTests) that re-exposes the v3 guarantee - wraps runTest end-to-end in a separate semaphore, sized independently of maxConcurrency. Defaults to Infinity so it's opt-in and doesn't change behaviour for anyone.
  2. Or documentation on maxConcurrency that explicitly calls out the v3 to v4 behaviour change and points users at the workaround pattern (acquire/release in setup/teardown), so the next team that hits this knows what's happening before they spend a day debugging connection limits.
  3. Or a first-class hook in the runner API for "around test" with paired acquire/release semantics, so user-side semaphores have somewhere natural to live.

I'd be happy to put up a PR for option 1 if there's interest - it's a small change (a second limitConcurrency instance, used at the same call site #9653 removed) and it's strictly additive.

Reproduction

https://stackblitz.com/edit/vitest-dev-vitest-munmai6p?file=test%2Fleak.test.ts

Here's a basic example showing that hooks can exceed maxConcurrency.

System Info

System:
    OS: macOS 26.3
    CPU: (18) arm64 Apple M5 Pro
    Memory: 475.66 MB / 48.00 GB
    Shell: 5.9 - /bin/zsh
  Binaries:
    Node: 24.0.2 - /Users/dbousamra/.nvm/versions/node/v24.0.2/bin/node
    Yarn: 3.5.1 - /opt/homebrew/bin/yarn
    npm: 11.3.0 - /Users/dbousamra/.nvm/versions/node/v24.0.2/bin/npm
    pnpm: 10.11.0 - /Users/dbousamra/Library/pnpm/pnpm
    bun: 1.3.3 - /Users/dbousamra/.bun/bin/bun
    Deno: 2.5.6 - /Users/dbousamra/.deno/bin/deno
  Browsers:
    Chrome: 146.0.7680.178
    Firefox: 148.0
    Safari: 26.3

Used Package Manager

yarn

Validations

Metadata

Metadata

Assignees

Labels

p3-minor-bugAn edge case that only affects very specific usage (priority)

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions