Skip to content

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

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

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
Updated 9:12 PM PT - Jun 18th, 2026

@robobun, your commit c276620 has 1 failures in Build #63427 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 30595

That installs a local version of the PR into your bun-30595 executable, so you can run:

bun-30595 --bun

@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 (c276620): rebased onto main dae2e87; review threads all resolved; the diff is green. Single red lane in build 63427 is a known darwin-only socket race unrelated to this PR. Needs a maintainer to merge.

CI triage for build 63427 (main 63422 on the same base is green):

Rebase: one trivial conflict in test/js/node/fs/glob.test.ts from #31830 (the fs/glob port rewrote the "can filter out files" test to await Array.fromAsync directly, dropping .resolves; took main's version since my change was only adding await). No new un-awaited .resolves/.rejects sites landed in the 98 commits since the previous base.

Verified on this tip: expect-resolves-pending.test.ts 8/8; glob.test.ts 27/27; 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 becomes a 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 Outdated
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 11 commits June 19, 2026 02:02
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.
…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.
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.
@robobun

robobun commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #33289, closing to keep the review surface clean.

#33289 does this properly: it covers all three matcher entry points (.resolves / .rejects, async toThrow, async expect.extend), adds the per-test settlement registry so an un-awaited matcher cannot go silently vacuous, and uses #33267's nestedDispatchTicks as a regression oracle. This PR only deferred the .resolves / .rejects subject promise, left get_value_as_to_throw and execute_custom_matcher blocking, and leaned on the unhandled-rejection path for un-awaited failures.

Its 129-site await sweep across 27 test files is also superseded by the narrower #33279, which deliberately keeps the remaining un-awaited usages as the compatibility surface the settlement registry has to handle. The two overlap on 7 files.

Tracked by #33261 work item 1.

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() Using expect.resolves breaks concurrent tests

1 participant