-
Notifications
You must be signed in to change notification settings - Fork 4.4k
fix(console): print AggregateError wrapper instead of dropping it #29163
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
1955b21
deba3bc
b2f60ed
f2daa46
cd7672b
9f1c831
d3dc440
acfc93a
4fcac71
e7f4b9a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2454,31 +2454,10 @@ pub fn printErrorlikeObject( | |
| } | ||
| } | ||
|
|
||
| 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, | ||
|
|
@@ -3280,6 +3259,27 @@ fn printErrorInstance( | |
| try writer.writeAll("\n"); | ||
| } | ||
|
|
||
| // `AggregateError.errors` is a non-enumerable own property, so the | ||
| // property iterator above skips it. Walk it manually and queue each | ||
| // entry (of any type — Promise.any can reject with non-Error values) | ||
| // so the wrapper gets printed first and the children follow, matching | ||
| // Node's `[errors]: [...]` format. Queue errors BEFORE cause so the | ||
| // drain loop emits them in Node's order (wrapper → errors → cause). | ||
| 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.?)); | ||
| next_value.protect(); | ||
| bun.handleOom(self.errors_to_append.append(next_value)); | ||
| } | ||
| }; | ||
| var errors_iter = AggregateErrorsIterator{ .errors_to_append = &errors_to_append }; | ||
| const errors_array = error_instance.getErrorsProperty(this.global); | ||
| try errors_array.forEach(this.global, &errors_iter, AggregateErrorsIterator.visit); | ||
|
Comment on lines
+3268
to
+3280
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 The drain loop that replays queued Extended reasoning...What the bug is and how it manifests The new The specific code path that triggers it
current.* = switch (msg.metadata) {
.build => bun.api.BuildMessage.create(...),
.resolve => bun.api.ResolveMessage.create(...),
};
ret.* = ErrorableResolvedSource.err(err, globalThis.createAggregateError(errors, ...));When Why existing code doesn't prevent it The specialized formatting path for History of the regression Commit cd7672b had correctly fixed the drain loop to call Impact Any user who runs Bun on a script that dynamically imports a module with 2+ syntax errors (or uses Step-by-step proof
Fix In the drain loop (~line 3342), replace |
||
| } | ||
|
robobun marked this conversation as resolved.
|
||
|
|
||
| // "cause" is not enumerable, so the above loop won't see it. | ||
| if (!saw_cause) { | ||
| if (try error_instance.getOwn(this.global, "cause")) |cause| { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| // https://github.com/oven-sh/bun/issues/29157 | ||
|
|
||
| import { expect, test } from "bun:test"; | ||
| import { bunEnv, bunExe } from "harness"; | ||
|
|
||
| test.concurrent("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(stdout).toContain("AggregateError"); | ||
| expect(stdout).toContain("wrapper"); | ||
|
robobun marked this conversation as resolved.
|
||
| expect(stdout).toContain("child1"); | ||
| expect(stdout).toContain("child2"); | ||
| expect(exitCode).toBe(0); | ||
|
robobun marked this conversation as resolved.
|
||
| }); | ||
|
|
||
| test.concurrent("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(stdout).toContain("AggregateError"); | ||
| expect(stdout).toContain("inner"); | ||
| expect(exitCode).toBe(0); | ||
| }); | ||
|
|
||
| test.concurrent("uncaught AggregateError rejection still shows the wrapper", async () => { | ||
| await using proc = Bun.spawn({ | ||
| cmd: [ | ||
| bunExe(), | ||
| "-e", | ||
| `await Promise.any([Promise.reject(new Error("child_aaa")), Promise.reject(new Error("child_bbb"))]);`, | ||
| ], | ||
| env: bunEnv, | ||
| }); | ||
|
|
||
| const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); | ||
|
|
||
| expect(stderr).toContain("AggregateError"); | ||
| // Distinctive messages so the assertion can only pass when the inner | ||
| // errors are actually printed — single-char strings would match the | ||
| // wrapper name or stack-frame content even if the children were dropped. | ||
| expect(stderr).toContain("child_aaa"); | ||
| expect(stderr).toContain("child_bbb"); | ||
| expect(exitCode).not.toBe(0); | ||
|
claude[bot] marked this conversation as resolved.
|
||
| }); | ||
|
|
||
| test.concurrent("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(stdout).toContain("AggregateError"); | ||
| expect(stdout).toContain("wrapper"); | ||
| expect(stdout).toContain("child"); | ||
| expect(stdout).toContain("root cause"); | ||
| // Node's util.inspect emits "[errors]: [ ... ]" with the children before | ||
| // the cause. Assert that ordering so we don't regress to wrapper → cause → | ||
| // children (the pre-fix queueing order in errors_to_append). | ||
| const childIdx = stdout.indexOf("child"); | ||
| const causeIdx = stdout.indexOf("root cause"); | ||
| expect(childIdx).toBeGreaterThan(-1); | ||
| expect(causeIdx).toBeGreaterThan(-1); | ||
| expect(childIdx).toBeLessThan(causeIdx); | ||
| expect(exitCode).toBe(0); | ||
| }); | ||
Uh oh!
There was an error while loading. Please reload this page.