Skip to content

Cap expect() failure message rendering so huge values cannot swallow the assertion error - #37311

Closed
robobun wants to merge 4 commits into
mainfrom
farm/d8d16823/expect-message-truncation
Closed

robobun wants to merge 4 commits into
mainfrom
farm/d8d16823/expect-message-truncation

Conversation

@robobun

@robobun robobun commented Aug 10, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #37310

Problem

expect(<happy-dom Element>).toBeNull() on a large document got superlinearly slower, and past a size threshold it stopped throwing entirely, reporting a false assertion as a passing test.

Two compounding bugs:

  1. Rendering the received value into the matcher failure message was unbounded. A wide object graph re-expands through multiple paths (parent links plus child arrays), so a single 1,641-node happy-dom tree rendered a 1.3 GB failure message for one leaf node. That is the superlinear wall time.
  2. Once the rendered message exceeded the maximum JS string length, Zig::getErrorInstance returned an empty value with no pending exception, JSGlobalObject::throw turned that into JsError::Thrown with nothing actually thrown, and the host function returned normally. The failed assertion silently passed.

Repro (from the issue, happy-dom 20.x):

fanout=80  nodes=6,481  ms=68,534  threw=false   <-- silent pass

Fix

  • Add an output byte budget to the console formatter (max_output_bytes, default unlimited). The test runner's matcher formatter caps each rendered value at 1 MiB; when the budget is hit, the value walk stops and the message ends with ... [value truncated]. This bounds both the rendering time and the message size, so the message can always be materialized as a JS string. console.log, Bun.inspect, and other formatter users are unchanged.
  • Harden JSGlobalObject::throw: if the error instance could not be created and no exception is pending, throw a short fallback error instead of returning Thrown with nothing thrown. An assertion failure (or any throw) can no longer be silently dropped by message construction failing.

Verification

New test test/js/bun/test/expect-failure-message-truncation.test.ts: a ~41k-node object tree used to render a multi-MB failure message; now the message is capped and the assertion still throws. Fails on current main, passes with this change.

The issue's verbatim happy-dom repro, with the fix (debug + ASAN build):

fanout=20  nodes=421    ms=1490 threw=true
fanout=40  nodes=1641   ms=1484 threw=true
fanout=80  nodes=6481   ms=1444 threw=true
fanout=120 nodes=14521  ms=1438 threw=true

Every size throws, and the cost is flat instead of superlinear (previously 100s+ in a release build at fanout 120, with the throw lost past fanout 80).

Existing suites pass: expect.test.js (415 pass), test/js/bun/console (85 pass), test/js/bun/util/inspect.test.js (74 pass).

Rebase note: #36912 on main refactored ZigFormatter::fmt to delegate to Formatter::format_value. The cap for that path now lives entirely in format_value, which this PR already instrumented, so ZigFormatter::fmt keeps main's shape with no truncation code of its own. The branch was squashed to one commit during the rebase. All suites re-ran green after the rebase, including the expect-extend-matcher-utils-throw test that #36912 added.


[human-review] gate passed · iteration 4 · 7 files touched

fails on main (without fix)
ASAN without fix: 4 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/expect-failure-message-truncation.test.ts
bun test v1.4.3 (6a92015fc)

