Skip to content

Fix process abort when expect matcher utils inspect a value whose custom inspect throws - #36912

Open
robobun wants to merge 1 commit into
mainfrom
farm/dd96bbaf/expect-matcher-utils-throwing-inspect
Open

Fix process abort when expect matcher utils inspect a value whose custom inspect throws#36912
robobun wants to merge 1 commit into
mainfrom
farm/dd96bbaf/expect-matcher-utils-throwing-inspect

Conversation

@robobun

@robobun robobun commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Problem

In an expect.extend matcher, this.utils.printReceived(v), printExpected(v), and stringify(v) abort the whole process when inspecting v throws, and the user's own try/catch never runs:

import { expect } from "bun:test";
expect.extend({ m(received) {
  try { this.utils.printReceived(received); } catch {}  // catch never runs
  return { pass: true };
}});
expect({ [Symbol.for("nodejs.util.inspect.custom")]() { throw 1 } }).m();
// panic: a formatting trait implementation returned an error when the underlying stream did not

Cause

ExpectMatcherUtils::print_value formatted the value through the Display adapter (ZigFormatter) into a std::io::Write sink. When inspecting the value throws a JS exception, the Display impl returns fmt::Error while the in-memory Vec sink reported no error, so io::Write::write_fmt panics with "a formatting trait implementation returned an error when the underlying stream did not". The let _ = around the write cannot swallow a panic.

Fix

Added Formatter::format_value, a fallible single-value entry point that propagates the JsError instead of flattening it to fmt::Error, and switched print_value to it. The exception from the user's inspect method now surfaces as a normal catchable JS error from stringify/printExpected/printReceived (matching how the rest of the matcher pipeline propagates exceptions).

Verification

New test test/js/bun/test/expect-extend-matcher-utils-throw.test.ts crashes the process on bun 1.4.0-canary (1498d7b) and passes with this change. Also ran expect.test.js, expect-extend.test.js, expect-extend-asymmetric-match-throw.test.ts, expect-extend-preload.test.ts, and jest-extended.test.js (503 pass, 0 fail).

…ue throws

printReceived/printExpected/stringify formatted the value through the
Display adapter into a std::io::Write sink. When inspecting the value
threw a JS exception (e.g. a throwing [util.inspect.custom]), the
Display impl returned fmt::Error while the Vec sink reported no error,
so io::Write::write_fmt panicked with "a formatting trait
implementation returned an error when the underlying stream did not",
aborting the process and bypassing the user's try/catch.

Add Formatter::format_value, a fallible single-value entry point that
propagates the JsError, and use it in ExpectMatcherUtils::print_value
so the exception surfaces as a catchable JS error.
@coderabbitai

coderabbitai Bot commented Aug 4, 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: abdc6051-0eb2-4ed8-961c-ba408f32710e

📥 Commits

Reviewing files that changed from the base of the PR and between ace8f42 and 7c2c1d3.

📒 Files selected for processing (3)
  • src/jsc/ConsoleObject.rs
  • src/runtime/test_runner/expect.rs
  • test/js/bun/test/expect-extend-matcher-utils-throw.test.ts

Walkthrough

Changes

Formatter Error Propagation

Layer / File(s) Summary
Direct formatter value API
src/jsc/ConsoleObject.rs
Adds Formatter::format_value to format one JSValue and return formatting or inspection errors.
Matcher utility error propagation
src/runtime/test_runner/expect.rs, test/js/bun/test/expect-extend-matcher-utils-throw.test.ts
Matcher utility methods now return formatter errors directly. The regression test covers errors from custom object inspection.

Possibly related PRs

  • oven-sh/bun#36911: Adds a related direct JS-value formatting API for formatter error propagation.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the main fix for process aborts caused by throwing custom inspection methods in expect matcher utilities.
Description check ✅ Passed The description explains the problem, cause, fix, affected APIs, regression test, and verification results, although it does not use the template headings exactly.
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.

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

@robobun

robobun commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

Note: #36911 fixes the same panic mechanism at a different site (test.each title formatting) and adds an equivalent helper, ZigFormatter::write_to, to ConsoleObject.rs. The two helpers do the same thing from different entry points. Whichever PR lands second should drop its copy and reuse the other's; happy to rebase this one onto #36911 if that merges first.

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. bun:test: don't abort when formatting a test.each title value throws #36911 - Fixes the same a formatting trait implementation returned an error when the underlying stream did not panic from a throwing nodejs.util.inspect.custom by adding a near-identical fallible formatter entry point (ZigFormatter::write_to) to the same src/jsc/ConsoleObject.rs, differing only in call site.
  2. Bun__inspect: don't panic when user JS throws during error-message formatting #30980 - Same root cause and trigger (the ZigFormatter Display adapter panicking in write_fmt when user inspect throws), fixed for the Bun.inspect call site with a different technique — the three should settle on one approach.

🤖 Generated with Claude Code

Comment thread src/jsc/ConsoleObject.rs
robobun added a commit that referenced this pull request Aug 4, 2026
@robobun

robobun commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

