Skip to content

fix(bun:test): handle a non-numeric size property when formatting a Set or Map diff - #41148

Merged
Jarred-Sumner merged 1 commit into
mainfrom
robobun/1c82b72c/fix-pretty-format-size-assert
Sep 2, 2026
Merged

Jarred-Sumner merged 1 commit into
mainfrom
robobun/1c82b72c/fix-pretty-format-size-assert

Conversation

@robobun

@robobun robobun commented Sep 2, 2026 •

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes a fuzzer-found assertion failure in the jest pretty formatter:

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

The formatter in src/runtime/test_runner/pretty_format.rs reads the size property of Set, Map, WeakSet and WeakMap values while it renders an expect().toEqual() failure diff. It called to_int32() on that value. WeakSet and WeakMap have no size accessor on their prototype. A Set or Map instance can also shadow its accessor with Object.defineProperty. In both cases a user-defined own size property comes back as is. When that value is not a number, to_int32() falls through to JSC__JSValue__toInt32, which calls JSValue::asInt32(). A debug build aborts on the assertion. A release build reads a garbage length and reports a bare TypeError instead of the diff.

Repro. This aborts a debug build before this change:

const ws = new WeakSet();
ws.size = BigUint64Array;
Bun.jest(import.meta.path).expect(BigUint64Array).toEqual(ws);

The fix coerces the value only when it is a number. Any other value counts as 0. ToNumber never runs on the user value, so a Symbol or a throwing valueOf cannot replace the diff with its own error. The repro now prints the normal diff:

expect(received).toEqual(expected)

Expected: WeakSet {}
Received: [class BigUint64Array]

This supersedes #31292. The stale bot closed that PR after 90 days. The change is the same, rebased onto current main, where the crash still reproduces. The earlier review threads (CodeRabbit and the Symbol edge case) are already addressed in this diff. #30373 covers the weak-collection case with a different approach. The two changes compose.

How did you verify your code works?

  • The repro above no longer aborts a debug build. It prints the normal toEqual error.
  • Added test/js/bun/test/pretty-format-overridden-size.test.ts. It covers WeakSet, WeakMap, Set and Map with a constructor, a string, a plain object and a symbol as size. It fails without the fix (a debug build aborts, a release build prints Type error for three of the five cases) and passes with the fix.
  • Normal Set and Map diff output is unchanged.

[human-review] gate passed · iteration 6 · 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/pr_gate.xml" test/js/bun/test/pretty-format-overridden-size.test.ts
bun test v1.4.1 (a6c4cc276)

test/js/bun/test/pretty-format-overridden-size.test.ts:
54 |       stdout: "pipe",
55 |     });
56 | 
57 |     const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
58 | 
59 |     expect(stdout.trim().split("\n")).toEqual(["DIFF OK", "DIFF OK", "DIFF OK", "DIFF OK", "DIFF OK"]);
                                           ^
error: expect(received).toEqual(expected)

  [
-   "DIFF OK",
-   "DIFF OK",
-   "DIFF OK",
-   "DIFF OK",
-   "DIFF OK",
+   "",
  ]

- Expected  - 5
+ Received  + 1

      at <anonymous> (/workspace/bun/test/js/bun/test/pretty-format-overridden-size.test.ts:59:39)
(fail) pretty_format should handle collections with an overridden `size` property > non-numeric `size` on (Weak)Set/(Weak)Map still produces a toEqual diff [509.39ms]

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

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

test/js/bun/test/pretty-format-overridden-size.test.ts:
54 |       stdout: "pipe",
55 |     });
56 | 
57 |     const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
58 | 
59 |     expect(stdout.trim().split("\n")).toEqual(["DIFF OK", "DIFF OK", "DIFF OK", "DIFF OK", "DIFF OK"]);
                                           ^
error: expect(received).toEqual(expected)

  [
+   "UNEXPECTED: Type error",
+   "UNEXPECTED: Type error",
    "DIFF OK",
    "DIFF OK",
-   "DIFF OK",
-   "DIFF OK",
-   "DIFF OK",
+   "UNEXPECTED: Type error",
  ]

- Expected  - 3
+ Received  + 3

      at <anonymous> (/workspace/bun/test/js/bun/test/pretty-format-overridden-size.test.ts:59:39)
(fail) pretty_format should handle collections with an overridden `size` property > non-numeric `size` on (Weak)Set/(Weak)Map still produces a toEqual diff [8.07ms]

 0 pass
 1 fail
 1 expect() calls
Ran 1 test across 1 file. [70.00ms]
__F:1: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/pr_gate.xml" test/js/bun/test/pretty-format-overridden-size.test.ts
bun test v1.4.1 (a6c4cc276)

