Skip to content
Open
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
51 changes: 26 additions & 25 deletions src/bun.js/VirtualMachine.zig
Original file line number Diff line number Diff line change
Expand Up @@ -2454,31 +2454,10 @@
}
}

if (value.isAggregateError(this.global)) {
const AggregateErrorIterator = struct {
writer: Writer,
current_exception_list: ?*ExceptionList = null,
formatter: *ConsoleObject.Formatter,

pub fn iteratorWithColor(vm: *VM, globalObject: *JSGlobalObject, ctx: ?*anyopaque, nextValue: JSValue) callconv(.c) void {
iterator(vm, globalObject, nextValue, ctx.?, true);
}
pub fn iteratorWithOutColor(vm: *VM, globalObject: *JSGlobalObject, ctx: ?*anyopaque, nextValue: JSValue) callconv(.c) void {
iterator(vm, globalObject, nextValue, ctx.?, false);
}
fn iterator(_: *VM, _: *JSGlobalObject, nextValue: JSValue, ctx: ?*anyopaque, comptime color: bool) void {
const this_ = @as(*@This(), @ptrFromInt(@intFromPtr(ctx)));
VirtualMachine.get().printErrorlikeObject(nextValue, null, this_.current_exception_list, this_.formatter, Writer, this_.writer, color, allow_side_effects);
}
};
var iter = AggregateErrorIterator{ .writer = writer, .current_exception_list = exception_list, .formatter = formatter };
if (comptime allow_ansi_color) {
value.getErrorsProperty(this.global).forEach(this.global, &iter, AggregateErrorIterator.iteratorWithColor) catch return; // TODO: properly propagate exception upwards
} else {
value.getErrorsProperty(this.global).forEach(this.global, &iter, AggregateErrorIterator.iteratorWithOutColor) catch return; // TODO: properly propagate exception upwards
}
return;
}
// AggregateError is handled by printErrorInstance, which prints the
// wrapper (name / message / stack) and then fans out the non-enumerable
// `errors` array via the errors_to_append queue — see the cause block
// there for the same pattern.

was_internal = this.printErrorFromMaybePrivateData(
value,
Expand Down Expand Up @@ -3289,6 +3268,28 @@
}
}
}

