Skip to content

expect: drop the exception when a value throws while a failure message is formatted - #39565

Closed
robobun wants to merge 2 commits into
mainfrom
farm/88a96f78/expect-diff-format-pending-exception
Closed

robobun wants to merge 2 commits into
mainfrom
farm/88a96f78/expect-diff-format-pending-exception

Conversation

@robobun

@robobun robobun commented Aug 18, 2026 •

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes the rest of a fuzzer crash in the failure path of expect() matchers. Fingerprint c5d217fcf09dae60:

ASSERTION FAILED: Unexpected exception observed on thread ...
Error Exception: Maximum call stack size exceeded.
!exception()
ExceptionScope.h(62) : void JSC::ExceptionScope::releaseAssertNoException()

The fuzzer program recursed until the stack overflowed. In the frames that caught the overflow it ran expect(jestObject).toStrictEqual(Bun). The matcher fails and formats both values for its message. At that stack depth every call from native code into JavaScript throws a RangeError. One of these throws happened inside the formatter's property callback, and two places let the pending exception through.

The first place was JSC__JSValue__forEachPropertyOrdered. It went on to the next key with the callback's exception pending, and the native initializer of the next Bun property asserted. That is the line 62 assertion in the report. #39804 fixed it on main while this PR was open (the walk now returns after a callback throws), so this PR no longer touches bindings.cpp. The rebase dropped that hunk, which was the same line.

The second place is DiffFormatter::fmt (src/runtime/test_runner/diff_format.rs), and it is what this PR fixes. It ignored the result of both JestPrettyFormat::format calls. On main today:

  • When the received value throws while it is formatted, the expected value is formatted with the exception pending. The first native call of that pass asserts in a debug build: ASSERT_NO_PENDING_EXCEPTION in JSC__JSValue__getOwn, reached from Tag::get. This is the ExceptionScope.h(63) form of the same assertion.
  • When either value throws, throw_value sees the pending exception and returns without throwing the matcher's error. The matcher throws the getter's error (or the RangeError) instead of expect(received).toEqual(expected).

The fix is the clear_exception_except_termination() after a format call fails. The two calls are now one loop, so the same branch runs for both values. The message is built from what was formatted before the throw, and the matcher throws its own error. This is the policy create_error_instance (src/jsc/JSGlobalObject.rs:749) already applies to every other matcher message when a Display impl fails. DiffFormatter cannot return fmt::Error instead: matcher_hint (src/runtime/test_runner/expect.rs:2917) renders it with format!, which panics on a Display error, and matcher_hint reaches the same assertion today. A termination exception stays pending, as in create_error_instance.

Behavior change, in release builds too: a value that cannot be formatted no longer turns the failure into the getter's error. The matcher reports its own failure, and the side that threw is printed as far as it got (empty when the value itself threw). #34649 proposes the opposite policy for the same crash: keep the getter's error, which is what a release build prints today and what Jest does, by changing create_error_instance, DiffFormatter and matcher_hint to propagate, among other changes. The two PRs need one decision. If propagate is chosen, this PR should be closed and the tests below need the matcher's error replaced by the getter's error in their expected output.

This PR consolidates #28538 and #29784. Both contain this diff_format.rs change and nothing else. Their triggers (a RegExp whose toString throws, a Proxy that throws while it is formatted) are tests here now.

How did you verify your code works?

test/js/bun/test/expect-stack-overflow-crash.test.ts gets eight subprocess tests. The first four use a $$typeof getter, which the formatter reads from every object:

  • A throwing property on Bun that sorts right before Bun.Archive, and a marker property that sorts after it. The marker must not be in the message.
  • The same, nested one level down in a plain object.
  • The received value itself cannot be formatted. The expected value is still in the message, the getter's error is not.
  • The fuzzer's shape. The getter is plain JavaScript and only throws once the stack is exhausted. The matcher runs in the three deepest frames that caught the overflow. On the unfixed build before Propagate JS exceptions instead of clearing them where the caller is fallible #39804 this aborted with the exact text of the report, Error Exception: Maximum call stack size exceeded. at ExceptionScope.h(62), with DiffFormatter::fmt > JSC__JSValue__forEachPropertyOrdered > getPropertySlot > BunObject_lazyPropCb_Archive > to_js_host_call on the stack.

