Skip to content

test runner: don't panic formatting a FormData whose toJSON is not callable - #36915

Merged
dylan-conway merged 3 commits into
mainfrom
farm/61a541bc/formdata-tojson-pretty-format
Aug 5, 2026
Merged

dylan-conway merged 3 commits into
mainfrom
farm/61a541bc/formdata-tojson-pretty-format

Conversation

@robobun

@robobun robobun commented Aug 4, 2026 •

Copy link
Copy Markdown
Collaborator

Repro

// bun test ./x.test.mjs
import { test, expect } from "bun:test";
test("x", () => {
  const fd = new FormData();
  Object.defineProperty(fd, "toJSON", { value: undefined }); // or Object.setPrototypeOf(fd, null)
  expect(fd).toEqual(1);
});
panic: called `Option::unwrap()` on a `None` value
src/runtime/test_runner/pretty_format.rs:1585:87
oh no: Bun has crashed ... (exit 134)

One failing assertion takes down the entire bun test run.

Cause

The FormData branch of the jest pretty-formatter (Tag::Private in pretty_format.rs) did value.get(global, "toJSON")?.unwrap(). JSValue::get returns None when the property is missing or undefined, so a FormData with toJSON shadowed by undefined, or with its prototype swapped out, panicked as soon as any failing matcher tried to print the received value.

Fix

Only take the toJSON path when the property exists and is callable; otherwise fall through to the generic object printer (same shape ConsoleObject.rs already uses for console.log / Bun.inspect, which is why those never crashed). The callable check also covers toJSON: 42, which previously threw from inside the formatter instead of printing the value.

Verification

test/js/bun/test/expect-formdata-tojson-crash.test.ts spawns bun test on a fixture with the three variants (toJSON: undefined, null prototype, toJSON: 42) and asserts the run reports 3 fail / exit 1 with the FormData printed in the diff. Before the fix the child aborts with the panic above.


[review] gate passed · iteration 1 · 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/test/expect-formdata-tojson-crash.test.ts
bun test v1.4.0 (fa3a236a3)

test/js/bun/test/expect-formdata-tojson-crash.test.ts:
killed 1 dangling process
(fail) failing matcher on FormData without a callable toJSON does not abort the test runner [5005.66ms]
  ^ this test timed out after 5000ms.

 0 pass
 1 fail
Ran 1 test across 1 file. [6.98s]
error: script "bd" exited with code 1
__F:1:S:0

release without fix: all passed
bun test v1.4.0-canary.1 (fd458fc5d)

test/js/bun/test/expect-formdata-tojson-crash.test.ts:
(pass) failing matcher on FormData without a callable toJSON does not abort the test runner [8.69ms]

 1 pass
 0 fail
 5 expect() calls
Ran 1 test across 1 file. [157.00ms]
__F:0:S:0
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-formdata-tojson-crash.test.ts
bun test v1.4.0 (fa3a236a3)

test/js/bun/test/expect-formdata-tojson-crash.test.ts:
(pass) failing matcher on FormData without a callable toJSON does not abort the test runner [355.62ms]

 1 pass
 0 fail
 5 expect() calls