// `AggregateError.errors` is a non-enumerable own property, so the
// property iterator above skips it. Walk it manually and queue each
// entry so the wrapper gets printed first (matching Node's
// `[errors]: [...]` format) instead of the errors replacing it.
if (error_instance.isAggregateError(this.global)) {
const AggregateErrorsIterator = struct {
errors_to_append: *std.array_list.Managed(JSValue),

pub fn visit(_: *VM, _: *JSGlobalObject, ctx: ?*anyopaque, next_value: JSValue) callconv(.c) void {
const self: *@This() = @ptrCast(@alignCast(ctx.?));
if (next_value.jsType() == .ErrorInstance) {
next_value.protect();
bun.handleOom(self.errors_to_append.append(next_value));
}
}

Check failure on line 3286 in src/bun.js/VirtualMachine.zig

View check run for this annotation

Claude / Claude Code Review

Non-Error values in AggregateError.errors silently dropped

The new `AggregateErrorsIterator.visit()` callback only queues entries where `jsType() == .ErrorInstance`, silently dropping any non-Error values (strings, numbers, plain objects) from `AggregateError.errors`. Since `AggregateError` does not restrict `.errors` to `Error` instances and `Promise.any()` can reject with any reason, this is a realistic regression; remove the type guard or fall through to the generic formatter for non-`ErrorInstance` values.
Comment thread
robobun marked this conversation as resolved.
};
var errors_iter = AggregateErrorsIterator{ .errors_to_append = &errors_to_append };
error_instance.getErrorsProperty(this.global).forEach(this.global, &errors_iter, AggregateErrorsIterator.visit) catch {
if (this.global.hasException()) this.global.clearException();
};
}
Comment thread
robobun marked this conversation as resolved.
} else if (mode == .js and error_instance != .zero) {
// If you do reportError([1,2,3]] we should still show something at least.
const tag = try jsc.Formatter.Tag.getAdvanced(
Expand Down
116 changes: 116 additions & 0 deletions test/regression/issue/29157.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// https://github.com/oven-sh/bun/issues/29157
//
// Bun's native `console.dir` / `console.log` formatter early-returned on any
// AggregateError, walking `.errors` and printing each entry as a top-level
// error — dropping the AggregateError wrapper's name, message, and stack.
//
// `Promise.any([Promise.reject(Error(""))]).catch(console.dir)` should print
// `AggregateError: ... { [errors]: [ ... ] }` like Node. Bun instead printed
// just the inner `Error` with no trace of the wrapper.
//
// Fix: removed the AggregateError early-return in `printErrorlikeObject` and
// taught `printErrorInstance` to walk the non-enumerable `.errors` array the
// same way it already walks `.cause`.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

import { expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";

test("console.dir on AggregateError prints the wrapper, not just the inner errors", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`const err = new AggregateError([new Error("child1"), new Error("child2")], "wrapper");
console.dir(err);`,
],
env: bunEnv,
});

const [stdout, stderr, exitCode] = await Promise.all([
proc.stdout.text(),
proc.stderr.text(),
proc.exited,
]);

expect(exitCode).toBe(0);

Check warning on line 35 in test/regression/issue/29157.test.ts

View check run for this annotation

Claude / Claude Code Review

Tests check exitCode before stdout/stderr (CLAUDE.md ordering violation)

All four tests check `exitCode` before `stdout`/`stderr` content assertions, violating the project CLAUDE.md rule. The fix is to move each `expect(exitCode)` call to after the content assertions — this gives a more useful error message on test failure (showing what was actually output rather than just an exit code mismatch).
// The wrapper's name + message must be in the output. Pre-fix, this was
// absent — the formatter printed only "error: child1 ... error: child2".
expect(stdout).toContain("AggregateError");
expect(stdout).toContain("wrapper");
Comment thread
robobun marked this conversation as resolved.
// Children must still be printed below the wrapper.
expect(stdout).toContain("child1");
expect(stdout).toContain("child2");
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
});

test("Promise.any rejection surfaces as an AggregateError, not a stray inner error", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`await Promise.any([Promise.reject(new Error("inner"))]).catch(console.dir);`,
],
env: bunEnv,
});

const [stdout, stderr, exitCode] = await Promise.all([
proc.stdout.text(),
proc.stderr.text(),
proc.exited,
]);

expect(exitCode).toBe(0);
// Pre-fix Bun printed `Error:` with no mention of AggregateError at all —
// exactly the bug from the issue report.
expect(stdout).toContain("AggregateError");
expect(stdout).toContain("inner");
});

test("uncaught AggregateError rejection still shows the wrapper", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`await Promise.any([Promise.reject(new Error("a")), Promise.reject(new Error("b"))]);`,
],
env: bunEnv,
});

const [stdout, stderr, exitCode] = await Promise.all([
proc.stdout.text(),
proc.stderr.text(),
proc.exited,
]);

expect(exitCode).not.toBe(0);
// Uncaught rejection goes to stderr. The wrapper must appear there — the
// native error-reporter was fanning out `.errors` without printing the
// outer AggregateError at all.
expect(stderr).toContain("AggregateError");
expect(stderr).toContain("a");
expect(stderr).toContain("b");
});

test("AggregateError with a cause chain prints wrapper + errors + cause", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`const cause = new Error("root cause");
const err = new AggregateError([new Error("child")], "wrapper", { cause });
console.dir(err);`,
],
env: bunEnv,
});

const [stdout, stderr, exitCode] = await Promise.all([
proc.stdout.text(),
proc.stderr.text(),
proc.exited,
]);

expect(exitCode).toBe(0);
expect(stdout).toContain("AggregateError");
expect(stdout).toContain("wrapper");
expect(stdout).toContain("child");
expect(stdout).toContain("root cause");
});
Loading