From 1e26674dba1fe43b73cd423bfca3fa3602a02cc2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 22:42:36 +0000 Subject: [PATCH 01/11] console: guard AggregateError .errors recursion with visited set, stack check, and property validation The AggregateError branch of print_errorlike_object walked .errors via for_each -> agg_iter -> print_errorlike_object with no visited set, no stack_check, and no validation of the getDirect result. A self/mutual cycle, a 3000-deep chain of aggregates, or a deleted/accessor .errors property all reached the stack guard page (silent SIGSEGV) on every sink that uses the native error printer (console.log/error, Bun.inspect, uncaught throw, unhandled rejection, uncaughtException handler). The cause-chain printer already has both guards (stack_check at print_error_instance_js and the formatter.map [Circular] check), so the fix reuses the same visited-pool idiom keyed on the AggregateError object identity, adds the stack_check at the top of the branch, and only iterates when .errors is an object (clearing any exception for_each may raise). Seating StackCheck::init() on the two run_error_handler formatter sites makes the existing cause-chain depth guard effective on the uncaught-throw / unhandled-reject sinks, where Formatter::new left it defaulted and therefore always-true. With the guards in place the branch now falls through to print the AggregateError's own name/message/stack before its children, so an uncaught AggregateError shows its own message instead of only its members. --- src/jsc/VirtualMachine.rs | 101 +++++++--- src/runtime/jsc_hooks.rs | 1 + test/js/bun/util/inspect-error-cycle.test.ts | 202 +++++++++++++++++++ 3 files changed, 278 insertions(+), 26 deletions(-) create mode 100644 test/js/bun/util/inspect-error-cycle.test.ts diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 191c40190c98..a5428abeb95b 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -4469,6 +4469,7 @@ impl VirtualMachine { allow_side_effects: bool, ) { let mut formatter = crate::console_object::Formatter::new(self.global()); + formatter.stack_check = bun_core::util::StackCheck::init(); let colors = bun_core::Output::enable_ansi_colors_stderr(); self.print_errorlike_object( exception.value(), @@ -4784,7 +4785,59 @@ impl VirtualMachine { // once the AggregateError branch is taken). let global_ref = self.global(); - if value.is_aggregate_error(global_ref) { + let is_aggregate = value.is_aggregate_error(global_ref); + if is_aggregate { + use crate::console_object::formatter::visited; + + // Depth guard: this branch re-enters through `agg_iter` below with + // no intervening JSC stack check. + if !formatter.stack_check.is_safe_to_recurse() { + let marker = if allow_ansi_color { + bun_core::pretty_fmt!("[AggregateError: nesting too deep]\n", true) + } else { + bun_core::pretty_fmt!("[AggregateError: nesting too deep]\n", false) + }; + let _ = writer.write_all(marker.as_bytes()); + return; + } + + // Circular-ref guard keyed on the AggregateError object identity, + // same visited pool the cause-chain and object printers use. + if formatter.map_node.is_none() { + let mut node = NonNull::new(visited::Pool::get_node()) + .expect("ObjectPool::get_node always returns a valid heap node"); + let data = visited::node_data_mut(&mut node); + data.clear(); + formatter.map = core::mem::take(data); + formatter.map_node = Some(node); + } + let entry = formatter.map.get_or_put(value).expect("unreachable"); + if entry.found_existing { + let marker = if allow_ansi_color { + bun_core::pretty_fmt!("[Circular]\n", true) + } else { + bun_core::pretty_fmt!("[Circular]\n", false) + }; + let _ = writer.write_all(marker.as_bytes()); + return; + } + // Fall through so the AggregateError's own name/message/stack is + // printed before its children. + } + + // Note: reborrow so the add-to-error-list tail can still see it after + // `print_error_from_maybe_private_data`. + let mut exception_list = exception_list; + let was_internal = self.print_error_from_maybe_private_data( + value, + exception_list.as_deref_mut(), + formatter, + writer, + allow_ansi_color, + allow_side_effects, + ); + + if is_aggregate { // Note: `JSValue::for_each` takes a C-ABI fn // pointer + erased ctx, so thread the captures through a struct. // The C trampoline erases lifetimes via `*mut c_void`; round-trip @@ -4830,35 +4883,31 @@ impl VirtualMachine { ctx.allow_side_effects, ); } - let mut ctx = AggCtx { - formatter: std::ptr::from_mut(formatter), - writer: std::ptr::from_mut(writer), - exception_list: exception_list - .map(std::ptr::from_mut::) - .unwrap_or(core::ptr::null_mut()), - allow_ansi_color, - allow_side_effects, - }; - // `getErrorsProperty` is - // `getDirect` (own data prop, nothrow); `for_each` may throw, in - // which case the error is swallowed. + // `getErrorsProperty` is `getDirect` (own data prop, nothrow), so + // a deleted or accessor `errors` surfaces here as empty / a + // GetterSetter cell. let errors = value.get_errors_property(global_ref); - let _ = errors.for_each(global_ref, (&raw mut ctx).cast(), agg_iter); + if errors.is_object() { + let mut ctx = AggCtx { + formatter: std::ptr::from_mut(formatter), + writer: std::ptr::from_mut(writer), + exception_list: exception_list + .map(std::ptr::from_mut::) + .unwrap_or(core::ptr::null_mut()), + allow_ansi_color, + allow_side_effects, + }; + if errors + .for_each(global_ref, (&raw mut ctx).cast(), agg_iter) + .is_err() + { + self.global().clear_exception(); + } + } + let _ = formatter.map.remove(&value); return; } - // Note: reborrow so the add-to-error-list tail can still see it after - // `print_error_from_maybe_private_data`. - let mut exception_list = exception_list; - let was_internal = self.print_error_from_maybe_private_data( - value, - exception_list.as_deref_mut(), - formatter, - writer, - allow_ansi_color, - allow_side_effects, - ); - if was_internal { if let Some(exception_) = exception { let mut holder = crate::zig_exception::Holder::init(); diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index 0c719212132e..29771412460d 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::util::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/inspect-error-cycle.test.ts b/test/js/bun/util/inspect-error-cycle.test.ts new file mode 100644 index 000000000000..d392e57a1293 --- /dev/null +++ b/test/js/bun/util/inspect-error-cycle.test.ts @@ -0,0 +1,202 @@ +// Error-graph cycle / deep-chain segfaults in the native error printer. +// The AggregateError `errors` recursion had no stack check and no visited +// set, so self/mutual cycles and very deep nesting hit the stack guard page +// (silent SIGSEGV) via `print_errorlike_object` -> `for_each` -> `agg_iter`. +import { expect, test, describe } from "bun:test"; +import { bunEnv, bunExe } from "harness"; + +type Shape = { name: string; build: string }; +type Sink = { name: string; wrap: (b: string) => string; allowFail: boolean }; + +const shapes: Shape[] = [ + { + name: "self-cycle", + build: `const ae = new AggregateError([], "self"); ae.errors = [ae]; const e = ae;`, + }, + { + name: "mutual-cycle", + build: `const a = new AggregateError([], "A"); const b = new AggregateError([], "B"); a.errors = [b]; b.errors = [a]; const e = a;`, + }, + { + name: "deleted-errors", + build: `const ae = new AggregateError([new Error("x")], "del"); delete ae.errors; const e = ae;`, + }, + { + name: "accessor-errors", + build: `const ae = new AggregateError([], "acc"); Object.defineProperty(ae, "errors", { get() { throw new Error("boom"); } }); const e = ae;`, + }, + { + name: "non-iterable-errors", + build: `const ae = new AggregateError([], "ni"); ae.errors = 42; const e = ae;`, + }, + { + name: "mixed-agg-cause", + build: `const a = new AggregateError([], "A"); const c = new Error("C"); a.errors = [c]; c.cause = a; const e = a;`, + }, +]; + +const sinks: Sink[] = [ + { name: "console.log", wrap: b => `${b} console.log(e);`, allowFail: false }, + { name: "console.error", wrap: b => `${b} console.error(e);`, allowFail: false }, + { name: "Bun.inspect", wrap: b => `${b} Bun.inspect(e);`, allowFail: false }, + { name: "uncaught-throw", wrap: b => `${b} throw e;`, allowFail: true }, + { + name: "unhandled-reject", + wrap: b => `${b} Promise.reject(e); await 1;`, + allowFail: true, + }, + { + name: "uncaughtException-handler", + wrap: b => `process.on("uncaughtException", err => { console.error(err); process.exit(0); }); ${b} throw e;`, + allowFail: false, + }, +]; + +describe.concurrent("error-graph cycles do not crash the printer", () => { + for (const shape of shapes) { + for (const sink of sinks) { + const cell = `${shape.name} x ${sink.name}`; + test(cell, async () => { + const code = sink.wrap(shape.build); + await using proc = Bun.spawn({ + cmd: [bunExe(), "--no-install", "-e", code], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + proc.stdout.text(), + proc.stderr.text(), + proc.exited, + ]); + if (proc.signalCode) { + throw new Error( + `crashed with ${proc.signalCode}\nstdout: ${stdout.slice(0, 300)}\nstderr: ${stderr.slice(0, 300)}`, + ); + } + if (sink.allowFail) { + expect(exitCode).toBeLessThan(128); + } else { + if (exitCode !== 0) { + throw new Error(`exit ${exitCode}\nstdout: ${stdout.slice(0, 300)}\nstderr: ${stderr.slice(0, 300)}`); + } + expect(exitCode).toBe(0); + } + }); + } + } + +}); + +// Depth tests kept out of the concurrent matrix: each prints hundreds of +// headers before the stack guard fires, which is seconds under debug+ASAN. +// Release bun segfaults at ~2000 levels. +describe("error-graph depth does not crash the printer", () => { + const deepAgg = `let x = new AggregateError([], "leaf"); for (let i = 0; i < 3000; i++) x = new AggregateError([x], "n" + i); const e = x;`; + const deepCause = `let x = new Error("leaf"); for (let i = 0; i < 3000; i++) x = new Error("n" + i, { cause: x }); const e = x;`; + + for (const sink of sinks) { + test(`deep-aggregate x ${sink.name}`, async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "--no-install", "-e", sink.wrap(deepAgg)], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(proc.signalCode).toBeFalsy(); + if (sink.allowFail) expect(exitCode).toBeLessThan(128); + else expect(exitCode).toBe(0); + }); + } + + // The cause-chain depth guard exists but was inert on the uncaught / + // rejection path because the formatter's stack_check was never seated. + for (const sink of sinks.filter(s => s.allowFail)) { + test(`deep-cause x ${sink.name}`, async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "--no-install", "-e", sink.wrap(deepCause)], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(proc.signalCode).toBeFalsy(); + expect(exitCode).toBeLessThan(128); + }); + } +}); + +describe.concurrent("AggregateError printer output", () => { + test("self-cycle renders [Circular] and includes the header", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const ae = new AggregateError([], "outer"); ae.errors = [ae]; process.stdout.write(Bun.inspect(ae));`, + ], + env: { ...bunEnv, NO_COLOR: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + proc.stdout.text(), + proc.stderr.text(), + proc.exited, + ]); + expect(stderr).toBe(""); + expect(stdout).toContain("[Circular]"); + expect(stdout).toContain("outer"); + expect(exitCode).toBe(0); + }); + + test("deep nesting renders depth marker", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `let x = new AggregateError([], "leaf"); for (let i = 0; i < 3000; i++) x = new AggregateError([x], ""); process.stdout.write(Bun.inspect(x));`, + ], + env: { ...bunEnv, NO_COLOR: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toContain("[AggregateError: nesting too deep]"); + expect(exitCode).toBe(0); + }); + + test("uncaught AggregateError prints its own message", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", `throw new AggregateError([new Error("inner")], "outer message");`], + env: { ...bunEnv, NO_COLOR: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toContain("outer message"); + expect(stderr).toContain("inner"); + expect(exitCode).toBe(1); + }); + + test("deleted errors property prints header", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const ae = new AggregateError([new Error("x")], "msg"); delete ae.errors; process.stdout.write(Bun.inspect(ae));`, + ], + env: { ...bunEnv, NO_COLOR: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + proc.stdout.text(), + proc.stderr.text(), + proc.exited, + ]); + expect(stderr).toBe(""); + expect(stdout).toContain("msg"); + expect(exitCode).toBe(0); + }); +}); From ad9a91e527fb16e2081e75de68d194b46c428851 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 22:45:02 +0000 Subject: [PATCH 02/11] [autofix.ci] apply automated fixes --- test/js/bun/util/inspect-error-cycle.test.ts | 21 ++++---------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/test/js/bun/util/inspect-error-cycle.test.ts b/test/js/bun/util/inspect-error-cycle.test.ts index d392e57a1293..256581363d8e 100644 --- a/test/js/bun/util/inspect-error-cycle.test.ts +++ b/test/js/bun/util/inspect-error-cycle.test.ts @@ -2,7 +2,7 @@ // The AggregateError `errors` recursion had no stack check and no visited // set, so self/mutual cycles and very deep nesting hit the stack guard page // (silent SIGSEGV) via `print_errorlike_object` -> `for_each` -> `agg_iter`. -import { expect, test, describe } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe } from "harness"; type Shape = { name: string; build: string }; @@ -64,11 +64,7 @@ describe.concurrent("error-graph cycles do not crash the printer", () => { stdout: "pipe", stderr: "pipe", }); - const [stdout, stderr, exitCode] = await Promise.all([ - proc.stdout.text(), - proc.stderr.text(), - proc.exited, - ]); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); if (proc.signalCode) { throw new Error( `crashed with ${proc.signalCode}\nstdout: ${stdout.slice(0, 300)}\nstderr: ${stderr.slice(0, 300)}`, @@ -85,7 +81,6 @@ describe.concurrent("error-graph cycles do not crash the printer", () => { }); } } - }); // Depth tests kept out of the concurrent matrix: each prints hundreds of @@ -139,11 +134,7 @@ describe.concurrent("AggregateError printer output", () => { stdout: "pipe", stderr: "pipe", }); - const [stdout, stderr, exitCode] = await Promise.all([ - proc.stdout.text(), - proc.stderr.text(), - proc.exited, - ]); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).toBe(""); expect(stdout).toContain("[Circular]"); expect(stdout).toContain("outer"); @@ -190,11 +181,7 @@ describe.concurrent("AggregateError printer output", () => { stdout: "pipe", stderr: "pipe", }); - const [stdout, stderr, exitCode] = await Promise.all([ - proc.stdout.text(), - proc.stderr.text(), - proc.exited, - ]); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).toBe(""); expect(stdout).toContain("msg"); expect(exitCode).toBe(0); From 14c51112d14a1a673456381b23d949ab1be024a2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 22:47:36 +0000 Subject: [PATCH 03/11] trim advisory comments in the AggregateError guard block --- src/jsc/VirtualMachine.rs | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index a5428abeb95b..467198a9d830 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -4789,8 +4789,6 @@ impl VirtualMachine { if is_aggregate { use crate::console_object::formatter::visited; - // Depth guard: this branch re-enters through `agg_iter` below with - // no intervening JSC stack check. if !formatter.stack_check.is_safe_to_recurse() { let marker = if allow_ansi_color { bun_core::pretty_fmt!("[AggregateError: nesting too deep]\n", true) @@ -4801,8 +4799,6 @@ impl VirtualMachine { return; } - // Circular-ref guard keyed on the AggregateError object identity, - // same visited pool the cause-chain and object printers use. if formatter.map_node.is_none() { let mut node = NonNull::new(visited::Pool::get_node()) .expect("ObjectPool::get_node always returns a valid heap node"); @@ -4821,8 +4817,7 @@ impl VirtualMachine { let _ = writer.write_all(marker.as_bytes()); return; } - // Fall through so the AggregateError's own name/message/stack is - // printed before its children. + // Fall through: print this AggregateError's own header before its children. } // Note: reborrow so the add-to-error-list tail can still see it after @@ -4883,9 +4878,7 @@ impl VirtualMachine { ctx.allow_side_effects, ); } - // `getErrorsProperty` is `getDirect` (own data prop, nothrow), so - // a deleted or accessor `errors` surfaces here as empty / a - // GetterSetter cell. + // `getDirect`: empty / GetterSetter when `.errors` is deleted or an accessor. let errors = value.get_errors_property(global_ref); if errors.is_object() { let mut ctx = AggCtx { From ebdaed7400b55a248be2aa4cb6f3290dded3e3c9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:17:49 +0000 Subject: [PATCH 04/11] test: drop depth-marker assertion for Bun.inspect (stack-address dependent on release) --- test/js/bun/util/inspect-error-cycle.test.ts | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/test/js/bun/util/inspect-error-cycle.test.ts b/test/js/bun/util/inspect-error-cycle.test.ts index 256581363d8e..8f1d0dbf52a3 100644 --- a/test/js/bun/util/inspect-error-cycle.test.ts +++ b/test/js/bun/util/inspect-error-cycle.test.ts @@ -141,22 +141,6 @@ describe.concurrent("AggregateError printer output", () => { expect(exitCode).toBe(0); }); - test("deep nesting renders depth marker", async () => { - await using proc = Bun.spawn({ - cmd: [ - bunExe(), - "-e", - `let x = new AggregateError([], "leaf"); for (let i = 0; i < 3000; i++) x = new AggregateError([x], ""); process.stdout.write(Bun.inspect(x));`, - ], - env: { ...bunEnv, NO_COLOR: "1" }, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stdout).toContain("[AggregateError: nesting too deep]"); - expect(exitCode).toBe(0); - }); - test("uncaught AggregateError prints its own message", async () => { await using proc = Bun.spawn({ cmd: [bunExe(), "-e", `throw new AggregateError([new Error("inner")], "outer message");`], From b7b1a39c8e913b800937892a0d0f5c1f8a7b2615 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:40:14 +0000 Subject: [PATCH 05/11] test: run depth block concurrently; file now ~9s under debug+ASAN --- test/js/bun/util/inspect-error-cycle.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/js/bun/util/inspect-error-cycle.test.ts b/test/js/bun/util/inspect-error-cycle.test.ts index 8f1d0dbf52a3..0b9c76a243bc 100644 --- a/test/js/bun/util/inspect-error-cycle.test.ts +++ b/test/js/bun/util/inspect-error-cycle.test.ts @@ -83,10 +83,8 @@ describe.concurrent("error-graph cycles do not crash the printer", () => { } }); -// Depth tests kept out of the concurrent matrix: each prints hundreds of -// headers before the stack guard fires, which is seconds under debug+ASAN. // Release bun segfaults at ~2000 levels. -describe("error-graph depth does not crash the printer", () => { +describe.concurrent("error-graph depth does not crash the printer", () => { const deepAgg = `let x = new AggregateError([], "leaf"); for (let i = 0; i < 3000; i++) x = new AggregateError([x], "n" + i); const e = x;`; const deepCause = `let x = new Error("leaf"); for (let i = 0; i < 3000; i++) x = new Error("n" + i, { cause: x }); const e = x;`; From c3df4292ceeff7aff1d5e2b8db2c41e018a52102 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:44:03 +0000 Subject: [PATCH 06/11] ci: retrigger From c4ec5058307d170f86bc8376855224fad854a405 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:58:33 +0000 Subject: [PATCH 07/11] gate: retrigger (release build infra timeout) From 0eaf953ff0de51d60d3e7e10866f489be33d2690 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 26 Jul 2026 01:22:28 +0000 Subject: [PATCH 08/11] test: update jsx-template-string-crash snapshots for AggregateError header --- test/regression/issue/jsx-template-string-crash.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/regression/issue/jsx-template-string-crash.test.ts b/test/regression/issue/jsx-template-string-crash.test.ts index a04b036aef12..a244d9892c2e 100644 --- a/test/regression/issue/jsx-template-string-crash.test.ts +++ b/test/regression/issue/jsx-template-string-crash.test.ts @@ -16,7 +16,8 @@ test("JSX lexer should not crash with slice bounds issues", async () => { expect(exitCode).toBe(1); expect(normalizeBunSnapshot(stderr.toString().replace(/(Bun v.*)$/gm, ""))).toMatchInlineSnapshot(` - "1 | export function x(){return
} + "AggregateError: 2 errors building "/[eval]" + 1 | export function x(){return
} ^ error: Expected "{" but found "\`" at /[eval]:1:34 @@ -57,7 +58,8 @@ test.concurrent("#30959 JSX attribute with invalid '(' value parses cleanly in d const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(normalizeBunSnapshot(stderr.replace(/(Bun v.*)$/gm, ""))).toMatchInlineSnapshot(` - "1 | export function x(){return/[eval]" + 1 | export function x(){return/[eval]:1:32 From 747b90915e94089f7da6a8278e9b6e49e7e07976 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:03:56 +0000 Subject: [PATCH 09/11] console: let the header-print RangeError propagate instead of entering for_each with a pending exception Skip the .errors iteration when the header print already tripped the inner stack guard (formatter.failed or pending exception), and only clear a for_each error when formatter.failed is unset. Bun.inspect / console.* on a very deep AggregateError now throws RangeError like it does for a deep cause chain. --- src/jsc/VirtualMachine.rs | 3 ++- test/js/bun/util/inspect-error-cycle.test.ts | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 467198a9d830..449cc861aab9 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -4880,7 +4880,7 @@ impl VirtualMachine { } // `getDirect`: empty / GetterSetter when `.errors` is deleted or an accessor. let errors = value.get_errors_property(global_ref); - if errors.is_object() { + if !formatter.failed && !global_ref.has_exception() && errors.is_object() { let mut ctx = AggCtx { formatter: std::ptr::from_mut(formatter), writer: std::ptr::from_mut(writer), @@ -4893,6 +4893,7 @@ impl VirtualMachine { if errors .for_each(global_ref, (&raw mut ctx).cast(), agg_iter) .is_err() + && !formatter.failed { self.global().clear_exception(); } diff --git a/test/js/bun/util/inspect-error-cycle.test.ts b/test/js/bun/util/inspect-error-cycle.test.ts index 0b9c76a243bc..554ce138ef5f 100644 --- a/test/js/bun/util/inspect-error-cycle.test.ts +++ b/test/js/bun/util/inspect-error-cycle.test.ts @@ -98,8 +98,9 @@ describe.concurrent("error-graph depth does not crash the printer", () => { }); const [, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(proc.signalCode).toBeFalsy(); - if (sink.allowFail) expect(exitCode).toBeLessThan(128); - else expect(exitCode).toBe(0); + // On can_throw_stack_overflow sinks (Bun.inspect / console.*) the + // RangeError propagates like it does for a deep cause chain. + expect(exitCode).toBeLessThan(128); }); } From de1b576bfd7ba84dcfe013043ca34b155726c4de Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:59:48 +0000 Subject: [PATCH 10/11] console: gate .errors iteration on pending exception only, not formatter.failed formatter.failed is sticky across siblings on can_throw_stack_overflow=false sinks, so a deep branch would suppress every following sibling's .errors expansion. has_exception() alone covers the can_throw=true RangeError case; the post-for_each !failed guard still prevents clearing a propagating RangeError. --- src/jsc/VirtualMachine.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 449cc861aab9..02a4d70fed9c 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -4880,7 +4880,7 @@ impl VirtualMachine { } // `getDirect`: empty / GetterSetter when `.errors` is deleted or an accessor. let errors = value.get_errors_property(global_ref); - if !formatter.failed && !global_ref.has_exception() && errors.is_object() { + if !global_ref.has_exception() && errors.is_object() { let mut ctx = AggCtx { formatter: std::ptr::from_mut(formatter), writer: std::ptr::from_mut(writer), From 055856f5b20dd739022396473925a0f7f4353661 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 26 Jul 2026 03:41:22 +0000 Subject: [PATCH 11/11] console: only preserve the for_each exception when the stack guard actually threw Narrow the post-for_each clear guard to !(failed && can_throw_stack_overflow) so a hostile .errors iterator after a deep sibling is still cleared on the uncaught-throw / unhandled-reject sinks, while the RangeError on the Bun.inspect / console.* sinks keeps propagating. --- src/jsc/VirtualMachine.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 02a4d70fed9c..28e17d98d6a7 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -4893,7 +4893,7 @@ impl VirtualMachine { if errors .for_each(global_ref, (&raw mut ctx).cast(), agg_iter) .is_err() - && !formatter.failed + && !(formatter.failed && formatter.can_throw_stack_overflow) { self.global().clear_exception(); }