On the duplicate / same-class question raised above: I went through every to_fmt(...) consumer in src/ (153 call sites). Nearly all of them render through JSGlobalObject::throw / create_error_instance / ErrorCode::fmt, which write via core::fmt::Write and already cope with a failing Display impl without panicking. The user-reachable sites that render through std::io::Write::write_fmt (or .unwrap() its result) and can hit this panic are:

  1. ExpectMatcherUtils::print_value (matcher utils stringify / printExpected / printReceived): this PR.
  2. format_label in jest.rs ($var and %p in test.each / describe.each titles): bun:test: don't abort when formatting a test.each title value throws #36911. That PR now uses the same Formatter::format_value helper added here (identical hunk in ConsoleObject.rs), so both PRs share one approach and differ only in call site and tests. They can merge in either order; the second one rebases to drop the duplicate hunk.
  3. bun_inspect (Bun__inspect, used by the C++ error-message builders) in BunObject.rs: Bun__inspect: don't panic when user JS throws during error-message formatting #30980. With format_value available that site can become formatter.format_value::<false>(value, &mut array).is_err(), keeping the semantics it has there (empty string, exception left pending for the C++ caller's scope check).

ipc.rs also formats a value through to_fmt inside a scoped_log!, but that is debug-build-only logging and not reachable in release builds.

These were filed and picked up as separate reports, which is why they ended up as separate PRs. If you would rather review a single PR for the whole class, say the word and I will pull the jest.rs and BunObject.rs call-site changes plus their tests into this one.

Jarred-Sumner pushed a commit that referenced this pull request Aug 4, 2026
…36911)

### Problem

A `test.each` / `describe.each` title interpolating a value via `$path`
(or `%p`) aborts the test runner when formatting that value throws, e.g.
a throwing `[Symbol.for("nodejs.util.inspect.custom")]`:

```js
import { test } from "bun:test";
test.each([{ a: { b: { [Symbol.for("nodejs.util.inspect.custom")]() { throw 1 } } } }])("case $a.b", () => {});
```

```
panic: a formatting trait implementation returned an error when the underlying stream did not
```

### Cause

`format_label` in `src/runtime/test_runner/jest.rs` formats non-string
interpolated values with `write!(&mut list, "{}", value.to_fmt(&mut
formatter))` through `std::io::Write`. `ZigFormatter`'s `Display` impl
collapses the pending JS exception to `fmt::Error`, and
`std::io::Write::write_fmt` panics when the `Display` impl errors while
the sink (`Vec<u8>`) did not. The `%p` branch in the same function has
the identical bug.

### Fix

Add `Formatter::format_value`, a fallible single-value entry point that
propagates the `JsError` (thrown exception, termination, OOM) instead of
collapsing it to `fmt::Error`, and use it at both `format_label` sites.
The thrown exception now surfaces as a test error and `bun test` exits 1
instead of crashing.

The `Formatter::format_value` hunk is byte-identical to the one in
#36912 so the two merge cleanly in either order.

Sibling sites with the same `write!`-through-`Display` pattern that are
intentionally not touched here because they already have their own PRs:
`ExpectMatcherUtils::print_value` in `expect.rs` (#36912) and
`bun_inspect` in `BunObject.rs` (#30980).

### Verification

New test in `test/cli/test/bun-test.test.ts` covers `test.each` and
`describe.each` for both the `$path` and `%p` sites, one fixture file
per variant with a distinct error message (the declaration throw aborts
module evaluation, so a single file can only exercise the first one). It
aborts with the panic on a build without the fix and passes with it. The
full `bun-test.test.ts` file passes.

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 2 · Platform-specific test(s) that do not
run on this machine. Deferring to CI, which covers all platforms:
test/cli/test/bun-test.test.ts

<!-- robobun:evidence:end -->
springmin pushed a commit to springmin/bun that referenced this pull request Aug 4, 2026
…ven-sh#36911)

### Problem

A `test.each` / `describe.each` title interpolating a value via `$path`
(or `%p`) aborts the test runner when formatting that value throws, e.g.
a throwing `[Symbol.for("nodejs.util.inspect.custom")]`:

```js
import { test } from "bun:test";
test.each([{ a: { b: { [Symbol.for("nodejs.util.inspect.custom")]() { throw 1 } } } }])("case $a.b", () => {});
```

```
panic: a formatting trait implementation returned an error when the underlying stream did not
```

### Cause

`format_label` in `src/runtime/test_runner/jest.rs` formats non-string
interpolated values with `write!(&mut list, "{}", value.to_fmt(&mut
formatter))` through `std::io::Write`. `ZigFormatter`'s `Display` impl
collapses the pending JS exception to `fmt::Error`, and
`std::io::Write::write_fmt` panics when the `Display` impl errors while
the sink (`Vec<u8>`) did not. The `%p` branch in the same function has
the identical bug.

### Fix

Add `Formatter::format_value`, a fallible single-value entry point that
propagates the `JsError` (thrown exception, termination, OOM) instead of
collapsing it to `fmt::Error`, and use it at both `format_label` sites.
The thrown exception now surfaces as a test error and `bun test` exits 1
instead of crashing.

The `Formatter::format_value` hunk is byte-identical to the one in
oven-sh#36912 so the two merge cleanly in either order.

Sibling sites with the same `write!`-through-`Display` pattern that are
intentionally not touched here because they already have their own PRs:
`ExpectMatcherUtils::print_value` in `expect.rs` (oven-sh#36912) and
`bun_inspect` in `BunObject.rs` (oven-sh#30980).

### Verification

New test in `test/cli/test/bun-test.test.ts` covers `test.each` and
`describe.each` for both the `$path` and `%p` sites, one fixture file
per variant with a distinct error message (the declaration throw aborts
module evaluation, so a single file can only exercise the first one). It
aborts with the panic on a build without the fix and passes with it. The
full `bun-test.test.ts` file passes.

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 2 · Platform-specific test(s) that do not
run on this machine. Deferring to CI, which covers all platforms:
test/cli/test/bun-test.test.ts

<!-- robobun:evidence:end -->
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.

2 participants