From 24b6e7564b7e88414d1d7f6098c78c74870fad8f 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 1/6] 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 5d715e84b68f..2973992e4ac0 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -4558,6 +4558,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(), @@ -5564,6 +5565,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 3050fd50c7d7..7f114d3a0531 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -1194,6 +1194,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 bf63a9aa9574c6a23df5637ac1321f16f796b6cb 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 2/6] 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 96dae5137792..a2f7fd9a75d4 100644 --- a/src/jsc/ConsoleObject.rs +++ b/src/jsc/ConsoleObject.rs @@ -724,7 +724,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, @@ -1436,7 +1435,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)?; @@ -1499,7 +1497,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; @@ -1729,10 +1726,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, @@ -6015,7 +6011,6 @@ pub 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 2973992e4ac0..5d715e84b68f 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -4558,7 +4558,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(), @@ -5565,7 +5564,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 7f114d3a0531..3050fd50c7d7 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -1194,7 +1194,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 44ae3a68e28b4d0bc2571c2654862317a2503b2b 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 3/6] 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 2a99f15de0b0429b9df0816577918332a9aa3144 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 4/6] 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 113234ea045b..392c8e255c4d 100644 --- a/src/jsc/JSValue.rs +++ b/src/jsc/JSValue.rs @@ -2678,7 +2678,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 5d715e84b68f..9a9b5d7df85f 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -5600,8 +5600,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 0c59618aa3d4..8a8b48e345d3 100644 --- a/src/runtime/test_runner/pretty_format.rs +++ b/src/runtime/test_runner/pretty_format.rs @@ -3,7 +3,7 @@ use crate::test_runner::expect::JSValueTestExt; use core::ffi::c_void; use bun_collections::HashMap; -use bun_core::{fmt as bun_fmt, Output}; +use bun_core::{fmt as bun_fmt, Output, StackCheck}; use bun_jsc::{ self as jsc, ComptimeStringMapExt as _, JSGlobalObject, JSObject, JSPropertyIterator, JSType, JSValue, JsError, JsResult, VM, @@ -342,6 +342,7 @@ pub struct Formatter<'a> { pub failed: bool, pub estimated_line_length: usize, pub always_newline_scope: bool, + pub stack_check: StackCheck, } impl<'a> Formatter<'a> { @@ -357,6 +358,7 @@ impl<'a> Formatter<'a> { failed: false, estimated_line_length: 0, always_newline_scope: false, + stack_check: StackCheck::init(), } } @@ -1156,6 +1158,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 4acdf03b5110..368820feb0be 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 () => { + const dir = tempDirWithFiles("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 11ce7219c50283ba8501cf3079d0b9da0515b06a 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 5/6] 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 a2f7fd9a75d4..101574b49f0e 100644 --- a/src/jsc/ConsoleObject.rs +++ b/src/jsc/ConsoleObject.rs @@ -1726,8 +1726,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, @@ -3383,10 +3381,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 { @@ -3394,6 +3388,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 676c1bd4754489b43e2a4de1080fb3ee686faf42 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 6/6] 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 = {};