From 8425d62e4eb55f6df5912fc8da9ecda12021e51a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 21 Aug 2026 00:24:07 +0000 Subject: [PATCH 1/2] test runner: cap matcher failure message rendering, never lose the throw 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 --- 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 | 5 +- src/runtime/test_runner/mod.rs | 16 +++- .../expect-failure-message-truncation.test.ts | 88 +++++++++++++++++++ 7 files changed, 206 insertions(+), 5 deletions(-) create mode 100644 test/js/bun/test/expect-failure-message-truncation.test.ts diff --git a/src/bun_core/util.rs b/src/bun_core/util.rs index f2075cab56a7..29ac095bb089 100644 --- a/src/bun_core/util.rs +++ b/src/bun_core/util.rs @@ -1732,6 +1732,15 @@ pub mod io { panic!("io::Write::written_len: writer does not track bytes written"); } + /// Whether this sink has hit an output cap and is discarding writes. + /// Byte-budgeted sinks override this; producers can poll it to stop + /// generating output early. Writes after truncation still return + /// `Ok`, so infallible-sink callers need no changes. + #[inline] + fn is_truncated(&self) -> bool { + false + } + // ── provided helpers ──────────────────────────────────────────────── /// Write a single byte. @@ -1830,6 +1839,10 @@ pub mod io { fn written_len(&self) -> usize { (**self).written_len() } + #[inline] + fn is_truncated(&self) -> bool { + (**self).is_truncated() + } } impl Write for Box { @@ -1845,6 +1858,10 @@ pub mod io { fn written_len(&self) -> usize { (**self).written_len() } + #[inline] + fn is_truncated(&self) -> bool { + (**self).is_truncated() + } } /// In-memory growable sink. diff --git a/src/jsc/ConsoleObject.rs b/src/jsc/ConsoleObject.rs index 06c17d88b762..c7eb486b8264 100644 --- a/src/jsc/ConsoleObject.rs +++ b/src/jsc/ConsoleObject.rs @@ -1650,6 +1650,12 @@ pub mod formatter { /// printed as a string. Set true in the error printer so that /// `ShellError` prints a more readable message. pub(crate) format_buffer_as_text: bool, + /// Byte budget for one `ZigFormatter` (`Display`) render of a value. + /// Wide object graphs (e.g. DOM trees) can otherwise expand to + /// gigabytes — past `WTF::String::MaxLength` the resulting error + /// message cannot even be materialized as a JS string. Output is cut + /// at the budget with a truncation marker. `usize::MAX` = unlimited. + pub max_output_bytes: usize, } impl<'a> Formatter<'a> { @@ -1680,6 +1686,7 @@ pub mod formatter { can_throw_stack_overflow: false, error_display_level: ErrorDisplayLevel::Full, format_buffer_as_text: false, + max_output_bytes: usize::MAX, } } @@ -1715,6 +1722,7 @@ pub mod formatter { can_throw_stack_overflow: self.can_throw_stack_overflow, error_display_level: self.error_display_level, format_buffer_as_text: self.format_buffer_as_text, + max_output_bytes: self.max_output_bytes, } } @@ -1800,6 +1808,48 @@ pub mod formatter { } } + /// Byte sink that forwards to `inner` until a byte budget is exhausted, + /// then silently discards further writes and reports `is_truncated()`. + /// It never fails because of the cap, so the formatter's infallible-write + /// call sites (`.expect("unreachable")`) hold; `print_as_prelude` polls + /// `is_truncated()` and sets `Formatter.failed`, which is what stops the + /// walk over the value graph shortly after the cap. + struct TruncatingWriter<'a> { + inner: &'a mut dyn bun_io::Write, + remaining: usize, + truncated: bool, + } + + impl bun_io::Write for TruncatingWriter<'_> { + fn write_all(&mut self, buf: &[u8]) -> bun_io::Result<()> { + if self.truncated { + return Ok(()); + } + if buf.len() <= self.remaining { + self.remaining -= buf.len(); + return self.inner.write_all(buf); + } + // Keep the prefix that fits, backed off to a UTF-8 boundary so + // the fmt bridge doesn't emit a replacement char for a split + // code point. + let mut take = self.remaining; + while take > 0 && buf[take] & 0b1100_0000 == 0b1000_0000 { + take -= 1; + } + self.remaining = 0; + self.truncated = true; + if take > 0 { + self.inner.write_all(&buf[..take])?; + } + Ok(()) + } + + #[inline] + fn is_truncated(&self) -> bool { + self.truncated + } + } + impl core::fmt::Display for ZigFormatter<'_, '_> { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { let formatter: &mut Formatter<'_> = self @@ -3268,6 +3318,12 @@ pub mod formatter { if self.failed { return Ok(false); } + if writer_.is_truncated() { + // A byte-budgeted sink hit its cap; stop walking the value + // graph instead of rendering into a discarding writer. + self.failed = true; + return Ok(false); + } if self.global_this.has_exception() { return Err(jsc::JsError::Thrown); } @@ -5702,9 +5758,22 @@ pub mod formatter { let one = [value]; self.remaining_values = bun_ptr::RawSlice::new(&one); let global = self.global_this; + // Honor `max_output_bytes`: cap the rendered output and finish + // with a truncation marker when the budget is hit. + let mut sink = TruncatingWriter { + inner: &mut *writer, + remaining: self.max_output_bytes, + truncated: false, + }; let result = Tag::get(value, global) - .and_then(|tag| self.format::(tag, writer, value, global)); + .and_then(|tag| self.format::(tag, &mut sink, value, global)); + let truncated = sink.truncated; + drop(sink); self.remaining_values = bun_ptr::RawSlice::EMPTY; + if truncated && result.is_ok() { + self.failed = false; + let _ = writer.write_all(b"... [value truncated]"); + } result } } diff --git a/src/jsc/JSGlobalObject.rs b/src/jsc/JSGlobalObject.rs index 06966fbabd17..7d4727144aa7 100644 --- a/src/jsc/JSGlobalObject.rs +++ b/src/jsc/JSGlobalObject.rs @@ -839,7 +839,17 @@ impl JSGlobalObject { pub fn throw(&self, args: Arguments<'_>) -> JsError { let instance = self.create_error_instance(args); if instance.is_empty() { - debug_assert!(self.has_exception()); + if !self.has_exception() { + // Message creation can fail without leaving an exception + // pending (e.g. the rendered message exceeds the maximum JS + // string length). Returning `Thrown` with no pending + // exception would silently swallow the throw, so throw a + // fallback instead. + return self.throw_value( + ZigString::static_("error message is too long to display") + .to_error_instance(self), + ); + } return JsError::Thrown; } self.throw_value(instance) diff --git a/src/runtime/test_runner/ScopeFunctions.rs b/src/runtime/test_runner/ScopeFunctions.rs index 5f8b6607738d..943d99371c02 100644 --- a/src/runtime/test_runner/ScopeFunctions.rs +++ b/src/runtime/test_runner/ScopeFunctions.rs @@ -141,6 +141,7 @@ impl ScopeFunctions { let [array] = frame.arguments_as_array::<1>(); if array.is_undefined_or_null() || !array.is_array() { let mut formatter = bun_jsc::ConsoleObject::Formatter::new(global); + formatter.max_output_bytes = crate::test_runner::expect::MAX_MATCHER_OUTPUT_BYTES; return Err(global.throw(format_args!("Expected array, got {}", array.to_fmt(&mut formatter)))); } @@ -190,6 +191,7 @@ fn call_as_function(global: &JSGlobalObject, frame: &CallFrame) -> JsResultpropertyMatchers", false); let mut formatter = ConsoleObject::Formatter::new(global_this); + formatter.max_output_bytes = MAX_MATCHER_OUTPUT_BYTES; return throw!( self, global_this, signature, "\n\nExpected propertyMatchers to match properties from received object\n\nReceived: {}\n", @@ -1188,6 +1189,7 @@ impl Expect { if value.jest_snapshot_pretty_format(pretty_value, global_this).is_err() { let mut formatter = ConsoleObject::Formatter::new(global_this); + formatter.max_output_bytes = MAX_MATCHER_OUTPUT_BYTES; return Err(global_this.throw(format_args!( "Failed to pretty format value: {}", value.to_fmt(&mut formatter), @@ -1255,6 +1257,7 @@ impl Expect { } _ => { let mut formatter = ConsoleObject::Formatter::new(global_this); + formatter.max_output_bytes = MAX_MATCHER_OUTPUT_BYTES; global_this.throw(format_args!("Failed to snapshot value: {}", value.to_fmt(&mut formatter))) } }); diff --git a/src/runtime/test_runner/mod.rs b/src/runtime/test_runner/mod.rs index aa462e22e727..8016419601db 100644 --- a/src/runtime/test_runner/mod.rs +++ b/src/runtime/test_runner/mod.rs @@ -278,6 +278,12 @@ pub mod expect { #[derive(Copy, Clone, PartialEq, Eq)] pub enum BigIntCompare { LessThan, Equal, GreaterThan, Undefined } + /// Byte cap for each value rendered into a matcher failure message. + /// Wide object graphs (e.g. a DOM tree) can expand to gigabytes, and past + /// `WTF::String::MaxLength` the assertion error cannot be created at all, + /// losing the throw (#37310). + pub(crate) const MAX_MATCHER_OUTPUT_BYTES: usize = 1024 * 1024; + /// `super::make_formatter(global_this)` /// is the universal matcher pattern; `Formatter` has no `Default` (it /// borrows `global_this`), so provide the constructor every matcher @@ -286,6 +292,7 @@ pub mod expect { pub(crate) fn make_formatter(global: &JSGlobalObject) -> Formatter<'_> { let mut f = Formatter::new(global); f.quote_strings = true; + f.max_output_bytes = MAX_MATCHER_OUTPUT_BYTES; f } @@ -420,13 +427,18 @@ pub mod expect { /// Builder-style `.with_quote_strings(bool)` shim — `bun_jsc::Formatter` /// exposes `quote_strings` as a public field, not a chained setter. A /// handful of matcher modules write - /// `Formatter::new(g).with_quote_strings(true)`. + /// `Formatter::new(g).with_quote_strings(true)`; it is the other spelling + /// of [`make_formatter`], so it also applies the matcher output cap. pub trait FormatterTestExt: Sized { fn with_quote_strings(self, b: bool) -> Self; } impl<'a> FormatterTestExt for Formatter<'a> { #[inline] - fn with_quote_strings(mut self, b: bool) -> Self { self.quote_strings = b; self } + fn with_quote_strings(mut self, b: bool) -> Self { + self.quote_strings = b; + self.max_output_bytes = MAX_MATCHER_OUTPUT_BYTES; + self + } } // ── matcher modules ─────────────────────────────────────────────── diff --git a/test/js/bun/test/expect-failure-message-truncation.test.ts b/test/js/bun/test/expect-failure-message-truncation.test.ts new file mode 100644 index 000000000000..a85c0f1b0967 --- /dev/null +++ b/test/js/bun/test/expect-failure-message-truncation.test.ts @@ -0,0 +1,88 @@ +import { expect, test } from "bun:test"; + +// https://github.com/oven-sh/bun/issues/37310 +// Rendering the received value into a matcher failure message used to be +// unbounded: a wide object graph (e.g. a happy-dom tree) expanded to +// gigabytes, and past JSC's maximum string length the assertion error could +// not be created at all, so the failed assertion returned without throwing. + +function tree(depth: number, fanout: number): any { + const node: any = { tag: "div", className: `level-${depth}`, children: [] }; + if (depth > 0) { + for (let i = 0; i < fanout; i++) node.children.push(tree(depth - 1, fanout)); + } + return node; +} + +function messageOf(fn: () => void): string { + let err: Error | undefined; + try { + fn(); + } catch (e: any) { + err = e; + } + expect(err).toBeDefined(); + return err!.message; +} + +test("failure message for a huge object graph is truncated and still throws", () => { + const root = tree(4, 14); // ~41k nodes, renders several MB untruncated + const message = messageOf(() => expect(root).toBeNull()); + expect(message.length).toBeLessThan(2 * 1024 * 1024); + expect(message).toContain("[value truncated]"); +}); + +test("failure message for a huge string is truncated and still throws", () => { + // Strings render through a different path than object properties; this + // used to escape the whole string into the message. + const s = Buffer.alloc(2_000_000, "x").toString(); + const message = messageOf(() => expect(s).toBeNull()); + expect(message.length).toBeLessThan(2 * 1024 * 1024); + expect(message).toContain("[value truncated]"); +}); + +test("failure message for a huge Map is truncated and still throws", () => { + const big = Buffer.alloc(1024, "v").toString(); + const m = new Map(); + for (let i = 0; i < 2_000; i++) m.set("key" + i, big); // ~2MB rendered untruncated + const message = messageOf(() => expect(m).toBeNull()); + expect(message.length).toBeLessThan(2 * 1024 * 1024); + expect(message).toContain("[value truncated]"); +}); + +test("toThrow failure message for a huge thrown value is truncated", () => { + // toThrow builds its formatter through a different helper than toBeNull; + // both must apply the cap. + const root = tree(4, 14); + const message = messageOf(() => + expect(() => { + throw root; + }).toThrow("nope"), + ); + expect(message.length).toBeLessThan(4 * 1024 * 1024); + expect(message).toContain("[value truncated]"); +}); + +test("a getter throwing after the cap is hit does not replace the assertion error", () => { + // Element 0 exhausts the byte budget; element 1's tag classification then + // invokes a throwing getter. The pending exception must not surface in + // place of the assertion error. + const bad = {}; + Object.defineProperty(bad, "$$typeof", { + get() { + throw new Error("boom"); + }, + }); + const arr = [Buffer.alloc(2_000_000, "x").toString(), bad]; + const message = messageOf(() => expect(arr).toBeNull()); + expect(message).not.toBe("boom"); + // The signature may carry ANSI codes in CI, so match a contiguous token. + expect(message).toContain("toBeNull"); + expect(message.length).toBeLessThan(2 * 1024 * 1024); +}); + +test("failure message for a small value is not truncated", () => { + const message = messageOf(() => expect({ a: 1, b: [2, 3] }).toBeNull()); + expect(message).not.toContain("[value truncated]"); + expect(message).toContain("a: 1"); +}); From e41fa6a6b91f72d7e147d397d88354b6dc062957 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 12 Sep 2026 20:19:47 +0000 Subject: [PATCH 2/2] jsc: build the too-long-message fallback error from bun_core::String main no longer has ZigString in JSGlobalObject.rs. --- src/jsc/JSGlobalObject.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/jsc/JSGlobalObject.rs b/src/jsc/JSGlobalObject.rs index 9e21f6625e84..4cb8ae42b738 100644 --- a/src/jsc/JSGlobalObject.rs +++ b/src/jsc/JSGlobalObject.rs @@ -830,7 +830,7 @@ impl JSGlobalObject { // exception would silently swallow the throw, so throw a // fallback instead. return self.throw_value( - ZigString::static_("error message is too long to display") + BunString::static_("error message is too long to display") .to_error_instance(self), ); }