bun:test: settle async matchers with promise reactions instead of re-entering the event loop
What does this PR do?
expect(promise).resolves… / .rejects…, expect(asyncFn).toThrow*, and async expect.extend matchers used to call waitForPromise from inside the matcher — spinning the whole event loop (timers, I/O completions, GC, arbitrary user JS) from inside whatever frame called expect(). That synchronous re-entry is the bug class tracked in #33261 (work item 1), and it also made the matcher unable to observe anything that happens after the expect() call in the same test body: expect(p).resolves.toBe(25); resolve(25) deadlocked at 100% CPU until the test timeout (#14950), where Jest passes.
Now a matcher with promise flags and a promise subject returns a real Promise and never blocks:
- Native reactions on the subject re-invoke the same matcher once it settles (
Expect.reentry), so the second pass reuses the existing synchronous direction-check/error logic unchanged; asynctoThrowand async custom matchers resume from the recorded settlement instead of re-running the user's function. - The runner keeps the test open until every deferred matcher settles (
ExecutionSequence.pending_matcher_promises, epoch-guarded so per-test timeouts and retry/repeat attempts can abandon stale deferrals without cross-talk). - An un-awaited failing matcher fails its own test, attributed to the
expect()call line, and does not double as an unhandled-rejection report. The awaited/un-awaited decision is made one job after the matcher settles so code that adopts the matcher promise in the same microtask drain (e.g.async () => expect(p).rejects.toThrow(x)) is classified correctly. - The subject is adopted through the generic promise-resolve path, so a promise subclass or thenable works — e.g.
Bun.$'s lazy ShellPromise, which only starts when its own.thenis called; asserting.rejectson a shell command used to hang forever insidewaitForPromise(#14670). - Jest parity: matcher calls through
.resolves/.rejectsalways return a promise (even for an already-settled subject);expect(nonPromise).resolvesstill throws synchronously. bun-types: theMatchersinterface gains Jest's return-type parameter, so matcher chains through.resolves/.rejectsand asynctoThroware typed asPromise<void>.
Behavior changes (deliberate):
- Snapshot indices are assigned in settle order, not source order (Jest parity).
expect(promise).resolves.toMatchSnapshot()no longer blocks at theexpect()line, so it registers its snapshot when the subject settles; a synchronoustoMatchSnapshot()written after it in the same test now gets the lower index. Committed.snapfiles that interleave deferred and synchronous snapshot matchers under one test name may needbun test -uonce. This matches Jest, which also numbers snapshots at settlement; it is pinned by the "snapshot indices are assigned in settle order" test.
Deliberately unchanged (each keeps the pre-existing synchronous wait, so nothing gets less correct):
- Concurrent groups running more than one test (2+ adjacent
test.concurrents interleave, so the runner cannot resolve which of the group's sequences anexpect()belongs to),expect()outsidebun:test, and any other context with no single owning test: a deferral there could not gate its test's completion, so the matcher blocks exactly as before. A lonetest.concurrentis a group of one sequence, so it resolves normally and defers like a sequential test — both sides are pinned by tests. (Follow-up: group-level tracking for multi-test groups.) - Asymmetric-position matchers (
expect.resolvesTo/rejectsTo, async custom matchers insidetoEqual) run inside a synchronous deep-equality walk that needs a boolean back — there is no matcher invocation to re-run, so they cannot be deferred.
Why not a JS-layer .then wrapper (Jest's approach)?
Jest's expect(promise).resolves.toBe(x) is a thin JavaScript wrapper: .resolves/.rejects returns a proxy whose matcher methods do subject.then(...) and hand the resulting promise back to the caller. That works for Jest because the promise Jest needs to defer on is the subject, known before the matcher runs, and because Jest's whole expect lives in JS. Neither is true here:
- Only the
.resolves/.rejectssubject origin is expressible as a.thenwrapper. The other two deferral origins produce their promise inside the matcher, after the user's code has already run: asynctoThrowgets a promise back from calling the user's function, and an asyncexpect.extendmatcher gets one back from the user's matcher implementation. There is no subject promise to wrap ahead of time — the only way to defer is for the matcher itself to record where it was and resume on settlement, which is exactly what the native reentry pass does. A.thenwrapper would cover one of the three origins and leave the other two blocking. - Bun's
expecthas no JS layer to put the wrapper in. The matcher chain, flags, call counter,expect.assertionsbookkeeping, snapshot registration, and error/call-site attribution are all native (src/runtime/test_runner/expect.rs); there is noexpect.jssitting between the user and the matchers. Introducing one just to host a.thenshim would mean re-plumbing every one of those through a JS boundary — far more surface (and per-call overhead) than the native reactions this PR adds. - The end-of-test settlement gate needs runner integration regardless. The part of this PR that keeps a test open until its deferred matchers settle, attributes an un-awaited failure to the right test, and abandons stale deferrals on timeout/retry lives in the runner (
ExecutionSequence.pending_matcher_promises+ epochs). A JS.thenwrapper cannot do any of that — Jest gets it fromjest-circus, its runner. So even with a JS wrapper for the one origin it can express, the native runner work is still all required; the wrapper would save nothing.
Depends on #33267 (this branch is stacked on it: its nestedDispatchTicks counter is what the regression test uses to prove a matcher inside a Bun.serve fetch handler no longer re-enters the loop). #33279 prepared the test suite for this change.
Fixes #14950
Fixes #23420
Fixes #14670
How did you verify your code works?
New test/js/bun/test/expect-resolves-rejects.test.ts (46 subprocess tests, describe.concurrent): awaited pass/fail messages and locations (inline-snapshotted), un-awaited failure attribution + exit code, no unhandled-rejection reports for handled subjects, settlement ordering vs test completion, sync test bodies, returned-promise + un-awaited interplay, chained deferrals (.resolves.toThrow(fn)), async toThrow/.not.toThrow, synchronous throw for non-promise subjects, matcher-returns-a-Promise, expect.assertions across test.each/retry/repeat, deferred snapshot + inline-snapshot matchers (call-site resolution), the stale-timeout-retry interaction, the #14950 repro, the concurrent carve-out from both sides (a lone test.concurrent defers without blocking; two adjacent test.concurrents in one group keep the synchronous wait), and (debug builds) getEventLoopStats().nestedDispatchTicks === 0 for a matcher inside a Bun.serve fetch handler with a positive control that proves the counter detects the old behavior. 8 of those fail on an unpatched build (USE_SYSTEM_BUN=1), and the previously-blocking semantics they replace are covered by the existing expect.test.js suites, which pass unchanged apart from the two tests that asserted matchers return undefined (now assert a Promise).
Full local runs: expect.test.js, expect-extend*.test.*, expect-assertions.test.ts, expect-label.test.ts, jest-extended.test.js, jest-each.test.ts, jest-hooks.test.ts, test-test.test.ts, snapshot suites, pidfd-exit-nested-tick.test.ts (its nested-tick trigger swapped from expect().resolves — which no longer nests — to an HTMLRewriter async transform, so it still tests what it was written for), and test/integration/bun-types/bun-types.test.ts.