Skip to content

bun:test: bound expect() promise waits by the test timeout - #36716

Open
robobun wants to merge 6 commits into
mainfrom
claude/farm/3130629c/expect-resolves-timeout
Open

bun:test: bound expect() promise waits by the test timeout#36716
robobun wants to merge 6 commits into
mainfrom
claude/farm/3130629c/expect-resolves-timeout

Conversation

@robobun

@robobun robobun commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

expect(promise).resolves.<matcher>() (and .rejects, expect(asyncFn).toThrow(), and async expect.extend matchers) spin the event loop via wait_for_promise until the subject promise settles. When the promise can only settle after the matcher returns, the spin never ends:

test("promise resolves after expect call", () => {
  let resolve;
  expect(new Promise(r => (resolve = r))).resolves.toBe(25);
  resolve(25); // unreachable while the line above is spinning
});

On current canary this hangs bun test at 100% CPU forever. In 1.1.34 the per-test timeout would fire after 5s because handle_timeout called requestTermination(); e01f454 (#24355) removed that call to fix a crash where the termination exception escaped through autoTick, so nothing breaks the wait_for_promise loop anymore. The timer still fires inside the spin (via auto_tick -> drain_timers -> bun_test_timeout_callback), the runner records the timeout and tries to re-enter BunTest::run(), which returns immediately because in_run_loop is already set, and the spin continues.

This PR replaces the three wait_for_promise call sites in expect.rs with a bounded loop that also breaks once the enclosing test's deadline passes (read via TestRunner::get_active_timeout(), the same source spawnSync already uses for its own wait loop), and throws a matcher error naming the still-pending promise instead of hitting unreachable!() on the pending arm:

error: expect(received).resolves.toBe(expected)

Expected promise to settle within the test timeout
Received promise that is still pending: [Promise]

(fail) promise resolves after expect call [1009.53ms]
  ^ this test timed out after 1000ms.

Outside a timed test the deadline is EPOCH and the loop behaves exactly like wait_for_promise. requestTermination() stays removed, so #23865 stays fixed; its regression test snapshot is updated because the output now includes the new matcher error before the existing timed out after line.

This is a safety-net fix only (the timeout must fire; the runner must not spin forever). It does not change the matcher to return a Promise or give Jest parity for the un-awaited case; that is #33289, which this PR intentionally does not overlap with (no event_loop.rs / Execution.rs changes, no matcher signature changes).

Fixes #14950

How did you verify your code works?

New test/regression/issue/14950.test.ts spawns six child bun test runs with --timeout 500 and a 20s watchdog: .resolves on a post-call-resolved promise, .rejects on a never-settling promise, toThrow on an async fn returning one, an async custom matcher returning one, a second test after a hanging one, and an already-settled control. On an unpatched build the five hanging children are killed by the watchdog (hung: true); with the fix all six children exit on their own within ~1s and report the new matcher error plus timed out after Nms.

git stash push -- src/ && bun bd test test/regression/issue/14950.test.ts  # 1 pass 5 fail
git stash pop  && bun bd test test/regression/issue/14950.test.ts          # 6 pass 0 fail

Existing suites pass unchanged: expect.test.js (415 pass), expect-extend.test.js (28 pass), jest-extended.test.js (58 pass), expect-assertions.test.ts (1 pass), test-test.test.ts (24 pass), 23865.test.ts (snapshot updated, 1 pass).


[review] gate passed · iteration 1 · 3 files touched

fails on main (without fix)
ASAN without fix: 6 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" "test/regression/issue/14950.test.ts" "test/regression/issue/23865.test.ts"
bun test v1.4.0 (0ebfa04ff)

test/regression/issue/23865.test.ts:
12 | 
13 |   const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
14 | 
15 |   expect(exitCode).not.toBe(0);
16 |   expect(normalizeBunSnapshot(stdout)).toMatchInlineSnapshot(`"bun test <version> (<revision>)"`);
17 |   expect(normalizeBunSnapshot(stderr)).toMatchInlineSnapshot(`
                                            ^
error: expect(received).toMatchInlineSnapshot(expected)

  
  "23865.fixture.ts:
- 1 | // Should not crash
- 2 | test("abc", () => {
- 3 |   expect(async () => {
- 4 |     await Bun.sleep(100);
- 5 |     throw new Error("uh oh!");
- 6 |   }).toThrow("uh oh!");
-          ^
- error: Received function returned a promise that did not settle within the test timeout
-     at <anonymous> (file:NN:NN)
  (fail) abc
    ^ this test timed out after 50ms.
  
   0 pass
   1 fail
   1 expect() calls
  Ran 1 test across 1 file."
  

-
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (c14e9e3bc)

test/regression/issue/23865.test.ts:
(pass) 23865 [66.59ms]

test/regression/issue/14950.test.ts:
(pass) expect().resolves/.rejects on a not-yet-settled promise > .resolves on an already-resolved promise still passes synchronously [17.14ms]
(pass) expect().resolves/.rejects on a not-yet-settled promise > toThrow on an async fn returning a never-settling promise times out instead of hanging [528.31ms]
(pass) expect().resolves/.rejects on a not-yet-settled promise > .rejects on a never-settling promise times out instead of hanging [535.12ms]
(pass) expect().resolves/.rejects on a not-yet-settled promise > .resolves on a promise resolved after the matcher call times out instead of hanging [543.04ms]
(pass) expect().resolves/.rejects on a not-yet-settled promise > async custom matcher returning a never-settling promise times out instead of hanging [533.34ms]
(pass) expect().resolves/.rejects on a not-yet-settled promise > a test after one that spins on .resolves still runs [528.62ms]
(pass) expect().resolves/.rejects on a not-yet-settled promise > a late rejection from an abandoned toThrow async fn does not fail the next test [1419.
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" "test/regression/issue/14950.test.ts" "test/regression/issue/23865.test.ts"
bun test v1.4.0 (0ebfa04ff)

test/regression/issue/23865.test.ts:
(pass) 23865 [386.20ms]

test/regression/issue/14950.test.ts:
(pass) expect().resolves/.rejects on a not-yet-settled promise > .resolves on a promise resolved after the matcher call times out instead of hanging [938.29ms]
(pass) expect().resolves/.rejects on a not-yet-settled promise > async custom matcher returning a never-settling promise times out instead of hanging [907.48ms]
(pass) expect().resolves/.rejects on a not-yet-settled promise > a test after one that spins on .resolves still runs [927.71ms]
(pass) expect().resolves/.rejects on a not-yet-settled promise > toThrow on an async fn returning a never-settling promise times out instead of hanging [955.64ms]
(pass) expect().resolves/.rejects on a not-yet-settled promise > .rejects on a never-settling promise times out instead of hanging [967.70ms]
(pass) expect().resolves/.rejects on a not-yet-settled promise > .resolves on an already-reso
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 805ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/6] gen generated_host_exports.rs
generated_host_exports.rs: 94 exports (host=3, lazy=10, generic=81, rust=0); 239 extern-C blocks audited
[1/6] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m   Compiling�[0m bun_picohttp v0.0.0 (/workspace/bun/src/picohttp)
�[1m�[92m   Compiling�[0m bun_brotli v
... (truncated)
diff hotspot
src/runtime/test_runner/expect.rs   |  59 ++++++++++++++--
 test/regression/issue/14950.test.ts | 135 ++++++++++++++++++++++++++++++++++++
 test/regression/issue/23865.test.ts |   9 +++
 3 files changed, 198 insertions(+), 5 deletions(-)

gate history · 2 passed · 0 rejected · iteration 1

evidence per changed file
file                                 reads  edits  tests
src/runtime/test_runner/expect.rs       12      7      0
test/regression/issue/14950.test.ts      2      4      0
test/regression/issue/23865.test.ts      1      1      0

expect(promise).resolves/.rejects, expect(fn).toThrow on an async fn, and
async custom matchers spin the event loop via wait_for_promise until the
subject promise settles. When the promise can only settle after the matcher
returns (e.g. expect(p).resolves.toBe(x); resolve(x);) the spin never ends.

The per-test timeout timer fires inside that spin (via auto_tick ->
drain_timers -> bun_test_timeout_callback), but since e01f454 it no longer
calls requestTermination(), so nothing breaks the loop. The runner records
the timeout, returns immediately because it is re-entrant, and the spin
continues at 100% CPU forever.

Replace the three expect.rs wait_for_promise calls with a bounded loop that
also breaks once the enclosing test's deadline (read via
TestRunner::get_active_timeout(), the same source spawnSync already uses)
passes, and throw a matcher error naming the still-pending promise instead
of hitting unreachable!(). Outside a timed test the deadline is EPOCH and
the loop behaves exactly like wait_for_promise.

Fixes #14950
@robobun

robobun commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

Status (0ebfa04): reproduced on canary (child bun test spins forever at 100% CPU, --timeout never fires). Fix bounds the three expect.rs promise-wait loops by the active test's deadline and throws a matcher error on the pending arm. Review finding about the toThrow path leaving the abandoned promise unhandled is addressed in c14e9e3 (mark it handled before the wait, same as the two siblings; regression case added).

Local verification: test/regression/issue/14950.test.ts is 5 fail without the src/ change, 7 pass with it. expect.test.js 415 pass, expect-extend.test.js 28 pass, jest-extended.test.js 58 pass, 23865.test.ts snapshot updated. The robobun/evidence gate check passes on both ASAN and release.

CI: build #87458 finished 195 pass / 1 fail. test/regression/issue/14950.test.ts, 23865.test.ts, and the expect* suites are green on every lane that ran. The single red is test/cli/install/bun-upgrade.test.ts on windows-aarch64 ("Canary builds are not available for this platform yet"), which also fails on main #87044 and is unrelated to this diff. A handful of lanes (alpine x64, debian aarch64) never ran because agent provisioning failed (AWS EC2 "Unsupported" on the requested configuration). Earlier builds #87219 and #87308 had every build job expire before an agent picked it up. The diff itself is green; ready for a maintainer.

Note: this is the minimal "timeout must still fire" fix. Full Jest parity (matcher returns a Promise, un-awaited failure attribution) is #33289.

@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 11 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: a909b23b-7448-4e64-923b-70e675c19c5e

📥 Commits

Reviewing files that changed from the base of the PR and between f91d5c9 and 0ebfa04.

📒 Files selected for processing (3)
  • src/runtime/test_runner/expect.rs
  • test/regression/issue/14950.test.ts
  • test/regression/issue/23865.test.ts

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the claude label Aug 1, 2026
@robobun

robobun commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 4:08 AM PT - Aug 1st, 2026

@robobun, your commit ad31381 is building: #87182

Comment thread src/runtime/test_runner/expect.rs Outdated
Comment thread src/runtime/test_runner/expect.rs Outdated
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. bun:test: settle async matchers with promise reactions instead of re-entering the event loop #33289 - Fixes the same bun test waits at 100% cpu usage for a promise that will resolve after expect() #14950 wait_for_promise CPU spin in .resolves/.rejects/async matchers, using promise reactions instead of a timeout-bounded loop

🤖 Generated with Claude Code

Comment thread src/runtime/test_runner/expect.rs Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional findings (outside current diff — PR may have been updated during review):

  • 🔴 src/runtime/test_runner/expect.rs:941-948 — The still_pending early-return in get_value_as_to_throw bypasses promise.unwrap(..., MarkHandled), and — unlike the two sibling sites (process_promise L527, execute_custom_matcher L1539) — there is no promise.set_handled(vm) before the bounded wait here. If the abandoned async fn later rejects (e.g. expect(async () => { await Bun.sleep(200); throw ... }).toThrow() with a 100ms timeout), the rejection surfaces as an unhandled rejection attributed to whichever test is running when it fires. One-line fix: add promise.set_handled(global_this.vm()) before the bounded wait, matching the siblings.

    Extended reasoning...

    What the bug is

    This PR replaces three wait_for_promise call sites in expect.rs with wait_for_promise_bounded_by_test, each followed by an early return when the promise is still pending at the test deadline. Two of those sites — process_promise (~L527) and execute_custom_matcher (~L1539) — already had a pre-existing promise.set_handled(vm) call before the wait, so on their new early-return path the promise is already marked handled. The third site, get_value_as_to_throw, has no such call: it previously relied on promise.unwrap(global_this.vm(), UnwrapMode::MarkHandled) at L949 to set the handled flag, and that line is now skipped by the new if still_pending { return Err(...) } at L944-948.

    Code path

    if let Some(promise) = return_value.as_any_promise() {
        let still_pending = Self::wait_for_promise_bounded_by_test(global_this, promise);
        scope.apply(vm);                          // ← restores the runner's real on_unhandled_rejection
        if still_pending {
            return Err(global_this.throw(...));   // ← promise never marked handled
        }
        match promise.unwrap(global_this.vm(), js_promise::UnwrapMode::MarkHandled) {  // ← skipped

    scope.apply(vm) on L943 restores the runner's real on_unhandled_rejection handler (the UnhandledRejectionScope at the top of this function had swapped in on_quiet_unhandled_rejection_handler_capture_value). So once this function returns on the timeout path, any later rejection of promise routes through jest::on_unhandled_rejection, which attributes it to the currently-active test.

    Why existing code doesn't prevent it

    JSC's rejectPromise checks isHandledFlag at rejection time before calling hostPromiseRejectionTracker. JSC__JSPromise__setHandledmarkAsHandled() sets that flag, so calling it on a still-pending promise (as the two sibling sites do) suppresses the future report. Here the flag is never set on the timeout path, so nothing suppresses it.

    Step-by-step proof

    test("a", () => {
      expect(async () => { await Bun.sleep(200); throw new Error("late"); }).toThrow();
    }, 100);
    test("b", async () => {
      await Bun.sleep(300);
      expect(1).toBe(1);
    });
    1. Test a starts. The async fn is called and returns a pending promise; Bun.sleep(200) is queued.
    2. wait_for_promise_bounded_by_test spins until the 100ms deadline, then returns still_pending = true.
    3. scope.apply(vm) restores the runner's real unhandled-rejection handler.
    4. L944-948 throws the matcher error and returns. The promise is still pending, isHandledFlag is unset. Test a is reported as timed out — expected.
    5. Test b starts and awaits Bun.sleep(300). At t≈200ms, test a's Bun.sleep(200) fires, the async fn body throws, and its promise rejects.
    6. isHandledFlag is unset → JSC calls the host rejection tracker → jest::on_unhandled_rejectionunhandled_error_counter bumped on the currently-active test, which is b.
    7. Test b is reported as failed even though its own assertion passes.

    Note that the 23865 fixture (await Bun.sleep(100); throw ... with a 50ms timeout) is exactly this shape — it happens to only contain one test, but any user file with a follow-up test would hit this. Pre-PR, wait_for_promise spun until the promise settled at 200ms and unwrap(MarkHandled) always ran, so test b passed cleanly; this is a new regression path introduced by the PR that directly undermines its own "a test after one that spins ... still runs" goal.

    Why the new tests don't catch it

    The "a test after one that spins on .resolves still runs" case in 14950.test.ts goes through process_promise, which does call set_handled. The toThrow case uses () => new Promise(() => {}), which never rejects.

    Fix

    Match the two siblings — add before L942 (or inside the still_pending branch):

    promise.set_handled(global_this.vm());

    This falls squarely under REVIEW.md's "fix the whole class in the same PR — grep for every sibling site sharing the pattern" rule.

The still_pending early return skips unwrap(MarkHandled), so a promise
abandoned at the test deadline that later rejects would surface as an
unhandled rejection attributed to whichever test is running then. Mark
it handled up front like the process_promise and execute_custom_matcher
siblings already do, and add a regression case.
Comment thread src/runtime/test_runner/expect.rs

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — c14e9e3 addresses the earlier finding: the toThrow path now calls promise.set_handled() before the bounded wait, and the new "late rejection from an abandoned toThrow" case in 14950.test.ts covers exactly that scenario. No further issues found this pass. Deferring to a maintainer given this reworks how expect() spins the event loop against the test-timeout timer, and there's an open design question vs. #33289's promise-reaction approach.

What was reviewed:

  • wait_for_promise_bounded_by_test matches EventLoop::wait_for_promise structure (tick → auto_tick, execution_forbidden() guard) plus the deadline check; EPOCH → unbounded, so behavior outside a timed test is unchanged.
  • All three call sites now set_handled before the wait; the silent branch's bare Err(JsError::Thrown) matches the existing sibling arms in process_promise.
  • get_active_timeout() is the same source spawnSync uses; returns EPOCH when no active file/entry.
Extended reasoning...

Overview

Replaces three unbounded wait_for_promise calls in src/runtime/test_runner/expect.rs (.resolves/.rejects, toThrow on an async fn, and async expect.extend matchers) with a new wait_for_promise_bounded_by_test helper that also breaks when the enclosing test's deadline (via TestRunner::get_active_timeout()) passes, then throws a matcher error naming the still-pending promise. Adds test/regression/issue/14950.test.ts (7 spawned-child cases with a 20s watchdog) and updates the 23865.test.ts snapshot for the new error line.

Prior finding addressed

My earlier inline comment flagged that the toThrow path's new still_pending early return bypassed promise.unwrap(..., MarkHandled) without a prior set_handled, so a late rejection could surface as an unhandled rejection during the next test. Commit c14e9e3 adds promise.set_handled(global_this.vm()) before the bounded wait at that site (matching the two siblings) and adds a regression case ("a late rejection from an abandoned toThrow async fn does not fail the next test") that asserts 1 pass / 1 fail and not.toContain("Unhandled"). The comment thread is resolved.

Security risks

None. Test-runner-internal control flow; no untrusted-input parsing, auth, crypto, or network surface.

Level of scrutiny

High. This is a hand-rolled event-loop spin that interacts with the test-runner's timeout timer (which fires inside the spin via auto_tick), execution_forbidden(), and the in_run_loop reentrancy guard the PR description mentions. The loop structure mirrors EventLoop::wait_for_promise exactly plus a wall-clock deadline check, and get_active_timeout() is already the deadline source spawnSync uses — so the pattern has precedent. But the interaction between the timeout callback re-entering BunTest::run() mid-spin and this loop then breaking on the deadline is subtle enough that a maintainer familiar with the #24355 history should confirm.

Other factors

  • Design overlap with #33289: that PR takes a different approach (promise reactions instead of a bounded spin) to the same #14950 hang. The author explicitly scopes this as a minimal safety-net fix that doesn't overlap #33289's files, but which approach lands is a maintainer call.
  • Tests: gate evidence shows the new tests fail on main (children hung, killed by watchdog) and pass with the fix on both debug+ASAN and release. Existing expect.test.js (415), expect-extend.test.js (28), jest-extended.test.js (58) reported unchanged.
  • User-facing error messages: three new error strings are introduced — worth a quick maintainer eye for wording consistency with other matcher errors.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — c14e9e3 addresses the earlier finding (the toThrow path now calls set_handled before the bounded wait, and the new "late rejection" case in 14950.test.ts covers it). I didn't find further issues this pass, but this reworks how expect() spins the event loop and overlaps in intent with #33289's reaction-based approach, so it's worth a maintainer look before landing.

What was reviewed:

  • wait_for_promise_bounded_by_test mirrors event_loop.rs::wait_for_promise (early-exit, execution_forbidden, tick/auto_tick) plus the same get_active_timeout() deadline pattern spawnSync already uses.
  • All three modified sites now set_handled before the wait; the still_pending branches throw instead of reaching the pending unwrap arm.
  • The deadline == EPOCH case (no runner / no per-test timeout) preserves the old unbounded behavior.
Extended reasoning...

Overview

The PR replaces three unbounded wait_for_promise calls in src/runtime/test_runner/expect.rs (.resolves/.rejects, toThrow on an async fn, and async expect.extend matchers) with a new wait_for_promise_bounded_by_test helper that additionally breaks out when the enclosing test's deadline (TestRunner::get_active_timeout()) has passed, and throws a descriptive matcher error on the pending arm instead of hanging. New regression tests in test/regression/issue/14950.test.ts cover all three sites plus a subsequent-test-still-runs case, a late-rejection-does-not-leak case, and an already-settled control; 23865.test.ts's snapshot is updated to include the new error line.

Prior review resolved

My previous inline comment flagged that the toThrow site's early return bypassed unwrap(..., MarkHandled) without a prior set_handled, so a promise that rejects after the deadline would surface as an unhandled rejection during the next test. Commit c14e9e3 adds promise.set_handled(global_this.vm()) before the bounded wait at that site (matching the two siblings), and adds the "a late rejection from an abandoned toThrow async fn does not fail the next test" case asserting stderr does not contain "Unhandled" and that test "b" still passes. That fully addresses the finding.

Security risks

None. This is test-runner-internal control flow; no untrusted-input parsing, auth, crypto, or network surface is touched.

Level of scrutiny

Moderate-to-high. The diff is small (~60 lines of Rust), but it changes how expect() drives the event loop under bun test — a hot, subtle path where prior changes (#24355 removing requestTermination()) have caused regressions in both directions. The new loop is a faithful copy of event_loop.rs::wait_for_promise with one extra deadline comparison, and the deadline source is the same one spawnSync already consumes, so the mechanism is precedented. Still, this is not a mechanical/config change.

Other factors

  • Competing design: #33289 fixes the same issue by making the matcher return a promise (Jest parity) rather than bounding the spin. The PR description explicitly positions this as a non-overlapping safety-net stopgap, but which approach lands (or whether both do) is a maintainer call.
  • Test quality: Tests use describe.concurrent with subprocess spawns, a 20s watchdog well under the 60s per-test timeout, and assert on specific error text plus hung === false. The late-rejection test relies on wall-clock ordering (700ms reject inside a 900ms sleep after a 500ms timeout) which is generous enough not to worry me, though under extreme ASAN slowdown the child's inner 5s timeout on test "b" is the tightest bound.
  • Behavior outside a timed context: When Jest::runner() is None or the active timeout is unset, deadline == EPOCH and the loop degenerates to the original unbounded wait_for_promise, so non-bun test callers of expect() (if any) are unaffected.

Given the event-loop sensitivity and the open design question vs. #33289, I'm deferring rather than approving.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Hit this again on current main (b5afcac, also on 1.4.0-canary da3851e) while working through the test suite for #37993, with a second shape that is worth adding to 14950.test.ts: when the matcher call happens after an await in the test body, the per-test timeout does fire and the remaining tests run and are reported, but the summary never prints and the process never exits, because the wait loop is still on the stack underneath the runner.

import { expect, test } from "bun:test";

test("rejects matcher called after an await", async () => {
  await Bun.sleep(1);
  await expect(new Promise(() => {})).rejects.toThrow();
}, 500);

test("runs after", () => {
  expect(1).toBe(1);
});
(fail) rejects matcher called after an await [500.13ms]
  ^ this test timed out after 500ms.
(pass) runs after [2.98ms]
<hangs here at 100% CPU, no summary, never exits>

The sync-body shape from the PR description (matcher called before the first await) still reproduces as described: the timeout is never reported and the next test never runs. Capturing the deadline once at loop entry, as this PR does, covers both shapes, but only the first one has a test here.

Rebase note for the current conflict in expect.rs: since #37075, EventLoop::wait_for_promise returns Result<(), JsTerminated> (it bails when execution is forbidden or script_allowed() is false) and all three call sites propagate that with ?. The bounded loop needs to keep propagating termination on that branch and reserve the new "still pending" matcher error for the deadline branch.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bun test waits at 100% cpu usage for a promise that will resolve after expect()

2 participants