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..1717d2ffbd76 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", () => { @@ -87,6 +88,34 @@ 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."`); + }); + + 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", () => { expect(Bun.inspect({ a: { b: { c: { d: 1 } } } }, { depth: 0 })).toEqual("{\n a: [Object ...],\n}"); });