From a5e3c07a9b4602637c9025924a0939dccf380366 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 21 Jul 2026 06:55:16 +0000 Subject: [PATCH 1/3] Guard print_errorlike_object against unbounded AggregateError recursion Printing a deeply nested AggregateError (e.g. 2k levels of new AggregateError([inner])) SIGSEGVs every error-print path: console.log, Bun.inspect, uncaught throw, and unhandled rejection. The AggregateError branch of print_errorlike_object iterates .errors via JSValue::for_each -> agg_iter -> print_errorlike_object with no stack check, so the recursion runs until the native stack overflows. The Error cause chain is guarded one level deeper in print_error_instance_js, but that guard is only reached once the AggregateError loop bottoms out. Additionally, VirtualMachine::print_exception and the jsc_hooks print_exception entry point construct a Formatter with the default (unseated) StackCheck, so the existing cause-chain guard never fired on the uncaught-throw / unhandled-reject paths either. Fix: hoist the formatter's is_safe_to_recurse() check to the top of print_errorlike_object (matching the pattern in print_error_instance_js), and seat formatter.stack_check at the two print_exception entry points. --- src/jsc/VirtualMachine.rs | 12 ++++++++++ src/runtime/jsc_hooks.rs | 1 + test/js/node/util/bun-inspect.test.ts | 33 +++++++++++++++++++++++++++ 3 files changed, 46 insertions(+) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 5d715e84b68f..3be35426dce9 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(), @@ -4869,6 +4870,17 @@ impl VirtualMachine { // once the AggregateError branch is taken). let global_ref = self.global(); + // Stack-safety guard for the AggregateError recursion below (`agg_iter` + // → `print_errorlike_object`). The `cause` chain is already guarded in + // `print_error_instance_js`; this covers the `.errors` chain. + if !formatter.stack_check.is_safe_to_recurse() { + formatter.failed = true; + if formatter.can_throw_stack_overflow { + let _ = global_ref.throw_stack_overflow(); + } + return; + } + if value.is_aggregate_error(global_ref) { // Note: `JSValue::for_each` takes a C-ABI fn // pointer + erased ctx, so thread the captures through a struct. 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/node/util/bun-inspect.test.ts b/test/js/node/util/bun-inspect.test.ts index 65de3b0ed4a8..4d2bba79fded 100644 --- a/test/js/node/util/bun-inspect.test.ts +++ b/test/js/node/util/bun-inspect.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test"; import stripAnsi from "strip-ansi"; +import { bunEnv, bunExe } from "harness"; describe("Bun.inspect", () => { it("reports error instead of [native code]", () => { @@ -87,6 +88,38 @@ describe("Bun.inspect", () => { ); }); + it("stack overflow is thrown when it should be for AggregateError", () => { + let e: unknown = new Error("leaf"); + for (let i = 0; i < 16 * 1024; i++) { + e = new AggregateError([e], "agg"); + } + + expect(() => Bun.inspect(e)).toThrowErrorMatchingInlineSnapshot(`"Maximum call stack size exceeded."`); + }); + + describe.each(["log", "throw", "reject", "cause"])("printing a deeply nested error via %s", face => { + it.concurrent("does not crash", async () => { + const src = + face === "cause" + ? `let e = new Error("leaf"); for (let i = 0; i < 16 * 1024; i++) e = new Error("c", { cause: e }); throw e;` + : `let e = new Error("leaf"); for (let i = 0; i < 16 * 1024; i++) e = new AggregateError([e], "agg");` + + { + log: ` console.log(e);`, + throw: ` throw e;`, + reject: ` Promise.reject(e); await 0;`, + }[face]; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", src], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(proc.signalCode).toBeNull(); + expect(exitCode).toBe(1); + }); + }); + it("depth = 0", () => { expect(Bun.inspect({ a: { b: { c: { d: 1 } } } }, { depth: 0 })).toEqual("{\n a: [Object ...],\n}"); }); From 548c22885b4023d11ed40a1c3351c40b09ba835a Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 06:57:34 +0000 Subject: [PATCH 2/3] [autofix.ci] apply automated fixes --- test/js/node/util/bun-inspect.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/js/node/util/bun-inspect.test.ts b/test/js/node/util/bun-inspect.test.ts index 4d2bba79fded..f9ef6b254702 100644 --- a/test/js/node/util/bun-inspect.test.ts +++ b/test/js/node/util/bun-inspect.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; -import stripAnsi from "strip-ansi"; import { bunEnv, bunExe } from "harness"; +import stripAnsi from "strip-ansi"; describe("Bun.inspect", () => { it("reports error instead of [native code]", () => { From 383f3bc90498396489248b00e5d2b021f45c6e5f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 21 Jul 2026 07:03:06 +0000 Subject: [PATCH 3/3] test: flatten subprocess matrix and assert loop completion marker Switch to it.concurrent.each (flatter, same concurrency) and write a 'built' marker to stderr after constructing the 16K-deep chain so the test asserts the child actually reached the print/throw rather than passing on an unrelated exit-1. --- test/js/node/util/bun-inspect.test.ts | 36 ++++++++++++--------------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/test/js/node/util/bun-inspect.test.ts b/test/js/node/util/bun-inspect.test.ts index f9ef6b254702..1717d2ffbd76 100644 --- a/test/js/node/util/bun-inspect.test.ts +++ b/test/js/node/util/bun-inspect.test.ts @@ -97,27 +97,23 @@ describe("Bun.inspect", () => { expect(() => Bun.inspect(e)).toThrowErrorMatchingInlineSnapshot(`"Maximum call stack size exceeded."`); }); - describe.each(["log", "throw", "reject", "cause"])("printing a deeply nested error via %s", face => { - it.concurrent("does not crash", async () => { - const src = - face === "cause" - ? `let e = new Error("leaf"); for (let i = 0; i < 16 * 1024; i++) e = new Error("c", { cause: e }); throw e;` - : `let e = new Error("leaf"); for (let i = 0; i < 16 * 1024; i++) e = new AggregateError([e], "agg");` + - { - log: ` console.log(e);`, - throw: ` throw e;`, - reject: ` Promise.reject(e); await 0;`, - }[face]; - await using proc = Bun.spawn({ - cmd: [bunExe(), "-e", src], - env: bunEnv, - stdout: "pipe", - stderr: "pipe", - }); - const [, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(proc.signalCode).toBeNull(); - expect(exitCode).toBe(1); + it.concurrent.each([ + ["log", `e = new AggregateError([e], "agg");`, `console.log(e);`], + ["throw", `e = new AggregateError([e], "agg");`, `throw e;`], + ["reject", `e = new AggregateError([e], "agg");`, `Promise.reject(e); await 0;`], + ["cause", `e = new Error("c", { cause: e });`, `throw e;`], + ])("printing a deeply nested error via %s does not crash", async (_, wrap, emit) => { + const src = `let e = new Error("leaf"); for (let i = 0; i < 16 * 1024; i++) ${wrap} process.stderr.write("built\\n"); ${emit}`; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", src], + env: bunEnv, + stdout: "ignore", + stderr: "pipe", }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(stderr.slice(0, 6)).toBe("built\n"); + expect(proc.signalCode).toBeNull(); + expect(exitCode).toBe(1); }); it("depth = 0", () => {