Repository navigation
Conversation
…h the test timeout expect(p).resolves / .rejects, toThrow() on an async function and async expect.extend matchers wait for their promise by ticking the event loop from inside the matcher. The per-test timeout timer does fire during those ticks, but the runner cannot give up an entry whose callback is still on the stack, so a promise that never settles kept the whole bun test run ticking forever. The three waits now go through Expect::wait_for_promise, which polls a bun_test::WaitingEntry between ticks and gives up, throwing a matcher error, once the entry the matcher runs in has timed out. With a callback on the stack the matcher watches the deadline itself (its own entry's, or every executing entry of the group when that callback is itself blocked in a matcher, in which case it also queues the re-step the timer would have so the entry is reported as timed out). Reached from a continuation, it only notices the runner giving the entry or its group up, which also ends right away a continuation that outlived its file.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (6)
Included review availability: Your plan provides up to 5 included reviews per hour; 1 remains after this review. WalkthroughChangesMatcher timeout flow
Suggested reviewers: Merge Risk: ⚪ Minimal · up to This change addresses matcher timeout behavior, and no actionable merge-blocking risk remains beyond normal checks and review. 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Full details: Description checkExplanation The description clearly explains the problem, implementation, affected cases, timer behavior, and verification results. It does not use the exact template headings, but it provides the required information in equivalent sections. Comment |
|
Updated 6:26 PM PT - Aug 26th, 2026
✅ @robobun, your commit daabd8b90dfb7b87f10b06a82153c55e700bcfb2 passed in 🧪 To try this PR locally: bunx bun-pr 39089That installs a local version of the PR into your bun-39089 --bun |
|
Reproduced on the released binary: each of the six hanging fixtures in |
…3865 snapshot Debug builds report one more frame than release builds for errors thrown inside async test bodies, so the fixtures' stderr is compared without the frame lines. The 23865 fixture is a toThrow() blocked past its test's timeout and now prints the matcher's error before the timeout line.
|
Cross-references for triage, since this overlaps with two other open PRs:
|
…t gave up A wait reached from a continuation no longer steps the runner past the entry from inside the matcher. It marks the entry's sequence timed out at the deadline (what evaluate_timeout does when the runner re-steps) and throws; the completion that produces then reports the entry as timed out, attributed to it, so a test that passes on retry no longer exits 1 for the first attempt's late error. The timeout timer leaves the one running entry's deadline to such a wait (Execution::defers_timeout_to_matcher_wait) and comes back 1ms later for the rest. A wait whose entry the runner is already past (it was never awaited, and its test completed without it) waits on while the file runs, since its promise may still settle, and gives up when the file is done. The matcher's error now says whether the test timed out or ended. wait_for_promise_or_give_up asks the predicate after each tick, before the poll that may park: what the tick ran (the runner finishing the file) may be what ends the wait. Tests: a test that passes on retry after a blocked attempt exits 0; leaked waits settle while the file runs or end with it; the continuation fixtures are attributed to their test.
… by the clock The timer callback and the runner step sample the clock separately. On macOS the timer can fire inside the gap: the defer check says the entry is not due yet, then the step's fresh clock says it is and steps past the entry, so the wait reports the test as ended and the matcher error lands between tests. The wait now registers which entry (or group) it claims, and the timer defers exactly when the claim still names the entry that is due. A stale claim stops deferring, so a wait the runner moved past can no longer hold the timer off the current entry.
|
Findings from a second self-review pass, checked against daabd8b on a Linux debug build. None is a regression against main (main hangs in all three), so they are follow-ups for this branch or a later PR.
|
Problem
await expect(new Promise(() => {})).resolves.toBe("x")hangsbun testat 100% CPU instead of failing with the test timeout. Same for.rejects, asynctoThrow(), asyncexpect.extendmatchers andexpect.resolvesTo. This is the hang inbun testwaits at 100% cpu usage for a promise that will resolve after expect() #14950 (Jest parity for the un-awaited case stays with bun:test: settle async matchers with promise reactions instead of re-entering the event loop #33289).src/runtime/test_runner/expect.rs). The timeout timer fires but can only queue a re-step forBunTest::run, blocked behind the test callback.Fix
EventLoop::wait_for_promise_or_give_upasks a predicate before each poll. The three matcher waits poll abun_test::WaitingEntrythrough it. On give-up the matcher throwsReceived promise that was still pending when the test timed out(orended) and the test is reported as timed out.WaitingEntrychecks the clock against its entry's deadline, marks the entry's sequence timed out the wayevaluate_timeoutdoes, and throws. The timer callback leaves that deadline to it (defers_timeout_to_matcher_wait), so the completion lands on the entry, not on the run. A wait the test never awaited ends with the file.test/cli/test/test-timeout-behavior.test.ts, 11 new tests, 13/13 on Linux and Windows x64 debug builds. The released binary hangs on them. Other suites in Notes.Background
.resolvesin bun:test blocks in a nested event loop until the promise settles. event loop: never re-enter from inside a dispatch callback (de-block expect().resolves and HTMLRewriter.transform, then assert) #33261 wants that re-entry gone and bun:test: settle async matchers with promise reactions instead of re-entering the event loop #33289 replaces it with promise reactions. This PR bounds the wait until then and touches the same files as bun:test: remove unsafe from the test runner #40235.bun_test.rs):BunTest::rundrains queued results and never re-enters itself.Startre-steps the group and reports entries past their deadline. The one file timer queuesStartand callsrun.Notes
History. Until #24355 (the fix for #23865) the timeout timer requested VM termination, which crashed in the #23865 fixture. This PR supersedes #36716, which bounded the same three waits by
get_active_timeout()captured at loop entry. Both failed on main at 731aa92 (5 and 6 hanging fixtures). #36716 conflicts with main since #37075, hangs when the continuation reaches the matcher after the runner has finished the file (phaseDone,get_active_timeout()returns EPOCH), and does not cover hooks or concurrent groups. Its toThrowset_handledfix and its test cases (the #14950 shape, late rejection, already-settled) are folded in here.Attribution.
Execution::on_stack_entryis the entry whose callback the runner is synchronously inside.get_current_state_data()names what is executing (the entry, or only the group when tests run concurrently).RefDataValue::entryfinds an entry only while it is still the one executing, which is how the runner detects outdated completions. A continuation of a test the runner already gave up on is charged to whatever runs now (like itsexpect()calls are). Inside a concurrent group a nested wait is charged to the group as a whole. The per-invocation refs proposed in #38880 would sharpen both.What each case does (
EndsWheninbun_test.rs):EntryTimedOut: a callback is on the stack and is not itself blocked in a matcher. The wait watchesExecutionEntry::has_timed_outand arms the file timer for the deadline on each poll (wake_at). When it throws, the callback completes andstep_sequence_oneevaluates the overrun as for a sync test that ran too long: the error, thenthis test timed out after Nms.GroupTimedOut: the on-stack callback is itself blocked in a matcher (Execution::blocking_matcher_waits), so the JS running is some continuation of the group, which one unknowable. Once every executing entry of the group has timed out, the wait marks them so (evaluate_timeout) and gives up.Continuation: a continuation of the one running entry, the runner live. At the entry's deadline the wait marks the sequence timed out and gives up. The body's rejection then completes the entry, attributed, and the runner advances it (retry, repeat, or report). The timer callback, seeing a continuation wait whose claim still names the one running entry (Execution::matcher_wait_claim), only kills dangling processes and re-arms for the entry's deadline, so it never moves the runner past the entry first. If the runner is past the entry anyway (a wait the test never awaited, so the test completed without it), the wait goes on while the file runs and gives up once the file is done (ended).GroupOver: a continuation inside a concurrent group, entry unknown. Gives up once every executing entry has timed out (marking them), or once the group is over (ended).Windows. The first version of this branch hung in CI on both Windows lanes in the continuation fixture: the runner's timer never fired inside the nested wait. Reproduced on a Windows x64 debug build (100% CPU, no output). A JS timer inserted from inside the wait re-arms the libuv timer and unblocks it. Two fixtures avoid the limitation on purpose and say so: the concurrent-sibling one blocks both siblings on a promise that never settles, and the leaked waits are immediates, not timers.
Disarmed timer. With a server (or interval) keeping the loop alive, an earlier test's timer firing under the blocked wait disarmed the file timer, and the poll parked past the deadline. The GC idle timer woke it about once a second, so the test failed about 1 s late. With
BUN_GC_TIMER_DISABLE=1it hung. The new fixture runs with that variable set.toThrow.
get_value_as_to_throwdid not mark the function's promise handled before the wait, unlike the other two sites, so a rejection arriving after the wait gave up was reported as an unhandled rejection of the next test.Self-review findings addressed in 9aa670e. The first version stepped the runner (
handle_timeout+BunTest::run) from inside the wait for continuations, so their error always arrived as an outdated rejection (Unhandled error between tests): a test that passed on retry exited 1. It also said "timed out" when a leaked wait's test had merely finished, and gave up on such a wait at once instead of letting its promise settle while the file ran.Darwin race fixed in 454f6dc. The timer callback used to defer only when the entry had already timed out on the callback's own clock. macOS fires timers rounded to the millisecond, so the callback could run just before the deadline, decline to defer, and then step past the entry on
step's later clock sample: the matcher error arrived asUnhandled error between tests(build 106465, darwin x64, the concurrent-continuation and retry fixtures). The deferral now keys on the wait's claim alone, with no clock check, and a stale claim (the runner already past the wait's entry) stops deferring instead of holding the timer off the current entry.Also run:
expect.test.js,expect-extend.test.js,expect-assertions.test.ts,test-test.test.ts,jest-hooks.test.ts,bun-test.test.ts,done-async,test-failing,test-retry-repeats-basic,concurrent.test.ts,node-test.test.ts,23865.test.ts.expect-extend"intensive usage" andconcurrent_immediatetime out locally on a loaded debug build with or without this change (5 s default, about 5.2 s and 2 x 3 s children).Found on the way, not changed here. Immediates queued in the same batch as the callback a matcher blocks in are not run until it returns. The runner busy-loops rather than sleeping while only the test timeout is pending. A completion of a timed-out attempt that arrives while its retry runs is accepted as the retry's (
get_current_and_valid_execution_sequencecompares the entry and the repeat count, not the attempt).no test proof · iteration 1 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/cli/test/test-timeout-behavior.test.ts