Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions src/jsc/VirtualMachine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::StackCheck::init();
let colors = bun_core::Output::enable_ansi_colors_stderr();
self.print_errorlike_object(
exception.value(),
Expand Down Expand Up @@ -4785,6 +4786,45 @@ impl VirtualMachine {
let global_ref = self.global();

if value.is_aggregate_error(global_ref) {
// Stack-safety + cycle guard for the `.errors` recursion below
// (`agg_iter` → `print_errorlike_object`). An AggregateError may
// appear in its own `.errors` array, and `.errors` may nest
// arbitrarily deep; neither case should overflow the native stack.
// Mirrors the `cause`-chain guard in `print_error_instance_body`.
Comment thread
robobun marked this conversation as resolved.
Outdated
if !formatter.stack_check.is_safe_to_recurse() {
formatter.failed = true;
if formatter.can_throw_stack_overflow {
let _ = global_ref.throw_stack_overflow();
}
return;
}
Comment thread
robobun marked this conversation as resolved.
if formatter.map_node.is_none() {
let mut node =
NonNull::new(crate::console_object::formatter::visited::Pool::get_node())
.expect("ObjectPool::get_node always returns a valid heap node");
let data = crate::console_object::formatter::visited::node_data_mut(&mut node);
data.clear();
formatter.map = core::mem::take(data);
formatter.map_node = Some(node);
}
if formatter
.map
.get_or_put(value)
.expect("unreachable")
.found_existing
{
let _ = if allow_ansi_color {
writer.write_all(
bun_core::pretty_fmt!("<r><cyan>[Circular]<r>\n", true).as_bytes(),
)
} else {
writer.write_all(
bun_core::pretty_fmt!("<r><cyan>[Circular]<r>\n", false).as_bytes(),
)
};
return;
}

// 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
Expand Down Expand Up @@ -4844,6 +4884,7 @@ impl VirtualMachine {
// which case the error is swallowed.
let errors = value.get_errors_property(global_ref);
let _ = errors.for_each(global_ref, (&raw mut ctx).cast(), agg_iter);
let _ = formatter.map.remove(&value);
return;
}

Expand Down
1 change: 1 addition & 0 deletions src/runtime/jsc_hooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
36 changes: 36 additions & 0 deletions test/js/node/util/bun-inspect.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, expect, it } from "bun:test";
import { bunEnv, bunExe } from "harness";
import stripAnsi from "strip-ansi";

describe("Bun.inspect", () => {
Expand Down Expand Up @@ -87,6 +88,41 @@
);
});

it("self-referential AggregateError prints [Circular]", () => {
const ae: any = new AggregateError([], "circ");
ae.errors.push(ae);
const out = Bun.inspect(ae);
expect(out).toContain("[Circular]");
});

it("deeply nested AggregateError throws a stack overflow instead of crashing", () => {
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([
["console.log", `console.log(ae);`, 0],
["Bun.inspect", `process.stdout.write(Bun.inspect(ae));`, 0],
["throw", `throw ae;`, 1],
["Promise.reject", `Promise.reject(ae); await 0;`, 1],
])("self-referential AggregateError via %s does not crash", async (name, emit, expectedExit) => {

Check failure on line 111 in test/js/node/util/bun-inspect.test.ts

View check run for this annotation

Claude / Claude Code Review

StackCheck::init() seatings at print_exception entry points are untested

The two new `formatter.stack_check = StackCheck::init()` seatings (VirtualMachine.rs:4472, jsc_hooks.rs:1197) are load-bearing for the deep-nesting case on the throw/reject paths, but no test covers them — the four subprocess tests use a *self-referential* AggregateError (caught by the cycle guard at depth 1), and the deep-nesting test uses `Bun.inspect` (which seats `stack_check` independently at ConsoleObject.rs:1439). Deleting both lines leaves every test green while re-introducing SIGSEGV fo
Comment thread
robobun marked this conversation as resolved.
const src = `const ae = new AggregateError([], "circ"); ae.errors.push(ae); ${emit}`;
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", src],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const output = stdout + stderr;
expect(output).toContain("[Circular]");
expect(proc.signalCode).toBeNull();
expect(exitCode).toBe(expectedExit);
});

it("depth = 0", () => {
expect(Bun.inspect({ a: { b: { c: { d: 1 } } } }, { depth: 0 })).toEqual("{\n a: [Object ...],\n}");
});
Expand Down
Loading