Skip to content

test runner: don't crash pretty-printing a Map or Set whose size property is not a number - #39237

Closed
robobun wants to merge 2 commits into
mainfrom
farm/dd0a39a8/fix-pretty-format-size-coerce
Closed

robobun wants to merge 2 commits into
mainfrom
farm/dd0a39a8/fix-pretty-format-size-coerce

Conversation

@robobun

@robobun robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator

What does this PR do?

Found by fuzzing. A failing matcher on a Map, Set, WeakMap or WeakSet that carries a non-numeric size property took down the process while the failure message was being built.

const { expect } = require("bun:test");
const ws = new WeakSet();
ws.size = {};
expect(ws).toEqual(1);

Debug build:

ASSERTION FAILED: isInt32()
JavaScriptCore/JSCJSValue.h(683) : int32_t JSC::JSValue::asInt32() const

Release build: the matcher message is replaced by a bare TypeError: Type error for WeakSet/WeakMap (the garbage length is non-zero, so the formatter tries to iterate a non-iterable), and a Set/Map whose size was shadowed happens to print by accident.

Cause

The Tag::Map and Tag::Set branches of the jest pretty formatter (src/runtime/test_runner/pretty_format.rs, used for expect() diffs and snapshots) read size with an ordinary property get and then called to_int32() on it. to_int32() only handles numbers; for anything else it falls through to JSC__JSValue__toInt32, which is a plain asInt32(). size is only a prototype getter, so an own property of any type shadows it. This predates the Rust port, the Zig version had the same toInt32() call.

console.log / Bun.inspect (ConsoleObject.rs) read the same property but run it through coerce_to_i32, which is why they never crashed on the same values.

Fix

Use coerce_to_i32(global)? in both branches, matching ConsoleObject.rs. A non-numeric size now coerces the way it does for console.log ({} -> 0, "2" -> 2), and a size whose valueOf throws propagates the exception the same way a throwing size getter already did.

With the fix the repro above reports a normal matcher failure:

expect(received).toEqual(expected)

Expected: 1
Received: WeakSet {}

How did you verify your code works?

  • The fuzzer repro and the WeakMap, shadowed Set and shadowed Map variants all print a matcher failure and exit 1 with the debug build instead of aborting.
  • New test test/js/bun/test/expect-map-set-size-crash.test.ts fails on the current release ("Type error" messages) and on an unfixed debug build (abort), passes with bun bd test.
  • test/js/bun/test/snapshot-tests/snapshots/snapshot.test.ts (covers Map/Set/WeakMap/WeakSet pretty printing) and expect-formdata-tojson-crash.test.ts still pass with the debug build.

[review] gate passed · iteration 0 · 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-map-set-size-crash.test.ts
bun test v1.4.1 (adc354d99)

test/js/bun/test/expect-map-set-size-crash.test.ts:
29 |     stderr: "pipe",
30 |   });
31 | 
32 |   const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
33 | 
34 |   expect(stderr).toBe("");
                      ^
error: expect(received).toBe(expected)

- ""
+ "ASSERTION FAILED: isInt32()
+ /root/.bun/build-cache/webkit-76882271d74a6e7f-debug-asan/include/JavaScriptCore/JSCJSValue.h(683) : int32_t JSC::JSValue::asInt32() const
+ no stacktrace available"

- Expected  - 1
+ Received  + 3

      at <anonymous> (/workspace/bun/test/js/bun/test/expect-map-set-size-crash.test.ts:34:18)
(fail) failing matcher on a Map or Set whose size is not a number still reports the mismatch [506.12ms]

 0 pass
 1 fail
 1 expect() calls
Ran 1 test across 1 file. [2.54s]
error: script "bd" exited with code 1
__F:1:S:0

release without fix: 1 FAILED
bun test v1.4.1-canary.1 (adc354d99)

