From 8526b9674a65a00e091b57a7573a69416918f1ac Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:44:11 +0000 Subject: [PATCH 1/4] Print the AggregateError header before iterating its members The native error printer (console.log/error of an Error, uncaught throw, unhandled rejection, Bun.inspect) handled AggregateError by iterating .errors and printing each member, then returning. The aggregate's own 'AggregateError: ' line and stack were never printed, so a Promise.any() rejection or any thrown AggregateError showed only the member errors with no indication of what actually failed. Move the aggregate-branch iteration in print_errorlike_object to after print_error_from_maybe_private_data so the aggregate itself is rendered first (name, message, stack), then each .errors member follows with a blank-line separator, matching how the cause chain is already printed. --- src/jsc/VirtualMachine.rs | 26 ++++--- test/js/bun/util/inspect-error.test.js | 103 +++++++++++++++++-------- 2 files changed, 85 insertions(+), 44 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 6cf416165611..f76f3c1077d0 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -4761,6 +4761,18 @@ impl VirtualMachine { // once the AggregateError branch is taken). let global_ref = self.global(); + // 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 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. @@ -4797,6 +4809,7 @@ impl VirtualMachine { // SAFETY: `ctx.writer` borrows the caller's stack local, // live across the synchronous `for_each` call. let writer = unsafe { &mut *ctx.writer }; + let _ = writer.write_all(b"\n"); vm.print_errorlike_object( next_value, None, @@ -4811,6 +4824,7 @@ impl VirtualMachine { formatter: std::ptr::from_mut(formatter), writer: std::ptr::from_mut(writer), exception_list: exception_list + .as_deref_mut() .map(std::ptr::from_mut::) .unwrap_or(core::ptr::null_mut()), allow_ansi_color, @@ -4824,18 +4838,6 @@ impl VirtualMachine { 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/test/js/bun/util/inspect-error.test.js b/test/js/bun/util/inspect-error.test.js index 4d3f488dac81..4a426950385c 100644 --- a/test/js/bun/util/inspect-error.test.js +++ b/test/js/bun/util/inspect-error.test.js @@ -1,4 +1,5 @@ import { describe, expect, jest, test } from "bun:test"; +import { bunEnv, bunExe } from "harness"; test("error.cause", () => { const err = new Error("error 1"); @@ -9,21 +10,23 @@ test("error.cause", () => { .replaceAll(import.meta.dir.replaceAll("\\", "/"), "[dir]"), ).toMatchInlineSnapshot(` "1 | import { describe, expect, jest, test } from "bun:test"; -2 | -3 | test("error.cause", () => { -4 | const err = new Error("error 1"); -5 | const err2 = new Error("error 2", { cause: err }); +2 | import { bunEnv, bunExe } from "harness"; +3 | +4 | test("error.cause", () => { +5 | const err = new Error("error 1"); +6 | const err2 = new Error("error 2", { cause: err }); ^ error: error 2 - at ([dir]/inspect-error.test.js:5:20) + at ([dir]/inspect-error.test.js:6:20) 1 | import { describe, expect, jest, test } from "bun:test"; -2 | -3 | test("error.cause", () => { -4 | const err = new Error("error 1"); +2 | import { bunEnv, bunExe } from "harness"; +3 | +4 | test("error.cause", () => { +5 | const err = new Error("error 1"); ^ error: error 1 - at ([dir]/inspect-error.test.js:4:19) + at ([dir]/inspect-error.test.js:5:19) " `); }); @@ -35,15 +38,15 @@ test("Error", () => { .replaceAll("\\", "/") .replaceAll(import.meta.dir.replaceAll("\\", "/"), "[dir]"), ).toMatchInlineSnapshot(` -"27 | " -28 | \`); -29 | }); -30 | -31 | test("Error", () => { -32 | const err = new Error("my message"); +"30 | " +31 | \`); +32 | }); +33 | +34 | test("Error", () => { +35 | const err = new Error("my message"); ^ error: my message - at ([dir]/inspect-error.test.js:32:19) + at ([dir]/inspect-error.test.js:35:19) " `); }); @@ -73,19 +76,11 @@ note: "duplicateConstDecl" was originally declared here const normalizeError = str => { // remove debug-only stack trace frames - // like "at require (:1:21)" - if (str.includes(" (:")) { - const splits = str.split("\n"); - for (let i = 0; i < splits.length; i++) { - if (splits[i].includes(" (:")) { - splits.splice(i, 1); - i--; - } - } - return splits.join("\n"); - } - - return str; + // like "at require (:1:21)" or "at require (51:24)" + return str + .split("\n") + .filter(line => !/^\s+at .+ \(:?\d+:\d+\)$/.test(line)) + .join("\n"); }; test("Error inside minified file (no color) ", () => { @@ -111,7 +106,7 @@ test("Error inside minified file (no color) ", () => { error: error inside long minified file! at ([dir]/inspect-error-fixture.min.js:26:2850) at ([dir]/inspect-error-fixture.min.js:26:2890) - at ([dir]/inspect-error.test.js:92:7)" + at ([dir]/inspect-error.test.js:87:7)" `); } }); @@ -140,7 +135,7 @@ test("Error inside minified file (color) ", () => { error: error inside long minified file! at ([dir]/inspect-error-fixture.min.js:26:2850) at ([dir]/inspect-error-fixture.min.js:26:2890) - at ([dir]/inspect-error.test.js:120:7)" + at ([dir]/inspect-error.test.js:115:7)" `); } }); @@ -154,7 +149,7 @@ test("Inserted originalLine and originalColumn do not appear in node:util.inspec .replaceAll(import.meta.path.replaceAll("\\", "/"), "[file]"), ).toMatchInlineSnapshot(` "Error: my message - at ([file]:149:19)" + at ([file]:144:19)" `); }); @@ -175,6 +170,50 @@ describe("observable properties", () => { } }); +describe("AggregateError", () => { + // Build the aggregate's message (and the expected header) at runtime so the + // source-line preview that the error printer emits cannot contain the + // assertion string by accident. + const mk = `["TOP","AGG","MESSAGE"].join("-")`; + const header = ["AggregateError", ["TOP", "AGG", "MESSAGE"].join("-")].join(": "); + + test.concurrent.each([ + ["Bun.inspect", `process.stderr.write(Bun.inspect(new AggregateError([new Error("m1"), new RangeError("m2")], ${mk})))`, 0], + ["console.error", `console.error(new AggregateError([new Error("m1"), new RangeError("m2")], ${mk}))`, 0], + ["uncaught throw", `throw new AggregateError([new Error("m1"), new RangeError("m2")], ${mk})`, 1], + ["unhandled rejection", `Promise.reject(new AggregateError([new Error("m1"), new RangeError("m2")], ${mk}))`, 1], + ])("%s prints the aggregate header and each member", async (_, code, wantExit) => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", code], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const out = stdout + stderr; + expect(out).toContain(header); + expect(out).toContain("error: m1"); + expect(out).toContain("RangeError: m2"); + expect(out.indexOf(header)).toBeLessThan(out.indexOf("error: m1")); + expect(exitCode).toBe(wantExit); + }); + + test.concurrent("unhandled Promise.any rejection prints the aggregate header", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", `Promise.any([Promise.reject(new Error("r1")), Promise.reject(new Error("r2"))])`], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const out = stdout + stderr; + expect(out).toContain("AggregateError"); + expect(out).toContain("r1"); + expect(out).toContain("r2"); + expect(exitCode).toBe(1); + }); +}); + test("error.stack throwing an error doesn't lead to a crash", () => { const err = new Error("my message"); Object.defineProperty(err, "stack", { From f0928006ce7a2f9c3a3e6f1de33b5091a72cd329 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:46:30 +0000 Subject: [PATCH 2/4] [autofix.ci] apply automated fixes --- test/js/bun/util/inspect-error.test.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/js/bun/util/inspect-error.test.js b/test/js/bun/util/inspect-error.test.js index 4a426950385c..e579402de87b 100644 --- a/test/js/bun/util/inspect-error.test.js +++ b/test/js/bun/util/inspect-error.test.js @@ -178,7 +178,11 @@ describe("AggregateError", () => { const header = ["AggregateError", ["TOP", "AGG", "MESSAGE"].join("-")].join(": "); test.concurrent.each([ - ["Bun.inspect", `process.stderr.write(Bun.inspect(new AggregateError([new Error("m1"), new RangeError("m2")], ${mk})))`, 0], + [ + "Bun.inspect", + `process.stderr.write(Bun.inspect(new AggregateError([new Error("m1"), new RangeError("m2")], ${mk})))`, + 0, + ], ["console.error", `console.error(new AggregateError([new Error("m1"), new RangeError("m2")], ${mk}))`, 0], ["uncaught throw", `throw new AggregateError([new Error("m1"), new RangeError("m2")], ${mk})`, 1], ["unhandled rejection", `Promise.reject(new AggregateError([new Error("m1"), new RangeError("m2")], ${mk}))`, 1], From 573f9462ec44ded75abb1716aa642ba155aa26a6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:01:19 +0000 Subject: [PATCH 3/4] Update jsx-template-string-crash snapshot for the AggregateError header The transpiler's multi-error AggregateError now prints its '2 errors building ...' header before the individual BuildMessages. --- .../regression/issue/jsx-template-string-crash.test.ts | 10 ++++++++-- 1 file changed, 8 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..9ea270564de1 100644 --- a/test/regression/issue/jsx-template-string-crash.test.ts +++ b/test/regression/issue/jsx-template-string-crash.test.ts @@ -16,11 +16,14 @@ 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 + 1 | export function x(){return
} ^ error: Unterminated string literal @@ -57,11 +60,14 @@ 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 + 1 | export function x(){return Date: Wed, 22 Jul 2026 20:07:05 +0000 Subject: [PATCH 4/4] Strengthen AggregateError test ordering assertions --- test/js/bun/util/inspect-error.test.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/js/bun/util/inspect-error.test.js b/test/js/bun/util/inspect-error.test.js index e579402de87b..0389981b0aea 100644 --- a/test/js/bun/util/inspect-error.test.js +++ b/test/js/bun/util/inspect-error.test.js @@ -199,6 +199,7 @@ describe("AggregateError", () => { expect(out).toContain("error: m1"); expect(out).toContain("RangeError: m2"); expect(out.indexOf(header)).toBeLessThan(out.indexOf("error: m1")); + expect(out.indexOf("error: m1")).toBeLessThan(out.indexOf("RangeError: m2")); expect(exitCode).toBe(wantExit); }); @@ -212,8 +213,10 @@ describe("AggregateError", () => { const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); const out = stdout + stderr; expect(out).toContain("AggregateError"); - expect(out).toContain("r1"); - expect(out).toContain("r2"); + expect(out).toContain("error: r1"); + expect(out).toContain("error: r2"); + expect(out.indexOf("AggregateError")).toBeLessThan(out.indexOf("error: r1")); + expect(out.indexOf("error: r1")).toBeLessThan(out.indexOf("error: r2")); expect(exitCode).toBe(1); }); });