test/js/bun/test/expect-failure-message-truncation.test.ts:
26 | }
27 | 
28 | test("failure message for a huge object graph is truncated and still throws", () => {
29 |   const root = tree(4, 14); // ~41k nodes, renders several MB untruncated
30 |   const message = messageOf(() => expect(root).toBeNull());
31 |   expect(message.length).toBeLessThan(2 * 1024 * 1024);
                              ^
error: expect(received).toBeLessThan(expected)

Expected: < 2097152
Received: 5124593

      at <anonymous> (/workspace/bun/test/js/bun/test/expect-failure-message-truncation.test.ts:31:26)
(fail) failure message for a huge object graph is truncated and still throws [9089.21ms]
  ^ this test timed out after 5000ms.
36 |   // Strings render through a different path than object properties; this
37 |   // used to escape the whole string into the message.
38 |   const s = Buffer.alloc(2_000_000, "x").toString();
39 |   const message = messageOf(() => expect(s).
... (truncated)

release without fix: 4 FAILED
bun test v1.4.3-canary.1 (6a92015fc)

test/js/bun/test/expect-failure-message-truncation.test.ts:
26 | }
27 | 
28 | test("failure message for a huge object graph is truncated and still throws", () => {
29 |   const root = tree(4, 14); // ~41k nodes, renders several MB untruncated
30 |   const message = messageOf(() => expect(root).toBeNull());
31 |   expect(message.length).toBeLessThan(2 * 1024 * 1024);
                              ^
error: expect(received).toBeLessThan(expected)

Expected: < 2097152
Received: 5124593

      at <anonymous> (/workspace/bun/test/js/bun/test/expect-failure-message-truncation.test.ts:31:26)
(fail) failure message for a huge object graph is truncated and still throws [75.20ms]
36 |   // Strings render through a different path than object properties; this
37 |   // used to escape the whole string into the message.
38 |   const s = Buffer.alloc(2_000_000, "x").toString();
39 |   const message = messageOf(() => expect(s).toBeNull());
40 |   expect(message.length).toBeLessThan(2 * 1024 * 1024);
41 |   expect(message).toContain("[value truncated]");
                       ^
error: expect(received).toContain(expected)

Expected to contain: "[
... (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/pr_gate.xml" test/js/bun/test/expect-failure-message-truncation.test.ts
bun test v1.4.3 (6a92015fc)

test/js/bun/test/expect-failure-message-truncation.test.ts:
(pass) failure message for a huge object graph is truncated and still throws [2014.77ms]
(pass) failure message for a huge string is truncated and still throws [639.86ms]
(pass) failure message for a huge Map is truncated and still throws [780.48ms]
(pass) toThrow failure message for a huge thrown value is truncated [1959.00ms]
(pass) a getter throwing after the cap is hit does not replace the assertion error [589.68ms]
(pass) failure message for a small value is not truncated [5.44ms]

 6 pass
 0 fail
 25 expect() calls
Ran 6 tests across 1 file. [7.87s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     e41fa6a6b9
  features     baseline

23 deps, 131 codegen, 1172 objects in 647ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1244] install /workspace/bun
bun install v1.4.3-canary.1 (6a92015fc)

Checked 22 installs across 61 packages (no changes) [11.00ms]
[2/1244] gen ErrorCode+*.h
[3/1244] install /workspace/bun/packages/bun-error
bun install v1.4.3-canary.1 (6a92015fc)

Checked 1 install across 2 packages (no changes) [5.00ms]
[4/1244] install /workspace/bun/src/node-fallbacks
bun install v1.4.3-canary.1 (6a92015fc)

Checked 111 installs across 104 packages (no changes) [5.00ms]
[5/1244] fetch tinycc
[tinycc] up to date
[6/1243] fetch zlib
[zlib] up to date
[7/1243] gen node-fallbacks/react-refresh.js
Bundled 1 module in 4ms

  react-refresh.js  4.81 KB  (entry point)

[8/1243] gen bindgenv2
[9/1243] gen .bind.ts → GeneratedBindings.cpp
[10/1243] gen bake.{client,server,error}.js
-> bake.client.js, bake.server.js, bake.error.js
[11/1243] fetch libjpeg-turbo
[libjpeg
... (truncated)
diff hotspot
src/bun_core/util.rs                               | 17 +++++
 src/jsc/ConsoleObject.rs                           | 71 ++++++++++++++++-
 src/jsc/JSGlobalObject.rs                          | 12 ++-
 src/runtime/test_runner/ScopeFunctions.rs          |  2 +
 src/runtime/test_runner/expect.rs                  |  4 +-
 src/runtime/test_runner/mod.rs                     | 16 +++-
 .../test/expect-failure-message-truncation.test.ts | 88 ++++++++++++++++++++++
 7 files changed, 205 insertions(+), 5 deletions(-)

gate history · 6 passed · 0 rejected · iteration 4

evidence per changed file
file                                                      reads  edits  tests
src/bun_core/util.rs                                          2      2     16
src/jsc/ConsoleObject.rs                                      9     12     16
src/jsc/JSGlobalObject.rs                                     2      1     16
src/runtime/test_runner/ScopeFunctions.rs                     1      2     16
src/runtime/test_runner/expect.rs                             1      1     16
src/runtime/test_runner/mod.rs                                5      5     16
…t/js/bun/test/expect-failure-message-truncation.test.ts      1      7     16

root cause · written by the author bot

The failure message for expect(value).toBeNull() was built by walking the entire value graph with no size limit, so a DOM element rooted in a large document produced output that grew superlinearly and, once it exceeded WTF::String::MaxLength, could not be turned into an error instance, leaving nothing to throw and letting the false assertion pass silently. The fix gives the formatter a byte budget, wraps its sink in a TruncatingWriter that discards output past the cap while preserving valid UTF-8 and appends a truncation marker, and stops the graph walk as soon as truncation is detect…

@coderabbitai

coderabbitai Bot commented Aug 10, 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

Formatter now supports bounded UTF-8-safe output. Matcher formatting uses a 1 MiB limit. Oversized assertion messages include a truncation marker. Error creation now throws a fallback error when message creation fails without a pending exception. Regression tests cover large and small values.

Changes

Matcher output truncation

Layer / File(s) Summary
Formatter byte-budget enforcement
src/bun_core/util.rs, src/jsc/ConsoleObject.rs
Write exposes truncation state. Formatter carries a configurable byte limit. TruncatingWriter preserves valid UTF-8 and appends ... [value truncated] when the limit is exceeded.
Assertion error handling and matcher configuration
src/runtime/test_runner/mod.rs, src/jsc/JSGlobalObject.rs
Matcher formatters use a 1 MiB limit. throw creates a fallback error when message creation fails without a pending exception.
Truncation regression coverage
test/js/bun/test/expect-failure-message-truncation.test.ts
Tests verify bounded messages for large values and preserve property details without a truncation marker for small values.

Possibly related PRs

Suggested reviewers: dylan-conway, jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The description references issue #37310 and clearly explains how the changes address its reported failure mode.
Out of Scope Changes check ✅ Passed The listed changes align with the stated objectives and include only formatter limits, throw handling, and related regression tests.
Description check ✅ Passed The description clearly explains the problem, implementation, regression coverage, and verification results. It does not use the exact template headings, but it provides the required information in eq…
Title check ✅ Passed The title clearly identifies the primary change: limiting expect() failure-message rendering to prevent oversized values from hiding assertion failures.

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

@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 `@src/runtime/test_runner/mod.rs`:
- Around line 382-386: Define a named constant for the 1 MiB matcher output
budget near the relevant test-runner configuration, then assign that constant to
f.max_output_bytes instead of using the literal shift. Keep the limit unchanged
and use a descriptive name that identifies it as the matcher output byte limit.
🪄 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: fbeaf424-4cf2-44e2-9e48-bc920c5a3c4e

📥 Commits

Reviewing files that changed from the base of the PR and between 827475e and 39a6f28.

📒 Files selected for processing (4)
  • src/jsc/ConsoleObject.rs
  • src/jsc/JSGlobalObject.rs
  • src/runtime/test_runner/mod.rs
  • test/js/bun/test/expect-failure-message-truncation.test.ts

Comment thread src/runtime/test_runner/mod.rs Resolved
Comment thread src/jsc/ConsoleObject.rs Resolved
Comment thread src/jsc/ConsoleObject.rs Resolved
Comment thread src/jsc/ConsoleObject.rs Resolved
Comment thread src/jsc/ConsoleObject.rs Resolved
Comment thread src/jsc/JSGlobalObject.rs Resolved
Comment thread src/runtime/test_runner/mod.rs Resolved
@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. test runner: stop printing shared references without bound in assertion diffs and snapshots #34179 - Also caps expect() failure-message rendering at 1 MiB with a truncation notice, applying the same fix in the test runner's DiffFormatter/pretty_format path (LimitedWriter + is_truncated() early-exit) instead of ConsoleObject.rs's Formatter.

🤖 Generated with Claude Code

@robobun

robobun commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator Author

Not a duplicate. #34179 caps the diff formatter used by the toEqual family (JestPrettyFormat / DiffFormatter, issue #34178). This PR caps the inline value formatter (ConsoleObject::Formatter) used by simple matchers like toBeNull, which #34179 explicitly left alone on the assumption that its depth bound was enough; #37310 shows a wide graph blows up on breadth despite the depth bound, and past the max JS string length the assertion error is silently lost. The two changes touch different code paths and are complementary.

Comment thread src/jsc/ConsoleObject.rs Resolved
Comment thread src/runtime/test_runner/mod.rs Resolved
Comment thread src/jsc/JSGlobalObject.rs Resolved
Comment thread src/bun_core/util.rs Resolved
Comment thread src/jsc/ConsoleObject.rs Resolved
Comment thread src/jsc/ConsoleObject.rs Resolved
Comment thread src/runtime/test_runner/mod.rs Resolved
Comment thread src/runtime/test_runner/mod.rs Resolved

@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 `@src/bun_core/util.rs`:
- Around line 1753-1760: Implement is_truncated() forwarding in the Write
implementations for &mut W and Box<W>, delegating each wrapper’s result to its
contained writer. Preserve the existing default behavior for other writers so
truncation state from TruncatingWriter reaches print_as_prelude through both
wrapper types.
🪄 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: 3030f863-8eb3-4f7c-b4f8-c657c378dc89

📥 Commits

Reviewing files that changed from the base of the PR and between a84c7f3 and a5cd571.

📒 Files selected for processing (4)
  • src/bun_core/util.rs
  • src/jsc/ConsoleObject.rs
  • src/runtime/test_runner/mod.rs
  • test/js/bun/test/expect-failure-message-truncation.test.ts

Comment thread src/bun_core/util.rs Resolved
Comment thread src/jsc/ConsoleObject.rs Resolved
Comment thread src/runtime/test_runner/mod.rs Resolved
Comment thread src/jsc/ConsoleObject.rs Resolved
Comment thread src/jsc/ConsoleObject.rs Resolved
Comment thread src/runtime/test_runner/expect.rs Resolved

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

I reviewed the latest revision and found no new issues — d7c1367 addresses the last remaining sibling sites in ScopeFunctions.rs, and all earlier findings (the raw-sink panic, the truncation-masking-a-pending-exception case, the is_truncated() wrapper forwarding, and the uncapped format_value/snapshot/toThrow paths) are resolved. Given the change touches JSGlobalObject::throw, the shared bun_io::Write trait, and the console formatter's core render loop, a human pass on the overall design (1 MiB cap value, the trait method addition, and the fallback error wording) would still be worthwhile.

What was reviewed:

  • TruncatingWriter never returns Err, so the ~30 .expect("unreachable") raw-sink sites in ConsoleObject.rs remain sound; print_as_prelude polls is_truncated() to stop the walk.
  • The if truncated && result.is_ok() guard preserves the pre-PR contract that a pending JS exception from a getter still propagates to create_error_instance's clear-and-throw path.
  • Grepped every Formatter::new in src/runtime/test_runner/ — all sites now either chain .with_quote_strings() (which sets the cap), go through make_formatter, or set max_output_bytes directly; pretty_format.rs is intentionally deferred to #34179.
  • format_value now honors max_output_bytes for the test.each %p/$var label path.
Extended reasoning...

Overview

This PR fixes #37310: expect(hugeObject).toBeNull() rendered the received value into the failure message with no output limit, so a wide object graph (e.g. a happy-dom DOM tree) expanded to gigabytes; past WTF::String::MaxLength the error string could not be materialized, create_error_instance returned empty with no pending exception, and JSGlobalObject::throw returned JsError::Thrown with nothing on the VM — a failed assertion silently passed.

The fix has two parts: (1) a byte-budgeted TruncatingWriter sink wired through the console formatter via a new max_output_bytes field (default unlimited; the test runner's matcher formatters set it to 1 MiB), with a new defaulted is_truncated() method on bun_io::Write that print_as_prelude polls to stop the value walk; and (2) a defensive fallback in JSGlobalObject::throw so an empty error instance with no pending exception throws a short placeholder instead of silently succeeding.

Files touched: src/bun_core/util.rs (Write trait), src/jsc/ConsoleObject.rs (formatter core, ~100 lines), src/jsc/JSGlobalObject.rs (throw), three test-runner files applying the cap at every formatter construction site, and a new 88-line test file covering object graphs, huge strings, huge Maps, toThrow, the getter-throws-after-cap edge case, and a small-value negative case.

Security risks

None identified. The change bounds output rather than expanding it, and the throw fallback fails closed (throws a generic error) rather than open. No user input reaches new parsing or allocation paths.

Level of scrutiny

Medium-high. ConsoleObject.rs is the shared value formatter used by console.log, Bun.inspect, and every matcher failure message — a subtle regression here would be broadly visible. JSGlobalObject::throw sits on every Rust-side throw path. The change is careful to preserve defaults (max_output_bytes = usize::MAX for non-test-runner callers; TruncatingWriter never returns Err so the formatter's infallible-write invariants hold), and the existing console/inspect/expect suites pass per the PR evidence, but the surface is critical enough that a maintainer should confirm the design choices — in particular the addition of a defaulted method to the public bun_io::Write trait, the 1 MiB cap value, and the fallback error message wording.

Other factors

This PR went through three prior review rounds from me, each of which surfaced a real issue that was then fixed: the initial TruncatingWriter returned Err and panicked ~30 raw-sink .expect("unreachable") sites on large strings/Maps; the truncation branch discarded a pending JS exception from a post-cap getter; is_truncated() was not forwarded through &mut W/Box<W>; and successive rounds enumerated remaining uncapped sibling sites (with_quote_strings callers, format_value, three snapshot-error arms in expect.rs, and finally the two .each() error paths in ScopeFunctions.rs). All are now addressed and covered by the new tests. The DiffFormatter/JestPrettyFormat path is explicitly deferred to the complementary #34179. This run's bug hunt found nothing new; I re-grepped every Formatter::new in the test runner and confirmed the class is closed modulo the documented exclusion. The number of iterations it took to converge is itself a signal that the change is non-trivial and merits a human glance.

@robobun

robobun commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator Author

CI summary for e74ad10: 195 of 196 jobs passed. The one failed job (darwin 14 x64 test-bun) died during git checkout with "Could not resolve host: github.com" on the agent, before any tests ran. The new truncation tests passed on every lane that ran, and the two flaky annotations (bun-add git URL timeout on alpine, passed on retry) are unrelated to this change. The diff is ready for review.

Rendering the received value into an expect() failure message was
unbounded. A wide object graph (e.g. a happy-dom DOM tree) expanded to
gigabytes of text, and once the rendered message exceeded the maximum
JS string length the Error instance could not be created, so the failed
assertion returned without throwing and the test passed.

Cap each value rendered into a matcher failure message at 1 MiB. The
budgeted sink discards past the cap and reports is_truncated(), a new
default method on the Write trait; print_as_prelude polls it to stop
the walk, and the message ends with a '... [value truncated]' marker.
The truncation branch only rewrites an Ok result so a pending exception
from a getter still propagates. Also make JSGlobalObject::throw fall
back to a short static message when error instance creation fails with
no pending exception.

Fixes #37310
@robobun
robobun force-pushed the farm/d8d16823/expect-message-truncation branch from e74ad10 to 8425d62 Compare August 21, 2026 00:33
@robobun

robobun commented Aug 21, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:43 PM PT - Sep 12th, 2026

✅ @robobun, your commit e41fa6a6b91f72d7e147d397d88354b6dc062957 passed in Build #114824! 🎉


🧪   To try this PR locally:

bunx bun-pr 37311

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

bun-37311 --bun

Comment thread src/jsc/ConsoleObject.rs Resolved

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

I reviewed the latest revision and didn't find any bugs — all prior findings from earlier rounds (the .expect("unreachable") panic, is_truncated() forwarding through &mut W/Box<W>, the truncated && result.is_ok() guard for pending getter exceptions, and the remaining bare-Formatter::new sites in expect.rs and ScopeFunctions.rs) are addressed. Because this extends the bun_io::Write trait, changes the JSGlobalObject::throw fallback semantics, and reroutes every format_value caller through TruncatingWriter, a human sign-off is still worthwhile.

The one open comment-cop flag on ConsoleObject.rs:5762 is a two-line explanatory comment, consistent with the earlier instances the author already addressed.

What was reviewed

  • TruncatingWriter now discards past the cap and never errors, so the ~30 raw-sink .expect("unreachable") sites remain sound; print_as_prelude polls is_truncated() to stop the walk.
  • ZigFormatter::fmt already routed through format_value, so moving the TruncatingWriter wrap there covers both the Display path and the direct jest.rs/expect.rs callers with one change; console.log/Bun.inspect inherit usize::MAX and are unaffected.
  • The throw() fallback only fires on empty-instance-with-no-pending-exception; throw_value's existing has_exception() guard is preserved.
Extended reasoning...

Overview

The PR fixes #37310: expect(hugeValue).toBeNull() rendered an unbounded failure message, and past WTF::String::MaxLength the assertion error could not be created, so the failed assertion silently passed. The fix adds a max_output_bytes budget to ConsoleObject::Formatter, wraps the sink in a discarding TruncatingWriter inside format_value, polls is_truncated() in print_as_prelude to stop the graph walk, and hardens JSGlobalObject::throw to throw a fallback error when create_error_instance returns empty with no pending exception. Every test-runner formatter construction site (make_formatter, with_quote_strings, and the bare Formatter::new sites in expect.rs/ScopeFunctions.rs) now sets the 1 MiB cap.

Security risks

None. The change bounds output size (strictly reduces resource consumption) and ensures a throw is never silently dropped. No untrusted-input parsing, auth, or crypto is touched.

Level of scrutiny

Medium-high. The change is behaviorally narrow (default usize::MAX leaves console.log/Bun.inspect unchanged), but it touches three widely-shared surfaces: it adds a defaulted method to the foundational bun_io::Write trait, changes the shared format_value entry point that both ZigFormatter (Display) and direct callers route through, and adds a fallback branch to JSGlobalObject::throw. Each of these has many downstream consumers.

Other factors

This PR went through four review rounds, each of which surfaced a real defect that was then fixed (raw-sink panic, missing wrapper forwarding, swallowed getter exception when truncated && Err, and three successive rounds of missed sibling Formatter::new sites). The final design is clean — the TruncatingWriter wrap lives in the single format_value choke point rather than duplicated at each entry — and the test file covers object graphs, huge strings, Maps, toThrow, a throwing getter after the cap, and the small-value negative case. CI passed 195/196 (one unrelated agent network failure). Given the number of iterations needed to converge and the breadth of the touched infrastructure, a maintainer sign-off on the trait extension and throw() fallback is appropriate.

…message-truncation

# Conflicts:
#	src/runtime/test_runner/expect.rs
main no longer has ZigString in JSGlobalObject.rs.
@robobun

robobun commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator Author

Updated this PR to current main (it was 594 commits behind).

  • One conflict, in src/runtime/test_runner/expect.rs: main now returns value.jest_snapshot_pretty_format(..) directly from match_and_fmt_snapshot, so the capped formatter in the old error path is gone with it. I took main's side.
  • One build error after the merge: ZigString no longer exists in src/jsc/JSGlobalObject.rs. e41fa6a builds the fallback error from bun_core::String (BunString::static_(..).to_error_instance(self)).
  • I checked every Formatter::new in src/runtime/test_runner/: each one either calls .with_quote_strings(true) (which applies the cap) or sets max_output_bytes on the next line. No new site is without the cap.

Verified on a debug ASAN build of the merged tree: test/js/bun/test/expect-failure-message-truncation.test.ts 6 pass. The walk does stop at the cap: expect(tree).toBeNull() takes 2.6 s for a tree of 41k nodes and 3.4 s for 537k nodes, both with a message of 1,048,637 chars (stock bun prints 5.1 MB for the small one). Also expect.test.js 416 pass, console-log.test.ts, bun/util/inspect.test.js pass.

#34179 (the diff and snapshot formatter) no longer touches bun_core or bun_io, so the two PRs do not overlap in source files other than src/runtime/test_runner/mod.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.

Code review found no issues

No high-confidence issues detected in this change.

@robobun

robobun commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator Author

Closing in favor of #44833, which fixes #37310 on canary by printing a DOM node in a failure message as markup, so a failing toBeNull() stays about a millisecond at every tree size. That is a targeted fix for the reported case, and the lost throw was already gone in v1.4.4. This PR took the broader approach of a generic 1 MiB cap on all matcher output, which is not needed now that the specific case is handled and is a behavior change (truncating failure messages) that was not requested. Thanks for the pointer to #44833.

@robobun robobun closed this Nov 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.

expect(<happy-dom Element>).toBeNull() takes ~40s+ on a large tree and then SILENTLY PASSES (throw is lost)

1 participant