Skip to content

Bun__inspect: don't panic when user JS throws during error-message formatting - #30980

Closed
robobun wants to merge 1 commit into
mainfrom
farm/76f8d2f6/bun-inspect-fmt-panic
Closed

Bun__inspect: don't panic when user JS throws during error-message formatting#30980
robobun wants to merge 1 commit into
mainfrom
farm/76f8d2f6/bun-inspect-fmt-panic

Conversation

@robobun

@robobun robobun commented May 18, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes a panic in Bun__inspect (the native helper used by JSValueToStringSafe in ErrorCode.cpp to render values in messages like ERR_INVALID_ARG_VALUE).

When the value being rendered has a throwing [Symbol.for("nodejs.util.inspect.custom")] (or any user JS that throws while the console formatter walks the value), ZigFormatter's Display impl returns fmt::Error. The sink was a Vec<u8> via std::io::Write, whose write_fmt panics with

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

because the in-memory stream never errored. The existing .is_err() check never saw the error — the panic happens inside write_fmt before it returns.

Fix: write through core::fmt::Write (bun_core::fmt::VecWriter) so the error is observable and we return an empty string as the code originally intended. The user's thrown exception then propagates normally.

Repro (crashed before, throws cleanly after)

const obj = {
  [Symbol.for("nodejs.util.inspect.custom")]() {
    throw new Error("boom");
  },
};
new Bun.Cookie("n", "v", { expires: obj });

How did you verify your code works?

  • Reproduced the exact Fuzzilli panic fingerprint (panic:a formatting trait implementation returned an error when the) with the minimal repro above; symbolicated stack confirms std::io::default_write_fmt::<Vec<u8>>bun_inspectJSValueToStringSafe.
  • After the fix, the repro throws a regular JS error instead of crashing.
  • Added a regression test in test/js/bun/cookie/cookie-expires-validation.test.ts that crashes on the baked Bun and passes with this change.

[stamp-90s] gate passed · iteration 15 · 2 files touched

fails on main (without fix)
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/cookie/cookie-expires-validation.test.ts
bun test v1.4.0 (f28a7bb6c)

test/js/bun/cookie/cookie-expires-validation.test.ts:
(pass) Bun.Cookie expires validation > Date objects > accepts valid Date in future [4.30ms]
(pass) Bun.Cookie expires validation > Date objects > accepts valid Date in past [2.78ms]
(pass) Bun.Cookie expires validation > Date objects > throws for invalid Date (NaN) [4.25ms]
(pass) Bun.Cookie expires validation > Number values > accepts positive integer [2.01ms]
(pass) Bun.Cookie expires validation > Number values > accepts zero [2.31ms]
(pass) Bun.Cookie expires validation > Number values > throws for NaN [2.62ms]
(pass) Bun.Cookie expires validation > Number values > throws for Infinity [2.89ms]
(pass) Bun.Cookie expires validation > Number values > throws for negative Infinity [2.94ms]
(pass) Bun.Cookie expires validation > Special values > handles undefined [1.43ms]
(pass) Bun.Cookie expires validation > Special values > handles null [1.72ms]
(pass) Bun.Cookie expires validation > Special values > t
... (truncated)

release without fix: 1 FAILED
bun test v1.4.0-canary.1 (b58cd4685)

test/js/bun/cookie/cookie-expires-validation.test.ts:
(pass) Bun.Cookie expires validation > Date objects > accepts valid Date in future [0.10ms]
(pass) Bun.Cookie expires validation > Date objects > accepts valid Date in past [0.04ms]
(pass) Bun.Cookie expires validation > Date objects > throws for invalid Date (NaN) [0.07ms]
(pass) Bun.Cookie expires validation > Number values > accepts positive integer [0.03ms]
(pass) Bun.Cookie expires validation > Number values > accepts zero [0.02ms]
(pass) Bun.Cookie expires validation > Number values > throws for NaN [0.03ms]
(pass) Bun.Cookie expires validation > Number values > throws for Infinity [0.02ms]
(pass) Bun.Cookie expires validation > Number values > throws for negative Infinity [0.02ms]
(pass) Bun.Cookie expires validation > Special values > handles undefined [0.03ms]
(pass) Bun.Cookie expires validation > Special values > handles null [0.02ms]
(pass) Bun.Cookie expires validation > Special values > throws for non-date objects [0.07ms]
 99 |         env: bunEnv,