Ran 1 test across 1 file. [2.34s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 708ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/6] gen generated_host_exports.rs
generated_host_exports.rs: 94 exports (host=3, lazy=10, generic=81, rust=0); 240 extern-C blocks audited
[1/6] 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_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m   Compiling�[0m bun_picohttp v0.0.0 (/workspace/bun/src/picohttp)
�[1m�[92m   Compiling�[0m bun_brotli v
... (truncated)
diff hotspot
src/runtime/test_runner/pretty_format.rs           | 29 ++++++++-----
 .../bun/test/expect-formdata-tojson-crash.test.ts  | 47 ++++++++++++++++++++++
 2 files changed, 65 insertions(+), 11 deletions(-)

gate history · 2 passed · 0 rejected · iteration 1

evidence per changed file
file                                                   reads  edits  tests
src/runtime/test_runner/pretty_format.rs                   4      4      0
test/js/bun/test/expect-formdata-tojson-crash.test.ts      3      6      0

@coderabbitai

coderabbitai Bot commented Aug 4, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

FormData formatting now checks that toJSON is callable before invocation. Missing or non-callable values use Event-style formatting. A subprocess regression test verifies safe assertion failures and diagnostics.

Changes

FormData formatting

Layer / File(s) Summary
Conditional toJSON formatting and regression coverage
src/runtime/test_runner/pretty_format.rs, test/js/bun/test/expect-formdata-tojson-crash.test.ts
Callable toJSON values retain JSON formatting. Missing or non-callable values use Event-style formatting. The regression test checks output, error text, failure count, and exit status.

Possibly related PRs

  • oven-sh/bun#36911: Both changes make test-runner formatting safer when JavaScript formatting fails.
  • oven-sh/bun#36912: Both changes update formatting paths in src/runtime/test_runner.

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.
Title check ✅ Passed The title clearly and concisely describes the fix for FormData formatting when toJSON is not callable.
Description check ✅ Passed The description explains the cause, fix, reproduction, and verification results, including the required verification information.

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

@github-actions github-actions Bot added the claude label Aug 4, 2026
@robobun

robobun commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced on 1.4.0-canary (bun test on a FormData with toJSON shadowed by undefined or a null prototype panics at pretty_format.rs:1585, exit 134). Fix guards the toJSON lookup and falls back to the generic object printer; test/js/bun/test/expect-formdata-tojson-crash.test.ts fails before and passes after with bun bd test. Waiting on CI.

@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 replacing an .unwrap() on user-reachable input with the same Tag::Object / JSType::Event fallback already used elsewhere in this file (lines ~1688, ~1873). One nit inline about draining stdout in the test.

Extended reasoning...

Overview

This PR fixes a panic in the jest pretty-formatter's FormData branch (src/runtime/test_runner/pretty_format.rs). The old code did value.get(global, "toJSON")?.unwrap(), which panicked when toJSON was shadowed with undefined or the prototype was nulled out — taking down the entire bun test run on a single failing assertion. The fix wraps the lookup in if let Some(...).filter(|f| f.is_callable()), calling toJSON only when it exists and is callable, and otherwise falling through to the generic object printer via print_as::<Tag::Object>(..., value, JSType::Event). A new spawned-fixture test covers both the shadowed-toJSON and null-prototype variants and asserts the child reports 2 fail / exit 1 rather than aborting.

Correctness

  • The fallback shape (print_as::<W, { Tag::Object }, ...>(writer.ctx, value, JSType::Event)) is byte-identical to the existing generic Tag::Private fallback at ~1688-1690 and the Tag::Event fallback at ~1873-1875 in the same file, so there's no novel code path here.
  • Inside the Tag::Object printer (~2302-2380), the js_type argument isn't consulted — it iterates own properties and, when none are found, prints <ClassName> {} via get_class_name. For the shadowed-toJSON case that yields FormData {}, satisfying the test's "Received: FormData" assertion.
  • is_callable() is a non-throwing FFI check (JSC__JSValue__isCallable), so the .filter closure can't introduce an unchecked exception.
  • The .filter(is_callable) also covers toJSON: 42 (previously threw from .call() inside the formatter); that now prints the object instead. This mirrors the intent of the ConsoleObject.rs FormData branch, which already guards with if let Some(...).

Security risks

None. This is diff-formatting code for test-runner output; no untrusted-input parsing, auth, or I/O boundaries are touched.

Level of scrutiny

Low-to-medium. It's a ~15-line change in a display/formatting path, replacing a panicking .unwrap() with an established fallback pattern from the same function. REVIEW.md explicitly calls out "user-reachable failures are recoverable errors, never panics" — this is a textbook instance.

Other factors

  • The one inline finding is a nit: the test spawn leaves stdout at its default "pipe" without draining it. bun test writes results to stderr and the fixture emits nothing on stdout, so there's no realistic deadlock — it's a harness-convention consistency point, not a blocker.
  • The test is hermetic (tempDir + spawned child, no network), asserts stderr content before exit code, and would fail on an unfixed build (child aborts with exit 134 instead of 1, no "2 fail" line).

Comment thread test/js/bun/test/expect-formdata-tojson-crash.test.ts Outdated
robobun added 2 commits August 4, 2026 19:52
…llable

The FormData branch of the jest pretty-formatter unwrapped the result
of looking up `toJSON`, so a FormData with `toJSON` shadowed by
undefined (or with a null prototype) panicked with
'called Option::unwrap() on a None value' as soon as a failing matcher
tried to print it, aborting the whole `bun test` run.

Only take the toJSON path when the property exists and is callable,
otherwise fall through to the generic object printer like the console
formatter already does.
@robobun
robobun force-pushed the farm/61a541bc/formdata-tojson-pretty-format branch from d9b66ac to fd458fc Compare August 4, 2026 19:54
@robobun

robobun commented Aug 4, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 2:05 PM PT - Aug 4th, 2026

✅ @robobun, your commit fa3a236a39684192b23d6a1abc09249bc4f3a813 passed in Build #88956! 🎉


🧪   To try this PR locally:

bunx bun-pr 36915

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

bun-36915 --bun

@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 .unwrap() → guarded if let with the Tag::Object/JSType::Event fallback matches the existing generic-printer pattern at pretty_format.rs:1688, and the earlier stdout-drain nit was addressed in fd458fc. The remaining inline note is a test-coverage suggestion, not a correctness issue.

Extended reasoning...

Overview

This PR fixes a panic in the jest pretty-formatter's FormData branch (src/runtime/test_runner/pretty_format.rs). The old code did value.get(global, "toJSON")?.unwrap(), which panics when get() returns None (property missing or undefined) — reachable from user code by shadowing toJSON or nulling the prototype, and triggered whenever any failing matcher tries to print such a value. The fix guards with if let Some(f) = ... .filter(|f| f.is_callable()) and, when absent/non-callable, falls through to print_as::<_, { Tag::Object }, _>(.., value, JSType::Event). A new spawned-subprocess test asserts the child bun test reports 2 fail / exit 1 instead of aborting.

Security risks

None. This is diagnostic-output formatting inside the test runner; no auth, crypto, filesystem, or network surface is touched. The change strictly removes a user-reachable panic.

Level of scrutiny

Low-to-medium. The fallback is byte-for-byte the same call already used at pretty_format.rs:1688-1690 and :1873-1875 for other Tag::Private values with no special handler, so there is no new behavior to reason about — only which branch is taken. The sibling ConsoleObject.rs FormData path already handles get() == None without unwrapping (it swallows call errors via unwrap_or_else(take_exception)), so this brings the test-runner formatter to parity rather than diverging. The ? on get() still propagates a throwing getter correctly.

Other factors

  • My previous nit (stdout piped but not drained) was addressed in fd458fc; the test now drains stdout and stderr concurrently with proc.exited, uses tempDir/bunEnv/bunExe, and asserts stderr content before exit code per the harness template.
  • robobun confirmed fails-before/passes-after with bun bd test.
  • The one open inline finding is a nit: the is_callable() filter isn't independently pinned by a fixture case (both existing cases short-circuit at get() → None). That's a coverage refinement worth adding but doesn't affect correctness of the fix, so it doesn't block approval.

Comment thread test/js/bun/test/expect-formdata-tojson-crash.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 — both earlier nits (drain stdout; pin the is_callable guard with a toJSON: 42 case) are addressed in fd458fc and fa3a236.

What was reviewed:

  • The .unwrap() → if let Some(...).filter(is_callable) change and its fallback to print_as::<Tag::Object>(…, JSType::Event), which matches the existing DOMWrapper fallback at pretty_format.rs:1688.
  • Confirmed forEachPropertyOrdered uses DontEnumPropertiesMode::Include, so the non-enumerable toJSON: 42 own prop is iterated and the "toJSON": 42 assertion actually pins the callable filter.
  • Null-prototype case has zero own JS properties, so it hits the iter_i == 0 → get_class_name path (no get_prototype() call on a null proto).
Extended reasoning...

Overview

Two files: a ~15-line change in src/runtime/test_runner/pretty_format.rs replacing an .unwrap() on value.get(global, "toJSON") with a guarded if let Some(...).filter(|f| f.is_callable()), plus a graceful fallback to the generic object printer; and a new spawned-fixture regression test at test/js/bun/test/expect-formdata-tojson-crash.test.ts covering three variants (undefined-shadowed, null prototype, non-callable value).

Security risks

None. This is display-only formatter code inside bun test's failing-matcher printer. No auth/crypto/parsing of untrusted external input; the worst pre-fix outcome was already a panic, and the fix strictly narrows behavior to "print the object instead of crashing".

Level of scrutiny

Low-medium. It's a crash fix (.unwrap() on user-manipulable state → a REVIEW.md "user-reachable failures are recoverable errors, never panics" violation), so the mechanism is unambiguous. I checked that the fallback shape matches the existing Tag::Private DOMWrapper fallback three lines below (print_as::<Tag::Object>(…, JSType::Event) at line 1688), that the Tag::Object arm doesn't consume the js_type argument so the JSType::Event choice is purely conventional, and that ConsoleObject.rs already handles the get() == None case (which is why console.log never crashed). The ? on .get() still propagates a throwing getter as before.

Other factors

Both of my earlier review comments have been addressed in follow-up commits: stdout is now drained in the Promise.all, and the third fixture case exercises .filter(|f| f.is_callable()) (I verified via bindings.cpp:5437 that forEachPropertyOrdered includes non-enumerable own props, so the "toJSON": 42 assertion is reachable and load-bearing). The test uses tempDir/bunEnv/bunExe, drains both pipes, asserts stderr content before exit code, and asserts exitCode === 1 (so a panic/exit-134 fails it). No outstanding reviewer comments.

@dylan-conway
dylan-conway merged commit 9e36e9f into main Aug 5, 2026
55 checks passed
@dylan-conway
dylan-conway deleted the farm/61a541bc/formdata-tojson-pretty-format branch August 5, 2026 03:42
springmin pushed a commit to springmin/bun that referenced this pull request Aug 5, 2026
…llable (oven-sh#36915)

### Repro

```js
// bun test ./x.test.mjs
import { test, expect } from "bun:test";
test("x", () => {
  const fd = new FormData();
  Object.defineProperty(fd, "toJSON", { value: undefined }); // or Object.setPrototypeOf(fd, null)
  expect(fd).toEqual(1);
});
```

```
panic: called `Option::unwrap()` on a `None` value
src/runtime/test_runner/pretty_format.rs:1585:87
oh no: Bun has crashed ... (exit 134)
```

One failing assertion takes down the entire `bun test` run.

### Cause

The FormData branch of the jest pretty-formatter (`Tag::Private` in
`pretty_format.rs`) did `value.get(global, "toJSON")?.unwrap()`.
`JSValue::get` returns `None` when the property is missing or
`undefined`, so a FormData with `toJSON` shadowed by `undefined`, or
with its prototype swapped out, panicked as soon as any failing matcher
tried to print the received value.

### Fix

Only take the `toJSON` path when the property exists and is callable;
otherwise fall through to the generic object printer (same shape
`ConsoleObject.rs` already uses for `console.log` / `Bun.inspect`, which
is why those never crashed). The callable check also covers `toJSON:
42`, which previously threw from inside the formatter instead of
printing the value.

### Verification

`test/js/bun/test/expect-formdata-tojson-crash.test.ts` spawns `bun
test` on a fixture with the three variants (`toJSON: undefined`, null
prototype, `toJSON: 42`) and asserts the run reports `3 fail` / exit 1
with the FormData printed in the diff. Before the fix the child aborts
with the panic above.

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

---

**[review]** gate passed · iteration 1 · 2 files touched

<details><summary>fails on main (without fix)</summary>

```console
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/test/expect-formdata-tojson-crash.test.ts
bun test v1.4.0 (fa3a236)

test/js/bun/test/expect-formdata-tojson-crash.test.ts:
killed 1 dangling process
(fail) failing matcher on FormData without a callable toJSON does not abort the test runner [5005.66ms]
  ^ this test timed out after 5000ms.

 0 pass
 1 fail
Ran 1 test across 1 file. [6.98s]
error: script "bd" exited with code 1
__F:1:S:0

release without fix: all passed
bun test v1.4.0-canary.1 (fd458fc)

test/js/bun/test/expect-formdata-tojson-crash.test.ts:
(pass) failing matcher on FormData without a callable toJSON does not abort the test runner [8.69ms]

 1 pass
 0 fail
 5 expect() calls
Ran 1 test across 1 file. [157.00ms]
__F:0:S:0
```

</details>

<details><summary>passes on PR (with fix)</summary>

```console
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-formdata-tojson-crash.test.ts
bun test v1.4.0 (fa3a236)

test/js/bun/test/expect-formdata-tojson-crash.test.ts:
(pass) failing matcher on FormData without a callable toJSON does not abort the test runner [355.62ms]

 1 pass
 0 fail
 5 expect() calls
Ran 1 test across 1 file. [2.34s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 708ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/6] gen generated_host_exports.rs
generated_host_exports.rs: 94 exports (host=3, lazy=10, generic=81, rust=0); 240 extern-C blocks audited
[1/6] 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_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m   Compiling�[0m bun_picohttp v0.0.0 (/workspace/bun/src/picohttp)
�[1m�[92m   Compiling�[0m bun_brotli v
... (truncated)
```

</details>

<details><summary>diff hotspot</summary>

```
src/runtime/test_runner/pretty_format.rs           | 29 ++++++++-----
 .../bun/test/expect-formdata-tojson-crash.test.ts  | 47 ++++++++++++++++++++++
 2 files changed, 65 insertions(+), 11 deletions(-)
```

</details>

**gate history** · 2 passed · 0 rejected · iteration 1

<details><summary>evidence per changed file</summary>

```
file                                                   reads  edits  tests
src/runtime/test_runner/pretty_format.rs                   4      4      0
test/js/bun/test/expect-formdata-tojson-crash.test.ts      3      6      0
```

</details>

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

3 participants