bun:test: settle async matchers with promise reactions instead of re-entering the event loop

AL
11d ago
+3499-426

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; async toThrow and 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 .then is called; asserting .rejects on a shell command used to hang forever inside waitForPromise (#14670).
  • Jest parity: matcher calls through .resolves/.rejects always return a promise (even for an already-settled subject); expect(nonPromise).resolves still throws synchronously.
  • bun-types: the Matchers interface gains Jest's return-type parameter, so matcher chains through .resolves/.rejects and async toThrow are typed as Promise<void>.

Behavior changes (deliberate):

  • Snapshot indices are assigned in settle order, not source order (Jest parity). expect(promise).resolves.toMatchSnapshot() no longer blocks at the expect() line, so it registers its snapshot when the subject settles; a synchronous toMatchSnapshot() written after it in the same test now gets the lower index. Committed .snap files that interleave deferred and synchronous snapshot matchers under one test name may need bun test -u once. 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 an expect() belongs to), expect() outside bun: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 lone test.concurrent is 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 inside toEqual) 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/.rejects subject origin is expressible as a .then wrapper. The other two deferral origins produce their promise inside the matcher, after the user's code has already run: async toThrow gets a promise back from calling the user's function, and an async expect.extend matcher 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 .then wrapper would cover one of the three origins and leave the other two blocking.
  • Bun's expect has no JS layer to put the wrapper in. The matcher chain, flags, call counter, expect.assertions bookkeeping, snapshot registration, and error/call-site attribution are all native (src/runtime/test_runner/expect.rs); there is no expect.js sitting between the user and the matchers. Introducing one just to host a .then shim 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 .then wrapper cannot do any of that — Jest gets it from jest-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.