bun test: report a test that completes after its timeout as timed out - #39440
bun test: report a test that completes after its timeout as timed out#39440robobun wants to merge 5 commits into
Conversation
|
Status: reproduced on main with the three blocking bodies from the report (busy loop, |
|
Warning Review limit reached
Next review available in: 8 minutes Limit details: You’ve used all 5 included reviews currently available. Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits within each organization. For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (3)
Comment |
|
Pushed 61ab141 for the one review comment (the fixture helper now drains stdout and snapshots it). The CodeRabbit "Review failed" notice and the two failed Actions jobs on the first push ( |
There was a problem hiding this comment.
I re-reviewed after 61ab141 (which addressed the stdout-drain nit) and didn't find any bugs. The fix mirrors the existing synchronous-return path's evaluate_timeout call and the callback_completed flag correctly gates the done-callback hint at all three call sites. Because this sits in Execution::step — the core verdict path every bun test goes through — and interacts with retry/repeat, test.failing, and concurrent-group siblings, a maintainer look would still be worthwhile.
Checked: the new &mut *sequence_ptr borrow is dead before advance_sequence re-derives (matches the pattern used throughout this file); evaluate_timeout overwriting sequence.result on the async path now matches what the sync path already did for .failing/rejected promises; maybe_skip = true routes through advance_sequence identically to the sync path so afterEach handling is unchanged.
The comment-cop flag on bun_test.rs:1949 is the new 4-line doc comment on evaluate_timeout — it documents the callback_completed parameter, not a workaround, so it reads as a false positive to me.
Extended reasoning...
Overview
Two-file Rust change in the test runner plus new integration tests. Execution::step (the async-completion path) now calls evaluate_timeout on the just-completed entry before advancing the sequence, matching what step_sequence_one already does on the synchronous-return path. ExecutionEntry::evaluate_timeout gains a callback_completed: bool so the ...WithDoneCallback result variants are only produced when the timer fires while the callback is genuinely still waiting. Four new subprocess fixtures in test-test.test.ts cover sequential/concurrent × sleepSync/busy-loop/spawnSync and both done()-late shapes.
Security risks
None. No untrusted input parsing, no auth/crypto, no FFI surface change. The unsafe blocks added follow the file's established raw-pointer-reborrow pattern (NonNull into execution.sequences, dereferenced at point of use, dead before the next reborrow).
Level of scrutiny
High — Execution::step is on the hot path of every bun test run and decides pass/fail verdicts. The change is small (~15 Rust lines) and mirrors existing logic, but the interaction surface is wide: retry/repeat (a blocked test now correctly counts as failed and consumes a retry), test.failing (a failing test that both throws and exceeds its deadline now reports timeout rather than pass — this already happened on the sync path, so the async path is being made consistent), concurrent-group siblings, and hook vs. test entries. I traced each of these and they look correct, but a maintainer who owns the test runner should confirm the .failing-plus-timeout precedence is the intended one.
Other factors
My previous review's stdout-drain nit was addressed in 61ab141 and the snapshots now include stdout. The github-actions comment-cop fired on the new doc comment for evaluate_timeout; that comment is a 4-line parameter description, not a workaround justification, so I read it as a false positive — but it is technically unresolved. The PR description lists an extensive set of related test files run on the debug build and notes the one-line conflict with two other open PRs touching the same function.
|
The stdout-drain comment was addressed in 61ab141 (thread resolved). Since then: f9070c0 trims the |
|
CI summary for the first push (build 100002, e463433): 3 test files failed, all in |
|
Updated 4:12 PM PT - Aug 18th, 2026
❌ @robobun, your commit 4400766 has 1 failures in
🧪 To try this PR locally: bunx bun-pr 39440That installs a local version of the PR into your bun-39440 --bun |
|
Reworked after the review finding on |
|
State of the PR: code is unchanged since 2f04881 (d75b4dc is an empty commit to get a fresh CI run). The last review thread ( |
There was a problem hiding this comment.
I reviewed this revision and found no remaining issues — the earlier findings (the stdout drain, the completion-time-vs-process-time regression, and the sequential done(err) gap deferred to #39112) are all addressed or acknowledged. Because this changes test-verdict semantics in the runner's unsafe-Rust core and intentionally leaves one sibling path to a follow-up PR, a human look is still worthwhile.
What was reviewed:
handle_callback_completedreusesget_current_and_valid_execution_sequenceso stale/mismatched completions are ignored, matchingstep's guard.- The
callback_completedflag onevaluate_timeout: verified all three call sites pass the right value and that the...WithDoneCallbackvariants are treated identically by every consumer (reporter, junit, debugger status mapping). - The fifth fixture's ordering (queueMicrotask → sibling blocks before the queue drains) does exercise the arrival-vs-processing distinction the earlier revision got wrong.
Extended reasoning...
Overview
The PR adds a deadline check at the moment an async test/hook callback completes (promise then/catch and done()), so a test that blocks synchronously past its timeout after resuming from the event loop is reported as timed out instead of passing. It touches src/runtime/test_runner/Execution.rs (new handle_callback_completed, a callback_completed flag on evaluate_timeout, two call-site updates), src/runtime/test_runner/bun_test.rs (two on_callback_completed hooks and the wrapper), and adds five subprocess fixtures in test/js/bun/test/test-test.test.ts.
Security risks
None. No untrusted input, no auth/crypto/permissions surface. The change reads a timestamp and writes an enum on a runner-owned struct.
Level of scrutiny
High. This is the test runner's verdict machinery — heavy unsafe pointer aliasing over UnsafeCell, and a user-visible pass→fail semantic change. An earlier revision of this PR did introduce a real pass→fail regression (judging at queue-drain time instead of completion time), which I flagged and the author fixed; the new fixture covers it. That history alone argues for a maintainer sign-off on the final shape.
Other factors
- All five prior review threads (mine and the comment lint's) are resolved. The one intentionally-unfixed sibling — sequential
done(err)past deadline still printing plain(fail)— is unchanged from main, still a failure, and documented as landing via #39112 which restructures that block. - Test coverage is good: five fixtures cover sequential, concurrent-with-sibling (busy loop and spawnSync), done-callback (timer and synchronous), and the regression guard for a sibling blocking after a completed test. The author reports 5 consecutive clean debug runs plus the broader test-runner suite.
- CI failures on the last two builds are all
test/cli/installfetches from api.github.com returning 429/503 — unrelated to the diff. - The
unsafein the new function follows the file's existing pattern exactly (get_current_and_valid_execution_sequencereturningNonNullintoself.sequences, deref at point-of-use with a SAFETY comment), andhandle_uncaught_exceptionimmediately below is the same shape.
When a test callback resumes from the event loop and then blocks synchronously past its deadline, its completion is queued before the timeout timer can fire, and Execution::step advanced the sequence without looking at the deadline, so the test was reported as passing. Check the entry's deadline when processing the completion, as the synchronous-return path already does. Both completion paths now record a plain timeout: the hint about a missing done() call only applies while the callback is still running.
…cessed Evaluating the deadline in Execution::step used the time the queued completion was processed, so in a concurrent group a sibling blocking after a test had already finished could get that test reported as timed out. Evaluate in the promise and done() handlers instead, and add a test for the sibling case.
d75b4dc to
4400766
Compare
|
Rebased onto main (4400766). The only conflict was with #39570, which made |
There was a problem hiding this comment.
I reviewed this PR across several iterations and the current revision addresses everything raised (stdout drain, the completion-time vs. processing-time regression, and the done(err) edge deferred to #39112). No bugs found on this pass. Because it changes user-visible timeout semantics in the test runner — including how concurrent siblings are attributed when one blocks the event loop — a human look at the semantic call would still be worthwhile.
What was reviewed:
handle_callback_completedreusesget_current_and_valid_execution_sequence, so stale/mismatched completions are filtered the same waystepfilters them.- The
callback_completedflag onevaluate_timeoutonly suppresses the...WithDoneCallbackhint variants; junit/debugger/summary consumers already treat those identically to the plain timeout results. - The fifth fixture guards the pass→fail regression the earlier revision introduced; the 300ms-vs-100ms margins keep the outcomes scheduling-independent.
bun_test_catch(is_catch=true) still reacheson_callback_completedafteron_uncaught_exception, so a rejected promise past deadline getsFailBecauseTimeout(overwriting theFail/Passset byhandle_uncaught_exception), matching the stated precedence.
Extended reasoning...
Overview
The PR adds Execution::handle_callback_completed (called from the promise-resolution and done() handlers via BunTest::on_callback_completed) so that a test callback's deadline is checked at the moment the callback finishes, not only by the separate timeout timer. It threads a callback_completed: bool through ExecutionEntry::evaluate_timeout so the "before its done callback was called" hint is only produced by the timer path where done() really is still pending. Five subprocess fixtures in test/js/bun/test/test-test.test.ts cover sequential/concurrent × sleepSync/busy-loop/spawnSync, plus the done() variants and the sibling-blocks-after-completion guard.
Security risks
None. This is verdict computation inside the test runner; no untrusted input, no I/O surface changes, no auth/crypto.
Level of scrutiny
Medium-high. The test runner is a critical path for every user of bun test, and this changes pass/fail attribution: tests that previously (incorrectly) passed after synchronously blocking past their deadline will now fail, and in a test.concurrent group a test whose resumption is delayed past its deadline by a sibling's synchronous block will also be reported as timed out. The PR argues this is the honest interpretation (the printed duration already exceeds the timeout, and on main the outcome was scheduling-dependent), which is reasonable, but it is a semantic decision a maintainer should ratify. The Rust side is small and follows the file's existing NonNull/unsafe-deref patterns exactly.
Other factors
- All four prior review threads are resolved. The one remaining known gap (sequential
done(err)past deadline still prints plain(fail)) is not a regression, still fails the test, and is documented as covered once #39112 restructures that block. - CI is reported green on the re-run (build 100047); earlier failures were GitHub 429/503 on unrelated install tests.
- The description notes one-line signature conflicts with open PRs #38153 and #39089 that also touch
evaluate_timeout— worth a maintainer being aware for landing order. - The implementation is careful about the exact concern I raised on the earlier revision: the deadline is now sampled at completion arrival (
Timespec::now_force_real_time()insidehandle_callback_completed), not at queue-drain time, and the fifth fixture pins that.
Problem
await, or in a timer callback) and then blocks synchronously past its timeout is reported as(pass). Blocking with a busy loop,Bun.sleepSync, orBun.spawnSyncall reproduce it, for plaintest()as well astest.concurrent():bun_test_then/done(),src/runtime/test_runner/bun_test.rs) is queued without looking at the entry's deadline, andExecution::step(src/runtime/test_runner/Execution.rs) advances the sequence as a pass. The timer fires afterwards and finds the sequence already finished. The synchronous-return path instep_sequence_onealready callsevaluate_timeoutafter the callback returns; the asynchronous completion path had no equivalent.spawnSyncflavour inside atest.concurrentgroup with a sibling still running used to fail correctly in 1.3.14 (the wait loop fired the runner's timeout callback at the earliest deadline in the group). Since spawnSync: drain piped stdio to EOF after the direct child exits #33832,TestRunner::get_active_timeoutreturns the latest deadline in the group when the caller is no longer on the stack (src/runtime/test_runner/jest.rs), so it no longer kills a sibling's child, and the verdict was left to the completion path above, which dropped it. spawnSync: don't run the bun:test timeout callback while the isolated loop is active #38883 is not the cause: its deferred callback still reports the verdict whenever the wait loop does hit a deadline.Fix
BunTest::on_callback_completed, called from the promise handler and fromdone()right before the completion is queued, runsExecution::handle_callback_completed: if the completion still belongs to the running entry,evaluate_timeoutis applied against the current time. This is the same check the synchronous-return path makes, made at the equivalent moment for asynchronous callbacks.step, because the queued completion may only be processed after other callbacks have run; a test that finished in time must not be blamed for a sibling that blocks afterwards (found in review on an earlier revision, covered by the last fixture below).test.concurrentgroup this includes a test held up by a sibling blocking the event loop; its printed duration already exceeds its timeout in that case, and on main such a test passed or failed depending on the order in which its resume and the timeout timer happened to fire after the block.spawnSyncin a multi-test concurrent group still waits for the child (there is no way to tell which test the child belongs to); the test is reported as timed out when the callback completes.on_unhandled_rejection,src/runtime/test_runner/jest.rs) is aborted rather than completed and is already a failure, so that path is intentionally left alone. Todaydone(err)also reports through it, so a sequential test that overruns and then callsdone(err)still prints the error with a plain(fail)rather than the timed-out line (unchanged from main; the concurrent variant does get the line). bun test: fail the test or hook whose done() received an error #39112 changesdone(err)to report directly against its own entry, after which theon_callback_completedcall inbun_test_done_callbackcovers it too, so this PR does not restructure that block.test.failingpass. The synchronous-return path and the timer path already behave this way on main (test.failingbodies that overrun are reported astimed outon both); the asynchronous completion path was the only one that did not.ExecutionEntry::evaluate_timeouttakes acallback_completedflag. Both completion paths (synchronous return and the new one) recordFailBecauseTimeout/FailBecauseHookTimeout; the...WithDoneCallbackvariants, whose message is "timed out ..., before its done callback was called. If a done callback was not intended, remove the last parameter", are now only produced by the timer path, where the callback really is still waiting. Previously adonetest that blocked and then calleddone()synchronously printed that hint althoughdone()had been called. Every other consumer of those variants (junit, GitHub annotations, summary counts) already treats them identically to the plain ones.test/js/bun/test/test-test.test.ts, describea test that completes after its timeout has passed is reported as timed out, five fixtures run throughbun test: sequentialBun.sleepSyncafter anawait(the next test still runs);test.concurrentbusy loop with a sibling pending;test.concurrentBun.spawnSyncwith a sibling pending;done()called late both from a timer callback and synchronously; and atest.concurrenttest that returns in time while its sibling blocks for 300ms before the runner processes the completion, which must pass. On main the first three fixtures report2 pass, the fourth reports the timer test as(pass)and the synchronous one with the done-callback hint, and the fifth passes; the fifth fails on the earlier revision of this branch that checked the deadline instep. With the fix all five match their snapshots (5 consecutive runs on the debug build). The overrunning fixtures block for 300ms against a 100ms timeout, and the timer heap fires in deadline order, so none of the outcomes depend on scheduling.test/js/bun/test/{bun_test,concurrent,concurrent_immediate,done-async,test-retry-repeats-basic,test-on-test-finished,failure-skip,jest-hooks,test-error-code-done-callback,test-failing}.test.ts,test/cli/test/{test-timeout-behavior,retry-flag,rerun-each}.test.ts,test/cli/test/bun-test.test.ts -t timeout,test/js/node/test_runner/node-test.test.ts;cargo fmt --all -- --checkis clean.evaluate_timeout(different bugs); whichever lands second has a one-line conflict in its signature or call sites.Background
timespecdeadline set when it starts (on_entry_started). A sequence is a test plus itsbeforeEach/afterEachentries; a concurrent group holds the sequences that may run at the same time (one sequence for a plaintest()).run_test_callbackeither gets a result synchronously (returnsSome, handled instep_sequence_one) or registersthen/catchhandlers on the returned promise and adonecallback. When the last of those fires, it pushes aRefDataValuenaming the entry onto the file's result queue and schedules a task;BunTest::runlater drains the queue intoExecution::step, which checks that the entry is still the sequence's active one (get_current_and_valid_execution_sequence) and advances it. The new hook sits at the push and reuses that same validity check;stepis unchanged.handle_timeoutpushes aStartonto the same queue andstep_groupre-walks the running sequences, callingevaluate_timeouton each one still executing. It is the only other place a timeout verdict comes from, and it cannot run while JavaScript is blocking.spawnSyncinbun test: the wait loop ticks an isolated event loop and asks the runner for a deadline to wake up at (get_active_timeout); when that deadline passes it kills the child and, since spawnSync: don't run the bun:test timeout callback while the isolated loop is active #38883, reports the timeout once the loop is torn down. With several concurrent tests running and no caller on the stack it uses the latest deadline so that it never kills a child belonging to a test that still has time.Behaviour on 1.3.14, main, and this branch for the reported repros
Body runs after
await Bun.sleep(50)in a test with a 300ms timeout; "sibling" is a secondtest.concurrentwith a 5s timeout.Earlier revision
The first revision evaluated the deadline inside
Execution::step, i.e. when the queued completion was processed. A review comment pointed out that a sibling blocking between a test's completion being queued and the queue being drained then got that test reported as timed out; reproduced and replaced by the completion-time hook above, with the fifth fixture covering it.no test proof · iteration 1 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/test/test-test.test.ts