Skip to content

fix(expect): race slow async poll fn against timeout deadline#9929

Open
Anas0709 wants to merge 1 commit intovitest-dev:mainfrom
Anas0709:fix/expect-poll-timeout-race
Open

fix(expect): race slow async poll fn against timeout deadline#9929
Anas0709 wants to merge 1 commit intovitest-dev:mainfrom
Anas0709:fix/expect-poll-timeout-race

Conversation

@Anas0709
Copy link
Copy Markdown

Summary

Closes #9844

When the expect.poll callback is a slow async function, await fn() blocks for the full duration of fn regardless of the configured timeout. The timeout flag fired correctly but could not interrupt the in-flight await, so tests took orders of magnitude longer than expected (or hung until the global test timeout).

Root cause: the poll loop did await fn() unconditionally — no race against the deadline.

Fix: create a deadline Promise that rejects when the timeout fires and use Promise.race([fn(), deadlinePromise]) on non-last-attempt iterations. The last attempt still awaits fn() directly to preserve the existing "one final chance" semantics (_isLastPollAttempt). When the deadline wins, the last recorded assertion error (if any) is surfaced with the expected cause message so output is consistent with the normal timeout path.

Behaviour before / after

// fn takes 1000 ms, timeout is 100 ms
test('why 2000', async () => {
  await expect.poll(
    async () => { await new Promise(r => setTimeout(r, 1000)) },
    { timeout: 100 },
  ).toBe(1)
})
Before After
why 2000 fails after ~2075 ms fails after ~100 ms ✅
stuck (fn: 10 s) hangs until 5 s test timeout fails after ~100 ms ✅
existing fast-fn tests
should handle success on last attempt ✅ (last-chance preserved)

Test plan

  • Added slow async fn is interrupted when timeout expires — asserts the test fails in < 1 s with "Matcher did not succeed in time."
  • Added slow async fn timeout surfaces last assertion error when available — asserts the last assertion message is preserved when fn slows down after the first fast call
  • All existing expect-poll tests still pass across threads, vmThreads, and forks pools

When the poll callback is a slow async function, `await fn()` would
block for the full duration of fn regardless of the configured timeout.
The timeout flag fired correctly but couldn't interrupt the in-flight
await, so tests could take orders of magnitude longer than expected or
hang until the global test timeout.

Fix by creating a deadline promise that rejects when the timeout fires
and racing it against fn() on non-last-attempt iterations. The last
attempt still awaits fn() directly to preserve the existing "one final
chance" semantics. When the deadline wins the race, the last recorded
assertion error (if any) is surfaced with the expected cause message so
the output remains consistent with the normal timeout path.

Fixes vitest-dev#9844
@netlify
Copy link
Copy Markdown

netlify bot commented Mar 20, 2026

Deploy Preview for vitest-dev ready!

Built without sensitive environment variables

Name Link
🔨 Latest commit a86900a
🔍 Latest deploy log https://app.netlify.com/projects/vitest-dev/deploys/69bd90c41ab3d800088df1da
😎 Deploy Preview https://deploy-preview-9929--vitest-dev.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

expect.poll(.., { timeout }) doesn't fail early on given timeout

1 participant