The other four run a RegExp whose toString throws, an array Proxy whose get trap throws, and an object whose Symbol.toStringTag getter throws on both sides of toEqual, and the toStringTag case through matcherHint in an expect.extend matcher. None of these values is touched by the comparison, only by the message.

On a debug build of main (025570f4b6, which includes #39804) all eight fail: the cases where the received value throws abort with the line 63 assertion, the others print the getter's error. All eight also fail with USE_SYSTEM_BUN=1 bun test (1.4.0-canary.1). They pass with bun bd test, in about 4 seconds for the file. The original test in the file is unchanged.

Also run with the fix on the rebased branch: test/js/bun/test/expect.test.js, test/js/bun/test/printing/diffexample.test.ts, test/js/bun/util/inspect.test.js. A local probe of ten value types (arrays, Maps, Errors, Dates, class instances, Proxies, nested objects) through toEqual, toStrictEqual and toMatchSnapshot with a throwing $$typeof getter runs clean on the debug build.

The fuzzer program itself does not crash on main in a fresh process. It needed state left behind by earlier programs in the same long-lived fuzzing process, which is why the report calls it flaky. The tests above set up that state directly.


[review] gate passed · iteration 0 · 2 files touched

fails on main (without fix)
ASAN without fix: 8 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/test/expect-stack-overflow-crash.test.ts
bun test v1.4.1 (4448a2e21)

test/js/bun/test/expect-stack-overflow-crash.test.ts:
(pass) expect does not crash when called after catching stack overflow [913.59ms]
88 |         Bun.jest().expect({}).toStrictEqual(Bun);
89 |       } catch (e) {
90 |         console.log(e.message.split("\\n")[0], e.message.includes(${JSON.stringify(marker)}));
91 |       }
92 |     `);
93 |     expect(result).toEqual({
                        ^
error: expect(received).toEqual(expected)

  {
    "exitCode": 0,
    "stderr": "",
    "stdout": 
- "expect(received).toStrictEqual(expected) false
+ "boom false
  "
  ,
  }

- Expected  - 1
+ Received  + 1

      at <anonymous> (/workspace/bun/test/js/bun/test/expect-stack-overflow-crash.test.ts:93:20)
(fail) failing matcher when formatting a value throws > the property that throws ends the walk of that object [385.30ms]
123 |         Bun.jest().expect(received).toEqual({ a: 1 });
124 |       } catch (e) {
125 |         console.log(JSON.stringify(e.message));
... (truncated)

release without fix: 10 FAILED
bun test v1.4.0-canary.1 (4448a2e21)

test/js/bun/test/expect-stack-overflow-crash.test.ts:
(pass) expect does not crash when called after catching stack overflow [16.37ms]
88 |         Bun.jest().expect({}).toStrictEqual(Bun);
89 |       } catch (e) {
90 |         console.log(e.message.split("\\n")[0], e.message.includes(${JSON.stringify(marker)}));
91 |       }
92 |     `);
93 |     expect(result).toEqual({
                        ^
error: expect(received).toEqual(expected)

  {
    "exitCode": 0,
    "stderr": "",
    "stdout": 
- "expect(received).toStrictEqual(expected) false
+ "boom false
  "
  ,
  }

- Expected  - 1
+ Received  + 1

      at <anonymous> (/workspace/bun/test/js/bun/test/expect-stack-overflow-crash.test.ts:93:20)
(fail) failing matcher when formatting a value throws > the property that throws ends the walk of that object [8.47ms]
106 |         Bun.jest().expect(received).toEqual({});
107 |       } catch (e) {
108 |         console.log(e.message.split("\\n")[0], e.message.includes(${JSON.stringify(marker)}));
109 |       }
110 |     `);
111 |     expect(result).toEqual({
                         ^
error: expect(received).toEqual(expected)

  {
 
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/test/expect-stack-overflow-crash.test.ts
bun test v1.4.1 (4448a2e21)

test/js/bun/test/expect-stack-overflow-crash.test.ts:
(pass) expect does not crash when called after catching stack overflow [800.00ms]
(pass) failing matcher when formatting a value throws > the exception also ends the walk of the enclosing object [309.85ms]
(pass) failing matcher when formatting a value throws > the matcher throws its own error when the received value itself cannot be formatted [315.58ms]
(pass) failing matcher when formatting a value throws > the property that throws ends the walk of that object [556.34ms]
(pass) failing matcher when formatting a value throws > a RegExp whose toString throws on either side of the diff [391.46ms]
(pass) failing matcher when formatting a value throws > an array Proxy whose get trap throws on either side of the diff [307.40ms]
(pass) failing matcher when formatting a value throws > a getter that overflows the stack while the message is formatted [641.25ms]
(pass) failing matcher when formatting a value th
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 671ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/5] gen generated_host_exports.rs
generated_host_exports.rs: 120 exports (host=5, lazy=10, generic=105, rust=0); 244 extern-C blocks audited
[1/5] cargo bun_runtime → libbun_runtime.a (--target x86_64-unknown-linux-gnu)

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

�[1m�[92m   Compiling�[0m bun_runtime v0.0.0 (/workspace/bun/src/runtime)
�[1m�[92m    Finished�[0m `release` profile [optimized + debuginfo] target(s) in 4m 26s
[2/5] link bun-profile
[4/5] strip bun
[4/5] bun-profile --revision
1.4.1-canary.1+884ca9afc
[build] done
bun test v1.4.1-canary.1 (884ca9afc)

test/js/bun/test/expect-stack-overflow-crash.test.ts:
(pass) expect does not crash when called after catching stack overflow [13.54ms]
(pass) failing matcher when formatting a value throws > the property that throws ends the walk of that object [6.74ms]
(pass) failing matcher when formatting a value throws > the exception also ends the walk of the enclosing object [7.03ms]
(pass) fail
... (truncated)
diff hotspot
src/runtime/test_runner/diff_format.rs             |  31 ++--
 .../bun/test/expect-stack-overflow-crash.test.ts   | 164 ++++++++++++++++++++-
 2 files changed, 177 insertions(+), 18 deletions(-)

gate history · 3 passed · 0 rejected · iteration 0

evidence per changed file
file                                                  reads  edits  tests
src/runtime/test_runner/diff_format.rs                    2      2      0
test/js/bun/test/expect-stack-overflow-crash.test.ts      5      7      0

@coderabbitai

coderabbitai Bot commented Aug 18, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 1e168311-87a1-494f-bff8-420cf8e17cd0

📥 Commits

Reviewing files that changed from the base of the PR and between b746c07 and 884ca9a.

📒 Files selected for processing (2)
  • src/runtime/test_runner/diff_format.rs
  • test/js/bun/test/expect-stack-overflow-crash.test.ts

Included review availability: Your plan provides up to 5 included reviews per hour; 1 remains after this review.


Walkthrough

Changes

Matcher formatting

Layer / File(s) Summary
Shared value formatting exception handling
src/runtime/test_runner/diff_format.rs
DiffFormatter now formats received and expected values through shared logic. Non-termination exceptions are cleared while termination exceptions remain pending.
Matcher formatting regression coverage
test/js/bun/test/expect-stack-overflow-crash.test.ts
Concurrent tests cover value-inspection failures, stack overflow, symmetric comparisons, matcher error propagation, and custom matcher hints.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
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 description explains the change, affected behavior, test coverage, verification results, and remaining policy decision.
Title check ✅ Passed The title clearly summarizes the main change: handling exceptions thrown while formatting matcher failure messages.

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

@robobun

robobun commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator Author

Status: the automated review did not run on this push, so there is no review feedback to act on yet. The change is complete from my side. I am waiting for the CI build.

For reviewers, the two hunks are independent. The bindings.cpp hunk is the same line as #37331. The diff_format.rs hunk is needed on top of it: with only the walk fixed, all four new tests still abort a debug build at the second format call in DiffFormatter.

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

LGTM — small, well-traced fuzzer crash fix that applies two established patterns.

What was reviewed:

  • RETURN_IF_EXCEPTION after iter(...) in forEachPropertyOrdered mirrors the sibling forEachPropertyImpl (bindings.cpp:5688) exactly, and the early return skips properties.releaseData() the same way lines 5608/5688 already do.
  • clear_exception_except_termination() after a failed JestPrettyFormat::format matches create_error_instance (JSGlobalObject.rs:749); the loop dedupe of the two calls is behavior-preserving on the success path.
  • Tests drain pipes concurrently, assert combined {stdout, stderr, exitCode}, and cover the walk, nested walk, received-throws, and the fuzzer's stack-overflow shape.
Extended reasoning...

Overview

Two source hunks fixing a debug-build releaseAssertNoException crash in the expect() failure-message formatter, plus four subprocess regression tests. The C++ hunk adds RETURN_IF_EXCEPTION(scope, void()) after the per-property callback in JSC__JSValue__forEachPropertyOrdered, replacing a TODO comment. The Rust hunk collapses two ignored JestPrettyFormat::format calls into a loop and clears any pending non-termination exception when a call fails, replacing two more TODOs.

Security risks

None. This is exception-scope hygiene in the test runner's diff formatter and the property-enumeration binding. No untrusted-input parsing, auth, crypto, or filesystem changes.

Level of scrutiny

Medium. It touches JSC exception handling in native code, which is a common source of debug assertions and UAFs, but both hunks are direct copies of adjacent established code: the C++ line is identical to forEachPropertyImpl at bindings.cpp:5573/5688 (same file, same loop shape), and the Rust pattern matches four call sites in JSGlobalObject.rs (749, 765, 794, 808). I checked that the early return skipping properties.releaseData() is not a leak concern — the sibling function already returns early past the same call at 5608 and 5688, and PropertyNameArrayBuilder is stack-RAII.

Other factors

The PR description is unusually thorough: it names the exact assertion, traces the backtrace to to_js_host_call, explains why both hunks are needed independently, verifies each test fails on the unfixed build (USE_SYSTEM_BUN=1), and reruns the adjacent test suites. The user-visible behavior change (failure message truncates at the throwing property rather than skipping it, and the matcher throws its own error rather than leaking the getter's) is a strict improvement and only affects the already-pathological case of a getter throwing during failure-message formatting. The tests follow harness conventions (bunEnv/bunExe, describe.concurrent, concurrent pipe draining, combined-object assertions). The one subtlety I considered — a termination exception left pending by clear_exception_except_termination before the second loop iteration — matches how create_error_instance already behaves and is the intended semantics for termination.

@robobun

robobun commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the review. There is nothing to change from it.

On the one point it raises, a termination exception that the first format call leaves pending: the second format call is safe with it pending. The calls that the formatter makes either return at once on a pending exception (get_own in src/jsc/JSValue.rs:2370, and every check_slow wrapper) or skip their check for a termination exception (assert_no_exception in src/jsc/TopExceptionScope.rs:314, and ASSERT_NO_PENDING_EXCEPTION in src/jsc/bindings/headers-handwritten.h:481). throw_value then sees the termination and returns without throwing the matcher's error, so the termination keeps unwinding. This is the same sequence as create_error_instance.

CI: the build is still running. So far every failed test passed on retry, and none of them touch expect() or the formatter. The one failed GitHub check is a PR triage job that failed while it set itself up. It does not run any code from this branch.

@robobun

robobun commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator Author

Cross-reference: the bindings.cpp hunk here (RETURN_IF_EXCEPTION after the ordered walk's callback) is the same one-line change as #37331, opened Aug 10, so one of the two should absorb the other. Separately, #34884 adds one field (can_throw_stack_overflow: false) to the FormatOptions literal that sits in the context of this PR's diff_format.rs hunk; whichever lands second gets a one-line conflict there, and the field simply stays in the literal. The two changes are otherwise independent: #34884's stack guard never returns an error on the diff path, so it does not reach the clear_exception branch added here.

@robobun

robobun commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

Two more triggers reach the same line 63 assertion (ASSERT_NO_PENDING_EXCEPTION at the top of JSC__JSValue__getOwn, called from Tag::get while the second value is formatted). I cherry-picked this change onto 6e906e4 and ran both against a debug build. Neither aborts with this change. Without it, both abort.

  1. A value whose Symbol.toStringTag getter throws. PropertyIterator::handle_first_property in pretty_format.rs reads the tag through get_name_property, so the object throws as soon as its first printable property is reached.
const v = { get [Symbol.toStringTag]() { throw new Error("tag-throws") }, a: 1 };
expect(v).toEqual({ z: 9 });

The same holds for toStrictEqual, toMatchObject, .not.toEqual, for v on the expected side, and for v nested in a larger object. With this change each matcher throws its own error. The received side of the diff is empty (+ Received + 0). In the nested case, expect({ a: 1, b: v }).toEqual({ a: 2, b: v }), both sides of the diff stop at "b": and nothing marks the cut.

  1. this.utils.matcherHint("toBeBar", v, { z: 9 }) inside an expect.extend matcher. matcher_hint in expect.rs renders the same DiffFormatter through format!, so it hit the same assertion. With this change it returns the hint, again with an empty received side.

A FormData whose toJSON throws, compared with a non-empty object, is a third variant. It now prints FormData (entries) and then the expected value.

Stock 1.4.0 reports the getter's error (tag-throws) as the test failure for all of these, because throw_value does not replace a pending exception. So the visible change of this PR for these inputs is: the matcher's error replaces the getter's error, and the value that threw prints as empty. #34649 picks the other option and keeps the getter's error. One of the two policies needs a decision before either PR lands. #28538 and #29784 contain the diff_format.rs half of this PR and nothing else.

@robobun
robobun force-pushed the farm/88a96f78/expect-diff-format-pending-exception branch 2 times, most recently from 795fdbb to 2eb640f Compare August 21, 2026 08:38
@robobun

robobun commented Aug 21, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 11:38 AM PT - Aug 24th, 2026

✅ @robobun, your commit 884ca9afc0b192eb76e3167848e15401b9b05cd3 passed in Build #104993! 🎉


🧪   To try this PR locally:

bunx bun-pr 39565

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

bun-39565 --bun

Comment thread test/js/bun/test/expect-stack-overflow-crash.test.ts Outdated
@robobun robobun changed the title expect: stop the property walk and drop the exception when a value throws while a failure message is formatted expect: drop the exception when a value throws while a failure message is formatted Aug 21, 2026
@robobun

robobun commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased on main and updated the description. The state of the PR is now:

  • Propagate JS exceptions instead of clearing them where the caller is fallible #39804 landed the bindings.cpp hunk (the ordered property walk stops when the callback throws). The rebase dropped that hunk from this PR. The conflict was only that line. This PR now changes diff_format.rs and the test file.
  • The diff_format.rs change is still needed on main. On a debug build of main (025570f4b6) all eight new tests fail. The cases where the received value throws abort with the ExceptionScope.h(63) assertion in JSC__JSValue__getOwn. The other cases print the getter's error instead of the matcher's error. With this change all eight pass.
  • Added tests for the other triggers found so far: a RegExp whose toString throws, an array Proxy whose get trap throws, an object whose Symbol.toStringTag getter throws, each on both sides of the diff, and matcherHint from an expect.extend matcher. The file runs in about 4 seconds on a debug build.
  • Applied the review comment: the exit status is checked before the message is parsed (64d2c4f).
  • Closed Clear pending JSC exception in DiffFormatter catch blocks #28538 and test: clear pending JS exception when diff formatting throws #29784. Both contained this diff_format.rs change and nothing else. Their triggers are tests here.

One decision is open for a maintainer, and the description records it. This PR keeps the matcher's own error and prints the value that threw as far as it got. #34649 keeps the getter's error instead, which is what a release build prints today and what Jest does. If that policy is chosen, close this PR. The tests here then need the expected output changed from the matcher's error to the getter's error.

The automated review comments above this one are rate limit notices with no content to act on.

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

Re-reviewed after 64d2c4f — the assertion-ordering nit is addressed and I found no further issues. I'm not auto-approving because the PR itself flags an open maintainer decision: this change picks "clear the exception and throw the matcher's own error" over #34649's "propagate the getter's error" (today's release-build and Jest behavior). That is a user-visible behavior choice, and it uses clear_exception_except_termination(), so a maintainer should confirm the policy before this lands.

What was reviewed:

  • DiffFormatter::fmt loop: verified clear_exception_except_termination matches the precedent in create_error_instance and leaves termination exceptions pending.
  • Checked that the second loop iteration is safe when a termination exception survives the first (formatter entry points early-return on pending exceptions).
  • Eight new subprocess tests: pipes drained concurrently, combined {stdout, stderr, exitCode} assertions, describe.concurrent for the ~4s runtime.
Extended reasoning...

Overview

The PR changes src/runtime/test_runner/diff_format.rs to check the return of two JestPrettyFormat::format calls (previously discarded with let _ =) and clear any pending non-termination exception when formatting fails. The two sequential calls are folded into a loop so both sides get identical handling. Eight subprocess regression tests are added to test/js/bun/test/expect-stack-overflow-crash.test.ts covering the fuzzer's stack-overflow shape plus RegExp toString, Proxy get, Symbol.toStringTag, and matcherHint triggers.

Security risks

None. This is the test-runner failure-message formatting path; no untrusted input, no auth/crypto/network surface.

Level of scrutiny

Medium-high. The Rust change is small and mechanically sound, but it deliberately swallows a JS exception via clear_exception_except_termination() — a pattern REVIEW.md explicitly flags ("Never clearException()"). The PR body justifies it by precedent (create_error_instance in JSGlobalObject.rs does the same for other matcher messages) and by necessity (matcher_hint renders DiffFormatter via format!, which panics on fmt::Error, so propagation would require wider changes). The justification is coherent, but it also changes release-build behavior: users who today see the getter's own error will instead see the matcher's error with a truncated diff.

Other factors

The decisive factor against auto-approval is that the author explicitly flags an unresolved policy choice between this PR and #34649, which takes the opposite approach (propagate the getter's error, matching Jest). The PR description says "The two PRs need one decision. If propagate is chosen, this PR should be closed." That is a design decision a maintainer should make, not an automated reviewer.

My earlier nit (assert exit status before JSON.parse) was applied in 64d2c4f. The tests follow harness conventions well: bunEnv/bunExe, concurrent pipe draining, describe.concurrent, combined-object assertions. The stack-overflow test's expected output (Error expect(received)...) locks in the new policy, so it will need updating if #34649 is chosen instead — which the author already noted.

@robobun

robobun commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks. The re-review asks for nothing further, so the code stays as it is at 64d2c4f. No review thread is open.

What is left is the maintainer decision in the description. In short: when a value throws while a failure message is formatted, this PR makes the matcher throw its own error and prints the value as far as it got. #34649 keeps the getter's error instead, which is what a release build prints today and what Jest does. Either choice fixes the debug abort. If #34649's policy is chosen, close this PR. The tests here are then still useful with the expected output changed from the matcher's error to the getter's error.

CI on 64d2c4f: the two red tests (test/bake/deinitialization.test.ts, test/js/bun/http/bun-server.test.ts, both Windows 2019) also fail on main and are reported separately. The tests from this PR passed on every lane.

…e is formatted

DiffFormatter ignored the result of the two JestPrettyFormat::format
calls. When the first value threw, the second value was formatted with
the exception pending. Debug builds abort on the first native call of
that second pass. Release builds then threw that exception from the
matcher instead of the matcher's own error. DiffFormatter now drops the
exception and diffs what was formatted, so the matcher reports its own
failure. This also covers matcherHint, which renders the same
DiffFormatter with format!.

The ordered property walk that let the exception out of the formatter's
callback is fixed on main since #39804.
@robobun
robobun force-pushed the farm/88a96f78/expect-diff-format-pending-exception branch from 64d2c4f to 884ca9a Compare August 24, 2026 18:19
@robobun

robobun commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased on main (b746c078b6). The conflict was in the test file only: #40363 appended two toMatchObject stack depth tests at the same place where this PR appends its describe block. Both blocks are kept, main's first. diff_format.rs did not conflict.

On the new base, with main's diff_format.rs the eight new tests still fail (3 pass, 8 fail). With this PR all eleven tests in the file pass on a debug build.

The open maintainer decision in the description is unchanged.

@robobun

robobun commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator Author

The automated review of the rebased head (884ca9a) has no comments to act on, and no review thread is open. CI for that head is build 104993, still running. Nothing else changed: the PR waits on the maintainer decision recorded in the description.

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

I reviewed this PR and didn't find any bugs — the clear_exception_except_termination() call after a failed format matches the established pattern in create_error_instance (JSGlobalObject.rs:736), and the eight new subprocess tests cover the crash and the behavior change from multiple angles. My earlier nit about asserting exit status before JSON.parse was addressed in 64d2c4f.

I'm not approving because the description and thread flag an open policy decision for a maintainer: this PR surfaces the matcher's own error with a truncated diff, while #34649 keeps the getter's error (today's release behavior and Jest's). That's a user-visible behavior choice between two competing PRs that a human should make.

Extended reasoning...

Overview

The PR touches two files: src/runtime/test_runner/diff_format.rs (14 lines net) and test/js/bun/test/expect-stack-overflow-crash.test.ts (+162 lines, eight new subprocess tests). In DiffFormatter::fmt, the two JestPrettyFormat::format calls previously discarded their result with let _ = ... // TODO:. When formatting a value ran user code that threw (a $$typeof getter, toString, a Proxy trap, Symbol.toStringTag), the pending exception leaked into the second format call and then into throw_value, tripping ASSERT_NO_PENDING_EXCEPTION in debug builds and replacing the matcher's error with the getter's error in release builds. The fix folds both calls into a loop and calls global_this.clear_exception_except_termination() when format returns Err.

Security risks

None. This is error-message formatting in the test runner's failure path. No untrusted-input parsing, auth, crypto, or filesystem paths are touched.

Level of scrutiny

Medium. The Rust change is small and mirrors the exact pattern already used in create_error_instance, create_type_error_instance, create_syntax_error_instance, and create_range_error_instance (JSGlobalObject.rs:733-799) — clear the non-termination exception and proceed with what was formatted so far. Termination exceptions correctly stay pending. The tests are thorough: they cover the original fuzzer shape (stack overflow at depth), throwing getters on both received and expected sides, nested objects, RegExp toString, array Proxy get, Symbol.toStringTag, and the matcherHint path from expect.extend. Each is a subprocess test asserting the combined {stdout, stderr, exitCode} object per repo convention.

Other factors

The reason I'm deferring rather than approving is the explicit open maintainer decision the author has flagged in the description and in three follow-up comments: this PR changes release-build behavior so that a value which throws during formatting no longer surfaces the getter's error — the matcher throws its own error instead, with the throwing side printed as far as it got (possibly empty). #34649 takes the opposite approach (propagate the getter's error, matching Jest). The author states "The two PRs need one decision" and "If propagate is chosen, this PR should be closed." That is a user-facing API-behavior choice between two competing PRs, not something an automated review should settle.

My prior inline nit (assert exit status before JSON.parse(result.stdout)) was addressed in 64d2c4f and the thread is resolved. The subsequent rebase onto b746c07 only reordered test blocks in the test file; diff_format.rs did not conflict.

@robobun

robobun commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks. No change is needed from this review, and no review thread is open. The review's summary of the open decision matches the description: a maintainer picks between this PR (the matcher throws its own error) and #34649 (the getter's error is kept). CI for 884ca9a is build 104993, still running, with no red test so far.

@robobun

robobun commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator Author

The automated review above is the same walkthrough of 884ca9a, re-posted with no comments to act on. No review thread is open. CI build 104993 for that head has 175 of 181 jobs passed and 6 still running. No test is red. The only entries so far passed on retry and none of them touch expect() or the formatter.

@robobun

robobun commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #40068, which moves the value formatting out of Display::fmt into a fallible DiffFormatter::new and propagates the error from every caller. That settles the open decision in this PR's description: the getter's error is kept, the matcher does not throw its own error. I ran the test file from this PR against main with #40068 merged: no abort in any case. The failing matcher reports the getter's error (Error: boom, or RangeError for the stack overflow case) instead of the matcher's own error, which is the only difference from the expectations here. The coverage moves to #40919, a test-only PR on top of #40068 with the expectations updated to the propagated error. Closing in favor of #40068.

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.

1 participant