test/js/bun/test/expect-map-set-size-crash.test.ts:
30 |   });
31 | 
32 |   const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
33 | 
34 |   expect(stderr).toBe("");
35 |   expect(normalizeBunSnapshot(stdout)).toMatchInlineSnapshot(`
                                            ^
error: expect(received).toMatchInlineSnapshot(expected)

  
- "expect(received).toEqual(expected)
- 
- Expected: 1
- Received: WeakSet {}
- 
+ "Type error
+ Type error
  expect(received).toEqual(expected)
  
- Expected: 1
- Received: WeakMap {}
+ - 1
+ + Set {
+ +   1,
+ +   2,
+ + }
  
- expect(received).toEqual(expected)
- 
- Expected: 1
- Received: Set {}
- 
- expect(received).toEqual(expected)
- 
- Expected: 1
- Received: Map {}
+ - Expected  - 1
+ + Received  + 4
  
  expect(received).toEqual(expected)
  
  - 1
- + {
- +   "nested": WeakMap {},
+ + Map {
+ +   1 => 2,
  + }
  
  - Expected  - 1
- + Received  + 3"
- 
+ + Received  + 3
+ 
+ Type error"
+ 

- Expected  - 20
+ Received  + 15

      at <anonymous> (/workspace/bun/test/js/bun/test/expect-map-set-size-crash.test.ts:35:40)
(fail) fail
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/test/expect-map-set-size-crash.test.ts
bun test v1.4.1 (adc354d99)

test/js/bun/test/expect-map-set-size-crash.test.ts:
(pass) failing matcher on a Map or Set whose size is not a number still reports the mismatch [419.12ms]

 1 pass
 0 fail
 1 snapshots, 3 expect() calls
Ran 1 test across 1 file. [2.41s]
__F:0:S:0

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

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

�[1m�[92m   Compiling�[0m bun_react_compiler v0.0.0 (/workspace/bun/src/react_compiler)
�[1m�[92m   Compiling�[0m bun_css v0.0.0 (/workspace/bun/src/css)
�[1m�[92m   Compiling�[0m bun_js_parser v0.0.0 (/workspace/bun/src/js_parser)
�[1m�[92m   Compiling�[0m bun_resolver v0.0.0 (/workspace/bun/src/resolver)
�[1m�[92m   Compiling�[0m bun_ini v0.0.0 (/workspace/bun/src/ini)
�[1m�[92m   Compiling�[0m bun_bundler v0.0.0 (/workspace/bun/src/bundler)
�[1m�[92m   Compiling�[0m bun_router v0.0.0 (/workspace/bun/src/router)
�[1m�[92m   Compiling�[0m bun_standalone_graph v0.0.0 (/workspace/bun/src/standalone_graph)
�[1m�[92m   Compiling�[0m bun_transpiler v0.0.0 (/workspace/bun/src/transpiler)
�
... (truncated)
diff hotspot
src/runtime/test_runner/pretty_format.rs           |  4 +-
 test/js/bun/test/expect-map-set-size-crash.test.ts | 67 ++++++++++++++++++++++
 2 files changed, 69 insertions(+), 2 deletions(-)

gate history · 2 passed · 0 rejected · iteration 0

evidence per changed file
file                                                reads  edits  tests
src/runtime/test_runner/pretty_format.rs                4      2      0
test/js/bun/test/expect-map-set-size-crash.test.ts      2      7      0

@coderabbitai

coderabbitai Bot commented Aug 15, 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: 93554fff-0c97-4fb7-b055-c3508a425ec1

📥 Commits

Reviewing files that changed from the base of the PR and between 06820dc and 8052536.

📒 Files selected for processing (2)
  • src/runtime/test_runner/pretty_format.rs
  • test/js/bun/test/expect-map-set-size-crash.test.ts

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


Walkthrough

Changes

Map and Set size coercion

Layer / File(s) Summary
Fallible Map and Set size formatting
src/runtime/test_runner/pretty_format.rs
Map and Set formatting now uses coerce_to_i32 for size conversion and propagates coercion errors.
Malformed size regression coverage
test/js/bun/test/expect-map-set-size-crash.test.ts
A subprocess test covers malformed, overridden, and cyclic size values. It checks mismatch output, stderr, and exit status.

Suggested reviewers: jarred-sumner, dylan-conway

🚥 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 primary fix: preventing crashes when pretty-printing Map or Set values with nonnumeric size properties.
Description check ✅ Passed The description includes both required sections, explains the cause and fix, and provides detailed verification results for debug, release, regression, and existing tests.

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

@robobun

robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 2:11 AM PT - Aug 26th, 2026

❌ @robobun, your commit 8052536 has 1 failures in Build #106177 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 39237

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

bun-39237 --bun

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

CodeRabbit hit its review limit on this one, so there is no automated review here. The change itself is the two coerce_to_i32 lines in pretty_format.rs plus the new test; CI is running on #98791.

@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 — two-line swap from to_int32() to coerce_to_i32(global)?, mirroring the identical size handling in ConsoleObject.rs.

What was reviewed:

  • Confirmed ConsoleObject.rs:4659/4801 uses the same coerce_to_i32 pattern for Map/Set size.
  • Checked the early ? doesn't leak formatter state — quote_strings is saved/mutated only after the coercion succeeds.
  • The remaining to_int32() calls in pretty_format.rs (ExpectCloseTo cached number/digits) are pre-validated numbers, not user-shadowable getters — different class.
  • Test covers all four tag variants (WeakSet/WeakMap/Set/Map) with distinct non-numeric size types and asserts full matcher output via inline snapshot.
Extended reasoning...

Overview

Two-line change in src/runtime/test_runner/pretty_format.rs: the Tag::Map and Tag::Set branches now call length_value.coerce_to_i32(self.global_this)? instead of length_value.to_int32() when reading the size property. to_int32() is a non-throwing accessor that assumes the value is already a number and falls through to JSC's asInt32() (a debug assertion / release UB) on anything else. coerce_to_i32 runs the full ToInt32 abstract operation and propagates any exception via JsResult. Accompanied by a new subprocess test that exercises WeakSet, WeakMap, Set and Map with shadowed non-numeric size properties.

Security risks

None. This is the jest pretty-formatter used for expect() diffs and snapshots — output-only, in-process, no I/O or auth surface. The change replaces an unchecked cast with a proper coercion, which is strictly safer.

Level of scrutiny

Low. The fix is mechanical and copies the exact pattern already used at src/jsc/ConsoleObject.rs:4657-4659 and :4799-4801 for the same property on the same JS types. The surrounding function already returns JsResult (the .get(...)? on the preceding line confirms it), so the added ? composes cleanly. I checked that the early return on a throwing coercion doesn't leave self.quote_strings or self.indent in a bad state — both are saved/mutated only after the coercion, so no restoration is needed on that path.

Other factors

  • Bug-class coverage: grepped for other to_int32() calls in pretty_format.rs; the two remaining ones (lines 2834-2835) read cached ExpectCloseTo values that are validated at matcher construction — not the same shadowable-prototype-getter class.
  • Test quality: spawns a subprocess with bunExe()/bunEnv, drains stdout/stderr/exited concurrently, asserts stderr is empty, uses normalizeBunSnapshot + inline snapshot on the full matcher output for all four types, and asserts exitCode last — matches the harness conventions in CLAUDE.md. The PR description states it fails on the current release ("Type error") and aborts on unfixed debug, satisfying the fails-for-the-right-reason requirement.
  • No prior reviews or outstanding comments.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Nothing to change from the review. Format, clippy and the lint jobs are green; waiting on the Buildkite build (#98791) before this is ready to merge.

The jest pretty formatter used for expect() failure messages read the
size property off Map, Set, WeakMap and WeakSet values and passed it to
to_int32(), which only accepts numbers. A user-defined size property
holding anything else hit the isInt32() assertion in debug builds and
read garbage in release builds, where a WeakSet or WeakMap then failed
with a bare TypeError instead of the matcher message.

Coerce the value the same way ConsoleObject already does.
@robobun
robobun force-pushed the farm/dd0a39a8/fix-pretty-format-size-coerce branch from 5e1dc93 to 8052536 Compare August 26, 2026 08:51
@robobun

robobun commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator Author

The fuzzer reported a second instance of this crash at 93eecdc, a WeakMap whose size is set to the WeakMap itself, nested inside the expected object of a failing toStrictEqual:

const v2 = new WeakMap();
v2.size = v2;
const v3 = {};
v3[Symbol] = v2;
const v5 = Bun.jest();
v5.expect(v5).toStrictEqual(v3);

It is the same to_int32() call on the size property. The branch is rebased onto current main. With the fix the script prints a normal matcher failure and exits 1. I added this shape (a Map with a self-referential size, nested in an object) to the regression test.

@robobun

robobun commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator Author

The CodeRabbit pass on the rebased head (8052536) found nothing to act on. Format, clippy and the lint jobs are green again; the Buildkite build is #106177.

@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 — two-line fix aligning the jest pretty formatter with the identical coerce_to_i32(self.global_this)? pattern already used in ConsoleObject.rs.

What was reviewed:

  • Both Tag::Map and Tag::Set branches now coerce and propagate via ?; the early-return-on-zero and indent/quote_strings restore paths still hold.
  • Checked the remaining to_int32() calls in this file (lines 2810-2811) — those read internally-cached ExpectCloseTo values, not user-shadowable properties, so not the same bug class.
  • Test follows harness conventions (bunExe/bunEnv, concurrent pipe drain, stderr/stdout asserted before exit code, inline snapshot); the new commit adds a nested self-referential size case.
Extended reasoning...

Overview

The PR fixes a crash in the test-runner's jest-style pretty formatter (src/runtime/test_runner/pretty_format.rs) when formatting a Map/Set/WeakMap/WeakSet whose size property has been shadowed with a non-number. Two call sites change to_int32() (which asserts isInt32() and is UB on non-numbers) to coerce_to_i32(self.global_this)?, which performs proper JS ToInt32 coercion and propagates any thrown exception. This exactly mirrors the existing handling in src/jsc/ConsoleObject.rs:4570 and :4712 for the same property read. A new test spawns a subprocess exercising five variants (WeakSet with {}, WeakMap with "abc", Set with defined-property {}, Map with null, and a nested WeakMap whose size is itself) and snapshots the matcher output.

Security risks

None. This is output formatting for test-failure messages; the only user-controlled data is the value being formatted, and the change moves from an unchecked cast to a proper throwing coercion — strictly safer. No auth, crypto, network, or filesystem paths are touched.

Level of scrutiny

Low-to-moderate. The diff is two lines of native code plus a test. The fix is mechanical (swap a non-throwing accessor for the coercing equivalent), follows an established in-tree pattern verbatim, and satisfies REVIEW.md's "coercing conversions (toInt32(global), never asInt32 on user values)" rule directly. Exception propagation via ? is correct here because the enclosing function already returns JsResult and both branches already use ? on the preceding .get() call. I checked the two other to_int32() calls in the same file; they operate on internally-cached matcher values (ExpectCloseTo number/digits), not user-shadowable prototype getters, so they are not part of this bug class.

Other factors

The test file follows the repo's harness conventions cleanly: bunExe()/bunEnv, -e for a single-file subprocess, await using on the spawn handle, Promise.all to drain both pipes concurrently, stderr and stdout asserted before the exit code, and normalizeBunSnapshot with an inline snapshot. The PR body includes gate evidence showing the test fails on main (debug assertion / release TypeError) and passes with the fix. No CODEOWNERS entry covers src/runtime/test_runner/ or test/js/bun/test/. The only prior review was COMMENTED (non-blocking), and a follow-up commit since then only extended test coverage. Exit reason was dry_streak.

@robobun

robobun commented Aug 26, 2026 •

Copy link
Copy Markdown
Collaborator Author

Nothing to change from the second review either. The Buildkite build (#106177) has finished: 180 of 181 jobs passed. The one red test, test/cli/run/require-cache.test.ts on debian 13 x64-asan, is a timeout in a require.cache leak test that also fails on main and does not touch this code. I reported it for main-break triage separately. The other failures in that build passed on retry. The new test here passed on every lane.

@robobun

robobun commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: this was fixed on main by #41148 (commit 4e1eeff). That change handles a non-numeric size on Set, Map, WeakSet and WeakMap in the same two branches of src/runtime/test_runner/pretty_format.rs, and adds test/js/bun/test/pretty-format-overridden-size.test.ts.

Verified on a debug build of main at 1564c1e:

  • The repro from this PR prints a normal matcher failure (Expected: 1 / Received: WeakSet {}). No assertion, no bare Type error.
  • The test file from this PR (expect-map-set-size-crash.test.ts) passes unchanged against main, including its inline snapshot.

One difference between the two approaches: #41148 treats any non-number size as 0 and never runs ToNumber on the user value. So a numeric string such as "2" prints Set {} instead of the entries, and a Symbol or a throwing valueOf cannot replace the diff with its own error. That is the behavior that landed.

@robobun robobun closed this Sep 2, 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.

1 participant