test/js/bun/test/pretty-format-overridden-size.test.ts:
(pass) pretty_format should handle collections with an overridden `size` property > non-numeric `size` on (Weak)Set/(Weak)Map still produces a toEqual diff [324.98ms]

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

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 624ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/5] gen generated_host_exports.rs
generated_host_exports.rs: 122 exports (host=5, lazy=10, generic=107, rust=0); 244 extern-C blocks audited
[1/5] cargo bun_runtime → libbun_runtime.a
�[1m�[92m   Compiling�[0m bun_runtime v0.0.0 (/workspace/bun/src/runtime)
�[1m�[92m    Finished�[0m `release` profile [optimized + debuginfo] target(s) in 4m 14s
[2/5] link bun-profile
[4/5] strip bun
[4/5] bun-profile --revision
1.4.1-canary.1+611222352
[build] done
bun test v1.4.1-canary.1 (611222352)

test/js/bun/test/pretty-format-overridden-size.test.ts:
(pass) pretty_format should handle collections with an overridden `size` property > non-numeric `size` on (Weak)Set/(Weak)Map still produces a toEqual diff [6.93ms]

 1 pass
 0 fail
 2 expect() calls
Ran 1 test across 1 file. [68.00ms]
__F:0:S:0
diff hotspot
src/runtime/test_runner/pretty_format.rs           | 12 ++++-
 .../bun/test/pretty-format-overridden-size.test.ts | 62 ++++++++++++++++++++++
 2 files changed, 72 insertions(+), 2 deletions(-)

gate history · 1 passed · 0 rejected · iteration 6

evidence per changed file
file                                                    reads  edits  tests
src/runtime/test_runner/pretty_format.rs                    2      2     12
test/js/bun/test/pretty-format-overridden-size.test.ts      0      2     10

… diff

The jest pretty formatter reads the `size` property of Set, Map, WeakSet
and WeakMap values and called `to_int32()` on it. WeakSet and WeakMap have
no `size` accessor, and a Set or Map instance can shadow its accessor with
an own property. A non-number then reached `JSC::JSValue::asInt32()`, which
asserts in debug builds and reads garbage in release builds.

Only coerce the value when it is a number. Treat any other value as 0, so
the diff renders and no ToNumber conversion runs on a user value.
@coderabbitai

coderabbitai Bot commented Sep 2, 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: Essentials

Run ID: fa308a0b-f5b2-45fd-9595-753a90927f96

📥 Commits

Reviewing files that changed from the base of the PR and between 6f27257 and 6112223.

📒 Files selected for processing (2)
  • src/runtime/test_runner/pretty_format.rs
  • test/js/bun/test/pretty-format-overridden-size.test.ts

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


Walkthrough

Changes

Collection size formatting

Layer / File(s) Summary
Map and Set size validation
src/runtime/test_runner/pretty_format.rs
Numeric sizes use fallible conversion. Non-numeric sizes use zero.
Overridden size regression coverage
test/js/bun/test/pretty-format-overridden-size.test.ts
Tests expected toEqual diffs for invalid or overridden sizes on Set, Map, WeakSet, and WeakMap. Five cases must complete with exit code 0.

Suggested reviewers: jarred-sumner, dylan-conway

Merge Risk: ⚪ Minimal · up to 61122

The change safely handles non-numeric Set and Map size values during diff formatting and includes focused regression coverage; no actionable merge-blocking risk remains after normal checks and review.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The description includes both required sections. It clearly explains the assertion failure, the implementation change, affected collection types, regression coverage, and verification results.
Title check ✅ Passed The title clearly identifies the main fix: handling a non-numeric size property when formatting Set or Map diffs. It is concise and directly related to the changes.

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 for the asInt32() assertion, both Map and Set arms guarded identically.

What was reviewed:

  • Both formatting arms (Map at pretty_format.rs:1721, Set at :1769) now gate on is_number() before coercing; non-numeric size falls back to 0, so Symbol/throwing-valueOf never reach ToNumber.
  • Checked the two remaining to_int32() calls in this file (:2818-2819) — those read ExpectCloseTo's internally-cached number/digits, not a user-overridable property, so not the same bug class.
  • Test spawns with bunExe()/bunEnv -e, drains stdout/stderr/exited concurrently, asserts positive diff output before exit code, and covers WeakSet/WeakMap/Set/Map × constructor/string/object/Symbol.
Extended reasoning...

Overview

This PR fixes a fuzzer-found debug-build assertion (ASSERTION FAILED: isInt32()) in the Jest pretty formatter at src/runtime/test_runner/pretty_format.rs. When rendering a toEqual diff for Set/Map/WeakSet/WeakMap, the formatter reads the size property and previously called to_int32() unconditionally. For non-numeric values (WeakSet/WeakMap have no prototype size, and Set/Map can have it shadowed via Object.defineProperty), to_int32() falls through to JSC__JSValue__toInt32, which calls JSValue::asInt32() and aborts on the debug assertion. The fix guards with is_number() and uses the throwing coerce_to_i32(global)? when numeric, defaulting to 0 otherwise. A new test covers all four collection types with constructor, string, plain object, and Symbol as size.

Security risks

None. This is output formatting in the test runner's diff renderer. No untrusted-input parsing, no filesystem/network access, no auth/crypto. The change strictly narrows the code path (fewer values reach the FFI coercion), and the ? on coerce_to_i32 propagates any exception rather than swallowing it.

Level of scrutiny

Low-to-moderate. The change is 8 lines across two identical sibling arms and directly implements the REVIEW.md rule "Never call non-throwing accessors (asNumber, jsCast, getDirect) on user values without validating type first" / "coercing conversions (toInt32(global), never asInt32 on user values)". I verified the whole bug class was addressed in this file: the only other .to_int32() calls (lines 2818-2819) operate on ExpectCloseTo's internally cached number/digits values, which are set by Bun's own matcher construction rather than read off a user object, so they are not the same class. The is_number() gate also means ToNumber never runs on the user value, so a Symbol or throwing valueOf cannot replace the diff with its own error — the PR description's stated design goal holds.

Other factors

The test follows harness conventions: bunExe()/bunEnv, -e inline script, Promise.all draining stdout/stderr/exited, stdout asserted before exit code, positive assertion on the diff header (not a "no panic" check). It covers the variant matrix across all four collection types and four non-numeric value shapes. No CODEOWNERS entry covers the changed paths. The bug hunt ran to dry_streak with no findings and no outstanding reviewer objections in the timeline. The only minor nit is that the test file's header comment says "Regression test" without a linked issue number and lives in test/js/bun/test/ rather than an existing file — acceptable given there's no obvious existing pretty-format test file to append to, and not worth blocking on.

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