From b71539e095ed23b4a66f061bdbc0d0b1655e8043 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 21 Jul 2026 05:22:20 +0000 Subject: [PATCH 01/15] jsc: seat StackCheck on the uncaught-exception printer's Formatter Throwing or rejecting a deeply nested non-Error value (e.g. a 50k-deep array) SIGSEGVed the process instead of printing the error and exiting 1. Formatter::new() leaves stack_check at StackCheck::default(), whose cached_stack_end is 0 so is_safe_to_recurse() always passes. format2 / Bun.inspect overwrite it with StackCheck::init(), but print_exception and the non-Exception branch of the jsc_hooks print_exception hook did not, so the print_as recursion guard never fired and print_array walked the value until the native stack overflowed. console.log of the same value correctly throws RangeError because its formatter is seated; the uncaught-exception printer is now at least as safe. --- src/jsc/VirtualMachine.rs | 2 ++ src/runtime/jsc_hooks.rs | 1 + test/js/bun/util/reportError.test.ts | 29 +++++++++++++++++++++++++++- 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 962044c4b021..e71774a1060a 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -4919,6 +4919,7 @@ impl VirtualMachine { allow_side_effects: bool, ) { let mut formatter = crate::console_object::Formatter::new(self.global()); + formatter.stack_check = bun_core::StackCheck::init(); let colors = bun_core::Output::enable_ansi_colors_stderr(); self.print_errorlike_object( exception.value(), @@ -6009,6 +6010,7 @@ impl VirtualMachine { allow_ansi_color: bool, ) -> crate::CrateResult<()> { let mut default_formatter = crate::console_object::Formatter::new(self.global()); + default_formatter.stack_check = bun_core::StackCheck::init(); let f = formatter.unwrap_or(&mut default_formatter); self.print_error_instance_body( zig_exception, diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index 5eb8bfa11816..4cccb0b993e7 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -1263,6 +1263,7 @@ fn print_exception( vm_ref.print_exception(exception, exception_list, writer, true); } else { let mut formatter = bun_jsc::console_object::Formatter::new(global); + formatter.stack_check = bun_core::StackCheck::init(); // `Formatter::new` already // defaults `error_display_level` to `Full` (ConsoleObject.rs:1176). let colors = bun_core::Output::enable_ansi_colors_stderr(); diff --git a/test/js/bun/util/reportError.test.ts b/test/js/bun/util/reportError.test.ts index 3075af55f04f..3ab435e2c47a 100644 --- a/test/js/bun/util/reportError.test.ts +++ b/test/js/bun/util/reportError.test.ts @@ -1,5 +1,5 @@ import { spawnSync } from "bun"; -import { expect, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe } from "harness"; import { join } from "path"; @@ -122,3 +122,30 @@ test("native error printer handles lone surrogates in message and stack frame na expect(proc.signalCode).toBeNull(); expect(exitCode).toBe(1); }); + +// The uncaught-exception printer constructs its own Formatter. Before this fix +// it left `stack_check` at the always-passes default, so formatting a deeply +// nested non-Error value recursed until the native stack overflowed (SIGSEGV). +describe.each(["throw a;", "Promise.reject(a);"])("%s", stmt => { + test.concurrent("native error printer survives a deeply nested thrown value", async () => { + const src = ` + let a = []; + for (let i = 0; i < 50000; i++) a = [a]; + try { console.log(a); } catch (e) { console.error("control:" + e.constructor.name); } + ${stmt} + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", src], + env: { ...bunEnv, NO_COLOR: "1" }, + stdout: "ignore", + stderr: "pipe", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + + // console.log of the same value is guarded (throws RangeError); the + // uncaught-exception printer must be at least as safe. + expect(stderr).toContain("control:RangeError"); + expect(proc.signalCode).toBeNull(); + expect(exitCode).toBe(1); + }); +}); From 9710a9d38ea9e97cdf1808465eb9e5d1ffaf552c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 21 Jul 2026 05:45:01 +0000 Subject: [PATCH 02/15] Move StackCheck::init() into Formatter::new() instead of per-call-site Addresses review: the invariant belongs in the constructor. Formatter::new() takes a &JSGlobalObject so it is always on a JS thread where Bun__StackCheck__getMaxStack() returns the configured bound (and degrades to 0 = always-passes on an unconfigured thread, same as the old default). Drops the three per-call-site seatings added in the previous commit and the four pre-existing now-redundant ones in ConsoleObject.rs. --- src/jsc/ConsoleObject.rs | 11 +++-------- src/jsc/VirtualMachine.rs | 2 -- src/runtime/jsc_hooks.rs | 1 - 3 files changed, 3 insertions(+), 11 deletions(-) diff --git a/src/jsc/ConsoleObject.rs b/src/jsc/ConsoleObject.rs index 2dbefc463781..bce383ef270d 100644 --- a/src/jsc/ConsoleObject.rs +++ b/src/jsc/ConsoleObject.rs @@ -667,7 +667,6 @@ impl<'a> TablePrinter<'a> { f.single_line = true; f.max_depth = 5; f.can_throw_stack_overflow = true; - f.stack_check = StackCheck::init(); f }, values_col_width: None, @@ -1338,7 +1337,6 @@ pub fn format2( fmt.max_depth = options.max_depth; fmt.single_line = options.single_line; fmt.indent = u32::from(options.default_indent); - fmt.stack_check = StackCheck::init(); fmt.can_throw_stack_overflow = true; fmt.error_display_level = options.error_display_level; let tag = formatter::Tag::get(vals[0], global)?; @@ -1401,7 +1399,6 @@ pub fn format2( fmt.max_depth = options.max_depth; fmt.single_line = options.single_line; fmt.indent = u32::from(options.default_indent); - fmt.stack_check = StackCheck::init(); fmt.can_throw_stack_overflow = true; fmt.error_display_level = options.error_display_level; let mut tag: formatter::TagResult; @@ -1631,10 +1628,9 @@ pub mod formatter { ordered_properties: false, custom_formatted_object: CustomFormattedObject::default(), disable_inspect_custom: false, - // `StackCheck::default()` has `cached_stack_end = 0` ⇒ the - // check always passes; callers that want a real bound - // overwrite with `StackCheck::init()` explicitly. - stack_check: StackCheck::default(), + // `print_as_prelude` is the only recursion guard on the array + // path, so seat a live bound here (one thread-local read). + stack_check: StackCheck::init(), can_throw_stack_overflow: false, error_display_level: ErrorDisplayLevel::Full, format_buffer_as_text: false, @@ -5848,7 +5844,6 @@ pub(crate) extern "C" fn Bun__ConsoleObject__timeLog( fmt.max_depth = bun_options_types::context::try_get() .and_then(|ctx| ctx.runtime_options.console_depth) .unwrap_or(DEFAULT_CONSOLE_LOG_DEPTH); - fmt.stack_check = StackCheck::init(); fmt.can_throw_stack_overflow = true; let console = vm_console(global); // SAFETY: see [`vm_console`] — points at the live boxed `ConsoleObject` for diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index e71774a1060a..962044c4b021 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -4919,7 +4919,6 @@ impl VirtualMachine { allow_side_effects: bool, ) { let mut formatter = crate::console_object::Formatter::new(self.global()); - formatter.stack_check = bun_core::StackCheck::init(); let colors = bun_core::Output::enable_ansi_colors_stderr(); self.print_errorlike_object( exception.value(), @@ -6010,7 +6009,6 @@ impl VirtualMachine { allow_ansi_color: bool, ) -> crate::CrateResult<()> { let mut default_formatter = crate::console_object::Formatter::new(self.global()); - default_formatter.stack_check = bun_core::StackCheck::init(); let f = formatter.unwrap_or(&mut default_formatter); self.print_error_instance_body( zig_exception, diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index 4cccb0b993e7..5eb8bfa11816 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -1263,7 +1263,6 @@ fn print_exception( vm_ref.print_exception(exception, exception_list, writer, true); } else { let mut formatter = bun_jsc::console_object::Formatter::new(global); - formatter.stack_check = bun_core::StackCheck::init(); // `Formatter::new` already // defaults `error_display_level` to `Full` (ConsoleObject.rs:1176). let colors = bun_core::Output::enable_ansi_colors_stderr(); From 4b3439a5e69d16b02e3febe2d5db3ad521c06862 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 21 Jul 2026 05:52:40 +0000 Subject: [PATCH 03/15] test: assert uncaught printer completes (Bun version trailer), drop history comment --- test/js/bun/util/reportError.test.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/test/js/bun/util/reportError.test.ts b/test/js/bun/util/reportError.test.ts index 3ab435e2c47a..d5eb5da968dd 100644 --- a/test/js/bun/util/reportError.test.ts +++ b/test/js/bun/util/reportError.test.ts @@ -123,9 +123,6 @@ test("native error printer handles lone surrogates in message and stack frame na expect(exitCode).toBe(1); }); -// The uncaught-exception printer constructs its own Formatter. Before this fix -// it left `stack_check` at the always-passes default, so formatting a deeply -// nested non-Error value recursed until the native stack overflowed (SIGSEGV). describe.each(["throw a;", "Promise.reject(a);"])("%s", stmt => { test.concurrent("native error printer survives a deeply nested thrown value", async () => { const src = ` @@ -141,11 +138,11 @@ describe.each(["throw a;", "Promise.reject(a);"])("%s", stmt => { stderr: "pipe", }); const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + const lines = stderr.trimEnd().split("\n"); - // console.log of the same value is guarded (throws RangeError); the - // uncaught-exception printer must be at least as safe. - expect(stderr).toContain("control:RangeError"); + expect(lines[0]).toBe("control:RangeError"); expect(proc.signalCode).toBeNull(); + expect(lines.at(-1)).toMatch(/^Bun v\S+ \(.+\)$/); expect(exitCode).toBe(1); }); }); From 7b3da13c5c267dadd407bf462723a2e0ba8ad587 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 21 Jul 2026 06:17:13 +0000 Subject: [PATCH 04/15] Extend stack guard to pretty_format::Formatter; ignore stderr in test pretty_format::Formatter (the test runner's snapshot/diff formatter) is a separate struct from console_object::Formatter and had no recursion bound, so expect(50k-deep-array).toEqual([]) / .toMatchSnapshot() SIGSEGVed via the same mechanism. Add a stack_check field seated in new() and a is_safe_to_recurse() guard at the top of print_as, mirroring ConsoleObject. Test: use stderr: "ignore" and assert only signalCode/exitCode. On release builds the printer recurses ~20k-49k levels (Linux 8MB stack, 128KB guard) writing ~K^2 bytes before the guard fires; piping that into .text() crashed the Windows aarch64 lane in the previous CI run. Also drop the now-redundant stack_check.update() in JSValue::to_fmt (its only caller) and the stale "seated by the caller" comment in VirtualMachine.rs. --- src/jsc/JSValue.rs | 1 - src/jsc/VirtualMachine.rs | 3 +-- src/runtime/test_runner/pretty_format.rs | 8 ++++++- .../bun/test/pretty-format-overflow.test.ts | 23 +++++++++++++++++++ test/js/bun/util/reportError.test.ts | 13 ++++------- 5 files changed, 35 insertions(+), 13 deletions(-) diff --git a/src/jsc/JSValue.rs b/src/jsc/JSValue.rs index 09e31b639806..522eda7fb6a4 100644 --- a/src/jsc/JSValue.rs +++ b/src/jsc/JSValue.rs @@ -2633,7 +2633,6 @@ impl JSValue { formatter: &'a mut crate::console_object::Formatter<'b>, ) -> crate::console_object::formatter::ZigFormatter<'a, 'b> { formatter.remaining_values = bun_ptr::RawSlice::EMPTY; - formatter.stack_check.update(); crate::console_object::formatter::ZigFormatter::new(formatter, self) } diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 962044c4b021..c18e76baebc3 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -6045,8 +6045,7 @@ impl VirtualMachine { // re-check here with an extra `MAX_PATH_BYTES * 3` of slack on Windows // to cover the transpiler's nested path buffers — same parity-level // protection the Object path gets from C++ `forEachProperty`'s - // `vm.isSafeToRecurse()`. The formatter's `stack_check` was seated by - // the caller (`format2` / `Bun.inspect`). + // `vm.isSafeToRecurse()`. let extra_headroom: usize = if cfg!(windows) { // 3× PathBuffer ≈ 288 KB — empirically enough for the // `remap_zig_exception` → `transpile_source_code` chain on the diff --git a/src/runtime/test_runner/pretty_format.rs b/src/runtime/test_runner/pretty_format.rs index ea6af49be92a..7e9958ada928 100644 --- a/src/runtime/test_runner/pretty_format.rs +++ b/src/runtime/test_runner/pretty_format.rs @@ -2,7 +2,7 @@ use crate::test_runner::expect::JSValueTestExt; use core::ffi::c_void; use bun_collections::HashMap; -use bun_core::fmt as bun_fmt; +use bun_core::{fmt as bun_fmt, StackCheck}; use bun_jsc::{ self as jsc, ComptimeStringMapExt as _, JSGlobalObject, JSObject, JSPropertyIterator, JSType, JSValue, JsError, JsResult, VM, @@ -318,6 +318,7 @@ pub struct Formatter<'a> { pub(crate) failed: bool, pub(crate) estimated_line_length: usize, pub(crate) always_newline_scope: bool, + pub(crate) stack_check: StackCheck, } impl<'a> Formatter<'a> { @@ -332,6 +333,7 @@ impl<'a> Formatter<'a> { failed: false, estimated_line_length: 0, always_newline_scope: false, + stack_check: StackCheck::init(), } } @@ -1041,6 +1043,10 @@ impl<'a> Formatter<'a> { if self.failed { return Ok(()); } + if !self.stack_check.is_safe_to_recurse() { + self.failed = true; + return Ok(()); + } // reshaped for borrowck — `WrappedWriter` borrows both writer_ // and &mut self.estimated_line_length; we use a local wrapper and sync // `failed` at scope exit. estimated_line_length is unused by WrappedWriter diff --git a/test/js/bun/test/pretty-format-overflow.test.ts b/test/js/bun/test/pretty-format-overflow.test.ts index 462743825c65..c13a2ddf9ea9 100644 --- a/test/js/bun/test/pretty-format-overflow.test.ts +++ b/test/js/bun/test/pretty-format-overflow.test.ts @@ -50,4 +50,27 @@ test("deep nesting", () => { // Verify it actually formatted and showed the diff (not just crashed) expect(stderr).toContain("expect(received).toEqual(expected)"); }, 30000); + + test.concurrent("deeply nested array via toEqual", async () => { + await using dir = tempDir("pretty-format-stack", { + "deep.test.ts": ` + import { test, expect } from "bun:test"; + test("deep", () => { + let a: unknown = []; + for (let i = 0; i < 50000; i++) a = [a]; + expect(a).toEqual([]); + }); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "deep.test.ts"], + env: bunEnv, + cwd: dir, + stdout: "ignore", + stderr: "ignore", + }); + await proc.exited; + expect(proc.signalCode).toBeNull(); + expect(proc.exitCode).toBe(1); + }); }); diff --git a/test/js/bun/util/reportError.test.ts b/test/js/bun/util/reportError.test.ts index d5eb5da968dd..a0bd182ebc15 100644 --- a/test/js/bun/util/reportError.test.ts +++ b/test/js/bun/util/reportError.test.ts @@ -128,21 +128,16 @@ describe.each(["throw a;", "Promise.reject(a);"])("%s", stmt => { const src = ` let a = []; for (let i = 0; i < 50000; i++) a = [a]; - try { console.log(a); } catch (e) { console.error("control:" + e.constructor.name); } ${stmt} `; await using proc = Bun.spawn({ cmd: [bunExe(), "-e", src], - env: { ...bunEnv, NO_COLOR: "1" }, + env: bunEnv, stdout: "ignore", - stderr: "pipe", + stderr: "ignore", }); - const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); - const lines = stderr.trimEnd().split("\n"); - - expect(lines[0]).toBe("control:RangeError"); + await proc.exited; expect(proc.signalCode).toBeNull(); - expect(lines.at(-1)).toMatch(/^Bun v\S+ \(.+\)$/); - expect(exitCode).toBe(1); + expect(proc.exitCode).toBe(1); }); }); From a5e78977c8270f4cecc46ed1dbdb8ed21cf28aa0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 21 Jul 2026 06:37:51 +0000 Subject: [PATCH 05/15] print_as_prelude: check stack before the !can_circ early-return Tag::Proxy is not in can_have_circular_references(), so the early return at !can_circ skipped the is_safe_to_recurse() check and a 50k-deep Proxy chain still SIGSEGVed via print_proxy -> self.format recursion. Stack safety is orthogonal to circular-ref bookkeeping; move the check above the early return so it covers every recursive tag. --- src/jsc/ConsoleObject.rs | 9 +++------ test/js/bun/util/reportError.test.ts | 10 +++++++--- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/jsc/ConsoleObject.rs b/src/jsc/ConsoleObject.rs index bce383ef270d..19c942f34663 100644 --- a/src/jsc/ConsoleObject.rs +++ b/src/jsc/ConsoleObject.rs @@ -1628,8 +1628,6 @@ pub mod formatter { ordered_properties: false, custom_formatted_object: CustomFormattedObject::default(), disable_inspect_custom: false, - // `print_as_prelude` is the only recursion guard on the array - // path, so seat a live bound here (one thread-local read). stack_check: StackCheck::init(), can_throw_stack_overflow: false, error_display_level: ErrorDisplayLevel::Full, @@ -3215,10 +3213,6 @@ pub mod formatter { if self.global_this.has_exception() { return Err(jsc::JsError::Thrown); } - if !can_circ { - return Ok(true); - } - if !self.stack_check.is_safe_to_recurse() { self.failed = true; if self.can_throw_stack_overflow { @@ -3226,6 +3220,9 @@ pub mod formatter { } return Ok(false); } + if !can_circ { + return Ok(true); + } if self.map_node.is_none() { let mut node = core::ptr::NonNull::new(visited::Pool::get_node()) diff --git a/test/js/bun/util/reportError.test.ts b/test/js/bun/util/reportError.test.ts index a0bd182ebc15..178b37eccdea 100644 --- a/test/js/bun/util/reportError.test.ts +++ b/test/js/bun/util/reportError.test.ts @@ -123,11 +123,15 @@ test("native error printer handles lone surrogates in message and stack frame na expect(exitCode).toBe(1); }); -describe.each(["throw a;", "Promise.reject(a);"])("%s", stmt => { +describe.each([ + ["array", "[a]", "throw a;"], + ["array", "[a]", "Promise.reject(a);"], + ["Proxy", "new Proxy(a,{})", "throw a;"], +])("%s / %s", (_, wrap, stmt) => { test.concurrent("native error printer survives a deeply nested thrown value", async () => { const src = ` - let a = []; - for (let i = 0; i < 50000; i++) a = [a]; + let a = {}; + for (let i = 0; i < 50000; i++) a = ${wrap}; ${stmt} `; await using proc = Bun.spawn({ From 0f832d9a37ba42c55059314299aee500f21b612a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 21 Jul 2026 07:57:59 +0000 Subject: [PATCH 06/15] test: include stmt in describe.each title so variants are distinguishable --- test/js/bun/util/reportError.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/js/bun/util/reportError.test.ts b/test/js/bun/util/reportError.test.ts index 178b37eccdea..a220d5cdde05 100644 --- a/test/js/bun/util/reportError.test.ts +++ b/test/js/bun/util/reportError.test.ts @@ -127,7 +127,7 @@ describe.each([ ["array", "[a]", "throw a;"], ["array", "[a]", "Promise.reject(a);"], ["Proxy", "new Proxy(a,{})", "throw a;"], -])("%s / %s", (_, wrap, stmt) => { +])("%s / %s / %s", (_, wrap, stmt) => { test.concurrent("native error printer survives a deeply nested thrown value", async () => { const src = ` let a = {}; From be64e5bb90027cfd2894fc9e2d613fbb0ae72c44 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:43:19 +0000 Subject: [PATCH 07/15] Drop now-unused StackCheck::update and its last caller in format_value format_value's only callers construct via make_formatter -> Formatter::new(), which now seats the bound, so the re-seat was redundant; with it gone, StackCheck::update has no callers. --- src/bun_core/util.rs | 6 +----- src/jsc/ConsoleObject.rs | 1 - 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/src/bun_core/util.rs b/src/bun_core/util.rs index 3230802279fc..0b1768101ef5 100644 --- a/src/bun_core/util.rs +++ b/src/bun_core/util.rs @@ -2191,7 +2191,7 @@ unsafe extern "C" { } impl Default for StackCheck { /// `cached_stack_end` defaults to `0`, so - /// `is_safe_to_recurse()` always reports true until `init`/`update`. + /// `is_safe_to_recurse()` always reports true; use `init()` for a real bound. #[inline] fn default() -> Self { Self { @@ -2210,10 +2210,6 @@ impl StackCheck { cached_stack_end: Bun__StackCheck__getMaxStack() as usize, } } - #[inline] - pub fn update(&mut self) { - self.cached_stack_end = Bun__StackCheck__getMaxStack() as usize; - } /// Is there enough stack space to safely recurse? /// Threshold: `> 256K` on Windows, `> 128K` elsewhere. #[inline] diff --git a/src/jsc/ConsoleObject.rs b/src/jsc/ConsoleObject.rs index 19c942f34663..60e16ac6125b 100644 --- a/src/jsc/ConsoleObject.rs +++ b/src/jsc/ConsoleObject.rs @@ -5617,7 +5617,6 @@ pub mod formatter { value: JSValue, writer: &mut dyn bun_io::Write, ) -> JsResult<()> { - self.stack_check.update(); let one = [value]; self.remaining_values = bun_ptr::RawSlice::new(&one); let global = self.global_this; From 143d380fdb1bbb718169263a56386bc57004960f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:15:39 +0000 Subject: [PATCH 08/15] Fold in the pretty_format test matrix from #34885 and make snapshot serialization throw on stack exhaustion pretty_format::Formatter gains the console formatter's can_throw_stack_overflow switch, carried in from FormatOptions. The diff formatter keeps truncating; snapshot serialization (toMatchSnapshot, toMatchInlineSnapshot) opts in, so a value too deep to serialize fails the test with the stack overflow RangeError instead of a truncated serialization being written to the .snap file or the test file and compared on later runs. The snapshot matcher reports that exception directly: the "Failed to pretty format value" wrapper cannot render the value while an exception is pending, so it always came out empty. Tests: failing toEqual on deep array / object / React element chains assert that a diff is still printed; toMatchSnapshot and toMatchInlineSnapshot on a deep object assert the RangeError and that nothing was written; the uncaught printer matrix gains a deep error.cause chain, which recurses through print_error_instance_js and its is_safe_to_recurse_with_extra check. --- src/jsc/ConsoleObject.rs | 5 + src/runtime/test_runner/diff_format.rs | 1 + src/runtime/test_runner/mod.rs | 1 + src/runtime/test_runner/pretty_format.rs | 17 +++- .../bun/test/pretty-format-overflow.test.ts | 91 +++++++++++++++---- test/js/bun/util/reportError.test.ts | 31 ++++--- 6 files changed, 117 insertions(+), 29 deletions(-) diff --git a/src/jsc/ConsoleObject.rs b/src/jsc/ConsoleObject.rs index 60e16ac6125b..c87c1bc134a8 100644 --- a/src/jsc/ConsoleObject.rs +++ b/src/jsc/ConsoleObject.rs @@ -1598,7 +1598,12 @@ pub mod formatter { pub ordered_properties: bool, pub(crate) custom_formatted_object: CustomFormattedObject, pub(crate) disable_inspect_custom: bool, + /// Seated by `new()`. `StackCheck::default()` has no stack bound and + /// would let a deep enough value recurse until the native stack + /// overflows. pub(crate) stack_check: StackCheck, + /// On an exhausted stack, throw a `RangeError` instead of silently + /// truncating the output. pub(crate) can_throw_stack_overflow: bool, pub(crate) error_display_level: ErrorDisplayLevel, /// If `ArrayBuffer`-like objects contain ASCII text, the buffer is diff --git a/src/runtime/test_runner/diff_format.rs b/src/runtime/test_runner/diff_format.rs index 60080b79e0e3..eb23dc3bfed3 100644 --- a/src/runtime/test_runner/diff_format.rs +++ b/src/runtime/test_runner/diff_format.rs @@ -28,6 +28,7 @@ impl<'a> DiffFormatter<'a> { add_newline: false, flush: false, quote_strings: true, + can_throw_stack_overflow: false, }; let mut received_buf: Vec = Vec::new(); JestPrettyFormat::format( diff --git a/src/runtime/test_runner/mod.rs b/src/runtime/test_runner/mod.rs index 5cb29c73f6d6..41a3a03c29dd 100644 --- a/src/runtime/test_runner/mod.rs +++ b/src/runtime/test_runner/mod.rs @@ -226,6 +226,7 @@ pub mod expect { add_newline: false, flush: false, quote_strings: true, + can_throw_stack_overflow: true, }; JestPrettyFormat::format( MessageLevel::Debug, diff --git a/src/runtime/test_runner/pretty_format.rs b/src/runtime/test_runner/pretty_format.rs index 7e9958ada928..bc269c3c2429 100644 --- a/src/runtime/test_runner/pretty_format.rs +++ b/src/runtime/test_runner/pretty_format.rs @@ -101,6 +101,8 @@ pub struct FormatOptions { pub(crate) add_newline: bool, pub flush: bool, pub(crate) quote_strings: bool, + /// Seats `Formatter::can_throw_stack_overflow`. + pub(crate) can_throw_stack_overflow: bool, } impl JestPrettyFormat { @@ -146,6 +148,7 @@ impl JestPrettyFormat { if len == 1 { fmt = Formatter::new(global); fmt.quote_strings = options.quote_strings; + fmt.can_throw_stack_overflow = options.can_throw_stack_overflow; let tag = Tag::get(vals[0], global)?; if tag.tag == Tag::String { @@ -192,6 +195,7 @@ impl JestPrettyFormat { fmt = Formatter::new(global); fmt.remaining_values = &vals[..len][1..]; fmt.quote_strings = options.quote_strings; + fmt.can_throw_stack_overflow = options.can_throw_stack_overflow; let result: JsResult<()> = (|| { let mut this_value: JSValue = vals[0]; @@ -318,7 +322,12 @@ pub struct Formatter<'a> { pub(crate) failed: bool, pub(crate) estimated_line_length: usize, pub(crate) always_newline_scope: bool, - pub(crate) stack_check: StackCheck, + stack_check: StackCheck, + /// On an exhausted stack, throw a `RangeError` instead of silently + /// truncating the output. Set for snapshot serialization, where a + /// truncated result would be stored as if it were complete; left off for + /// diff output, which is only diagnostic. + can_throw_stack_overflow: bool, } impl<'a> Formatter<'a> { @@ -334,6 +343,7 @@ impl<'a> Formatter<'a> { estimated_line_length: 0, always_newline_scope: false, stack_check: StackCheck::init(), + can_throw_stack_overflow: false, } } @@ -1043,8 +1053,13 @@ impl<'a> Formatter<'a> { if self.failed { return Ok(()); } + // Ahead of the circular-reference bookkeeping below, which only some + // of the tags that recurse into child values take part in. if !self.stack_check.is_safe_to_recurse() { self.failed = true; + if self.can_throw_stack_overflow { + return Err(self.global_this.throw_stack_overflow()); + } return Ok(()); } // reshaped for borrowck — `WrappedWriter` borrows both writer_ diff --git a/test/js/bun/test/pretty-format-overflow.test.ts b/test/js/bun/test/pretty-format-overflow.test.ts index c13a2ddf9ea9..4356c3c382dd 100644 --- a/test/js/bun/test/pretty-format-overflow.test.ts +++ b/test/js/bun/test/pretty-format-overflow.test.ts @@ -4,6 +4,8 @@ import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, tempDir } from "harness"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; describe("pretty_format should handle deeply nested objects without crashing", () => { test("deeply nested object with many properties", async () => { @@ -50,27 +52,82 @@ test("deep nesting", () => { // Verify it actually formatted and showed the diff (not just crashed) expect(stderr).toContain("expect(received).toEqual(expected)"); }, 30000); +}); - test.concurrent("deeply nested array via toEqual", async () => { - await using dir = tempDir("pretty-format-stack", { - "deep.test.ts": ` - import { test, expect } from "bun:test"; - test("deep", () => { - let a: unknown = []; - for (let i = 0; i < 50000; i++) a = [a]; - expect(a).toEqual([]); - }); - `, - }); +// Formatting a value nested deeper than the native stack can hold used to recurse until the +// process died with SIGSEGV, taking the whole `bun test` run down with it. The formatter now stops +// before that point: a failure diff shows what was rendered, and a snapshot refuses to serialize +// the value (a truncated snapshot would be stored as if it were complete). Each case runs in a +// subprocess so a regression fails the test instead of the runner. +describe.concurrent("pretty_format on a value deeper than the native stack", () => { + // Several times deeper than the native stack holds for any of these shapes on any platform + // (a release build gets through about 23k array levels or 5k object levels on Linux's 8 MB main + // thread stack; macOS and Windows builds get an 18 MB stack, debug builds have far larger frames). + const depth = 100_000; + const shapes = { + array: `let v = []; for (let i = 0; i < ${depth}; i++) v = [v];`, + object: `let v = {}; for (let i = 0; i < ${depth}; i++) v = { k: v };`, + // React elements are formatted by their own arm (Tag::JSX), not the object one. + "React element": ` + let v = "x"; + for (let i = 0; i < ${depth}; i++) { + v = { $$typeof: Symbol.for("react.element"), type: "div", key: null, ref: null, props: { children: v } }; + }`, + }; + + async function runDeepTest(body: string) { + const source = ` + import { test, expect } from "bun:test"; + test("deep", () => { + ${body} + }); + `; + using dir = tempDir("pretty-format-deep", { "deep.test.ts": source }); await using proc = Bun.spawn({ cmd: [bunExe(), "test", "deep.test.ts"], - env: bunEnv, - cwd: dir, + // Snapshot writing is disabled under CI=1 (which bunEnv sets); allow it so that "no snapshot + // was written" below is the formatter's doing. + env: { ...bunEnv, CI: "false" }, + cwd: String(dir), stdout: "ignore", - stderr: "ignore", + stderr: "pipe", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + return { + stderr, + exitCode, + snapshotFileWritten: existsSync(join(String(dir), "__snapshots__", "deep.test.ts.snap")), + testFileRewritten: readFileSync(join(String(dir), "deep.test.ts"), "utf8") !== source, + }; + } + + for (const [name, build] of Object.entries(shapes)) { + test(`failing toEqual on a deep ${name} still prints a diff`, async () => { + const { stderr, exitCode } = await runDeepTest(`${build}\nexpect(v).toEqual(1);`); + expect(stderr).toContain("expect(received).toEqual(expected)"); + expect(stderr).toContain("+ Received"); + expect(stderr).toContain("1 fail"); + expect(exitCode).toBe(1); }); - await proc.exited; - expect(proc.signalCode).toBeNull(); - expect(proc.exitCode).toBe(1); + } + + test("toMatchSnapshot on a deep object throws instead of writing a truncated snapshot", async () => { + const { stderr, exitCode, snapshotFileWritten } = await runDeepTest( + `${shapes.object}\nexpect(v).toMatchSnapshot();`, + ); + expect(stderr).toContain("RangeError: Maximum call stack size exceeded"); + expect(stderr).toContain("1 fail"); + expect(snapshotFileWritten).toBe(false); + expect(exitCode).toBe(1); + }); + + test("toMatchInlineSnapshot on a deep object throws instead of writing a truncated snapshot", async () => { + const { stderr, exitCode, testFileRewritten } = await runDeepTest( + `${shapes.object}\nexpect(v).toMatchInlineSnapshot();`, + ); + expect(stderr).toContain("RangeError: Maximum call stack size exceeded"); + expect(stderr).toContain("1 fail"); + expect(testFileRewritten).toBe(false); + expect(exitCode).toBe(1); }); }); diff --git a/test/js/bun/util/reportError.test.ts b/test/js/bun/util/reportError.test.ts index a220d5cdde05..13d65fe0c360 100644 --- a/test/js/bun/util/reportError.test.ts +++ b/test/js/bun/util/reportError.test.ts @@ -123,21 +123,30 @@ test("native error printer handles lone surrogates in message and stack frame na expect(exitCode).toBe(1); }); -describe.each([ - ["array", "[a]", "throw a;"], - ["array", "[a]", "Promise.reject(a);"], - ["Proxy", "new Proxy(a,{})", "throw a;"], -])("%s / %s / %s", (_, wrap, stmt) => { - test.concurrent("native error printer survives a deeply nested thrown value", async () => { - const src = ` - let a = {}; - for (let i = 0; i < 50000; i++) a = ${wrap}; - ${stmt} - `; +// The uncaught exception printer used to recurse into a deeply nested value +// until the native stack overflowed and the process died with SIGSEGV. Each +// shape recurses through a different path: arrays through the value formatter, +// a Proxy through its target (a tag the formatter does not track for circular +// references), error.cause through the error printer itself. +describe("native error printer survives a deeply nested uncaught value", () => { + const cases: [name: string, src: string][] = [ + ["throw array", "let a = []; for (let i = 0; i < 50000; i++) a = [a]; throw a;"], + ["reject with array", "let a = []; for (let i = 0; i < 50000; i++) a = [a]; Promise.reject(a);"], + ["throw Proxy chain", "let a = {}; for (let i = 0; i < 50000; i++) a = new Proxy(a, {}); throw a;"], + [ + "throw error.cause chain", + "let e = new Error('root'); for (let i = 0; i < 10000; i++) e = new Error('wrap', { cause: e }); throw e;", + ], + ]; + + test.concurrent.each(cases)("%s", async (_, src) => { await using proc = Bun.spawn({ cmd: [bunExe(), "-e", src], env: bunEnv, stdout: "ignore", + // Everything that fits on the stack is still printed. For the array that + // is hundreds of megabytes of indentation on a release build, so don't + // pipe it; the assertion is that the printer gave up instead of crashing. stderr: "ignore", }); await proc.exited; From 8e893e542679a940d34747fae9281db5c8ea32e3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:27:20 +0000 Subject: [PATCH 09/15] Trim field and branch comments that restate the code --- src/jsc/ConsoleObject.rs | 5 ----- src/runtime/test_runner/pretty_format.rs | 9 ++------- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/src/jsc/ConsoleObject.rs b/src/jsc/ConsoleObject.rs index c87c1bc134a8..60e16ac6125b 100644 --- a/src/jsc/ConsoleObject.rs +++ b/src/jsc/ConsoleObject.rs @@ -1598,12 +1598,7 @@ pub mod formatter { pub ordered_properties: bool, pub(crate) custom_formatted_object: CustomFormattedObject, pub(crate) disable_inspect_custom: bool, - /// Seated by `new()`. `StackCheck::default()` has no stack bound and - /// would let a deep enough value recurse until the native stack - /// overflows. pub(crate) stack_check: StackCheck, - /// On an exhausted stack, throw a `RangeError` instead of silently - /// truncating the output. pub(crate) can_throw_stack_overflow: bool, pub(crate) error_display_level: ErrorDisplayLevel, /// If `ArrayBuffer`-like objects contain ASCII text, the buffer is diff --git a/src/runtime/test_runner/pretty_format.rs b/src/runtime/test_runner/pretty_format.rs index bc269c3c2429..52633b39c8a1 100644 --- a/src/runtime/test_runner/pretty_format.rs +++ b/src/runtime/test_runner/pretty_format.rs @@ -101,7 +101,6 @@ pub struct FormatOptions { pub(crate) add_newline: bool, pub flush: bool, pub(crate) quote_strings: bool, - /// Seats `Formatter::can_throw_stack_overflow`. pub(crate) can_throw_stack_overflow: bool, } @@ -323,10 +322,8 @@ pub struct Formatter<'a> { pub(crate) estimated_line_length: usize, pub(crate) always_newline_scope: bool, stack_check: StackCheck, - /// On an exhausted stack, throw a `RangeError` instead of silently - /// truncating the output. Set for snapshot serialization, where a - /// truncated result would be stored as if it were complete; left off for - /// diff output, which is only diagnostic. + /// Snapshot serialization sets this so a too-deep value fails the test + /// rather than storing a truncated snapshot; diff output leaves it off. can_throw_stack_overflow: bool, } @@ -1053,8 +1050,6 @@ impl<'a> Formatter<'a> { if self.failed { return Ok(()); } - // Ahead of the circular-reference bookkeeping below, which only some - // of the tags that recurse into child values take part in. if !self.stack_check.is_safe_to_recurse() { self.failed = true; if self.can_throw_stack_overflow { From 62ce631ada6f6336c093a7fc612a92c3b3c34cf3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:28:41 +0000 Subject: [PATCH 10/15] Drop remaining field doc comment; rationale lives in the two construction sites --- src/runtime/test_runner/pretty_format.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/runtime/test_runner/pretty_format.rs b/src/runtime/test_runner/pretty_format.rs index 52633b39c8a1..85c61d782156 100644 --- a/src/runtime/test_runner/pretty_format.rs +++ b/src/runtime/test_runner/pretty_format.rs @@ -322,8 +322,6 @@ pub struct Formatter<'a> { pub(crate) estimated_line_length: usize, pub(crate) always_newline_scope: bool, stack_check: StackCheck, - /// Snapshot serialization sets this so a too-deep value fails the test - /// rather than storing a truncated snapshot; diff output leaves it off. can_throw_stack_overflow: bool, } From e7f6acc75f4a7616894c2006d6a779785d3547f6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 18 Aug 2026 04:26:06 +0000 Subject: [PATCH 11/15] pretty_format: throw the stack overflow after the walk unwinds, not at the leaf JSC__JSValue__forEachPropertyOrdered drops an exception thrown inside its callback when further properties follow, so throwing where the limit was hit only reached the snapshot matcher for arrays and single-key objects; for any other object the truncated serialization was written as a snapshot. The guard now just marks the formatter (alongside `failed`, which unwinds the walk) and JestPrettyFormat::format throws once at the end when the caller asked for it, which does not depend on any walk propagating the exception. The snapshot tests use a two-key object so that they cover this. The snapshot matcher change is dropped: #37334 already makes the matchers rethrow the formatter's error, so the tests accept either message. StackCheck's Default impl, the unbounded value this bug came from, has no users left and is removed along with update(). --- src/bun_core/util.rs | 10 ------- src/runtime/test_runner/pretty_format.rs | 29 +++++++++++++------ .../bun/test/pretty-format-overflow.test.ts | 17 +++++++---- 3 files changed, 32 insertions(+), 24 deletions(-) diff --git a/src/bun_core/util.rs b/src/bun_core/util.rs index 0b1768101ef5..b63f1c5556e5 100644 --- a/src/bun_core/util.rs +++ b/src/bun_core/util.rs @@ -2189,16 +2189,6 @@ unsafe extern "C" { #[cfg(windows)] safe fn clock_gettime_monotonic(sec: &mut i64, nsec: &mut i64); } -impl Default for StackCheck { - /// `cached_stack_end` defaults to `0`, so - /// `is_safe_to_recurse()` always reports true; use `init()` for a real bound. - #[inline] - fn default() -> Self { - Self { - cached_stack_end: 0, - } - } -} impl StackCheck { #[inline] pub fn configure_thread() { diff --git a/src/runtime/test_runner/pretty_format.rs b/src/runtime/test_runner/pretty_format.rs index 85c61d782156..943ccd34a9b4 100644 --- a/src/runtime/test_runner/pretty_format.rs +++ b/src/runtime/test_runner/pretty_format.rs @@ -101,6 +101,8 @@ pub struct FormatOptions { pub(crate) add_newline: bool, pub flush: bool, pub(crate) quote_strings: bool, + /// Fail with the stack overflow error instead of returning the truncated + /// output of a value deeper than the native stack allows. pub(crate) can_throw_stack_overflow: bool, } @@ -147,7 +149,6 @@ impl JestPrettyFormat { if len == 1 { fmt = Formatter::new(global); fmt.quote_strings = options.quote_strings; - fmt.can_throw_stack_overflow = options.can_throw_stack_overflow; let tag = Tag::get(vals[0], global)?; if tag.tag == Tag::String { @@ -186,7 +187,7 @@ impl JestPrettyFormat { } let _ = writer.flush(); - return Ok(()); + return fmt.finish(options); } // The flush must fire on every exit including `?` propagation from @@ -194,7 +195,6 @@ impl JestPrettyFormat { fmt = Formatter::new(global); fmt.remaining_values = &vals[..len][1..]; fmt.quote_strings = options.quote_strings; - fmt.can_throw_stack_overflow = options.can_throw_stack_overflow; let result: JsResult<()> = (|| { let mut this_value: JSValue = vals[0]; @@ -258,7 +258,8 @@ impl JestPrettyFormat { } // map_node release handled by `impl Drop for Formatter`. - result + result?; + fmt.finish(options) } } @@ -322,7 +323,9 @@ pub struct Formatter<'a> { pub(crate) estimated_line_length: usize, pub(crate) always_newline_scope: bool, stack_check: StackCheck, - can_throw_stack_overflow: bool, + /// `stack_check` cut the walk short (and set `failed`, which makes the + /// rest of the walk unwind without printing). + hit_stack_limit: bool, } impl<'a> Formatter<'a> { @@ -338,10 +341,20 @@ impl<'a> Formatter<'a> { estimated_line_length: 0, always_newline_scope: false, stack_check: StackCheck::init(), - can_throw_stack_overflow: false, + hit_stack_limit: false, } } + /// Thrown once the walk has unwound rather than where the limit was hit, + /// so it does not depend on the C++ property/entry walks in between (whose + /// callbacks cannot return an error) carrying a pending exception back out. + fn finish(&self, options: FormatOptions) -> JsResult<()> { + if self.hit_stack_limit && options.can_throw_stack_overflow { + return Err(self.global_this.throw_stack_overflow()); + } + Ok(()) + } + pub(crate) fn good_time_for_a_new_line(&mut self) -> bool { if self.estimated_line_length > 80 { self.reset_line(); @@ -1050,9 +1063,7 @@ impl<'a> Formatter<'a> { } if !self.stack_check.is_safe_to_recurse() { self.failed = true; - if self.can_throw_stack_overflow { - return Err(self.global_this.throw_stack_overflow()); - } + self.hit_stack_limit = true; return Ok(()); } // reshaped for borrowck — `WrappedWriter` borrows both writer_ diff --git a/test/js/bun/test/pretty-format-overflow.test.ts b/test/js/bun/test/pretty-format-overflow.test.ts index 4356c3c382dd..bd72c9a1044f 100644 --- a/test/js/bun/test/pretty-format-overflow.test.ts +++ b/test/js/bun/test/pretty-format-overflow.test.ts @@ -66,7 +66,10 @@ describe.concurrent("pretty_format on a value deeper than the native stack", () const depth = 100_000; const shapes = { array: `let v = []; for (let i = 0; i < ${depth}; i++) v = [v];`, - object: `let v = {}; for (let i = 0; i < ${depth}; i++) v = { k: v };`, + // The second key matters for the snapshot cases below: the property walk formats "k" and then + // goes on to "x", and it does not carry an exception thrown while formatting "k" back out, so + // refusing the snapshot must not rely on one. + object: `let v = {}; for (let i = 0; i < ${depth}; i++) v = { k: v, x: 1 };`, // React elements are formatted by their own arm (Tag::JSX), not the object one. "React element": ` let v = "x"; @@ -111,21 +114,25 @@ describe.concurrent("pretty_format on a value deeper than the native stack", () }); } - test("toMatchSnapshot on a deep object throws instead of writing a truncated snapshot", async () => { + // The formatter throws the stack overflow RangeError; the snapshot matchers currently report it + // as "Failed to pretty format value" (#37334 changes them to rethrow the error itself). + const formattingFailed = /Maximum call stack size exceeded|Failed to pretty format value/; + + test("toMatchSnapshot on a deep object fails instead of writing a truncated snapshot", async () => { const { stderr, exitCode, snapshotFileWritten } = await runDeepTest( `${shapes.object}\nexpect(v).toMatchSnapshot();`, ); - expect(stderr).toContain("RangeError: Maximum call stack size exceeded"); + expect(stderr).toMatch(formattingFailed); expect(stderr).toContain("1 fail"); expect(snapshotFileWritten).toBe(false); expect(exitCode).toBe(1); }); - test("toMatchInlineSnapshot on a deep object throws instead of writing a truncated snapshot", async () => { + test("toMatchInlineSnapshot on a deep object fails instead of writing a truncated snapshot", async () => { const { stderr, exitCode, testFileRewritten } = await runDeepTest( `${shapes.object}\nexpect(v).toMatchInlineSnapshot();`, ); - expect(stderr).toContain("RangeError: Maximum call stack size exceeded"); + expect(stderr).toMatch(formattingFailed); expect(stderr).toContain("1 fail"); expect(testFileRewritten).toBe(false); expect(exitCode).toBe(1); From ee2036ecd84dbe6e24169f040e3eaa6d0374b5f6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 18 Aug 2026 04:37:21 +0000 Subject: [PATCH 12/15] Trim comments on the deferred stack-overflow throw --- src/runtime/test_runner/pretty_format.rs | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/runtime/test_runner/pretty_format.rs b/src/runtime/test_runner/pretty_format.rs index 943ccd34a9b4..7fec0c071eed 100644 --- a/src/runtime/test_runner/pretty_format.rs +++ b/src/runtime/test_runner/pretty_format.rs @@ -101,8 +101,6 @@ pub struct FormatOptions { pub(crate) add_newline: bool, pub flush: bool, pub(crate) quote_strings: bool, - /// Fail with the stack overflow error instead of returning the truncated - /// output of a value deeper than the native stack allows. pub(crate) can_throw_stack_overflow: bool, } @@ -323,8 +321,6 @@ pub struct Formatter<'a> { pub(crate) estimated_line_length: usize, pub(crate) always_newline_scope: bool, stack_check: StackCheck, - /// `stack_check` cut the walk short (and set `failed`, which makes the - /// rest of the walk unwind without printing). hit_stack_limit: bool, } @@ -345,9 +341,7 @@ impl<'a> Formatter<'a> { } } - /// Thrown once the walk has unwound rather than where the limit was hit, - /// so it does not depend on the C++ property/entry walks in between (whose - /// callbacks cannot return an error) carrying a pending exception back out. + /// Deferred to here because the C++ property walks drop exceptions thrown from their callbacks. fn finish(&self, options: FormatOptions) -> JsResult<()> { if self.hit_stack_limit && options.can_throw_stack_overflow { return Err(self.global_this.throw_stack_overflow()); From 6f71143427e31003c4e899c7735a1e67a87c4cb7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:15:04 +0000 Subject: [PATCH 13/15] Test the throwing branch through a React element chain and pin a depth floor for pretty_format Bun.inspect of a React element chain used to overflow the native stack like the Proxy chain did (elements are not tracked for circular references, and the stack check sat behind that tracking); the uncaught-printer tests only exercise the truncating branch, so this covers the throwing one in-process callers get. Nesting through a prop keeps the rendering one line per level. pretty_format gets a floor case: a 256-deep object must still come out in full as a snapshot and in a diff, so the limit cannot creep down into depths in everyday use unnoticed (a debug build with ASAN currently gets through about 500 levels). --- .../bun/test/pretty-format-overflow.test.ts | 28 +++++++++++++---- test/js/node/util/bun-inspect.test.ts | 31 +++++++++++++++++++ 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/test/js/bun/test/pretty-format-overflow.test.ts b/test/js/bun/test/pretty-format-overflow.test.ts index bd72c9a1044f..221acfe0f7b0 100644 --- a/test/js/bun/test/pretty-format-overflow.test.ts +++ b/test/js/bun/test/pretty-format-overflow.test.ts @@ -59,7 +59,7 @@ test("deep nesting", () => { // before that point: a failure diff shows what was rendered, and a snapshot refuses to serialize // the value (a truncated snapshot would be stored as if it were complete). Each case runs in a // subprocess so a regression fails the test instead of the runner. -describe.concurrent("pretty_format on a value deeper than the native stack", () => { +describe.concurrent("pretty_format and the native stack limit", () => { // Several times deeper than the native stack holds for any of these shapes on any platform // (a release build gets through about 23k array levels or 5k object levels on Linux's 8 MB main // thread stack; macOS and Windows builds get an 18 MB stack, debug builds have far larger frames). @@ -96,10 +96,11 @@ describe.concurrent("pretty_format on a value deeper than the native stack", () stderr: "pipe", }); const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + const snapshotFile = join(String(dir), "__snapshots__", "deep.test.ts.snap"); return { stderr, exitCode, - snapshotFileWritten: existsSync(join(String(dir), "__snapshots__", "deep.test.ts.snap")), + snapshot: existsSync(snapshotFile) ? readFileSync(snapshotFile, "utf8") : null, testFileRewritten: readFileSync(join(String(dir), "deep.test.ts"), "utf8") !== source, }; } @@ -119,12 +120,10 @@ describe.concurrent("pretty_format on a value deeper than the native stack", () const formattingFailed = /Maximum call stack size exceeded|Failed to pretty format value/; test("toMatchSnapshot on a deep object fails instead of writing a truncated snapshot", async () => { - const { stderr, exitCode, snapshotFileWritten } = await runDeepTest( - `${shapes.object}\nexpect(v).toMatchSnapshot();`, - ); + const { stderr, exitCode, snapshot } = await runDeepTest(`${shapes.object}\nexpect(v).toMatchSnapshot();`); expect(stderr).toMatch(formattingFailed); expect(stderr).toContain("1 fail"); - expect(snapshotFileWritten).toBe(false); + expect(snapshot).toBeNull(); expect(exitCode).toBe(1); }); @@ -137,4 +136,21 @@ describe.concurrent("pretty_format on a value deeper than the native stack", () expect(testFileRewritten).toBe(false); expect(exitCode).toBe(1); }); + + // The limit has to stay well clear of depths in everyday use. A debug build with ASAN, the build + // with the largest frames, gets through about 500 object levels before the guard fires (a release + // build about ten times that), so this value has to come out in full both as a snapshot and in a + // diff, leaf included. + test("a 256-deep object is still serialized in full", async () => { + const { stderr, exitCode, snapshot } = await runDeepTest(` + let v = "floor-leaf"; + for (let i = 0; i < 256; i++) v = { k: v, x: 1 }; + expect(v).toMatchSnapshot(); + expect(v).toEqual(1); + `); + expect(snapshot).toContain('"floor-leaf"'); + expect(stderr).toContain('"floor-leaf"'); + expect(stderr).toContain("1 fail"); + expect(exitCode).toBe(1); + }); }); diff --git a/test/js/node/util/bun-inspect.test.ts b/test/js/node/util/bun-inspect.test.ts index 9e56d984c0ee..c61b6b8c2bc1 100644 --- a/test/js/node/util/bun-inspect.test.ts +++ b/test/js/node/util/bun-inspect.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "bun:test"; +import { bunEnv, bunExe } from "harness"; import stripAnsi from "strip-ansi"; describe("Bun.inspect", () => { @@ -139,6 +140,36 @@ describe("Bun.inspect", () => { ); }); + // React elements are not tracked for circular references, and the stack check used to be + // skipped together with that tracking, so unlike the object and Error chains above this chain + // overflowed the native stack and killed the process (at any `depth`: element props are not + // depth limited). Nesting through a prop rather than `children` keeps the rendering one line, so + // the levels that do get rendered before the limit stay small. Run in a subprocess so a + // regression fails this test instead of taking the test runner down. + it("stack overflow is thrown when it should be for React element chains", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `let v = "leaf"; + for (let i = 0; i < 100_000; i++) { + v = { $$typeof: Symbol.for("react.element"), type: "div", key: null, ref: null, props: { x: v } }; + } + try { + Bun.inspect(v); + console.log("returned"); + } catch (e) { + console.log(e.name + ": " + e.message); + }`, + ], + env: bunEnv, + stdout: "pipe", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout).toBe("RangeError: Maximum call stack size exceeded.\n"); + expect(exitCode).toBe(0); + }); + it("depth = 0", () => { expect(Bun.inspect({ a: { b: { c: { d: 1 } } } }, { depth: 0 })).toEqual("{\n a: [Object ...],\n}"); }); From ee069540f1136af04925ca4fdcb3c20991c10209 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:47:24 +0000 Subject: [PATCH 14/15] pretty_format: pass the writer through the asymmetric matcher bridge unchanged amf_print_as took a `&mut dyn Write` and wrapped it in AsFmt, and the ConsoleFormatter::print_as it went through wrapped that in a FmtAdapter, so every nested matcher added two adapters around the writer. A write issued N matchers deep then went through 2N adapter frames, all of them below the stack check at the top of print_as, and a failing toEqual against a ~5k-deep expect.objectContaining chain still overflowed the stack on a release build. The bridge is now generic over the writer and calls format() on it directly; the FormatTag mapping it shared with the write_format bridge becomes a From impl used by both. reportError depths are sized for the 18 MB stacks of the macOS and Windows builds, so an unfixed printer overflows there as well. --- src/runtime/test_runner/pretty_format.rs | 69 ++++++++++--------- .../bun/test/pretty-format-overflow.test.ts | 15 ++++ test/js/bun/util/reportError.test.ts | 17 +++-- 3 files changed, 66 insertions(+), 35 deletions(-) diff --git a/src/runtime/test_runner/pretty_format.rs b/src/runtime/test_runner/pretty_format.rs index 7fec0c071eed..de97071f4021 100644 --- a/src/runtime/test_runner/pretty_format.rs +++ b/src/runtime/test_runner/pretty_format.rs @@ -2602,8 +2602,7 @@ impl<'a> Formatter<'a> { /// for nested values. The trait is the layering seam — it lives in `bun_jsc`, /// the webcore types call through it generically, and this file supplies the /// test-runner-specific dispatch. Mirrors the `bun_jsc::console_object` -/// `Formatter` impl (`src/jsc/lib.rs`) one-for-one, mapping the -/// `bun_jsc::FormatTag` enum onto this file's smaller `Tag` set. +/// `Formatter` impl (`src/jsc/lib.rs`) one-for-one. impl bun_jsc::ConsoleFormatter for Formatter<'_> { #[inline] fn global_this(&self) -> &JSGlobalObject { self.global_this } @@ -2632,12 +2631,26 @@ impl bun_jsc::ConsoleFormatter for Formatter<'_> { value: JSValue, cell: JSType, ) -> JsResult<()> { + let mut sink = bun_io::FmtAdapter::new(writer); + let global = self.global_this; + self.format::<_, ENABLE_ANSI_COLORS>( + TagResult { tag: tag.into(), cell }, + &mut sink, + value, + global, + ) + } +} + +/// Maps the wider `console_object::Tag` onto this file's `Tag`. Only the +/// variants the `write_format` hooks and the asymmetric matchers actually emit +/// are reachable (Boolean / Double / Object / Array / Private / String); the +/// rest collapse onto `Object` so any future caller still renders something +/// useful. +impl From for Tag { + fn from(tag: bun_jsc::FormatTag) -> Self { use bun_jsc::FormatTag as Ft; - // Map the wider `console_object::Tag` onto this file's `Tag`. Only the - // variants the `write_format` hooks actually emit are reachable - // (Boolean / Double / Object / Private / String); the rest collapse - // onto `Object` so any future caller still renders something useful. - let local = match tag { + match tag { Ft::StringPossiblyFormatted => Tag::StringPossiblyFormatted, Ft::String => Tag::String, Ft::Undefined => Tag::Undefined, @@ -2671,15 +2684,7 @@ impl bun_jsc::ConsoleFormatter for Formatter<'_> { | Ft::CustomGetterSetter | Ft::Proxy | Ft::RevokedProxy => Tag::Object, - }; - let mut sink = bun_io::FmtAdapter::new(writer); - let global = self.global_this; - self.format::<_, ENABLE_ANSI_COLORS>( - TagResult { tag: local, cell }, - &mut sink, - value, - global, - ) + } } } @@ -2695,10 +2700,15 @@ pub trait AsymmetricMatcherFormatter { fn amf_quote_strings(&mut self) -> &mut bool; /// `printAs(tag, …)` routed through the formatter's own runtime /// dispatcher. Only `Object` / `String` / `Array` are reached. - fn amf_print_as( + /// + /// Generic over the writer so that the caller's writer goes down as is. A + /// matcher nested in a matcher re-enters this for every level, and a + /// writer adapter added per level would stack up into one frame per level + /// inside every single write, below the stack check in `print_as`. + fn amf_print_as( &mut self, tag: bun_jsc::FormatTag, - w: &mut dyn bun_io::Write, + w: &mut W, v: JSValue, cell: JSType, ) -> JsResult<()>; @@ -2711,18 +2721,15 @@ impl AsymmetricMatcherFormatter for Formatter<'_> { fn amf_global_this(&self) -> &JSGlobalObject { self.global_this } #[inline] fn amf_quote_strings(&mut self) -> &mut bool { &mut self.quote_strings } - fn amf_print_as( + fn amf_print_as( &mut self, tag: bun_jsc::FormatTag, - w: &mut dyn bun_io::Write, + w: &mut W, v: JSValue, cell: JSType, ) -> JsResult<()> { - // Reuse the `ConsoleFormatter` bridge above (FormatTag → local `Tag` - // mapping + `format` dispatch). `AsFmt` adapts `dyn bun_io::Write` → - // `core::fmt::Write` for the trait method's signature. - let mut bridge = AsFmt::new(w); - ::print_as::<_, C>(self, tag, &mut bridge, v, cell) + let global = self.global_this; + self.format::(TagResult { tag: tag.into(), cell }, w, v, global) } } @@ -2733,10 +2740,10 @@ impl AsymmetricMatcherFormatter for bun_jsc::console_object::Formatter<'_> { fn amf_global_this(&self) -> &JSGlobalObject { self.global_this } #[inline] fn amf_quote_strings(&mut self) -> &mut bool { &mut self.quote_strings } - fn amf_print_as( + fn amf_print_as( &mut self, tag: bun_jsc::FormatTag, - w: &mut dyn bun_io::Write, + w: &mut W, v: JSValue, cell: JSType, ) -> JsResult<()> { @@ -2868,7 +2875,7 @@ impl JestPrettyFormat { this.amf_add_for_new_line(b"ObjectContaining ".len()); writer.write_all(b"ObjectContaining "); } - this.amf_print_as::( + this.amf_print_as::<_, ENABLE_ANSI_COLORS>( bun_jsc::FormatTag::Object, &mut *writer.ctx, object_value, JSType::Object, )?; } else if let Some(matcher) = value.as_class_ref::() { @@ -2887,7 +2894,7 @@ impl JestPrettyFormat { this.amf_add_for_new_line(b"StringContaining ".len()); writer.write_all(b"StringContaining "); } - this.amf_print_as::( + this.amf_print_as::<_, ENABLE_ANSI_COLORS>( bun_jsc::FormatTag::String, &mut *writer.ctx, substring_value, JSType::String, )?; } else if let Some(matcher) = value.as_class_ref::() { @@ -2910,7 +2917,7 @@ impl JestPrettyFormat { if test_value.is_reg_exp() { *this.amf_quote_strings() = false; } - this.amf_print_as::( + this.amf_print_as::<_, ENABLE_ANSI_COLORS>( bun_jsc::FormatTag::String, &mut *writer.ctx, test_value, JSType::String, )?; *this.amf_quote_strings() = original_quote_strings; @@ -2942,7 +2949,7 @@ impl JestPrettyFormat { this.amf_add_for_new_line(matcher_name.length() + 1); writer.print(format_args!("{}", matcher_name)); writer.write_all(b" "); - this.amf_print_as::( + this.amf_print_as::<_, ENABLE_ANSI_COLORS>( bun_jsc::FormatTag::Array, &mut *writer.ctx, args_value, JSType::Array, )?; } diff --git a/test/js/bun/test/pretty-format-overflow.test.ts b/test/js/bun/test/pretty-format-overflow.test.ts index 221acfe0f7b0..29c994b1f06d 100644 --- a/test/js/bun/test/pretty-format-overflow.test.ts +++ b/test/js/bun/test/pretty-format-overflow.test.ts @@ -115,6 +115,21 @@ describe.concurrent("pretty_format and the native stack limit", () => { }); } + // Asymmetric matchers are rendered through a separate entry point that used to wrap the writer + // once per level, so every write from deep inside the chain went through one extra frame per + // level below the stack check, and a release build still crashed after the check was added. + test("failing toEqual against a deep expect.objectContaining chain still prints a diff", async () => { + const { stderr, exitCode } = await runDeepTest(` + let v = expect.objectContaining({ leaf: 1 }); + for (let i = 0; i < 50_000; i++) v = expect.objectContaining({ k: v }); + expect({ k: 2 }).toEqual(v); + `); + expect(stderr).toContain("expect(received).toEqual(expected)"); + expect(stderr).toContain('"k": ObjectContaining {'); + expect(stderr).toContain("1 fail"); + expect(exitCode).toBe(1); + }); + // The formatter throws the stack overflow RangeError; the snapshot matchers currently report it // as "Failed to pretty format value" (#37334 changes them to rethrow the error itself). const formattingFailed = /Maximum call stack size exceeded|Failed to pretty format value/; diff --git a/test/js/bun/util/reportError.test.ts b/test/js/bun/util/reportError.test.ts index 13d65fe0c360..9c31b8f21a78 100644 --- a/test/js/bun/util/reportError.test.ts +++ b/test/js/bun/util/reportError.test.ts @@ -129,13 +129,22 @@ test("native error printer handles lone surrogates in message and stack frame na // a Proxy through its target (a tag the formatter does not track for circular // references), error.cause through the error printer itself. describe("native error printer survives a deeply nested uncaught value", () => { + // Depths are sized so that an unfixed printer overflows on every platform, + // including the 18 MB main thread stack of the macOS and Windows builds (Linux + // has 8 MB): a release build spends at least about 175 bytes of stack per + // array or Proxy level, so 18 MB holds at most about 105k of them, and + // several KB per error level. A fixed printer stops at the stack limit, so + // the depth does not change how much it prints. const cases: [name: string, src: string][] = [ - ["throw array", "let a = []; for (let i = 0; i < 50000; i++) a = [a]; throw a;"], - ["reject with array", "let a = []; for (let i = 0; i < 50000; i++) a = [a]; Promise.reject(a);"], - ["throw Proxy chain", "let a = {}; for (let i = 0; i < 50000; i++) a = new Proxy(a, {}); throw a;"], + ["throw array", "let a = []; for (let i = 0; i < 150_000; i++) a = [a]; throw a;"], + ["reject with array", "let a = []; for (let i = 0; i < 150_000; i++) a = [a]; Promise.reject(a);"], + [ + "throw Proxy chain", + "const handler = {}; let a = {}; for (let i = 0; i < 150_000; i++) a = new Proxy(a, handler); throw a;", + ], [ "throw error.cause chain", - "let e = new Error('root'); for (let i = 0; i < 10000; i++) e = new Error('wrap', { cause: e }); throw e;", + "let e = new Error('root'); for (let i = 0; i < 10_000; i++) e = new Error('wrap', { cause: e }); throw e;", ], ]; From 5a24ce6c33670e6a7d78f5e9e262d0f66c0d127c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:56:52 +0000 Subject: [PATCH 15/15] Trim comments on the matcher bridge --- src/runtime/test_runner/pretty_format.rs | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/src/runtime/test_runner/pretty_format.rs b/src/runtime/test_runner/pretty_format.rs index de97071f4021..54c68adbc9f1 100644 --- a/src/runtime/test_runner/pretty_format.rs +++ b/src/runtime/test_runner/pretty_format.rs @@ -2642,11 +2642,7 @@ impl bun_jsc::ConsoleFormatter for Formatter<'_> { } } -/// Maps the wider `console_object::Tag` onto this file's `Tag`. Only the -/// variants the `write_format` hooks and the asymmetric matchers actually emit -/// are reachable (Boolean / Double / Object / Array / Private / String); the -/// rest collapse onto `Object` so any future caller still renders something -/// useful. +/// Variants this file has no arm for collapse onto `Object`. impl From for Tag { fn from(tag: bun_jsc::FormatTag) -> Self { use bun_jsc::FormatTag as Ft; @@ -2700,11 +2696,7 @@ pub trait AsymmetricMatcherFormatter { fn amf_quote_strings(&mut self) -> &mut bool; /// `printAs(tag, …)` routed through the formatter's own runtime /// dispatcher. Only `Object` / `String` / `Array` are reached. - /// - /// Generic over the writer so that the caller's writer goes down as is. A - /// matcher nested in a matcher re-enters this for every level, and a - /// writer adapter added per level would stack up into one frame per level - /// inside every single write, below the stack check in `print_as`. + /// Generic rather than `dyn` so nested matchers do not stack one writer adapter per level. fn amf_print_as( &mut self, tag: bun_jsc::FormatTag,