100 |         stdout: "pipe",
101 |         stderr: "pipe",
102 |       });
103 |       const [stdout, stderr, exi
... (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/cookie/cookie-expires-validation.test.ts
bun test v1.4.0 (f28a7bb6c)

test/js/bun/cookie/cookie-expires-validation.test.ts:
(pass) Bun.Cookie expires validation > Date objects > accepts valid Date in future [4.75ms]
(pass) Bun.Cookie expires validation > Date objects > accepts valid Date in past [2.46ms]
(pass) Bun.Cookie expires validation > Date objects > throws for invalid Date (NaN) [4.23ms]
(pass) Bun.Cookie expires validation > Number values > accepts positive integer [2.03ms]
(pass) Bun.Cookie expires validation > Number values > accepts zero [2.19ms]
(pass) Bun.Cookie expires validation > Number values > throws for NaN [2.56ms]
(pass) Bun.Cookie expires validation > Number values > throws for Infinity [3.61ms]
(pass) Bun.Cookie expires validation > Number values > throws for negative Infinity [2.98ms]
(pass) Bun.Cookie expires validation > Special values > handles undefined [1.42ms]
(pass) Bun.Cookie expires validation > Special values > handles null [1.87ms]
(pass) Bun.Cookie expires validation > Special values > t
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 655ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/72] gen generated_host_exports.rs
generated_host_exports.rs: 92 exports (host=3, lazy=10, generic=79, rust=0); 239 extern-C blocks audited
[1/72] cargo bun_bin → libbun_rust.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   Compiling�[0m bun_bin v0.0.0 (/workspace/bun/src/bun_bin)
�[1m�[92m    Finished�[0m `release` profile [optimized + debuginfo] target(s) in 4m 26s
[68/72] cxx obj/codegen/ZigGeneratedClasses.cpp.o
[69/72] link bun-profile
[71/72] strip bun
[71/72] bun-profile --revision
1.4.0-canary.1+f28a7bb6c
[build] done
bun test v1.4.0-canary.1 (f28a7bb6c)

test/js/bun/cookie/cookie-expires-validation.test.ts:
(pass) Bun.Cookie expires validation > Date objects > accepts valid Date in future [0.14ms]
(pass) Bun.Cookie expires validation > Date objects > accepts valid Date in past [0.06ms]
(pass) Bun.Cookie expires valida
... (truncated)
diff hotspot
src/runtime/api/BunObject.rs                       |  9 ++++++-
 .../bun/cookie/cookie-expires-validation.test.ts   | 31 ++++++++++++++++++++++
 2 files changed, 39 insertions(+), 1 deletion(-)

gate history · 2 passed · 1 rejected · iteration 15

evidence per changed file
file                                                  reads  edits  tests
src/runtime/api/BunObject.rs                              4      3     18
test/js/bun/cookie/cookie-expires-validation.test.ts      5      5     18

@robobun

robobun commented May 18, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 5:29 AM PT - Jul 2nd, 2026

@robobun, your commit e42fedb has some failures in Build #67909 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 30980

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

bun-30980 --bun

@coderabbitai

coderabbitai Bot commented May 18, 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

The PR updates bun_inspect to use VecWriter for formatted output and preserve empty-string fallback behavior when formatting fails. A subprocess test verifies that a throwing custom inspect hook produces a JavaScript error without crashing the process.

Changes

Object inspection error handling

Layer / File(s) Summary
Inspect formatting error handling and validation
src/runtime/api/BunObject.rs, test/js/bun/cookie/cookie-expires-validation.test.ts
bun_inspect now writes formatted output through core::fmt::Write and bun_core::fmt::VecWriter. The test harness adds subprocess imports and verifies the error output and zero exit status for a throwing nodejs.util.inspect.custom hook.

Suggested reviewers: dylan-conway, 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.
Title check ✅ Passed The title clearly identifies the main change: preventing Bun__inspect from panicking when user JavaScript throws during formatting.
Description check ✅ Passed The description includes both required sections and provides clear implementation details, reproduction steps, and verification results.

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

@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 — targeted fix swapping the Vec<u8> sink to core::fmt::Write via the existing VecWriter helper so a throwing Display impl surfaces as Err instead of panicking; regression test included.

Extended reasoning...

Overview

This PR touches three files: a one-line logic change in src/runtime/api/BunObject.rs (bun_inspect), a trivial assertASSERT macro swap in src/jsc/bindings/wtf-bindings.cpp, and a new regression test in test/js/bun/util/inspect-error.test.js. The core change replaces write!(&mut Vec<u8>, ...) (which resolves to std::io::Write::write_fmt and panics when a Display impl returns fmt::Error without an underlying I/O error) with write!(bun_core::fmt::VecWriter(&mut array), ...), which goes through core::fmt::Write and returns the error so the existing .is_err() branch actually fires.

Security risks

None. This is a panic-to-graceful-error conversion in a value-formatting helper. No auth, crypto, permissions, parsing of untrusted input beyond what was already happening, or new attack surface. If anything, it removes a user-triggerable process crash.

Level of scrutiny

Low. The fix is mechanical and follows an established pattern — bun_core::fmt::VecWriter already exists precisely for this purpose (per its doc comment at src/bun_core/fmt.rs:800-820) and is used identically in FrameworkRouter.rs, JSGlobalObject.rs, clap/lib.rs, and shell_parser/braces.rs. The local use core::fmt::Write; doesn't conflict with the module-level use std::io::Write as _; (anonymous import), and VecWriter only implements core::fmt::Write, so method resolution is unambiguous. The ASSERT swap in wtf-bindings.cpp is a debug-build hygiene fix matching surrounding WTF conventions.

Other factors

A regression test is included that crashes on the baked Bun and passes with the fix. The inline note about the same pattern in jest.rs::format_label is explicitly pre-existing and non-blocking — worth a follow-up but out of scope here. No CODEOWNERS cover these paths and there are no outstanding reviewer comments.

Comment thread src/runtime/api/BunObject.rs

@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 — the inline notes are pre-existing sibling sites, fine for a follow-up.

Extended reasoning...

Overview

Three small changes: (1) bun_inspect in src/runtime/api/BunObject.rs now writes through core::fmt::Write via the existing bun_core::fmt::VecWriter adapter instead of std::io::Write on Vec<u8>, so a fmt::Error from ZigFormatter's Display impl (when user JS throws inside inspect.custom) surfaces as a Result and hits the existing .is_err() early-return instead of panicking inside std's default_write_fmt; (2) a regression test in cookie-expires-validation.test.ts exercising the JSValueToStringSafeBun__inspect path; (3) assertASSERT in wtf-bindings.cpp to fix a debug-build break from a missing transitive <cassert>.

Security risks

None. This narrows a user-triggerable native panic into a normal JS throw; no new data exposure, parsing, or privilege surface. The ASSERT swap is debug-only and semantically equivalent.

Level of scrutiny

Low–medium. The Rust change is a one-expression trait-resolution swap onto an established in-tree helper (VecWriter is already used in JSGlobalObject.rs, FrameworkRouter.rs, braces.rs, etc.) whose write_str is infallible, so .is_err() can only be true when the Display impl itself errored — exactly the intended behavior. The test and the ASSERT change are trivial.

Other factors

The two inline notes (jest.rs format_label and expect.rs print_value) are explicitly pre-existing instances of the same pattern in code this PR doesn't touch — non-blocking and reasonable to defer. Since my prior review the only delta is relocating the regression test into cookie-expires-validation.test.ts. The fix is minimal, targeted, has a regression test, and strictly reduces crash surface.

Comment on lines +783 to +789
if write!(
bun_core::fmt::VecWriter(&mut array),
"{}",
value.to_fmt(&mut formatter)
)
.is_err()
{

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.

🟣 🟣 Heads up (pre-existing): there is one more instance of this same std::io::Write::write_fmt panic in src/runtime/test_runner/expect.rs ExpectMatcherUtils::print_value (expect.rs:2866). writer is a &mut MutableString whose std::io::Write::write() is infallible (MutableString.rs:476-480) and only std::io::Write is in scope (expect.rs:2850), so let _ = write!(writer, "{}", value.to_fmt(&mut formatter)); panics inside write_fmt when ZigFormatter's Display returns fmt::Error — reachable from user JS via this.utils.stringify/printExpected/printReceived in expect.extend matchers. Same one-line core::fmt::Write / VecWriter fix; might be worth folding in alongside the jest.rs site so the Fuzzilli fingerprint is fully eliminated.

Extended reasoning...

Summary

This is a third site (in addition to bun_inspect fixed by this PR and jest.rs format_label already flagged in the earlier comment) with the identical std::io::Write::write_fmt-on-infallible-sink panic. It lives in src/runtime/test_runner/expect.rs inside ExpectMatcherUtils::print_value, the helper that backs this.utils.stringify, this.utils.printExpected, and this.utils.printReceived for custom Jest matchers.

This is pre-existing: the PR does not touch expect.rs, add callers, or change its behavior. Flagging it non-blocking because it is the same root cause / Fuzzilli fingerprint the PR is closing out, and the previous comment only listed the jest.rs sites.

Code path

  • expect.rs:2850use std::io::Write as _; (no core::fmt::Write anywhere in the file).
  • expect.rs:2856let writer = mutable_string.writer(); where MutableString::writer returns &mut Self (MutableString.rs:58-60), i.e. &mut MutableString.
  • expect.rs:2866let _ = write!(writer, "{}", value.to_fmt(&mut formatter)); with a ConsoleObject::Formatter.
  • MutableString implements std::io::Write (MutableString.rs:476-480) with an infallible write() that just extend_from_slices into a Vec<u8> and returns Ok(buf.len()). There is no core::fmt::Write impl for MutableString in the repo.
  • value.to_fmt(&mut formatter) returns a ZigFormatter, whose Display::fmt (ConsoleObject.rs:1922-1953) maps both Tag::get failures and formatter.format failures to Err(core::fmt::Error) via .map_err(|_| core::fmt::Error)? — i.e. it returns fmt::Error when user JS throws during inspection.

Because only std::io::Write is in scope and MutableString only implements std::io::Write, write!(writer, …) resolves to <MutableString as std::io::Write>::write_fmt. Exactly as described in this PR for bun_inspect, std's default_write_fmt panics with "a formatting trait implementation returned an error when the underlying stream did not" when the Display impl errors but the underlying MutableString sink never set an I/O error.

Why nothing prevents it

The let _ = discards a Result it never receives — the panic happens inside write_fmt before it returns. The wrapping print_value_catched (expect.rs:2880-2887) only handles JsResult errors via unwrap_or_else; it cannot catch a Rust panic. The asymmetric-matcher self-print path (expect.rs:2784) hits the same helper.

Step-by-step proof

import { expect, test } from "bun:test";
expect.extend({
  myMatcher(received) {
    return { pass: false, message: () => this.utils.printReceived(received) };
  },
});
const bad = { [Symbol.for("nodejs.util.inspect.custom")]() { throw new Error("boom"); } };
test("x", () => expect(bad).myMatcher());
  1. The matcher fails and Jest evaluates message(), which calls this.utils.printReceived(bad)Expect_print_received (expect.rs:2905-2911) → print_value_catchedprint_value.
  2. At expect.rs:2866, bad.to_fmt(&mut formatter) constructs a ZigFormatter.
  3. write!(writer, "{}", <ZigFormatter>) resolves to <MutableString as std::io::Write>::write_fmt (only std::io::Write is in scope; MutableString has no core::fmt::Write impl).
  4. Inside write_fmt, <ZigFormatter as Display>::fmt runs the console formatter, which invokes bad's inspect.custom. That throws; the formatter maps the JS exception to core::fmt::Error and Display::fmt returns Err(fmt::Error).
  5. std::io::default_write_fmt sees a formatting error with no underlying I/O error (the MutableString sink's write() never fails) and panics with "a formatting trait implementation returned an error when the underlying stream did not".
  6. The let _ = is never reached; print_value_catched's unwrap_or_else never runs; the bun test process crashes.

Impact

A user-supplied object with a throwing custom inspect, passed to a custom matcher that uses this.utils.stringify / printExpected / printReceived (the standard way to build matcher messages), crashes the entire bun test process instead of failing the test. Same crash class and Fuzzilli fingerprint (panic:a formatting trait implementation returned an error when the) that this PR is closing out.

Suggested fix

Same as bun_inspect: bring core::fmt::Write into scope and write through a core::fmt::Write sink (e.g. bun_core::fmt::VecWriter(&mut mutable_string.list), or add a core::fmt::Write impl for MutableString) so the error surfaces as a Result. Then the existing let _ = / print_value_catched fallback degrades gracefully (or you can render a placeholder) instead of panicking.

@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 — targeted fix with a regression test; my earlier notes about jest.rs/expect.rs are pre-existing and non-blocking.

Extended reasoning...

Overview

This PR fixes a process-crash panic in bun_inspect (src/runtime/api/BunObject.rs:773-793), the native helper behind JSValueToStringSafe used to render received values into error messages like ERR_INVALID_ARG_VALUE. When the value being inspected has a throwing [Symbol.for("nodejs.util.inspect.custom")], ZigFormatter's Display impl returns fmt::Error. Previously the sink was Vec<u8> via std::io::Write, whose write_fmt panics (rather than returning Err) when a Display impl errors but the underlying stream did not — so the existing .is_err() guard never ran. The fix routes through core::fmt::Write via bun_core::fmt::VecWriter (verified at src/bun_core/fmt.rs:812-826: public tuple struct implementing only core::fmt::Write), making the error observable so the function returns BunString::empty() and the user's thrown exception propagates as a JS error. A regression test is added to cookie-expires-validation.test.ts. The PR also swaps assertASSERT in wtf-bindings.cpp, matching the WTF convention already used elsewhere in that file.

Security risks

None. This is a crash-hardening change in error-message formatting; no auth, crypto, permissions, parsing of untrusted input beyond what already existed, or new attack surface. The behavior change is strictly panic → clean JS throw.

Level of scrutiny

Low-to-moderate. The diff is ~15 lines across three files, the mechanism is well-understood (trait resolution: VecWriter only implements core::fmt::Write, so write! resolves to core::fmt::Write::write_fmt which returns Result instead of panicking), and the PR includes a regression test that exercises the exact crash path. The local use core::fmt::Write; does not conflict with the file-level use std::io::Write as _; because VecWriter does not implement std::io::Write. The assertASSERT change is a trivial convention/build fix.

Other factors

I previously left two non-blocking inline comments noting that the identical std::io::Write::write_fmt-on-infallible-sink panic pattern remains in jest.rs format_label and expect.rs print_value. Those are pre-existing, not introduced here, and explicitly flagged as optional follow-ups — they do not block this fix. The bug-hunting system found no issues in the current revision, and the only commit since my last comment is a CI retrigger.

@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 — targeted crash fix with a regression test; my earlier notes about jest.rs/expect.rs are pre-existing and non-blocking.

Extended reasoning...

Overview

This PR fixes a Rust panic in bun_inspect (src/runtime/api/BunObject.rs) that fires when user JS throws inside [Symbol.for("nodejs.util.inspect.custom")] while Bun is rendering a value into an error message (e.g. ERR_INVALID_ARG_VALUE for Bun.Cookie's expires option). The fix swaps the sink from std::io::Write on Vec<u8> — whose write_fmt panics when a Display impl returns fmt::Error without an underlying I/O error — to core::fmt::Write via the existing bun_core::fmt::VecWriter, so the error becomes an observable Result and the function returns an empty string as originally intended. A regression test is added to test/js/bun/cookie/cookie-expires-validation.test.ts.

I verified VecWriter at src/bun_core/fmt.rs:812-826: it's a public tuple struct over &mut Vec<u8> with an infallible core::fmt::Write::write_str. With the local use core::fmt::Write; and a VecWriter receiver (which does not implement std::io::Write), write! unambiguously resolves to core::fmt::Write::write_fmt, so .is_err() now actually catches the formatter error. The pattern is already used elsewhere (src/clap/lib.rs:1043, src/shell_parser/braces.rs:467).

Security risks

None. This converts an unconditional process abort into the originally-intended graceful fallback (empty string + the user's thrown JS exception propagates). No new inputs are accepted, no parsing/validation is relaxed, and the behavior on the success path is byte-identical.

Level of scrutiny

Low–medium. The diff is ~12 lines of Rust plus a 14-line test. The mechanism is well-understood (std's io::Write::write_fmt panic-on-fmt-error is documented behavior), the replacement helper is pre-existing and used elsewhere in the repo, and the local use is scoped to the function so it can't perturb trait resolution for other write! calls in the file.

Other factors

  • The PR description mentions a wtf-bindings.cpp assertASSERT drive-by, but the current diff only contains the two files above; that hunk appears to have been dropped, which is fine.
  • I previously left two inline comments noting the same panic pattern still exists in jest.rs (format_label) and expect.rs (ExpectMatcherUtils::print_value). Those are explicitly pre-existing, out of this PR's scope, and non-blocking — worth a follow-up but not a reason to hold this fix.
  • Bug-hunter found no issues; CodeRabbit had no actionable comments.

@robobun
robobun force-pushed the farm/76f8d2f6/bun-inspect-fmt-panic branch from 9bb53a7 to 2f3e1ba Compare May 19, 2026 04:17

@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 — correct, minimal fix with a regression test; my earlier notes on the jest.rs/expect.rs sites are pre-existing and non-blocking.

Extended reasoning...

Overview

This PR fixes a panic in bun_inspect (src/runtime/api/BunObject.rs) by switching the formatting sink from std::io::Write on Vec<u8> to core::fmt::Write via the existing bun_core::fmt::VecWriter wrapper. When ZigFormatter's Display impl returns fmt::Error (because user JS threw during inspection), core::fmt::Write::write_fmt propagates that as an Err instead of panicking inside std's default_write_fmt. The existing .is_err() branch then returns BunString::empty() and the pending JS exception surfaces normally. A regression test in cookie-expires-validation.test.ts exercises the exact crash path.

I verified VecWriter at src/bun_core/fmt.rs:812-826 — it's a thin core::fmt::Write impl over &mut Vec<u8> already used in src/clap/lib.rs and src/shell_parser/braces.rs, so this follows an established pattern with no new abstractions.

Security risks

None. This is error-path hardening in a value-rendering helper; no auth, crypto, parsing of untrusted input beyond what was already there, and no new data exposure. The change strictly reduces attack surface by converting a process abort into a catchable JS error.

Level of scrutiny

Low. ~10 lines of mechanical change (trait swap + comment) in a single function, plus a focused test. The logic is identical to the original intent — the .is_err() check was already there, it just couldn't fire because the panic preempted it. The fix makes the existing code work as written.

Other factors

  • Bug hunting system found no issues.
  • I previously left two inline comments flagging the same pre-existing pattern in jest.rs format_label and expect.rs print_value; both are explicitly non-blocking and out of scope for this PR.
  • The local use core::fmt::Write; is scoped to the function body, so it can't accidentally change write! resolution elsewhere in the file.
  • Regression test is well-placed and asserts .toThrow() rather than a specific message, so it won't be brittle.

@robobun
robobun force-pushed the farm/76f8d2f6/bun-inspect-fmt-panic branch from 2f3e1ba to 72da2b6 Compare May 22, 2026 13:58

@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 — the core::fmt::Write / VecWriter switch correctly surfaces the formatter error as a Result instead of panicking inside std::io::default_write_fmt. (My earlier notes on the jest.rs/expect.rs sibling sites are pre-existing and non-blocking; fine as a follow-up.)

Extended reasoning...

Overview

Two-file change: src/runtime/api/BunObject.rs swaps the write! sink in bun_inspect from Vec<u8> (via std::io::Write) to bun_core::fmt::VecWriter(&mut array) (via core::fmt::Write), and test/js/bun/cookie/cookie-expires-validation.test.ts adds a regression test that passes a throwing [Symbol.for("nodejs.util.inspect.custom")] object as expires to Bun.Cookie.

I verified VecWriter at src/bun_core/fmt.rs:829-843 — it's a thin core::fmt::Write adapter over &mut Vec<u8> whose write_str just extend_from_slices and returns Ok(()). With core::fmt::Write in scope, write!(VecWriter(...), ...) resolves to core::fmt::Write::write_fmt, which propagates fmt::Error from ZigFormatter's Display impl as a Result rather than triggering std's "formatting trait returned an error when the underlying stream did not" panic. The existing .is_err() branch then returns BunString::empty() as originally intended, and the pending JS exception propagates to the caller.

Security risks

None. This is purely a panic-to-graceful-error conversion in an inspection/formatting helper; no auth, crypto, parsing of untrusted input boundaries, or permission logic is touched.

Level of scrutiny

Low. The diff is ~12 lines of substantive change in one function plus a 14-line test. The pattern (VecWriter + core::fmt::Write) is already used elsewhere in the repo (e.g. FrameworkRouter.rs, braces.rs, JSGlobalObject.rs), so this follows an established idiom. No CODEOWNERS apply to the touched paths.

Other factors

I previously left two non-blocking heads-up comments noting the identical panic pattern still exists in jest.rs format_label and expect.rs ExpectMatcherUtils::print_value. Those are explicitly pre-existing, out of scope for this PR, and were framed as optional follow-ups — they do not block this fix, which is correct and self-contained for the Bun__inspect / JSValueToStringSafe path it targets. The added regression test directly exercises the crash-before/throw-after behavior.

@robobun
robobun force-pushed the farm/76f8d2f6/bun-inspect-fmt-panic branch from 72da2b6 to 2066eb7 Compare June 19, 2026 14:40

@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 — targeted swap from std::io::Write to core::fmt::Write via the existing VecWriter so the formatter error surfaces as a Result instead of panicking. The test-structure nit and my earlier pre-existing-site notes are non-blocking.

Extended reasoning...

Overview

This PR changes ~12 lines in src/runtime/api/BunObject.rs (bun_inspect) to route the write! call through core::fmt::Write (via the existing bun_core::fmt::VecWriter helper) instead of std::io::Write on Vec<u8>. The latter panics inside default_write_fmt when a Display impl returns fmt::Error but the in-memory sink never errored — which is exactly what ZigFormatter does when user JS (e.g. a throwing inspect.custom) throws during formatting. With core::fmt::Write, the error surfaces as the Result the existing .is_err() branch was already written to handle, and bun_inspect returns an empty string so the user's JS exception propagates normally. A 14-line regression test is added to test/js/bun/cookie/cookie-expires-validation.test.ts.

I verified VecWriter at src/bun_core/fmt.rs:819-833: it is a public tuple struct over &mut Vec<u8> whose core::fmt::Write::write_str just extend_from_slices — semantically identical to writing into the Vec directly, minus the panic.

Security risks

None. This is a defensive change that converts a process-level Rust panic (DoS-class only, triggered by adversarial user JS objects passed into Bun APIs) into a clean catchable JS error. No new inputs, no parsing, no auth/crypto/permissions surface.

Level of scrutiny

Low-to-medium. It is native Rust at the FFI boundary, but the change is mechanical: only the Write trait the macro resolves to changes; the bytes written are identical on the success path, and the error path now returns BunString::empty() as the original code clearly intended. VecWriter is an existing repo helper designed for exactly this purpose.

Other factors

  • The one new inline finding is a nit about test structure (CLAUDE.md convention prefers spawned-subprocess fixtures for crash regressions). It is a fair test-quality note but does not affect the correctness of the fix; on regression the in-process test will still take the file down and CI will go red.
  • My two earlier inline comments on this PR flagged pre-existing sibling instances of the same panic pattern in jest.rs and expect.rs. Those were explicitly non-blocking follow-up suggestions and remain valid for a separate PR; they do not gate this fix.
  • No CODEOWNERS entries match the touched paths.
  • No human reviewer comments are outstanding.

Comment thread test/js/bun/cookie/cookie-expires-validation.test.ts Outdated
@robobun
robobun force-pushed the farm/76f8d2f6/bun-inspect-fmt-panic branch from 2066eb7 to ac2eb96 Compare June 19, 2026 15:04

@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 — targeted fix routes the formatter through core::fmt::Write so the error is observable, and the regression test now runs in a subprocess per the repo convention.

Extended reasoning...

Overview

This PR changes one function, bun_inspect in src/runtime/api/BunObject.rs, to write the ZigFormatter output through core::fmt::Write (via the existing bun_core::fmt::VecWriter adapter at fmt.rs:819-833) instead of std::io::Write on a Vec<u8>. The previous code's .is_err() branch was unreachable because std::io::default_write_fmt panics when a Display impl returns fmt::Error but the underlying in-memory sink never errored; the new code makes that branch reachable and returns BunString::empty() as originally intended, letting the pending JS exception propagate. A regression test in test/js/bun/cookie/cookie-expires-validation.test.ts exercises the path via new Bun.Cookie(..., { expires: obj }) where obj has a throwing inspect.custom.

Security risks

None. This is a crash-hardening change in a value-rendering helper used to build error messages. No auth, crypto, permissions, parsing of untrusted formats, or new external surface. The behavior change replaces a process abort with a normal JS throw.

Level of scrutiny

Low. The Rust diff is ~10 lines and mechanical: it swaps the write! sink from &mut Vec<u8> (resolves to std::io::Write::write_fmt) to bun_core::fmt::VecWriter(&mut array) (resolves to core::fmt::Write::write_fmt), with a local use core::fmt::Write; to bring the trait into scope. VecWriter is an existing, already-used adapter whose write_str is infallible (extend_from_slice + Ok(())), so the only way write! returns Err is when the Display impl itself errors — exactly the case being handled. Control flow on the success path is unchanged (array is still the byte buffer passed to BunString::clone_utf8).

Other factors

  • My earlier feedback about CLAUDE.md:199 (crash fixes need a spawned fixture) was addressed in the latest push: the test now spawns bunExe() -e with bunEnv, captures stdout/stderr/exitCode, and asserts exitCode: 0 + stdout: "threw:boom from inspect.custom", so a regression surfaces as a clean per-test failure.
  • The two earlier "heads up" comments about identical pre-existing patterns in jest.rs format_label and expect.rs print_value are explicitly out-of-scope follow-ups (not introduced or touched by this PR) and were marked non-blocking when raised.
  • No new bugs were found by the bug-hunting system on this revision.
  • VecWriter already has other in-tree callers (e.g. braces.rs:496, scanImportsAndExports.rs:560), so this follows an established pattern rather than introducing a new abstraction.

@robobun
robobun force-pushed the farm/76f8d2f6/bun-inspect-fmt-panic branch from ac2eb96 to ff3c9f8 Compare July 2, 2026 06:01

@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 — targeted fix routes the write through core::fmt::Write so the formatter error is observable, and the subprocess regression test now covers it cleanly.

Extended reasoning...

Overview

This PR changes one function, bun_inspect in src/runtime/api/BunObject.rs, swapping write!(&mut Vec<u8>, ...) (which resolves to std::io::Write::write_fmt and panics when a Display impl returns fmt::Error on an infallible sink) for write!(bun_core::fmt::VecWriter(&mut array), ...) via core::fmt::Write, which surfaces the error as a Result so the existing .is_err() branch can return BunString::empty(). VecWriter is an existing in-tree helper (src/bun_core/fmt.rs:819) that implements exactly core::fmt::Write, so trait resolution is unambiguous. A subprocess-spawned regression test in test/js/bun/cookie/cookie-expires-validation.test.ts exercises the crashing input and asserts the specific thrown message plus exitCode: 0.

Security risks

None. The change turns a native panic (process abort) into the intended empty-string fallback + propagated JS exception. No new inputs are accepted, no parsing is added, and no trust boundary changes.

Level of scrutiny

Low. This is a ~11-line mechanical trait-resolution fix using an existing repo helper, with a well-formed subprocess regression test. The fix is idiomatic and matches the helper's documented purpose. My earlier feedback (that the crash test should be spawned rather than in-process toThrow()) was addressed in the 2026-06-19 revision and the thread is resolved.

Other factors

The only new finding in this run is a style nit (4-line comment vs the 3-line CLAUDE.md cap) — non-blocking and easy to fold in on merge or ignore. My two earlier heads-up comments about identical pre-existing panic sites in jest.rs and expect.rs were explicitly non-blocking (not introduced here) and remain valid follow-up work; they don't gate this fix. No CODEOWNERS on the touched paths, no outstanding human review comments.

Comment thread src/runtime/api/BunObject.rs Outdated
@robobun
robobun force-pushed the farm/76f8d2f6/bun-inspect-fmt-panic branch from ff3c9f8 to 63ff14a Compare July 2, 2026 06:21

@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 — the fix is correct and my prior feedback has been addressed; the remaining inline nit (4-line test comment) is non-blocking.

Extended reasoning...

Overview

This PR fixes a native Rust panic in bun_inspect (src/runtime/api/BunObject.rs), the helper backing JSValueToStringSafe in ErrorCode.cpp. When the console formatter's Display impl returns fmt::Error (because user JS threw in [Symbol.for("nodejs.util.inspect.custom")]), writing to a Vec<u8> via std::io::Write::write_fmt panics inside std before the existing .is_err() check can observe it. The fix routes the write through core::fmt::Write via bun_core::fmt::VecWriter, so the error surfaces as a Result and the function returns an empty string as originally intended, letting the pending JS exception propagate. A subprocess-spawned regression test in test/js/bun/cookie/cookie-expires-validation.test.ts covers the crash.

Security risks

None. This is a crash-hardening fix in a diagnostic/error-message rendering path. No auth, crypto, permissions, or untrusted-input parsing surface is touched; the change strictly narrows behavior from "panic" to "return empty + propagate the already-pending JS exception".

Level of scrutiny

Low-to-medium. The diff is ~10 native lines plus one test. The mechanism is well-understood (std::io::default_write_fmt panics on formatter error with an infallible sink; core::fmt::Write::write_fmt returns Err instead), VecWriter is an existing in-tree helper (src/bun_core/fmt.rs:819), and the original .is_err() guard is preserved so intent is unchanged. The regression test follows the repo's crash-fix convention (spawned subprocess asserting {stdout, exitCode}).

Other factors

All substantive feedback from my earlier reviews has been addressed: the test was rewritten to spawn a subprocess so a regression fails cleanly rather than aborting the file, and the BunObject.rs comment was condensed to 3 lines. My two earlier heads-up notes about identical pre-existing panics in jest.rs format_label and expect.rs print_value were explicitly non-blocking and out of this PR's scope; the author reasonably kept the change focused. The only new finding this run is a nit — the 4-line test comment exceeds the CLAUDE.md rule-13 3-line cap — which is cosmetic, has zero functional impact, and does not gate merge.

Comment thread test/js/bun/cookie/cookie-expires-validation.test.ts Outdated
@robobun
robobun force-pushed the farm/76f8d2f6/bun-inspect-fmt-panic branch from 63ff14a to 9fcca5e Compare July 2, 2026 06:40

@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 — targeted panic fix with a proper subprocess regression test; all prior nits addressed. The pre-existing sibling sites in jest.rs/expect.rs remain noted for follow-up but don't block this.

Extended reasoning...

Overview

This PR fixes a native panic in bun_inspect (src/runtime/api/BunObject.rs), the FFI helper backing JSValueToStringSafe used to render values into error messages like ERR_INVALID_ARG_VALUE. The fix swaps the write! sink from std::io::Write on Vec<u8> (whose write_fmt panics when a Display impl returns fmt::Error without an underlying I/O error) to core::fmt::Write via the existing bun_core::fmt::VecWriter, so the error surfaces as a Result and the pre-existing .is_err() guard actually fires. A subprocess regression test is added to test/js/bun/cookie/cookie-expires-validation.test.ts. Net change is ~15 lines.

Security risks

None. This is error-message formatting only — no auth, crypto, permissions, or untrusted-input parsing changes. The behavioral change replaces a process-aborting panic with a catchable JS error, which is strictly a hardening improvement (a reachable panic on user input was itself a DoS vector).

Level of scrutiny

Low-to-moderate. The mechanism is well-understood and narrowly scoped: VecWriter is an existing in-tree helper (src/bun_core/fmt.rs:819) that implements core::fmt::Write by extend_from_slice, so the only behavioral difference is that fmt::Error from ZigFormatter now propagates as Err instead of panicking inside std. The success path is byte-identical. The 3-line comment at the call site explains why the indirection exists, which guards against a future "simplification" reintroducing the panic.

Other factors

All prior review feedback on this PR has been addressed: the regression test was rewritten to spawn a subprocess (so a future regression fails cleanly rather than aborting the test file), and both new comments were condensed to ≤3 lines per repo convention. The test follows the harness patterns (bunExe()/bunEnv, concurrent pipe drain, asserts {stdout, exitCode} before implicitly checking stderr via the object match). I previously flagged two pre-existing sibling sites with the identical panic pattern (jest.rs format_label and expect.rs print_value) — those were explicitly non-blocking heads-ups about code this PR does not touch, and leaving them for a follow-up is a reasonable scoping call that doesn't affect the correctness of this fix.

@robobun
robobun force-pushed the farm/76f8d2f6/bun-inspect-fmt-panic branch from 9fcca5e to e42fedb Compare July 2, 2026 07:20

@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 — all prior feedback addressed; the pre-existing sibling sites in jest.rs/expect.rs are non-blocking and can go in a follow-up.

Extended reasoning...

Overview

This PR changes ~10 lines in bun_inspect (src/runtime/api/BunObject.rs) to route the write! call through core::fmt::Write via bun_core::fmt::VecWriter instead of std::io::Write on Vec<u8>. The old path panics inside std::io::default_write_fmt when ZigFormatter's Display impl returns fmt::Error (which it does when user JS throws during inspection), because the underlying Vec<u8> sink never sets an I/O error. The new path surfaces the error as a Result, and the pre-existing .is_err() branch returns BunString::empty() so the caller's JS exception propagates normally. A spawned-subprocess regression test is added to test/js/bun/cookie/cookie-expires-validation.test.ts.

I verified bun_core::fmt::VecWriter exists (fmt.rs:819) and implements core::fmt::Write, so the trait resolution is unambiguous with the local use core::fmt::Write;.

Security risks

None. This is a crash-hardening change on the error-message rendering path; no auth, crypto, permissions, or untrusted-input parsing is touched. If anything it removes a user-triggerable process abort (mild DoS surface).

Level of scrutiny

Low-to-medium. The mechanism is a well-understood Rust std footgun (std::io::Write::write_fmt panics on formatter error over an infallible sink), the fix is the canonical remedy (route through core::fmt::Write), and behavior is otherwise byte-identical. The 3-line comment prevents accidental "simplification" back to the panicking form. The test follows harness conventions: spawned subprocess via bunExe()/bunEnv, pipes drained concurrently with Promise.all, asserts on {stdout, stderr, exitCode}.

Other factors

All actionable feedback from my earlier reviews has been addressed and the threads are resolved: the test was rewritten as a spawned fixture, and both new comments were condensed to 3 lines per CLAUDE.md rule 13. My two remaining inline notes (identical pre-existing panics in jest.rs format_label and expect.rs print_value) were explicitly filed as non-blocking pre-existing issues; the author is entitled to keep this PR scoped to the Fuzzilli-reported site and address the siblings separately. The bug-hunting system found no new issues on the current revision.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun rebase push

@robobun

robobun commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (ace8f42) and pushed. Clean rebase, no conflicts; the regression test passes on the rebuilt debug binary.

@robobun
robobun force-pushed the farm/76f8d2f6/bun-inspect-fmt-panic branch from e42fedb to 3f85ebb Compare August 4, 2026 19:22
Comment thread src/runtime/api/BunObject.rs Outdated
Comment on lines +723 to +725
// std::io::Write::write_fmt on Vec<u8> panics if a Display impl returns
// fmt::Error with no underlying I/O error (to_fmt does when user JS
// throws). Use core::fmt::Write so the error is observable.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

@robobun
robobun force-pushed the farm/76f8d2f6/bun-inspect-fmt-panic branch from 3f85ebb to 2456c7c Compare August 4, 2026 19:31
@robobun

robobun commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

Dropped the inline comment in bun_inspect. The call now matches the existing use core::fmt::Write; write!(VecWriter(..), ..).is_err() idiom used at the other sites in JSGlobalObject.rs, and VecWriter's doc comment already explains why it exists.

@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 — the VecWriter/core::fmt::Write swap is the established idiom for this exact failure mode, and all prior feedback (spawned-subprocess test, comment-cop) has been addressed.

What was reviewed

  • Confirmed VecWriter implements only core::fmt::Write (fmt.rs:794), so write! resolves to the non-panicking write_fmt and .is_err() now actually observes the error; the file-level use std::io::Write as _ at BunObject.rs:74 can't interfere since VecWriter doesn't implement it.
  • Pattern matches existing call sites in JSGlobalObject.rs, FrameworkRouter.rs, and scanImportsAndExports.rs.
  • Regression test spawns a subprocess per the crash-fix convention, drains stdout/stderr/exited concurrently, and asserts exitCode: 0 + the propagated JS error message — fails cleanly on regression rather than aborting the file.
  • The two pre-existing sibling sites I flagged earlier (jest.rs format_label, expect.rs print_value) remain out of scope here; they're tracked in the earlier non-blocking comments.
Extended reasoning...

Overview

Two-file change: src/runtime/api/BunObject.rs swaps the write! sink in bun_inspect from &mut Vec<u8> (which resolves to std::io::Write::write_fmt and panics when a Display impl returns fmt::Error on an infallible stream) to bun_core::fmt::VecWriter(&mut array) under core::fmt::Write, so the formatter error surfaces as a Result and the existing .is_err() branch returns BunString::empty(). test/js/bun/cookie/cookie-expires-validation.test.ts gains a spawned-subprocess regression test that constructs a Cookie with an expires object whose inspect.custom throws.

Security risks

None. This is a crash-hardening change on the error-message rendering path (JSValueToStringSafeBun__inspect). No new inputs are accepted, no privilege boundaries are crossed, and the behavior on error (return empty string, let the pending JS exception propagate) is what the pre-existing .is_err() branch already intended.

Level of scrutiny

Low-to-moderate. The Rust change is a 7-line mechanical swap to an in-tree helper (bun_core::fmt::VecWriter) that exists precisely for this purpose — its doc comment and the three other call sites in the repo confirm this is the canonical shape. I verified the file-scope use std::io::Write as _; at BunObject.rs:74 cannot cause the wrong write_fmt to be picked because VecWriter does not implement std::io::Write. The success path is byte-identical (VecWriter::write_str just extend_from_slices into the same Vec<u8>).

Other factors

Every piece of prior review feedback on this PR has been addressed and resolved: the in-process toThrow() test was rewritten to a spawned subprocess; both 4-line comments were condensed; the final github-actions comment-cop note was addressed by dropping the inline comment entirely (robobun's 2026-08-04 19:32 reply), and the current diff confirms no comment remains. Jarred requested a rebase which completed cleanly, and robobun reports the test passes on the rebuilt debug binary. My earlier non-blocking notes about the identical pattern in jest.rs and expect.rs are pre-existing, out of scope, and already recorded on the PR — they don't block this fix.

@robobun
robobun force-pushed the farm/76f8d2f6/bun-inspect-fmt-panic branch from 2456c7c to c5a940f Compare August 4, 2026 20:04

@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 — the VecWriter/core::fmt::Write route is correct and the comment-cop feedback is addressed.

What was reviewed:

  • bun_core::fmt::VecWriter implements only core::fmt::Write (fmt.rs:794), so write! now returns Err instead of panicking inside std::io::default_write_fmt; .is_err() observes it and returns BunString::empty(), letting the pending user exception propagate.
  • Regression test spawns a subprocess (per the crash-fix rule), drains stdout/stderr/exited concurrently, and asserts the specific "threw:boom from inspect.custom" message — so it locks in that the user's thrown error surfaces, not a generic ERR_INVALID_ARG_VALUE.
  • The two pre-existing sibling sites I flagged earlier (jest.rs format_label, expect.rs print_value) remain unchanged; they were noted as non-blocking and can be follow-ups.
Extended reasoning...

Overview

Two files touched: src/runtime/api/BunObject.rs (7-line change to bun_inspect, swapping write!(&mut Vec<u8>, ...) — which resolves to std::io::Write::write_fmt and panics when a Display impl returns fmt::Error on an infallible sink — for write!(bun_core::fmt::VecWriter(&mut array), ...) via core::fmt::Write, which returns the error as a Result) and test/js/bun/cookie/cookie-expires-validation.test.ts (adds one subprocess-spawning regression test).

Security risks

None. This is an error-propagation fix inside the console formatter's inspect path. No parsing of untrusted input, no auth/crypto/permissions surface. It converts a user-reachable native panic (DoS class) into a normal JS throw, which is strictly a hardening.

Level of scrutiny

Low-to-medium. The change is mechanical: it swaps the Write trait the write! macro dispatches to so the existing .is_err() check actually fires. I verified VecWriter (fmt.rs:786-800) implements only core::fmt::Write with an infallible write_str, so the only way write! returns Err is ZigFormatter's Display::fmt returning fmt::Error — exactly the case being handled. The pattern matches existing usage in JSGlobalObject.rs:1461 and scanImportsAndExports.rs:537. The use core::fmt::Write; is scoped locally to the function, avoiding trait-resolution ambiguity elsewhere in the file.

Other factors

All prior review feedback is resolved: the test was rewritten to spawn a subprocess (so a regression fails cleanly rather than aborting the file), both over-length comments were condensed, and after the github-actions comment-cop fired post-rebase, the inline Rust comment was dropped entirely — VecWriter's doc comment already carries the rationale. The two pre-existing sibling panic sites in jest.rs and expect.rs I flagged earlier remain, but were explicitly marked non-blocking; they're separate subsystems and reasonable follow-ups. Robobun confirmed the regression test passes on the rebuilt debug binary after rebasing onto main. No bugs surfaced in this run's bug hunt.

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 -->
…ws during formatting

Bun__inspect (used by JSValueToStringSafe to render values in error
messages like ERR_INVALID_ARG_VALUE) wrote the console formatter
output to a Vec<u8> via std::io::Write. When the value being
formatted has a throwing [inspect.custom] (or any JS callback that
throws during formatting), ZigFormatter's Display impl returns
fmt::Error — but the Vec<u8> sink itself never errors, so
std::io::Write::write_fmt panics with 'a formatting trait
implementation returned an error when the underlying stream did not'
instead of returning Err.

Route through core::fmt::Write (via VecWriter) so the error is
observable and we return an empty string as originally intended.
@robobun
robobun force-pushed the farm/76f8d2f6/bun-inspect-fmt-panic branch from c5a940f to f28a7bb Compare August 6, 2026 12:13
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@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: 1

🤖 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 `@test/js/bun/cookie/cookie-expires-validation.test.ts`:
- Around line 103-107: Update the expected object in the subprocess assertion
around Promise.all to include stderr: "" alongside stdout and exitCode, ensuring
the test validates all three subprocess results.
🪄 Autofix

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: 3cd3ac7a-43c6-4bed-ac69-d85c5789df2b

📥 Commits

Reviewing files that changed from the base of the PR and between 0ffabf6 and f28a7bb.

📒 Files selected for processing (2)
  • src/runtime/api/BunObject.rs
  • test/js/bun/cookie/cookie-expires-validation.test.ts

Comment thread test/js/bun/cookie/cookie-expires-validation.test.ts

@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 — the core::fmt::Write/VecWriter swap is the right fix and all prior feedback (subprocess-based crash test, comment length, comment-cop) has been addressed.

What was reviewed:

  • Confirmed VecWriter implements only core::fmt::Write (fmt.rs:794), so write! no longer routes through std::io::default_write_fmt and the .is_err() branch is now reachable.
  • Checked the file-level use std::io::Write as _; (line 74) doesn't shadow — VecWriter has no io::Write impl, so resolution is unambiguous.
  • Regression test spawns a subprocess, drains all three pipes concurrently, and asserts {stdout, exitCode} together — fails cleanly on regression rather than aborting the file.
  • The two pre-existing sibling sites I flagged earlier (jest.rs format_label, expect.rs print_value) remain untouched; those are separate and non-blocking here.
Extended reasoning...

Overview

This PR fixes a Fuzzilli-found panic in Bun__inspect (src/runtime/api/BunObject.rs), the native helper C++ calls via JSValueToStringSafe to render a received value into error messages like ERR_INVALID_ARG_VALUE. When the value's [Symbol.for("nodejs.util.inspect.custom")] throws, ZigFormatter::fmt returns fmt::Error; the previous sink was &mut Vec<u8> via std::io::Write, whose write_fmt panics ("a formatting trait implementation returned an error when the underlying stream did not") before the existing .is_err() guard could observe it. The fix routes the write through bun_core::fmt::VecWriter under core::fmt::Write, which surfaces the error as a Result so the function returns BunString::empty() and the pending JS exception propagates. A subprocess-based regression test is added to test/js/bun/cookie/cookie-expires-validation.test.ts.

Security risks

None. This is an error-formatting path; the change replaces a process abort with propagation of the user's own thrown exception. No new inputs are parsed, no bounds are changed, and no allocation sizing is affected.

Level of scrutiny

Low-to-medium. The Rust change is 8 lines and mechanically swaps trait resolution to an existing in-tree adapter (VecWriter, fmt.rs:786) that is already used the same way in scanImportsAndExports.rs and braces.rs. VecWriter implements only core::fmt::Write and its write_str is infallible, so the only way .is_err() triggers is the Display impl erroring — exactly the case being handled. I checked that the file-level use std::io::Write as _; at BunObject.rs:74 cannot re-hijack resolution because VecWriter has no io::Write impl.

Other factors

This PR has been through several review rounds and every piece of feedback has been applied: the crash test was rewritten to spawn a subprocess (so a regression fails the test rather than aborting the runner); both new comments were condensed to ≤3 lines; and after the comment-cop bot fired post-rebase, the inline BunObject.rs comment was dropped entirely — the VecWriter doc comment already carries the rationale, and the call site now matches the other in-tree uses without a per-site note. The gate evidence in the PR description shows the test failing on both debug/ASAN and release without the fix and passing with it. My earlier non-blocking heads-ups about identical pre-existing patterns in jest.rs and expect.rs are recorded on the PR for follow-up but do not block this focused fix.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: the panicking code no longer exists.

#31831 (merged 2026-08-06) switched JSValueToStringSafe in src/jsc/bindings/ErrorCode.cpp from Bun__inspect to Bun__inspect_singleline, which formats through the fallible ConsoleObject::format2 path and returns an empty string when the inspected value throws, and deleted Bun__inspect (bun_inspect in src/runtime/api/BunObject.rs) along with the write! through std::io::Write that panicked. #36911 had fixed the same pattern for test.each titles and deliberately left this site to this PR; #31831 then removed it.

Verified on current main (bdb7382): test/js/bun/cookie/cookie-expires-validation.test.ts from this branch, including the throws for objects whose inspect.custom throws case added here, run unmodified against a debug build of main, passes (23 pass) on two consecutive runs.

@robobun robobun closed this Aug 13, 2026
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