Conversation
…unning afterEach An unhandled rejection inside an async test made on_unhandled_rejection enqueue a completion token for the still-executing entry, so Execution::step advanced to afterEach and the next test while the body's returned promise was still pending. The rest of the test then ran with the next test's beforeEach/afterEach context. Track has_pending_promise on the sequence: run_test_callback sets it when the callback returns a pending promise (before draining rejections), and advance_sequence clears it. on_unhandled_rejection records the failure but skips the completion enqueue while the flag is set, leaving the body's own promise (or a timeout) to advance execution. done()-only tests keep advancing on an uncaught exception as before. The updated test-error-code-done-callback snapshot drops a frame that pointed into the previous test's body: with the old inline advance, an async+done test's done(err) synchronously started the next test, leaking its call site into the next test's async stack. Fixes #14644
|
Status: Gate confirmed fail-before/pass-after. Automated review found no issues. CI build 87165: the lanes covering this change (linux x64/aarch64, alpine, debian incl. ASAN, ubuntu, macOS, Windows x64/aarch64) all pass my test and the related test-runner tests. The remaining red is unrelated: |
|
Warning Review limit reached
Next review available in: 7 minutes 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 for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling 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 (6)
Comment |
|
Found 2 issues this PR may fix:
🤖 Generated with Claude Code |
|
On the two linked issues:
|
There was a problem hiding this comment.
I didn't find any bugs. The fix is small and looks correct, but it changes ordering semantics in the test runner's execution state machine (unhandled-rejection path now defers to the body's own promise/timeout instead of advancing immediately), so worth a human look.
What was reviewed:
- Traced the flag lifecycle: set in
run_test_callbackbefore the rejection drain, read only when the active entry is the test entry, cleared inadvance_sequenceandreset_sequence— no leak across entries or retries. - Checked
done-infinity.fixture.tscases still terminate: done-only tests never set the flag (no returned promise), andasync done => { … throw }advances viabun_test_catchon the body's own rejection. - Verified
bun_test_done_callback'svm.uncaught_exceptionroutes throughon_unhandled_rejectionunderisBunTest, which explains the dropped stack frame in the snapshot (next test no longer starts synchronously from insidedone(err)).
Extended reasoning...
Overview
Adds a has_pending_promise flag to ExecutionSequence. run_test_callback sets it when the callback returns a still-pending promise (before the handle_rejected_promises drain loop), advance_sequence clears it, and on_unhandled_rejection — when the flag is set and the active entry is the test entry — records the failure via on_uncaught_exception but skips add_result/BunTest::run. The body's own .then() handler (or a timeout) is what enqueues completion. Two regression tests cover the microtask-drain and macrotask paths; 14624 is tightened; one snapshot frame is dropped.
Security risks
None. No untrusted input, no boundary crossings; this is internal test-runner sequencing.
Level of scrutiny
Medium-high. The diff is ~30 lines of runtime code, but it sits in the test runner's execution state machine where done-callbacks, returned promises, unhandled rejections, timeouts, and maybe_skip all interact. I traced the paths I could think of (done-only, async+done, async-throw, macrotask rejection, concurrent groups, hooks, retry/repeat reset) and they hold up, but this is exactly the kind of subsystem where a maintainer's mental model catches an edge I don't have.
Other factors
done-infinity.fixture.tsstill terminates: tests 4–7 return no promise so the flag is never set; tests 1–3 are async and advance viabun_test_catchwhen the returned promise itself rejects (that path doesn't consult the flag).- Hooks: the flag can be set for an async hook, but
on_unhandled_rejectiononly reads it whenentry == test_entry; for hooks it falls through toRefDataValue::Startas before.advance_sequenceclears the flag before the next entry. - The snapshot change is a real behavioral improvement (next test no longer starts synchronously inside the previous test's
done(err)frame), not a mask. - One semantic shift worth a maintainer's eye: an async test whose body's promise never settles used to be advanced by an unhandled rejection; now it waits for the timeout. With the default timeout that's fine, and it's arguably more consistent with the no-rejection case, but it is a change.
…e snapshot hunks other PRs own afterEach/afterAll/onTestFinished called by a test body the runner has already given up on were added to whichever test was running by then. They now fail with an error, through the same ref expect() uses. The snapshot.rs reordering and the get_snapshot_name reclassification are not needed once the abandoned check happens before the matchers touch anything, and they overlap with #38799 and #38874, so they are gone. The two cases that fail the running test with an unhandled error now wait through done() instead of a returned promise, so they describe the situation the runner gives up on whether or not #36719 has landed.
Fixes #14644.
Repro
Before:
test a start/afterEach/beforeEach/test b/test a end(test a's body finishes while test b is already running).After:
test a start/test a end/afterEach/beforeEach/test b.Cause
jest::on_unhandled_rejectioncalledadd_result(current_state_data)with the currently-executing test entry's identity.BunTest::runthen dequeued that token andExecution::stepunconditionally calledadvance_sequencefor it, moving on toafterEachand the next test while the test body's returned promise was still pending. When the promise later settled, its completion was rejected byget_current_and_valid_execution_sequenceas stale.Fix
Track
has_pending_promiseonExecutionSequence.run_test_callbacksets it when the callback returns a promise that is stillPending(before draining rejections, so it is already set when the drain fires);advance_sequenceclears it.on_unhandled_rejectionstill records the failure viaon_uncaught_exceptionbut, when the flag is set, does not enqueue a completion token: the body's own promise settling (or a timeout) advances execution.Tests that only wait on a
done()callback (no returned promise) keep the existing behavior of advancing on an uncaught exception (done-infinity.fixture.ts).Tests
test/regression/issue/14644.test.tscovers the microtask-drain path and the macrotask path (rejection from asetTimeoutwhile awaiting).test/regression/issue/14624.test.tsnow also assertstest endis printed.test-error-code-done-callback.test.tssnapshot drops one frame: anasync done => { await Bun.sleep(0); done(err) }test'sdone()no longer synchronously starts the next test inline, so that next test's async stack no longer carries a frame pointing into the previous test's body.[review] gate passed · iteration 0 · 6 files touched
fails on main (without fix)
passes on PR (with fix)
diff hotspot
gate history · 1 passed · 0 rejected · iteration 0
evidence per changed file