Skip to content

bun:test: don't block on pending promises in expect().resolves/.rejects#30595

Open
robobun wants to merge 26 commits into
mainfrom
farm/4409f7f7/fix-expect-resolves-hang
Open

bun:test: don't block on pending promises in expect().resolves/.rejects#30595
robobun wants to merge 26 commits into
mainfrom
farm/4409f7f7/fix-expect-resolves-hang

Conversation

@robobun

@robobun robobun commented May 13, 2026

Copy link
Copy Markdown
Collaborator

Reproduction

// sum.test.js
test("promise resolves after expect call", () => {
  let resolve;
  expect(new Promise(r => (resolve = r))).resolves.toBe(25);
  resolve(25);
});
$ bun test sum.test.js
bun test v1.3.14

sum.test.js:
# … hangs forever at 100% CPU; --timeout has no effect

Jest passes this test in 1ms.

Root cause

Expect.processPromise() (called by every matcher's getValue()) ran vm.waitForPromise(promise) when .resolves/.rejects was set, which synchronously spins the event loop until the promise settles. But the matcher is invoked from inside the test function — resolve(25) is the next statement on the same call stack and can never run while waitForPromise() is spinning below it.

Fix

When .resolves/.rejects is set and the input promise is still pending, getValue() now:

  1. Records the callee (matcher fn) and its arguments
  2. Creates a new pending Promise and returns it from the matcher (stored in the existing, previously-unused resultValue slot)
  3. Attaches .then() to the input promise; when it settles, the matcher is re-invoked — the input promise is now settled so processPromise() extracts its value synchronously — and the returned Promise is resolved/rejected with the matcher's outcome

Already-settled promises still take the synchronous path through processPromise(), so there's no behavior change for expect(Promise.resolve(x)).resolves.….

This matches Jest's behavior where .resolves.<matcher>() returns a thenable: if you await/return it the test waits on the assertion; if you don't and it fails, it surfaces as an unhandled rejection that fails the test.

getValue() now takes the *CallFrame and returns ?JSValuenull means "deferred, return deferredResult(thisValue)". Every matcher call site is updated mechanically (orelse return this.deferredResult(thisValue)). applyCustomMatcher (for expect.extend) is updated the same way.

An is_async_rerun flag on Expect prevents double-counting the expectation on the re-invocation.

The new onResolve/onReject thenable handlers are registered in ZigGlobalObject::PromiseFunctions.

Not changed

  • processPromise() itself keeps its waitForPromise() fallback for readFlagsAndProcessPromise (asymmetric matchers called from C++), where flags.promise is .none in practice and there's no CallFrame to defer with.
  • getValueAsToThrow() / executeCustomMatcher() still waitForPromise() on a promise returned by the user's function/matcher — those aren't the .resolves/.rejects input promise and are a separate concern (see fix(test): invoke JS-level then() for lazy promise subclasses in expect().rejects/resolves #27260).

Verification

git stash push -- src/ && bun bd test test/js/bun/test/expect-resolves-pending.test.ts
  → 3 fail (subprocesses hang, killed after 20s, `timedOut: true`), exit 1
git stash pop && bun bd test test/js/bun/test/expect-resolves-pending.test.ts
  → 6 pass, exit 0

The test spawns subprocesses with a bounded Promise.race so unfixed builds fail cleanly instead of hanging the runner (on current main the test-level --timeout can't interrupt waitForPromise()).

Existing expect.test.js resolves/rejects tests, expect-extend.test.js, and jest-extended.test.js all pass. zig:check-all passes on all platforms.

Fixes #14950
Fixes #25181

Rebase onto #30412

Main's #30412 ("Rewrite Bun in Rust") landed while this PR was in review and ported src/test_runner/expect.zigsrc/runtime/test_runner/expect.rs (plus each expect/*.zig*.rs). The .zig files are no longer compiled; the live implementation is Rust, and it had the original wait_for_promise() hang.

Commit 45a36db ports the PendingMatcher mechanism to Rust:

  • Expect gains is_async_rerun / counted_expect_call / async_rerun_srcloc as Cell<_> fields.
  • get_value() / matcher_prelude() / mock_prologue() return MaybeDeferred / MatcherStart / MockStart enums; ready_matcher! / ready_value! / ready_mock! macros handle the early-return at each call site.
  • PendingMatcher holds Strong refs to the Expect wrapper, matcher fn, and args array, plus a JSPromiseStrong for the deferred result and per-defer snapshots of counted_expect_call / flags / caller CallerSrcLoc. On settle it re-invokes the matcher with the snapshotted state installed/restored via an RAII guard.
  • Bun__Expect__PendingMatcher__onResolve/onReject are exported via jsc_host_abi!{ #[no_mangle] } so C++ promiseHandlerID() matches them by fn-ptr identity (same pattern as Bun__TestScope__Describe2__*).

The earlier Zig commits in this branch document the design evolution and review rounds but touch only dead code after the rebase; the final commit reverts them.

@coderabbitai

coderabbitai Bot commented May 13, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

This PR implements async deferral for .resolves and .rejects matchers in Bun's test runner. When these matchers encounter pending promises, they return deferred promises that settle later instead of blocking, enabling concurrent test execution and correct assertion accounting.

Changes

Async Promise Deferral for .resolves and .rejects Matchers

Layer / File(s) Summary
Promise handler registration
src/jsc/bindings/headers.h, src/jsc/bindings/ZigGlobalObject.h, src/jsc/bindings/ZigGlobalObject.cpp
Bun__Expect__PendingMatcher__onResolve and Bun__Expect__PendingMatcher__onReject promise handler IDs are declared and registered in the JSC dispatch chain to route promise settlement callbacks.
Core deferral mechanism
src/test_runner/expect.zig
Expect.getValue now accepts a CallFrame and can return null to signal deferral. Implements PendingMatcher, maybeDeferMatcher, deferredResult, inline-snapshot SRCLoc handling for async reruns, and guards to prevent double-counting of assertions; integrates custom matcher cleanup/wiring.
Matcher integration
src/test_runner/expect/*.zig
Many matcher functions updated to call getValue with callFrame and to return deferredResult(thisValue) when getValue indicates deferral, integrating built-in and custom matchers into the deferred path.
Tests and test updates
test/js/bun/test/expect-resolves-pending.test.ts, test/js/*, test/cli/*
Adds an end-to-end test suite validating pending promise deferral (concurrency, assertion counting, snapshot writing, failing cases) and updates other tests to await promise-based expectations so assertions settle before test completion.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title directly and specifically describes the main fix: preventing blocking on pending promises in expect().resolves/.rejects matchers.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The PR description comprehensively explains the problem, root cause, solution, and verification approach.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

@robobun

robobun commented May 13, 2026

Copy link
Copy Markdown
Collaborator Author

@github-actions

Copy link
Copy Markdown
Contributor

Found 6 issues this PR may fix:

  1. Using expect.resolves breaks concurrent tests #25181 - expect.resolves blocks the event loop via synchronous spinning, breaking concurrent test execution — exactly what this PR's deferred-promise approach fixes
  2. Matchers under expect().resolves and expect().rejects should return a promise but return undefined instead #23420 - .resolves/.rejects matchers return undefined instead of a Promise — this PR makes them return a thenable
  3. Timeout in test when expecting promise from bun shell to throw #14670 - expect(promise).rejects.toThrow() times out on pending promises from bun shell — fixed by the deferred matcher path
  4. expect(...).resolves.toSatisfy(...) passes in the promise directly #15428 - .resolves.toSatisfy() passes the raw Promise object instead of the resolved value — likely fixed by the reworked promise unwrapping in getValue()
  5. [bun:test] expect resolves not toThrow fails #9687 - expect().resolves.not.toThrow() incorrectly reports "Thrown value: undefined" — likely fixed by the new promise resolution path
  6. false-negative test failure when using expect().resolves.not.toThrow() #14076 - .resolves.not.toThrow() produces false-negative test failures — same root cause as [bun:test] expect resolves not toThrow fails #9687, likely fixed

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #25181
Fixes #23420
Fixes #14670
Fixes #15428
Fixes #9687
Fixes #14076

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. fix(test): invoke JS-level then() for lazy promise subclasses in expect().rejects/resolves #27260 - Also fixes expect().resolves/.rejects hangs by routing pending promises through JS-level .then() instead of synchronously spinning in waitForPromise(), modifying the same processPromise logic in expect.zig

🤖 Generated with Claude Code

Comment thread src/test_runner/expect/toMatchInlineSnapshot.zig Outdated
Comment thread src/test_runner/expect.zig Outdated
Comment thread src/test_runner/expect.zig Outdated
@robobun

robobun commented May 13, 2026

Copy link
Copy Markdown
Collaborator Author

Status (5c06bfd): review threads all resolved; the diff is green. CI red comes from two repo-wide, branch-independent breakages (details below). Needs a maintainer to merge (or to confirm once those infra issues clear).

CI triage for builds 60754 / 60759:

  1. windows-x64-baseline-verify-baseline: the Intel SDE download (downloadmirror.intel.com/859732/... in .buildkite/ci.mjs) returns a 0-byte file (7-Zip: Cannot open the file as archive). Identical step is red on main's own builds 60678, 60647, 60590 (main's only red job in each).
  2. test/cli/install/bunx.test.ts > "should handle package that requires node 24" (the persistent failure behind the red ubuntu/alpine test lanes in 60754): the test runs bunx --bun @angular/cli@latest --help against the live registry, and Angular's latest release now prints The Angular CLI requires a minimum Node.js version of v22.22.3 or v24.15.0 or v26.0.0 (Bun reports node v24.3.0) and exits 3. Reproduces identically with the stock release bun (USE_SYSTEM_BUN=1, no code from this PR), so it breaks every branch's next build until Bun's reported node version is bumped or the test is pinned; main's test lanes were last green before this Angular release.
  3. Everything else in 60754 (spawn, sql-postgres, streams-leak, duckdb, valkey, cron) is in CI's own "flaky" annotation: failed once, passed on retry.

Verified on this tip: expect-resolves-pending.test.ts 8/8; full expect.test.js 397 pass (one unrelated deepEquals stress-test timeout from the slow debug container); cargo check clean; never-settling ×8 leak repro shows zero PendingMatcher-origin leaks.

Open follow-up (deferred, non-blocking per review): deferred-rerun error.stack points at the internal rerun frame rather than the original call site (thread #3231431336).


What this PR does (fixes #14950, #25181):

When .resolves/.rejects is set and the input promise is still pending, each matcher now goes through maybe_defer_matcher() instead of spinning in wait_for_promise(). That stores the Expect wrapper + matcher fn + args + deferred promise + per-call flags/srcloc snapshots in a GC-managed context array, attaches .then() callbacks (Bun__Expect__PendingMatcher__onResolve/onReject registered in ZigGlobalObject::PromiseFunctions), and returns the deferred promise for the caller to await. When the input settles, the callback re-invokes the matcher with is_async_rerun set and resolves/rejects the deferred with the result. If the input never settles, the reaction and its context are simply garbage-collected: nothing native outlives them.

Tests: test/js/bun/test/expect-resolves-pending.test.ts: 8/8 pass with the fix, 5/8 fail (subprocess 20s watchdog) on an unfixed build. Swept ~40 test files across test/ and packages/ that had un-awaited .resolves/.rejects.


find-issues bot cross-check:

Issue Fixed? Notes
#25181 .resolves no longer blocks, so test.concurrent overlaps again.
#14950 The original target of this PR.
#23420 Partial .resolves.<matcher>() returns a Promise when the input is pending; already-settled inputs still take the sync path and return undefined.
#14670 JSValue.then() uses JSC's internal performPromiseThen, doesn't invoke ShellPromise's JS-level .then(). Hard hang → clean timeout now; full fix is #27260.
#15428 toSatisfy doesn't go through get_value(). Separate bug.
#9687 / #14076 .resolves.not.toThrow() on a non-function is get_value_as_to_throw() logic; unchanged.

Comment thread test/js/bun/test/expect-resolves-pending.test.ts Outdated

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/test_runner/expect.zig`:
- Around line 288-322: In settle, replace the early "catch return" on calls that
can fail (the getLength call and the args_array.getIndex loop) with logic that
captures the exception via globalThis.tryTakeException() (falling back to
JSValue.js_undefined if none), calls this.deferred.reject(globalThis, exception)
catch {}, and then returns; this mirrors the matcher_fn.call error path and
ensures the deferred promise is always settled on error (refer to settle,
args_array.getLength, args_array.getIndex, and this.deferred.reject).

In `@test/js/bun/test/expect-resolves-pending.test.ts`:
- Around line 263-265: The test currently converts the regex result directly to
Number which yields NaN if the match fails; first capture the match into a
variable (e.g., const m = out.match(/MAX_INFLIGHT=(\d+)/)), assert the match
exists (use expect(m).toBeTruthy() or explicitly fail with a descriptive
message), then parse Number(m[1]) into maxInFlight and assert it equals 10; this
ensures a clear diagnostic when the MAX_INFLIGHT line is missing and uses the
existing identifiers maxInFlight and out.match for locating the change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 27bba966-d474-408a-bbdb-7e4010052f44

📥 Commits

Reviewing files that changed from the base of the PR and between c152296 and aca4057.

📒 Files selected for processing (2)
  • src/test_runner/expect.zig
  • test/js/bun/test/expect-resolves-pending.test.ts

Comment thread src/test_runner/expect.zig Outdated
Comment thread test/js/bun/test/expect-resolves-pending.test.ts
Comment thread src/test_runner/expect.zig 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/test_runner/expect.zig:936-939inlineSnapshot() uses async_rerun_srcloc whenever it's non-null, but the field is never cleared after a deferred re-run completes — rerun()'s defer only resets is_async_rerun, and maybeDeferMatcher() early-returns at the promise.status() != .pending check (L216) before updating it. So if the same Expect instance is reused — const e = expect(pending).resolves; await e.toBe(1); await e.toMatchInlineSnapshot(); — the second call takes the synchronous path with the first call's stale srcloc and writes the snapshot to the wrong source line. This is the same per-instance-state-not-reset class as the open counted_expect_call comment above, just a different field; gating this branch on is_async_rerun (or clearing async_rerun_srcloc in rerun()'s defer alongside is_async_rerun = false) fixes it consistently with that one.

    Extended reasoning...

    What the bug is

    inlineSnapshot() (expect.zig:936) selects the source location for the snapshot write with:

    const srcloc, const owns_srcloc = if (this.async_rerun_srcloc) |s|
        .{ s, false }
    else
        .{ callFrame.getCallerSrcLoc(globalThis), true };

    This branches on whether async_rerun_srcloc is non-null, not on whether the current invocation is actually a deferred re-run (is_async_rerun). The field is populated by maybeDeferMatcher() at L224-225 when it decides to defer, but is only cleared in finalize() on destruction (L502). rerun()'s defer block (L321-323) resets is_async_rerun but leaves async_rerun_srcloc untouched, and a grep confirms there are exactly four references to the field: declaration, set-on-defer, deref-on-finalize, and this read.

    The code path that triggers it

    .resolves / .rejects / .not are getters that mutate this.flags in place and return thisValue — they don't create a new Expect. So:

    let resolve;
    const p = new Promise(r => (resolve = r));
    const e = expect(p).resolves;        // one Expect instance
    await e.toBe(1);                     // line 10
    await e.toMatchInlineSnapshot();     // line 11

    both matcher calls share the same *Expect.

    Why nothing prevents it

    maybeDeferMatcher() has four early-return guards in order: flags.promise == .none, is_async_rerun, asAnyPromise() == null, and promise.status() != .pending (L213-216). The srcloc capture at L224-225 sits after all four. On the second matcher call the promise is now settled, so the function returns null at L216 without touching async_rerun_srcloc, leaving the value captured by the first call in place. The matcher then proceeds synchronously into inlineSnapshot() with is_async_rerun == false but async_rerun_srcloc != null, and the L936 check picks the stale location.

    Step-by-step proof

    1. e.toBe(1) (line 10): getValue()maybeDeferMatcher(). flags.promise == .resolves, is_async_rerun == false, value is a pending promise → passes all guards. L225 sets async_rerun_srcloc = {file, line: 10, …}. PendingMatcher is created; toBe returns the deferred promise.
    2. resolve(1) (microtask) → onResolvesettle()rerun(): sets is_async_rerun = true, re-invokes toBe, then defer resets is_async_rerun = false. async_rerun_srcloc is still {…, line: 10}. The await on line 10 resumes.
    3. e.toMatchInlineSnapshot() (line 11): getValue()maybeDeferMatcher(). flags.promise == .resolves, is_async_rerun == false, value is a promise, but promise.status() == .fulfilledreturns null at L216, skipping L224-225. processPromise() extracts 1 synchronously; getValue() returns it.
    4. inlineSnapshot() runs on the write path (no existing snapshot string). At L936, this.async_rerun_srcloc is non-null → srcloc = {…, line: 10}. The snapshot writer records line 10 as the insertion point and writes toMatchInlineSnapshot(`1`) over the toBe(1) call on line 10 instead of into the empty toMatchInlineSnapshot() on line 11 — corrupting the source file.

    Pre-PR there was no async_rerun_srcloc; inlineSnapshot() always called callFrame.getCallerSrcLoc(), which on step 3 would have correctly returned line 11.

    Impact

    The trigger requires reusing a saved expect().resolves instance across a deferred non-snapshot matcher followed by an inline-snapshot write — uncommon, but valid (Jest supports it and pre-PR Bun did too via waitForPromise). The consequence, however, is destructive: the snapshot updater silently writes to the wrong line of the user's test file. This is also the exact same per-instance-state-leak class as the still-open counted_expect_call comment (3230849139), just a different field, so it's worth fixing both consistently in the same pass.

    Suggested fix

    Either is sufficient and both compose with the counted_expect_call fix:

    • Gate the read on the re-run flag: change L936 to if (this.is_async_rerun) this.async_rerun_srcloc else null (or equivalent) so the stored location is only used when we're actually inside rerun().
    • Clear in rerun()'s defer: add if (expect.async_rerun_srcloc) |*s| { s.str.deref(); expect.async_rerun_srcloc = null; } next to expect.is_async_rerun = false; at L321-323, mirroring whatever reset is chosen for counted_expect_call.

Comment thread test/js/bun/test/expect-resolves-pending.test.ts Outdated
Comment thread src/test_runner/expect.zig Outdated
Comment thread src/test_runner/expect.zig Outdated
Comment thread src/test_runner/expect.zig Outdated
Comment thread test/js/node/fs/glob.test.ts
Comment thread src/test_runner/expect.zig Outdated
Comment thread test/js/bun/test/expect-resolves-pending.test.ts Outdated

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/test_runner/expect.zig`:
- Around line 226-229: The shared Expect field async_rerun_srcloc is being
overwritten when multiple pending matchers are created, causing later reruns to
use the wrong callsite; instead capture and store the caller src location per
PendingMatcher instance and restore it only when that PendingMatcher is actually
rerun. Update PendingMatcher.create (and the PendingMatcher struct) to accept
and retain the value returned by callframe.getCallerSrcLoc(globalThis) (instead
of writing to Expect.async_rerun_srcloc), and change the rerun path that
currently reads Expect.async_rerun_srcloc to read the stored src-loc from the
PendingMatcher being run; apply the same change for the other occurrences
referenced (lines around 334-340 and 957-960) so each deferred
.resolves/.rejects keeps its own src location.

In `@test/js/bun/test/expect-resolves-pending.test.ts`:
- Around line 15-34: The helper runFixture currently adds an internal 20s
watchdog (Promise.race with Bun.sleep + proc.kill("SIGKILL")) which duplicates
bun:test's timeout and forces callers to bump test timeouts; remove the
Promise.race/Bun.sleep/kill block and the timedOut logic so runFixture simply
awaits proc.exited and then reads proc.stdout/proc.stderr, relying on bun:test's
outer timeout/cleanup; apply the same removal for the other similar helper
blocks referenced (lines 39-118, 120-156, 161-220, 226-245, 251-286) to
eliminate fixture-level watchdogs.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 78bf6852-0130-4312-b370-bf3f992e12a3

📥 Commits

Reviewing files that changed from the base of the PR and between aca4057 and 4c3a38c.

📒 Files selected for processing (7)
  • src/test_runner/expect.zig
  • test/cli/install/migration/yarn-lock-migration.test.ts
  • test/cli/install/minimum-release-age.test.ts
  • test/js/bun/test/expect-resolves-pending.test.ts
  • test/js/node/fs/glob.test.ts
  • test/js/web/fetch/client-fetch.test.ts
  • test/js/web/fetch/wasm-streaming.test.ts

Comment thread src/test_runner/expect.zig Outdated
Comment thread test/js/bun/test/expect-resolves-pending.test.ts

@coderabbitai coderabbitai 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.

♻️ Duplicate comments (1)
test/js/bun/test/expect-resolves-pending.test.ts (1)

15-34: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Let bun:test own the hang budget.

The Promise.race(...) watchdog on Line 26 duplicates bun:test's timeout/cleanup for an await using proc = Bun.spawn(...) subprocess and is what forces the 40_000 overrides on Lines 118, 156, 220, 258, and 299. I’d drop the helper-level timeout/kill path and the per-test timeout overrides together so the outer test timeout remains the single source of truth.

Based on learnings, in test/**/*.test.ts files that use tempDir and await using proc = Bun.spawn(...), avoid fixture-level watchdogs and rely on Bun’s outer timeout; for test/js/bun/**, do not add explicit per-test timeouts unless they are already established as required by the file.

Also applies to: 118-118, 156-156, 220-220, 258-258, 299-299

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/js/bun/test/expect-resolves-pending.test.ts` around lines 15 - 34, The
helper runFixture currently implements its own watchdog (the Promise.race +
timedOut check, proc.kill("SIGKILL") and subsequent await proc.exited) which
duplicates bun:test's timeout/cleanup; remove that watchdog logic (the
Promise.race call, the timedOut boolean, the kill path and its conditional
await) so runFixture simply spawns the subprocess with await using proc =
Bun.spawn(...) and then reads proc.stdout.text() and proc.stderr.text(); also
remove the corresponding per-test explicit timeout overrides that were added to
accommodate this helper-level kill behavior so the file relies solely on
bun:test's outer timeout mechanism.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@test/js/bun/test/expect-resolves-pending.test.ts`:
- Around line 15-34: The helper runFixture currently implements its own watchdog
(the Promise.race + timedOut check, proc.kill("SIGKILL") and subsequent await
proc.exited) which duplicates bun:test's timeout/cleanup; remove that watchdog
logic (the Promise.race call, the timedOut boolean, the kill path and its
conditional await) so runFixture simply spawns the subprocess with await using
proc = Bun.spawn(...) and then reads proc.stdout.text() and proc.stderr.text();
also remove the corresponding per-test explicit timeout overrides that were
added to accommodate this helper-level kill behavior so the file relies solely
on bun:test's outer timeout mechanism.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 3d265888-218e-4586-b25a-89e1c64e1c7a

📥 Commits

Reviewing files that changed from the base of the PR and between 7c2c737 and ac7eeb4.

📒 Files selected for processing (2)
  • src/test_runner/expect.zig
  • test/js/bun/test/expect-resolves-pending.test.ts

Comment thread test/js/web/fetch/client-fetch.test.ts
Comment thread src/test_runner/expect.zig Outdated
Comment thread src/test_runner/expect.zig Outdated
Comment thread test/js/bun/http/serve.test.ts
Comment thread src/test_runner/expect.zig 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.

No outstanding issues from my side after several review rounds, but this is a user-visible behavior change to core bun:test semantics (every .resolves/.rejects matcher now defers instead of blocking, with knock-on effects across ~26 test files that previously relied on the blocking behavior) — worth a human look before merging.

Extended reasoning...

Overview

This PR replaces the synchronous vm.waitForPromise() spin in expect().resolves/.rejects with a deferred-rerun mechanism: when the input promise is still pending, the matcher captures its callee/args/srcloc/flags into a heap-allocated PendingMatcher, attaches a .then() to the input promise, and returns a new promise to the caller. When the input settles, the matcher is re-invoked and the returned promise is resolved/rejected with the outcome. Two new promise-handler IDs are registered in ZigGlobalObject. The getValue() signature changes to return ?JSValue, and ~70 matcher files in src/test_runner/expect/ are updated mechanically with orelse return this.deferredResult(thisValue). About 26 test files are swept to add await to assertions that previously relied on the blocking behavior. A new 322-line subprocess test file pins the fix.

Security risks

None identified. The change is confined to the test runner; no auth, crypto, network, filesystem, or user-input parsing surfaces are touched. The new promise handlers only re-invoke an already-registered matcher with already-captured arguments.

Level of scrutiny

High. This is a behavior change visible to every bun test user: .resolves/.rejects matchers on pending promises now return a thenable instead of blocking, which means un-awaited assertions that previously "worked" (by blocking) now silently pass or surface as later unhandled rejections — exactly what required the ~70-site test-suite sweep in this PR. The core expect.zig changes involve subtle shared-state management on the Expect instance (is_async_rerun, counted_expect_call, async_rerun_srcloc, flags) across the defer/rerun pair, with save/restore semantics that took several review rounds to get right for instance-reuse and nested-rerun cases. The inline-snapshot path needed special handling to capture the call-site source location before the user's frame leaves the stack.

Other factors

  • Iterative review history: I flagged ~12 issues across multiple rounds (assertion double-counting, inline-snapshot srcloc on rerun, counted_expect_call leaking across reused instances, flags/srcloc/is_async_rerun not snapshotted per-PendingMatcher, applyCustomMatcher not calling postMatch, ~70 missed un-awaited test sites that caused real CI failures including a 14-platform deterministic failure in deinitialization.test.ts). All were fixed and all threads are resolved. The number and depth of issues found is itself a signal that this change is non-trivial.
  • Acknowledged follow-up deferred: error stack traces for failed deferred matchers no longer include the user's call-site frame (the author agreed this is valid but is leaving it for a follow-up PR since the await site still gives a usable line number in the common case).
  • CI: green on the latest build (53965); the 3 remaining failures are confirmed pre-existing main flakes per robobun.
  • Test coverage: comprehensive new subprocess-based test file covering hang regression, failing-assertion propagation, expect.assertions counting across both matcher orderings and custom matchers, inline-snapshot writing through the deferred path, instance reuse, and test.concurrent overlap.

Given the scope (103 files), the user-visible semantic change, the JSC-bindings touch, and the demonstrated subtlety of the state-management edge cases, this should have human review before merge.

@robobun robobun force-pushed the farm/4409f7f7/fix-expect-resolves-hang branch from c49e09a to 45a36db Compare May 14, 2026 22:18
Comment thread src/runtime/test_runner/expect.rs
Comment thread src/runtime/test_runner/expect.rs Outdated
Comment thread src/runtime/test_runner/expect.rs Outdated
Comment thread test/js/bun/dns/resolve-dns.test.ts
Comment thread src/runtime/test_runner/expect.rs Outdated
Comment thread src/runtime/test_runner/expect.rs Outdated
@robobun robobun force-pushed the farm/4409f7f7/fix-expect-resolves-hang branch from ed7abd1 to b287117 Compare May 16, 2026 11:23
Comment thread src/codegen/bake-codegen.ts Outdated
Comment thread src/runtime/test_runner/expect.rs
@robobun robobun force-pushed the farm/4409f7f7/fix-expect-resolves-hang branch from 770a652 to 2eb1538 Compare May 21, 2026 07:51
Comment thread src/runtime/test_runner/expect.rs Outdated
robobun added 23 commits June 5, 2026 13:30
… counting, unbounded matcher args

- Capture the caller's source location in maybeDeferMatcher() and use
  it in inlineSnapshot() on the deferred re-run, since the user's frame
  is no longer on the stack. Fixes regression for
  await expect(asyncFn()).resolves.toMatchInlineSnapshot().

- Replace the is_async_rerun guard in incrementExpectCallCounter() with
  a counted_expect_call flag on the Expect instance. Matchers call the
  counter both before and after getValue(); the old guard under-counted
  the ~40 matchers that increment after.

- Use a stackFallback allocator for the re-run's argument list instead
  of a hard 32-element cap, since toHaveBeenCalledWith and custom
  matchers are variadic.

Added test coverage for all three.
The per-Expect counted_expect_call flag was never reset, so calling
two matchers on the same expect() instance only counted one
assertion. Reset it in postMatch() so each matcher call starts fresh,
and have PendingMatcher snapshot the flag at defer time and restore
it before the re-run so the deferred path still dedups correctly
regardless of whether the matcher increments before or after
getValue().
…ied on blocking

- expect.zig: replace banned `catch bun.outOfMemory()` with
  `bun.handleOom(...)`.

- Add `await` to `.resolves`/`.rejects` assertions in four test
  files that previously relied on the (now-removed) synchronous
  waitForPromise() blocking behavior. Without the await, the test
  function returns before the promise settles, letting resources
  (servers, temp dirs, subprocesses) tear down underneath the
  still-running assertion.

  Affected:
  - test/js/node/fs/glob.test.ts (4 lines)
  - test/js/web/fetch/client-fetch.test.ts (8 lines)
  - test/cli/install/migration/yarn-lock-migration.test.ts (2 lines)
  - test/cli/install/minimum-release-age.test.ts (1 line)
- wasm-streaming.test.ts: add await to 20 .resolves/.rejects
  assertions that relied on the old blocking waitForPromise().
  Without await, the WebAssembly compilation was still in flight when
  the process started tearing down, tripping
  'ASSERTION FAILED: m_object' in ThreadSafeWeakPtr::strongRef().
  Awaiting exposed a pre-existing JSC quirk where the missing-imports
  error message differs if the module was previously compiled in the
  same process; relax that one assertion to match either message.

- expect.zig inlineSnapshot(): gate the captured async_rerun_srcloc
  on is_async_rerun, not just non-null, so a stale location from a
  previous deferred matcher on the same expect() isn't reused for a
  later synchronous call.

- expect.zig applyCustomMatcher(): call postMatch() via defer (like
  built-in matchers do) so counted_expect_call is reset and a
  subsequent matcher on the same expect() instance still counts.

- expect-resolves-pending.test.ts: split timedOut/out/exitCode
  assertions so subprocess output surfaces on non-hang failures;
  add a custom-then-builtin case to the expect.assertions test.
… concurrent subprocess tests

- fs.watch.test.ts (4 lines) and worker_blob.test.ts (1 line): add
  await to .resolves/.rejects assertions on genuinely-pending
  promises (fs.watch callbacks, Worker error events). Without await
  these tests now pass vacuously and leak intervals/watchers into
  later tests.

- PendingMatcher: snapshot Expect.flags at defer time and restore
  around the re-run. .not/.resolves/.rejects mutate flags in place
  on the shared Expect, so a later matcher call on a reused instance
  could flip flags.not before an earlier call's re-run fires.

- expect-resolves-pending.test.ts: describe.concurrent for the five
  independent subprocess tests (3.4s vs 6.4s locally; 20s vs 100s on
  a regressed build).
Two deferred .resolves/.rejects matchers on the same expect() instance
would previously share async_rerun_srcloc, so a later defer overwrote
the earlier one's call site before its re-run. Move the captured srcloc
into PendingMatcher (alongside flags_at_defer and was_counted_before_defer)
and lend it to Expect.async_rerun_srcloc only for the duration of rerun().
PendingMatcher now owns the srcloc string; Expect.finalize no longer
derefs it.

Test: two deferred toMatchInlineSnapshot() on one expect() each write
to their own line.
Completes the sweep started in 8d3e014 and extended in 4c3a38c/7c2c7375.
These tests had un-awaited `expect(p).resolves/.rejects.X()` calls on
genuinely-pending promises (response.text(), fetch(), dns.lookup(),
import(), file.unlink(), WebSocket open, etc.) that previously relied on
waitForPromise() blocking until settlement. With the deferred-matcher
path the assertion now runs later, so:

- serve.test.ts: subprocess.kill() / server dispose raced ahead of body
  reads; CI failure on x64-asan.
- inspect.test.ts: webSocket.send() fired before the open event; CI
  failure on 3 Windows lanes.
- resolve-dns.test.ts / worker_threads.test.ts / resolve.test.ts: sync
  test callbacks returned before the assertion ran; tests passed
  vacuously.

Intentionally left alone:
- expect.test.js:4675/4678 (already-settled promises still take the
  synchronous path)
- client-fetch.test.ts:500 (value is a Response, not a Promise)
- pidfd-exit-nested-tick.test.ts (the line was the nested-tick trigger,
  not an assertion; added a comment noting the trigger no longer fires)
test/bake/fixtures/deinitialization/test.ts is run as a subprocess by
deinitialization.test.ts and has two un-awaited
`expect(fetch(...)).rejects.toThrow()` calls. The first triggers the
plugin callback that stops the server; without blocking, control reached
the second fetch before the server was actually down. Missed in the
earlier sweep because the grep only covered *.test.{ts,js} files.

Fixes the all-platform deinitialization.test.ts failure from build 53957.
Completes the symmetry with flags and async_rerun_srcloc. A sibling
PendingMatcher on the same input promise can rerun inside
matcher_fn.call() when the matcher re-enters the event loop (toThrow's
getValueAsToThrow -> waitForPromise -> tick drains microtasks); the
inner defer must not clobber the outer rerun's is_async_rerun flag, or
inlineSnapshot() in the outer would fall back to getCallerSrcLoc() on a
frameless stack.
Main's #30412 rewrote expect.zig as expect.rs (and each expect/*.zig as
*.rs). The .zig files under src/runtime/test_runner/ are no longer
compiled — the live implementation is Rust. The rebase applied the
earlier Zig commits cleanly (git followed the src/test_runner/ →
src/runtime/test_runner/ rename), but they landed on dead code.

This ports the deferred-matcher mechanism to Rust and reverts the
dead Zig changes:

expect.rs:
- Expect gains is_async_rerun, counted_expect_call, async_rerun_srcloc
  (Cell<_> for interior mutability).
- get_value() now takes &CallFrame and returns MaybeDeferred; when the
  captured promise is still pending, it creates a PendingMatcher
  (Strong refs to the Expect wrapper, matcher fn, and args array; a
  JSPromiseStrong for the deferred result; and per-defer snapshots of
  counted_expect_call / flags / caller srcloc), attaches it via
  JSValue::then, and returns the deferred promise.
- matcher_prelude() / mock_prologue() / the shared run_* helpers
  propagate the deferred case.
- increment_expect_call_counter() is gated on counted_expect_call so
  a deferred rerun doesn't double-count; post_match() resets it.
- inline_snapshot() uses the PendingMatcher's captured srcloc during
  a rerun (the user's frame is gone from the stack).
- Bun__Expect__PendingMatcher__onResolve/onReject are exported via
  jsc_host_abi!{ #[no_mangle] } so promiseHandlerID() can match them by
  fn-ptr identity.

mod.rs: ready_matcher!/ready_value!/ready_mock! macros for the
early-return-on-Deferred pattern at each call site (defined at module
top-level, outside cfg_jsc!, so they're addressable via crate:: paths).

CallFrame.rs: CallerSrcLoc is now Clone+Copy (needed for Cell<Option<_>>).
jsc/lib.rs: re-export CallerSrcLoc.

35 expect/*.rs matcher files: mechanical update to pass the callframe
through and early-return the deferred promise via the ready_* macros.

Test: relaxed the MAX_INFLIGHT check from ==10 to >1 — the Rust runner
caps test.concurrent at 5 on ASAN builds (20 otherwise); the point is
>1 proves .resolves no longer serializes.
…tom_matcher

- Remove Expect::deferred_result() — the Rust port returns the deferred
  promise directly via MaybeDeferred::Deferred and the ready_*! macros,
  so the Zig-style resultValue slot read is unused.
- apply_custom_matcher: move the pre-existing auto_gc_on_drop() into
  the static-call branch only; the dynamic branch's post_match_guard()
  already runs auto_garbage_collect() via post_match(), so keeping
  both fired it twice per custom-matcher call.
- bake-codegen.ts: wrap the OVERLAY_CSS define value in JSON.stringify
  so it's a valid JS expression. Bun.build's define parser now rejects
  raw CSS as a value ("Operators are not allowed in JSON"); this was a
  latent bug exposed by the 1.3.14 host compiler, unrelated to this PR
  but blocks the build.
The deferred promise is returned directly via MaybeDeferred::Deferred
and rooted by PendingMatcher.deferred: JSPromiseStrong; the resultValue
cached slot is no longer read (its only reader was deferred_result(),
removed in ee7f710).
Matches the Bun__TestScope__Describe2__* pattern this code cites as
its model. Zero functional change.
…t tests

- maybe_defer_matcher doc still referenced the removed resultValue slot
  write; drop that clause.
- packages/bun-inspector-protocol/test/inspector/websocket.test.ts had
  13 un-awaited expect(...).resolves.<matcher>() calls in sync test
  bodies. These relied on the old blocking waitForPromise behavior and
  would race under the deferred path. Make the tests async and await
  each assertion.
The JSON.stringify() wrap around css() output was a local workaround
for the host bun's define-value parser choking on raw '*{...}' CSS.
#30679 fixed the JSON lexer to tokenize ?/*/(/)  without erroring so
parse_env_json's auto-quote fallback can recover — and that fix is
now in the rebase base.

Reverting restores bake-codegen as the in-tree exerciser of that path
and un-stales the two comments that cite it (json_lexer.rs:1304 and
bun-build-api.test.ts:76).
…ckStart return types

Both docs still described the old tuple return shapes and direct
destructuring. 45a36db changed the signatures to return
MatcherStart<'a>/MockStart<'a> enums so the Deferred(promise) path
can early-return; callers now unwrap via ready_matcher!/ready_mock!.
Align the docs with the actual signatures and mention the deferred
branch.
… leak

The test fires expect(Bun.sleep(1)).resolves.toBe(undefined) from a
sync onExit callback without awaiting it. Under the deferred-matcher
path that allocates a PendingMatcher Box and attaches it via .then()
to the sleep promise. On release-asan CI the process exits before the
1ms timer fires, so on_settle() never runs and the 72-byte Box leaks,
tripping LSan (surfaced by #31029's suppression cleanup).

Capture the deferred promise and await it after Promise.all(exits) so
the PendingMatcher is freed before process exit regardless of timing.
The assertion is now actually checked too (1 expect() call instead of
0 on the fast path).

The original purpose of this line was to force a synchronous nested
event-loop tick via waitForPromise; that no longer applies under the
deferred path (already noted in the preceding comment), and the test
still validates the level-triggered pidfd fix via the all-exits-fire
check.
The comment said 'self.expect_this: Strong', but self here is
&mut RerunGuard which has no such field. The Strong that keeps the
Expect wrapper alive is on the enclosing PendingMatcher, owned by
the Box in on_settle() which outlives _guard. Safety argument is
unchanged; only the field attribution was wrong.
The PendingMatcher Box (with Strong roots for the Expect wrapper,
matcher fn, args array, and deferred promise) was only freed in
on_settle(). If the input promise never settled before process exit —
e.g. an un-awaited expect(pending).resolves... whose promise races
teardown, as vendor/elysia's router.test.ts does — the Box leaked and
LeakSanitizer aborted the ASAN lane.

Keep all per-deferral state in a 9-slot JS array passed as the promise
reaction's context via then_with_value, and make the deferred promise
a plain GC JSPromise rather than a Strong. The reaction traces the
array, so a never-settling promise takes the whole bundle with it when
it is collected: nothing native outlives the reaction and there is
nothing left for LSan to report.

on_settle() now unpacks the array (flags/counted bits and srcloc
round-trip as number/boolean/string slots) and rerun() takes the
unpacked values; behaviour on the settle path is unchanged.
@robobun robobun force-pushed the farm/4409f7f7/fix-expect-resolves-hang branch from f1de6ac to 28a04ee Compare June 5, 2026 13:37
Comment thread test/js/web/fetch/fetch.test.ts
Comment thread src/runtime/test_runner/expect.rs
…comments

- test/js/web/fetch/fetch.test.ts: the maxRedirects test added by #31518
  (post-sweep, came in via rebase) fired expect(fetch()).rejects.toThrow
  without awaiting; under the deferred path the assertion would never be
  checked within the test. Same one-line await as the rest of the file.
- expect.rs: async_rerun_srcloc doc referenced the removed
  PendingMatcher.srcloc_at_defer field; it now describes the SLOT_SRCLOC_*
  context-array slots and on_settle() ownership.
- pidfd-exit-nested-tick.test.ts: drop the stale LSan rationale (nothing
  native is allocated since the GC-managed-context rewrite); the await is
  about completing the assertion within the test.
Comment thread src/runtime/test_runner/expect.rs Outdated
Comment thread src/runtime/test_runner/expect.rs Outdated
Read SLOT_DEFERRED first and move the remaining fallible slot reads into
rerun(), so any failure there feeds the deferred's reject arm instead of
leaving it pending. Also fix a stale comment in inline_snapshot that
referenced PendingMatcher owning the srcloc deref.

@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.

No new issues found and all prior review threads are resolved — but this is a substantial behavioral change to core test-runner internals (new async-deferral machinery, GC-managed promise-reaction state, unsafe Rust across the FFI boundary, C++ promise-handler registration) that went through many correctness iterations, so it warrants a human sign-off.

Extended reasoning...

Overview

This PR changes how expect().resolves/.rejects handle still-pending promises in bun:test: instead of synchronously spinning the event loop (waitForPromise), it defers the matcher via a .then() callback and returns a thenable, matching Jest semantics. The implementation touches:

  • Core runtime (src/runtime/test_runner/expect.rs, ~300 new lines): new PendingMatcher machinery storing per-deferral state in a 9-slot GC-managed JS array, RerunGuard RAII for save/restore of shared Expect state (is_async_rerun, flags, async_rerun_srcloc), new MaybeDeferred/MatcherStart/MockStart enums, and #[no_mangle] FFI thunks.
  • C++ bindings (ZigGlobalObject.cpp/.h, headers.h): two new PromiseFunctions enum entries and fn-ptr identity dispatch.
  • 35 matcher files mechanically updated to thread the CallFrame through and early-return on Deferred via new ready_matcher!/ready_value!/ready_mock! macros.
  • ~30 test files swept to await previously fire-and-forget .resolves/.rejects calls that relied on the removed blocking behavior.
  • A new 324-line regression test.

Security risks

None identified. The unsafe blocks dereference *mut Expect obtained from Expect::from_js on JS values rooted by the GC-managed ctx array for the duration of the rerun scope; the SAFETY comments and RerunGuard drop ordering look correct. No auth, crypto, filesystem, or network surface is touched beyond test fixtures.

Level of scrutiny

High. This is core test-runner runtime logic that affects every .resolves/.rejects assertion in every test suite (in-tree and user). The PR's own review history surfaced ~15 distinct correctness bugs (assertion double-counting, stale srcloc on instance reuse, flags leaking across deferred reruns, custom-matcher postMatch not firing, LSan leaks on never-settling promises, nested-rerun state clobbering), each fixed iteratively. The mechanism was also ported Zig→Rust mid-PR after #30412 landed. The final design (GC-managed context array, per-deferral snapshots, RAII save/restore) is sound but non-obvious, and a human should validate the overall approach and the unsafe invariants.

Other factors

  • All ~30 prior inline review threads (from me and CodeRabbit) are resolved; the bug hunter found nothing new this round.
  • One acknowledged non-blocking follow-up is deferred: the error.stack on a failing deferred rerun points at the internal rerun frame rather than the user's call site.
  • CI shows two failures (streams-leak.test.ts on one platform, bunx.test.ts across platforms) that the author's status comment attributes to unrelated infra/flakes; the windows-x64-baseline-verify-baseline red is a known Intel SDE mirror outage also affecting main.
  • The ~30 test-file edits are mechanical await additions, but they reflect a real behavioral change for any user code relying on the old blocking semantics.

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.

Using expect.resolves breaks concurrent tests bun test waits at 100% cpu usage for a promise that will resolve after expect()

1 participant