From e0f11a0ba43866959ccce59ff9df86ea7f63aa40 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 03:30:51 +0000 Subject: [PATCH 01/11] console: print AggregateError header and label [cause]/[errors] in native error output The native error printer used by console.error, Bun.inspect, uncaught throws, and unhandled rejections had four related problems: - print_errorlike_object short-circuited on AggregateError and iterated .errors only, so the AggregateError's own name, message, stack, and cause were never shown. - Appended cause/error-typed properties were printed as bare second blocks with no label, so it was impossible to tell which was the cause and which were AggregateError members. - V8StackTraceIterator::parseFrame returned false on 'at /path:l:c' frames (no function name), terminating iteration. A reassigned Error.stack containing such frames lost everything from that point on. - remap_zig_exception re-applied source-map remapping to frames that were already in original coordinates (remapped = true), so a reassigned .stack string round-tripped to wrong positions. Move the .errors iteration into print_error_instance_body so the aggregate prints its own header first and its members follow; track the origin of each appended error and emit a dim '[cause]:' / '[errors]:' / '[]:' label before each block; let the V8 stack parser treat a paren-less line as 'sourceURL:line:col' with an empty function name; and skip the secondary remap for frames already marked remapped. The 'getError frame missing after reading .stack' case in the issue is JavaScriptCore tail-call elimination, not a printer bug. Fixes #1352 --- src/jsc/VirtualMachine.rs | 129 +++++++--------- src/jsc/bindings/ZigException.cpp | 18 ++- test/js/bun/util/inspect-error.test.js | 143 +++++++++++++++--- .../issue/jsx-template-string-crash.test.ts | 16 +- 4 files changed, 202 insertions(+), 104 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index fc224cc8f479..bde7fb2ee52c 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -5158,74 +5158,6 @@ impl VirtualMachine { allow_ansi_color: bool, allow_side_effects: bool, ) { - // Note: the post-print stack/exception_list block is handled at the - // tail instead of via a drop guard (the body has no early-`?` returns - // once the AggregateError branch is taken). - let global_ref = self.global(); - - 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. - // The C trampoline erases lifetimes via `*mut c_void`; round-trip - // the caller's `&mut ExceptionList` as a raw pointer so child - // errors append to the same list. - struct AggCtx<'a> { - formatter: *mut crate::console_object::Formatter<'a>, - writer: *mut bun_core::io::Writer, - exception_list: *mut ExceptionList, - allow_ansi_color: bool, - allow_side_effects: bool, - } - extern "C" fn agg_iter( - _vm: *mut crate::VM, - _global: &JSGlobalObject, - ctx: *mut c_void, - next_value: JSValue, - ) { - // SAFETY: `ctx` is `&mut AggCtx` for the duration of `for_each`. - let ctx = unsafe { bun_ptr::callback_ctx::>(ctx) }; - // SAFETY: per-thread VM. - let vm = VirtualMachine::get().as_mut(); - let exception_list = if ctx.exception_list.is_null() { - None - } else { - // SAFETY: non-null branch; borrows the caller's stack - // `ExceptionList`, live for the synchronous `for_each`. - Some(unsafe { &mut *ctx.exception_list }) - }; - // SAFETY: `ctx.formatter` borrows the caller's stack local, - // live across the synchronous `for_each` call. - let formatter = unsafe { &mut *ctx.formatter }; - // SAFETY: `ctx.writer` borrows the caller's stack local, - // live across the synchronous `for_each` call. - let writer = unsafe { &mut *ctx.writer }; - vm.print_errorlike_object( - next_value, - None, - exception_list, - formatter, - writer, - ctx.allow_ansi_color, - 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. - let errors = value.get_errors_property(global_ref); - let _ = errors.for_each(global_ref, (&raw mut ctx).cast(), agg_iter); - 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; @@ -5245,7 +5177,7 @@ impl VirtualMachine { // — semantics unchanged because // `need_to_clear_parser_arena_on_deinit` is false here. let zig_exception: &mut ZigException = holder.zig_exception(); - exception_.get_stack_trace(global_ref, &mut zig_exception.stack); + exception_.get_stack_trace(self.global(), &mut zig_exception.stack); if zig_exception.stack.frames_len > 0 { let _ = Self::print_stack_trace(writer, &zig_exception.stack, allow_ansi_color); } @@ -5818,7 +5750,7 @@ impl VirtualMachine { if frames.len() > 1 { for i in 0..frames.len() { - if i == top || frames[i].position.is_invalid() { + if i == top || frames[i].position.is_invalid() || frames[i].remapped { continue; } let source_url = frames[i].source_url.to_utf8(); @@ -6279,17 +6211,18 @@ impl VirtualMachine { } // This is usually unsafe to do, but we are protecting them each time first. - let mut errors_to_append: Vec = Vec::new(); + let mut errors_to_append: Vec<(JSValue, bun_core::String)> = Vec::new(); // Each appended error is unprotected at scope exit. // `BackRef` (constructed from `&raw mut` via `NonNull` so the tag is // not popped by later `errors_to_append.push` reborrows) lets the drop // body read the Vec safely. - struct UnprotectAll(bun_ptr::BackRef>); + struct UnprotectAll(bun_ptr::BackRef>); impl Drop for UnprotectAll { fn drop(&mut self) { // BackRef invariant: borrows the caller's stack `Vec`, live for this scope. - for v in self.0.iter() { + for (v, label) in self.0.iter() { v.unprotect(); + label.deref(); } } } @@ -6332,7 +6265,8 @@ impl VirtualMachine { saw_cause = true; } value.protect(); - errors_to_append.push(value); + let label = field.dupe_ref(); + errors_to_append.push((value, label)); } else if kind.is_object() || kind.is_array() || value.is_primitive() @@ -6426,7 +6360,36 @@ impl VirtualMachine { if let Some(cause) = error_instance.get_own(global_ref, &key)? { if cause.is_cell() && cause.js_type() == JSType::ErrorInstance { cause.protect(); - errors_to_append.push(cause); + errors_to_append.push((cause, bun_core::String::static_(b"cause"))); + } + } + } + + // AggregateError's `.errors` is `DontEnum` so the own-property loop + // above skips it; iterate and append each member here so the + // aggregate prints its own header first (just above) and then each + // member, labeled `[errors]:`, reached uniformly whether the + // aggregate is top-level or reached via a cause chain. + if error_instance.is_aggregate_error(global_ref) { + let errors = error_instance.get_errors_property(global_ref); + if errors.is_cell() && errors.js_type().is_array() && errors != error_instance { + if let Ok(len) = errors.get_length(global_ref) { + let n = len.min(u64::from(u32::MAX)); + let mut i: u32 = 0; + while u64::from(i) < n { + let Ok(member) = errors.get_index(global_ref, i) else { + if allow_side_effects { + global_ref.clear_exception(); + } + break; + }; + i += 1; + member.protect(); + errors_to_append + .push((member, bun_core::String::static_(b"errors"))); + } + } else if allow_side_effects { + global_ref.clear_exception(); } } } @@ -6458,7 +6421,7 @@ impl VirtualMachine { } let mut exception_list = exception_list; - for &err in &errors_to_append { + for &(err, ref label) in &errors_to_append { // Circular-ref guard for cause chains. if formatter.map_node.is_none() { let mut node = NonNull::new(console_object::formatter::visited::Pool::get_node()) @@ -6472,19 +6435,29 @@ impl VirtualMachine { let entry = formatter.map.get_or_put(err).expect("unreachable"); if entry.found_existing { writer.write_all(b"\n")?; + if !label.is_empty() { + pretty_write!(writer, "[{}]: ", label)?; + } pretty_write!(writer, "[Circular]")?; continue; } writer.write_all(b"\n")?; - self.print_error_instance_js( + if !label.is_empty() { + pretty_write!(writer, "[{}]:\n", label)?; + } + // Route through the top-level printer so BuildMessage / + // ResolveMessage members of an AggregateError keep their own + // formatter rather than the generic ErrorInstance body. + self.print_errorlike_object( err, + None, exception_list.as_deref_mut(), formatter, writer, allow_ansi_color, allow_side_effects, - )?; + ); let _ = formatter.map.remove(&err); } diff --git a/src/jsc/bindings/ZigException.cpp b/src/jsc/bindings/ZigException.cpp index ea84d276eda9..f307a84c25fe 100644 --- a/src/jsc/bindings/ZigException.cpp +++ b/src/jsc/bindings/ZigException.cpp @@ -296,7 +296,9 @@ class V8StackTraceIterator { if (openingParentheses > closingParentheses) openingParentheses = WTF::notFound; - if (openingParentheses == WTF::notFound || closingParentheses == WTF::notFound) { + bool hasParens = openingParentheses != WTF::notFound && closingParentheses != WTF::notFound; + + if (!hasParens) { // Special case: "unknown" frames don't have parentheses but are valid // These appear in stack traces from certain error paths if (line == "unknown"_s) { @@ -305,12 +307,13 @@ class V8StackTraceIterator { return true; } - // For any other frame without parentheses, terminate parsing as before - offset = stack.length(); - return false; + // `at /path/file.js:1:2` (no function name). V8 and Bun both emit + // this shape for top-level / anonymous frames, so parse the whole + // line as the source location with an empty function name rather + // than terminating iteration. } - auto lineInner = StringView_slice(line, openingParentheses + 1, closingParentheses); + auto lineInner = hasParens ? StringView_slice(line, openingParentheses + 1, closingParentheses) : line; { auto marker1 = 0; @@ -383,6 +386,11 @@ class V8StackTraceIterator { } done_block: + if (!hasParens) { + frame.functionName = StringView(); + return true; + } + StringView functionName = line.substring(0, openingParentheses - 1); if (functionName == "global code"_s) { diff --git a/test/js/bun/util/inspect-error.test.js b/test/js/bun/util/inspect-error.test.js index 4d3f488dac81..cbffd9b3a299 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,24 @@ 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) +[cause]: 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 +39,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"); +"31 | " +32 | \`); +33 | }); +34 | +35 | test("Error", () => { +36 | const err = new Error("my message"); ^ error: my message - at ([dir]/inspect-error.test.js:32:19) + at ([dir]/inspect-error.test.js:36:19) " `); }); @@ -111,7 +115,8 @@ 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 require (51:24) + at ([dir]/inspect-error.test.js:96:7)" `); } }); @@ -140,7 +145,8 @@ 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 require (51:24) + at ([dir]/inspect-error.test.js:125:7)" `); } }); @@ -154,7 +160,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]:155:19)" `); }); @@ -188,3 +194,102 @@ test("error.stack throwing an error doesn't lead to a crash", () => { throw err; }).toThrow(); }); + +// https://github.com/oven-sh/bun/issues/1352 +describe("#1352 native error printer", () => { + // The expected header/message strings are built at runtime so that the + // source-line preview the printer emits (which quotes the `-e` source) + // cannot accidentally satisfy the assertion. + const src = ` +const m1 = new Error(["err", "one"].join("-")); +const m2 = new RangeError(["err", "two"].join("-")); +const cause = new TypeError(["the", "cause"].join("-")); +const agg = new AggregateError([m1, m2], ["agg", "msg"].join("-"), { cause }); +`; + const AGG = ["agg", "msg"].join("-"); + const M1 = ["err", "one"].join("-"); + const M2 = ["err", "two"].join("-"); + const CAUSE = ["the", "cause"].join("-"); + + async function run(code, { exit = 0 } = {}) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", code], + env: bunEnv, + stderr: "pipe", + stdout: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(exitCode).toBe(exit); + return { stdout, stderr }; + } + + test.concurrent.each([ + ["console.error", `${src}; console.error(agg);`, 0], + ["Bun.inspect", `${src}; process.stderr.write(Bun.inspect(agg));`, 0], + ["uncaught throw", `${src}; throw agg;`, 1], + ["unhandled rejection", `${src}; Promise.reject(agg);`, 1], + ])("AggregateError via %s prints header, [cause] and each [errors] member", async (_, code, exit) => { + const { stderr } = await run(code, { exit }); + + expect(stderr).toContain("AggregateError: " + AGG); + expect(stderr).toContain("[cause]:"); + expect(stderr).toContain("TypeError: " + CAUSE); + expect(stderr).toContain("[errors]:"); + expect(stderr).toContain("error: " + M1); + expect(stderr).toContain("RangeError: " + M2); + + // Header must precede the [cause] label which must precede [errors]. + const hdr = stderr.indexOf("AggregateError: " + AGG); + const causeLabel = stderr.indexOf("[cause]:"); + const errorsLabel = stderr.indexOf("[errors]:"); + expect(hdr).toBeGreaterThan(-1); + expect(causeLabel).toBeGreaterThan(hdr); + expect(errorsLabel).toBeGreaterThan(causeLabel); + }); + + test.concurrent("AggregateError reached via a cause chain prints its members", async () => { + const { stderr } = await run(`${src}; console.error(new Error("outer", { cause: agg }));`); + + expect(stderr).toContain("[cause]:"); + expect(stderr).toContain("AggregateError: " + AGG); + expect(stderr).toContain("[errors]:"); + expect(stderr).toContain("error: " + M1); + expect(stderr).toContain("RangeError: " + M2); + }); + + test.concurrent("error.cause is labeled with [cause]:", async () => { + const { stderr } = await run( + `const e = new Error(${JSON.stringify("outer-" + M1)}, { cause: new Error(${JSON.stringify("inner-" + M2)}) }); console.error(e);`, + ); + const causeLabel = stderr.indexOf("[cause]:"); + expect(causeLabel).toBeGreaterThan(-1); + expect(stderr.indexOf("error: inner-" + M2)).toBeGreaterThan(causeLabel); + expect(stderr.indexOf("error: outer-" + M1)).toBeLessThan(causeLabel); + }); + + test.concurrent("reassigned Error.stack (V8 format) is honored by console.error", async () => { + // After `.stack` materializes, overwrite it with another V8-format stack + // string whose second frame has no function name. The printer must honor + // the reassigned frames and not fall back to the original sourceURL/line. + const { stderr } = await run( + `const e = new Error("X"); void e.stack;` + + `e.stack = "Error: X\\n at fn (/fake-one.js:11:22)\\n at /fake-two.js:33:44";` + + `console.error(e);`, + ); + expect(stderr).toContain("at fn (/fake-one.js:11:22)"); + expect(stderr).toContain("at /fake-two.js:33:44"); + // Original creation site must not leak through. + expect(stderr).not.toContain("[eval]:1"); + }); + + test.concurrent("Promise.any rejection prints AggregateError header", async () => { + const { stderr } = await run( + `Promise.any([Promise.reject(new Error(${JSON.stringify(M1)})), Promise.reject(new Error(${JSON.stringify(M2)}))]);`, + { exit: 1 }, + ); + expect(stderr).toContain("AggregateError:"); + expect(stderr).toContain("[errors]:"); + expect(stderr).toContain("error: " + M1); + expect(stderr).toContain("error: " + M2); + }); +}); diff --git a/test/regression/issue/jsx-template-string-crash.test.ts b/test/regression/issue/jsx-template-string-crash.test.ts index a04b036aef12..fdc43db9b533 100644 --- a/test/regression/issue/jsx-template-string-crash.test.ts +++ b/test/regression/issue/jsx-template-string-crash.test.ts @@ -16,11 +16,17 @@ 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]" + + [errors]: + + 1 | export function x(){return
} ^ error: Expected "{" but found "\`" at /[eval]:1:34 + [errors]: + 1 | export function x(){return
} ^ error: Unterminated string literal @@ -57,11 +63,17 @@ 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]" + + [errors]: + + 1 | export function x(){return/[eval]:1:32 + [errors]: + 1 | export function x(){return Date: Sat, 1 Aug 2026 03:33:19 +0000 Subject: [PATCH 02/11] [autofix.ci] apply automated fixes --- src/jsc/VirtualMachine.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index bde7fb2ee52c..6e8e76688496 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -6385,8 +6385,7 @@ impl VirtualMachine { }; i += 1; member.protect(); - errors_to_append - .push((member, bun_core::String::static_(b"errors"))); + errors_to_append.push((member, bun_core::String::static_(b"errors"))); } } else if allow_side_effects { global_ref.clear_exception(); From 9ef8f5080c40b98818faa235e0105ff6ffe9f14f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 03:37:15 +0000 Subject: [PATCH 03/11] trim explanatory comments flagged by comment-cop --- src/jsc/VirtualMachine.rs | 9 +-------- src/jsc/bindings/ZigException.cpp | 5 +---- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 6e8e76688496..86994d4a6b10 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -6365,11 +6365,7 @@ impl VirtualMachine { } } - // AggregateError's `.errors` is `DontEnum` so the own-property loop - // above skips it; iterate and append each member here so the - // aggregate prints its own header first (just above) and then each - // member, labeled `[errors]:`, reached uniformly whether the - // aggregate is top-level or reached via a cause chain. + // `.errors` is DontEnum, so it was not seen by the loop above. if error_instance.is_aggregate_error(global_ref) { let errors = error_instance.get_errors_property(global_ref); if errors.is_cell() && errors.js_type().is_array() && errors != error_instance { @@ -6445,9 +6441,6 @@ impl VirtualMachine { if !label.is_empty() { pretty_write!(writer, "[{}]:\n", label)?; } - // Route through the top-level printer so BuildMessage / - // ResolveMessage members of an AggregateError keep their own - // formatter rather than the generic ErrorInstance body. self.print_errorlike_object( err, None, diff --git a/src/jsc/bindings/ZigException.cpp b/src/jsc/bindings/ZigException.cpp index f307a84c25fe..e152badf4705 100644 --- a/src/jsc/bindings/ZigException.cpp +++ b/src/jsc/bindings/ZigException.cpp @@ -307,10 +307,7 @@ class V8StackTraceIterator { return true; } - // `at /path/file.js:1:2` (no function name). V8 and Bun both emit - // this shape for top-level / anonymous frames, so parse the whole - // line as the source location with an empty function name rather - // than terminating iteration. + // `at /path/file.js:1:2`: parse as sourceURL:line:col with no function name. } auto lineInner = hasParens ? StringView_slice(line, openingParentheses + 1, closingParentheses) : line; From 47bb7eca850dfaa23e8443d1f22df556a98438ff Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 04:11:19 +0000 Subject: [PATCH 04/11] address review: normalizeError, async prefix, exception discipline, exit-code ordering - widen normalizeError to strip 'at require (51:24)' shaped builtin frames (debug-build coordinates that differ from release), and regenerate the minified-file snapshots without them - V8StackTraceIterator: strip a leading 'async ' and set isAsync in the paren-less branch so 'at async /path:l:c' does not bake the prefix into sourceURL - clear pending exceptions from get_length/get_index in the .errors iteration on both allow_side_effects branches, matching the old for_each behavior this replaces - assert exit code after stderr content in the #1352 test helper and add an 'at async /path:l:c' frame to the reassigned-stack test --- src/jsc/VirtualMachine.rs | 32 +++++++------- src/jsc/bindings/ZigException.cpp | 6 ++- test/js/bun/util/inspect-error.test.js | 58 +++++++++++--------------- 3 files changed, 48 insertions(+), 48 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 86994d4a6b10..27cafb91fff0 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -6369,22 +6369,26 @@ impl VirtualMachine { if error_instance.is_aggregate_error(global_ref) { let errors = error_instance.get_errors_property(global_ref); if errors.is_cell() && errors.js_type().is_array() && errors != error_instance { - if let Ok(len) = errors.get_length(global_ref) { - let n = len.min(u64::from(u32::MAX)); - let mut i: u32 = 0; - while u64::from(i) < n { - let Ok(member) = errors.get_index(global_ref, i) else { - if allow_side_effects { - global_ref.clear_exception(); + match errors.get_length(global_ref) { + Ok(len) => { + let n = len.min(u64::from(u32::MAX)); + let mut i: u32 = 0; + while u64::from(i) < n { + match errors.get_index(global_ref, i) { + Ok(member) => { + member.protect(); + errors_to_append + .push((member, bun_core::String::static_(b"errors"))); + } + Err(_) => { + global_ref.clear_exception(); + break; + } } - break; - }; - i += 1; - member.protect(); - errors_to_append.push((member, bun_core::String::static_(b"errors"))); + i += 1; + } } - } else if allow_side_effects { - global_ref.clear_exception(); + Err(_) => global_ref.clear_exception(), } } } diff --git a/src/jsc/bindings/ZigException.cpp b/src/jsc/bindings/ZigException.cpp index e152badf4705..1f27983043ed 100644 --- a/src/jsc/bindings/ZigException.cpp +++ b/src/jsc/bindings/ZigException.cpp @@ -307,7 +307,11 @@ class V8StackTraceIterator { return true; } - // `at /path/file.js:1:2`: parse as sourceURL:line:col with no function name. + // `at /path/file.js:1:2` or `at async /path/file.js:1:2` + if (line.startsWith("async "_s)) { + frame.isAsync = true; + line = line.substring(6); + } } auto lineInner = hasParens ? StringView_slice(line, openingParentheses + 1, closingParentheses) : line; diff --git a/test/js/bun/util/inspect-error.test.js b/test/js/bun/util/inspect-error.test.js index cbffd9b3a299..c4c593ceb795 100644 --- a/test/js/bun/util/inspect-error.test.js +++ b/test/js/bun/util/inspect-error.test.js @@ -75,22 +75,12 @@ 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; -}; +const normalizeError = str => + // remove debug-only internal-builtin frames like "at require (:1:21)" or "at require (51:24)" + str + .split("\n") + .filter(line => !/^\s+at .+ \(:?\d+:\d+\)$/.test(line)) + .join("\n"); test("Error inside minified file (no color) ", () => { try { @@ -115,8 +105,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 require (51:24) - at ([dir]/inspect-error.test.js:96:7)" + at ([dir]/inspect-error.test.js:86:7)" `); } }); @@ -145,8 +134,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 require (51:24) - at ([dir]/inspect-error.test.js:125:7)" + at ([dir]/inspect-error.test.js:114:7)" `); } }); @@ -160,7 +148,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]:155:19)" + at ([file]:143:19)" `); }); @@ -211,7 +199,7 @@ const agg = new AggregateError([m1, m2], ["agg", "msg"].join("-"), { cause }); const M2 = ["err", "two"].join("-"); const CAUSE = ["the", "cause"].join("-"); - async function run(code, { exit = 0 } = {}) { + async function run(code) { await using proc = Bun.spawn({ cmd: [bunExe(), "-e", code], env: bunEnv, @@ -219,8 +207,7 @@ const agg = new AggregateError([m1, m2], ["agg", "msg"].join("-"), { cause }); stdout: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(exitCode).toBe(exit); - return { stdout, stderr }; + return { stdout, stderr, exitCode }; } test.concurrent.each([ @@ -229,7 +216,7 @@ const agg = new AggregateError([m1, m2], ["agg", "msg"].join("-"), { cause }); ["uncaught throw", `${src}; throw agg;`, 1], ["unhandled rejection", `${src}; Promise.reject(agg);`, 1], ])("AggregateError via %s prints header, [cause] and each [errors] member", async (_, code, exit) => { - const { stderr } = await run(code, { exit }); + const { stderr, exitCode } = await run(code); expect(stderr).toContain("AggregateError: " + AGG); expect(stderr).toContain("[cause]:"); @@ -245,51 +232,56 @@ const agg = new AggregateError([m1, m2], ["agg", "msg"].join("-"), { cause }); expect(hdr).toBeGreaterThan(-1); expect(causeLabel).toBeGreaterThan(hdr); expect(errorsLabel).toBeGreaterThan(causeLabel); + expect(exitCode).toBe(exit); }); test.concurrent("AggregateError reached via a cause chain prints its members", async () => { - const { stderr } = await run(`${src}; console.error(new Error("outer", { cause: agg }));`); + const { stderr, exitCode } = await run(`${src}; console.error(new Error("outer", { cause: agg }));`); expect(stderr).toContain("[cause]:"); expect(stderr).toContain("AggregateError: " + AGG); expect(stderr).toContain("[errors]:"); expect(stderr).toContain("error: " + M1); expect(stderr).toContain("RangeError: " + M2); + expect(exitCode).toBe(0); }); test.concurrent("error.cause is labeled with [cause]:", async () => { - const { stderr } = await run( + const { stderr, exitCode } = await run( `const e = new Error(${JSON.stringify("outer-" + M1)}, { cause: new Error(${JSON.stringify("inner-" + M2)}) }); console.error(e);`, ); const causeLabel = stderr.indexOf("[cause]:"); expect(causeLabel).toBeGreaterThan(-1); expect(stderr.indexOf("error: inner-" + M2)).toBeGreaterThan(causeLabel); expect(stderr.indexOf("error: outer-" + M1)).toBeLessThan(causeLabel); + expect(exitCode).toBe(0); }); test.concurrent("reassigned Error.stack (V8 format) is honored by console.error", async () => { // After `.stack` materializes, overwrite it with another V8-format stack - // string whose second frame has no function name. The printer must honor - // the reassigned frames and not fall back to the original sourceURL/line. - const { stderr } = await run( + // string that mixes paren-ful, paren-less and `at async /path:l:c` frames. + const { stderr, exitCode } = await run( `const e = new Error("X"); void e.stack;` + - `e.stack = "Error: X\\n at fn (/fake-one.js:11:22)\\n at /fake-two.js:33:44";` + + `e.stack = "Error: X\\n at fn (/fake-one.js:11:22)\\n at /fake-two.js:33:44\\n at async /fake-three.mjs:55:66";` + `console.error(e);`, ); expect(stderr).toContain("at fn (/fake-one.js:11:22)"); expect(stderr).toContain("at /fake-two.js:33:44"); + expect(stderr).toContain("/fake-three.mjs:55:66"); + expect(stderr).not.toContain("async /fake-three.mjs"); // Original creation site must not leak through. expect(stderr).not.toContain("[eval]:1"); + expect(exitCode).toBe(0); }); test.concurrent("Promise.any rejection prints AggregateError header", async () => { - const { stderr } = await run( + const { stderr, exitCode } = await run( `Promise.any([Promise.reject(new Error(${JSON.stringify(M1)})), Promise.reject(new Error(${JSON.stringify(M2)}))]);`, - { exit: 1 }, ); expect(stderr).toContain("AggregateError:"); expect(stderr).toContain("[errors]:"); expect(stderr).toContain("error: " + M1); expect(stderr).toContain("error: " + M2); + expect(exitCode).toBe(1); }); }); From 4aa5db579c6441204a6277b4d58038d107af6c14 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 04:47:13 +0000 Subject: [PATCH 05/11] cap AggregateError .errors printed at 100 members --- src/jsc/VirtualMachine.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 27cafb91fff0..5d8ff83dea45 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -6367,13 +6367,13 @@ impl VirtualMachine { // `.errors` is DontEnum, so it was not seen by the loop above. if error_instance.is_aggregate_error(global_ref) { + const MAX_AGGREGATE_ERRORS_PRINTED: u64 = 100; let errors = error_instance.get_errors_property(global_ref); if errors.is_cell() && errors.js_type().is_array() && errors != error_instance { match errors.get_length(global_ref) { Ok(len) => { - let n = len.min(u64::from(u32::MAX)); - let mut i: u32 = 0; - while u64::from(i) < n { + let n = len.min(MAX_AGGREGATE_ERRORS_PRINTED) as u32; + for i in 0..n { match errors.get_index(global_ref, i) { Ok(member) => { member.protect(); @@ -6385,7 +6385,6 @@ impl VirtualMachine { break; } } - i += 1; } } Err(_) => global_ref.clear_exception(), From 3bf92b8df33178eee8f346a5a4f6cc7ed1da456d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:11:41 +0000 Subject: [PATCH 06/11] error printer: seat the stack check on the uncaught path, mark the top-level error visited, count omitted AggregateError members Both uncaught-exception sinks now build their formatter in VirtualMachine::print_exception, which initializes the stack check that print_error_instance_js consults, so a deep cause or .errors chain stops printing instead of overflowing the native stack. That check now reserves 256 KB of extra headroom on non-Windows platforms too: with the 128 KB default, a debug+ASAN build still overflowed in about one run in four because one print cycle (transpiler source lookup plus allocator slow paths) was measured using ~130 KB below a passing check. The error being printed is registered in the visited set while its cause/.errors members print, so a chain leading back to it renders as [Circular] right away, and the member loop stops once the formatter has failed. Members past the 100 member cap are reported as "... N more errors". Tests cover the cycle, tampered-property, depth and cap cases on the console and uncaught sinks, the exact uncaught layout, and the bun test crash from a module that fails to build for a second test file. --- src/jsc/VirtualMachine.rs | 108 +++++++--- src/runtime/jsc_hooks.rs | 41 ++-- test/js/bun/util/inspect-error.test.js | 263 +++++++++++++++++++++++-- 3 files changed, 342 insertions(+), 70 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 5d8ff83dea45..f50c5ba38683 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -4793,21 +4793,30 @@ impl VirtualMachine { } self.has_terminated = true; } + /// Prints an uncaught error (or unhandled rejection value) to `writer`. + /// `exception` is the JSC wrapper when the value was thrown, used to + /// recover the stack trace of internal errors. + /// /// Note: takes the concrete /// `bun_core::io::Writer` since every call site passes /// `Output.errorWriterBuffered()`. pub fn print_exception( &mut self, - exception: &Exception, + value: JSValue, + exception: Option<&Exception>, exception_list: Option<&mut ExceptionList>, writer: &mut bun_core::io::Writer, allow_side_effects: bool, ) { let mut formatter = crate::console_object::Formatter::new(self.global()); + // `Formatter::new` leaves the stack check inert; the `cause` / + // `.errors` recursion in `print_error_instance_js` relies on it to stop + // before a deep chain overflows the native stack. + formatter.stack_check = bun_core::StackCheck::init(); let colors = bun_core::Output::enable_ansi_colors_stderr(); self.print_errorlike_object( - exception.value(), - Some(exception), + value, + exception, exception_list, &mut formatter, writer, @@ -5148,7 +5157,7 @@ impl VirtualMachine { } /// Note: takes runtime bools and the concrete `bun_core::io::Writer`. - pub fn print_errorlike_object( + pub(crate) fn print_errorlike_object( &mut self, value: JSValue, exception: Option<&Exception>, @@ -5821,14 +5830,19 @@ impl VirtualMachine { // to cover the transpiler's nested path buffers — same parity-level // protection the Object path gets from C++ `forEachProperty`'s // `vm.isSafeToRecurse()`. The formatter's `stack_check` was seated by - // the caller (`format2` / `Bun.inspect`). + // the caller (`format2` / `Bun.inspect` / `print_exception`). let extra_headroom: usize = if cfg!(windows) { // 3× PathBuffer ≈ 288 KB — empirically enough for the // `remap_zig_exception` → `transpile_source_code` chain on the // 16K-deep Error test (`bun-inspect.test.ts`). bun_paths::MAX_PATH_BYTES * 3 } else { - 0 + // The same chain (transpiler plus allocator slow paths) also + // exceeds the 128 KB default here: a debug+ASAN build was measured + // using ~130 KB between a passing check and the guard page, so a + // deep chain overflowed in about one run in four depending on the + // initial stack offset. + 256 * 1024 }; if !formatter .stack_check @@ -6229,6 +6243,7 @@ impl VirtualMachine { let _unprotect_guard = UnprotectAll(bun_ptr::BackRef::from( NonNull::new(&raw mut errors_to_append).expect("stack addr"), )); + let mut errors_omitted: u64 = 0; if is_error_instance { let mut saw_cause = false; @@ -6365,20 +6380,26 @@ impl VirtualMachine { } } - // `.errors` is DontEnum, so it was not seen by the loop above. + // `.errors` is DontEnum, so it was not seen by the loop above. It is + // an ordinary writable property: `get_errors_property` (`getDirect`) + // returns empty when it was deleted (JSC's module loader also replays + // a cached load failure as an AggregateError without it), a + // GetterSetter cell when redefined as an accessor, or whatever it was + // reassigned to. if error_instance.is_aggregate_error(global_ref) { const MAX_AGGREGATE_ERRORS_PRINTED: u64 = 100; let errors = error_instance.get_errors_property(global_ref); - if errors.is_cell() && errors.js_type().is_array() && errors != error_instance { + if errors.is_cell() && errors.js_type().is_array() { match errors.get_length(global_ref) { Ok(len) => { - let n = len.min(MAX_AGGREGATE_ERRORS_PRINTED) as u32; - for i in 0..n { + let mut appended: u64 = 0; + for i in 0..len.min(MAX_AGGREGATE_ERRORS_PRINTED) as u32 { match errors.get_index(global_ref, i) { Ok(member) => { member.protect(); errors_to_append .push((member, bun_core::String::static_(b"errors"))); + appended += 1; } Err(_) => { global_ref.clear_exception(); @@ -6386,6 +6407,7 @@ impl VirtualMachine { } } } + errors_omitted = len - appended; } Err(_) => global_ref.clear_exception(), } @@ -6418,9 +6440,8 @@ impl VirtualMachine { )?; } - let mut exception_list = exception_list; - for &(err, ref label) in &errors_to_append { - // Circular-ref guard for cause chains. + if !errors_to_append.is_empty() { + // Circular-ref guard for cause / `.errors` chains. if formatter.map_node.is_none() { let mut node = NonNull::new(console_object::formatter::visited::Pool::get_node()) .expect("ObjectPool::get_node always returns a valid heap node"); @@ -6429,31 +6450,56 @@ impl VirtualMachine { formatter.map = core::mem::take(data); formatter.map_node = Some(node); } + // A nested error was registered by the level that appended it; the + // outermost one registers itself here so a chain leading back to it + // prints `[Circular]` instead of printing it a second time. + let registered_self = !formatter + .map + .get_or_put(error_instance) + .expect("unreachable") + .found_existing; + + let mut exception_list = exception_list; + for &(err, ref label) in &errors_to_append { + // Set by the writer failing or by the stack check in + // `print_error_instance_js` (which may have thrown a RangeError + // that is still pending): stop instead of formatting siblings. + if formatter.failed { + break; + } + let entry = formatter.map.get_or_put(err).expect("unreachable"); + if entry.found_existing { + writer.write_all(b"\n")?; + if !label.is_empty() { + pretty_write!(writer, "[{}]: ", label)?; + } + pretty_write!(writer, "[Circular]\n")?; + continue; + } - let entry = formatter.map.get_or_put(err).expect("unreachable"); - if entry.found_existing { writer.write_all(b"\n")?; if !label.is_empty() { - pretty_write!(writer, "[{}]: ", label)?; + pretty_write!(writer, "[{}]:\n", label)?; } - pretty_write!(writer, "[Circular]")?; - continue; + self.print_errorlike_object( + err, + None, + exception_list.as_deref_mut(), + formatter, + writer, + allow_ansi_color, + allow_side_effects, + ); + let _ = formatter.map.remove(&err); } - writer.write_all(b"\n")?; - if !label.is_empty() { - pretty_write!(writer, "[{}]:\n", label)?; + if registered_self { + let _ = formatter.map.remove(&error_instance); } - self.print_errorlike_object( - err, - None, - exception_list.as_deref_mut(), - formatter, - writer, - allow_ansi_color, - allow_side_effects, - ); - let _ = formatter.map.remove(&err); + } + + if errors_omitted > 0 && !formatter.failed { + pretty_write!(writer, "\n... {} more errors\n", errors_omitted)?; } Ok(()) diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index edb355d3b465..a32ba07bda2d 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -1235,9 +1235,8 @@ unsafe fn auto_tick_active(vm: *mut VirtualMachine) { unsafe { (*vm).on_after_event_loop() }; } -/// `printException` / `printErrorlikeObject` — formats `value` to stderr via -/// `ConsoleObject::Formatter`. Dispatched here so the high tier owns the -/// formatter. +/// `printException` / `printErrorlikeObject` — formats `value` (or the value +/// carried by the `Exception` it wraps) to stderr. fn print_exception( vm_ref: &mut VirtualMachine, value: JSValue, @@ -1249,28 +1248,20 @@ fn print_exception( // no early returns below. let writer = bun_core::Output::error_writer_buffered(); - let global = vm_ref.global(); - - if let Some(exception) = value.as_exception(vm_ref.jsc_vm) { - // SAFETY: `as_exception` returned a live `*mut Exception` owned by the - // JSC heap; we only read through it for the duration of this call. - let exception = unsafe { &*exception }; - vm_ref.print_exception(exception, exception_list, writer, true); - } else { - let mut formatter = bun_jsc::console_object::Formatter::new(global); - // `Formatter::new` already - // defaults `error_display_level` to `Full` (ConsoleObject.rs:1176). - let colors = bun_core::Output::enable_ansi_colors_stderr(); - vm_ref.print_errorlike_object( - value, - None, - exception_list, - &mut formatter, - writer, - colors, - true, - ); - // `defer formatter.deinit()` → Drop. + match value.as_exception(vm_ref.jsc_vm) { + Some(exception) => { + // SAFETY: `as_exception` returned a live `*mut Exception` owned by the + // JSC heap; we only read through it for the duration of this call. + let exception = unsafe { &*exception }; + vm_ref.print_exception( + exception.value(), + Some(exception), + exception_list, + writer, + true, + ); + } + None => vm_ref.print_exception(value, None, exception_list, writer, true), } let _ = writer.flush(); diff --git a/test/js/bun/util/inspect-error.test.js b/test/js/bun/util/inspect-error.test.js index c4c593ceb795..b57dcb0cee37 100644 --- a/test/js/bun/util/inspect-error.test.js +++ b/test/js/bun/util/inspect-error.test.js @@ -1,5 +1,5 @@ import { describe, expect, jest, test } from "bun:test"; -import { bunEnv, bunExe } from "harness"; +import { bunEnv, bunExe, normalizeBunSnapshot, tempDir } from "harness"; test("error.cause", () => { const err = new Error("error 1"); @@ -10,7 +10,7 @@ test("error.cause", () => { .replaceAll(import.meta.dir.replaceAll("\\", "/"), "[dir]"), ).toMatchInlineSnapshot(` "1 | import { describe, expect, jest, test } from "bun:test"; -2 | import { bunEnv, bunExe } from "harness"; +2 | import { bunEnv, bunExe, normalizeBunSnapshot, tempDir } from "harness"; 3 | 4 | test("error.cause", () => { 5 | const err = new Error("error 1"); @@ -21,7 +21,7 @@ error: error 2 [cause]: 1 | import { describe, expect, jest, test } from "bun:test"; -2 | import { bunEnv, bunExe } from "harness"; +2 | import { bunEnv, bunExe, normalizeBunSnapshot, tempDir } from "harness"; 3 | 4 | test("error.cause", () => { 5 | const err = new Error("error 1"); @@ -183,6 +183,17 @@ test("error.stack throwing an error doesn't lead to a crash", () => { }).toThrow(); }); +async function run(code) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", code], + env: bunEnv, + stderr: "pipe", + stdout: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; +} + // https://github.com/oven-sh/bun/issues/1352 describe("#1352 native error printer", () => { // The expected header/message strings are built at runtime so that the @@ -199,17 +210,6 @@ const agg = new AggregateError([m1, m2], ["agg", "msg"].join("-"), { cause }); const M2 = ["err", "two"].join("-"); const CAUSE = ["the", "cause"].join("-"); - async function run(code) { - await using proc = Bun.spawn({ - cmd: [bunExe(), "-e", code], - env: bunEnv, - stderr: "pipe", - stdout: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - return { stdout, stderr, exitCode }; - } - test.concurrent.each([ ["console.error", `${src}; console.error(agg);`, 0], ["Bun.inspect", `${src}; process.stderr.write(Bun.inspect(agg));`, 0], @@ -285,3 +285,238 @@ const agg = new AggregateError([m1, m2], ["agg", "msg"].join("-"), { cause }); expect(exitCode).toBe(1); }); }); + +const count = (haystack, needle) => haystack.split(needle).length - 1; + +// https://github.com/oven-sh/bun/issues/21528 +test.concurrent("uncaught AggregateError output layout", async () => { + using dir = tempDir("aggregate-error-layout", { + "index.js": `function foo() { + return new Error("foo!"); +} +function bar() { + return new Error("bar!"); +} +throw new AggregateError([foo(), bar()], "qux!"); +`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.js"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(normalizeBunSnapshot(stderr.replace(/^Bun v.*$/m, ""), String(dir))).toMatchInlineSnapshot(` + "2 | return new Error("foo!"); + 3 | } + 4 | function bar() { + 5 | return new Error("bar!"); + 6 | } + 7 | throw new AggregateError([foo(), bar()], "qux!"); + ^ + AggregateError: qux! + at /index.js:7:11 + + [errors]: + 2 | return new Error("foo!"); + 3 | } + 4 | function bar() { + 5 | return new Error("bar!"); + 6 | } + 7 | throw new AggregateError([foo(), bar()], "qux!"); + ^ + error: foo! + at /index.js:7:27 + + [errors]: + 2 | return new Error("foo!"); + 3 | } + 4 | function bar() { + 5 | return new Error("bar!"); + 6 | } + 7 | throw new AggregateError([foo(), bar()], "qux!"); + ^ + error: bar! + at /index.js:7:34" + `); + expect(exitCode).toBe(1); +}); + +// The printer used to walk .errors with no cycle or depth guard and without +// checking that the property still is an array, so every shape below crashed +// (or lost the aggregate's own header) on every sink that reaches it. +describe("AggregateError .errors printing is guarded", () => { + // Messages are assembled at runtime so the -e source preview the printer + // quotes cannot satisfy the assertions; counting a header therefore + // measures how many times the error itself was printed. + const SELF = ["self", "cycle"].join("-"); + const A = ["agg", "a"].join("-"); + const B = ["agg", "b"].join("-"); + const C = ["plain", "c"].join("-"); + + const shapes = [ + { + name: "errors containing the aggregate itself", + build: `const e = new AggregateError([], ["self", "cycle"].join("-")); e.errors.push(e);`, + check(stderr) { + expect(count(stderr, "AggregateError: " + SELF)).toBe(1); + expect(stderr).toContain("[errors]: [Circular]"); + }, + }, + { + name: "two aggregates containing each other", + build: + `const e = new AggregateError([], ["agg", "a"].join("-"));` + + `const b = new AggregateError([e], ["agg", "b"].join("-"));` + + `e.errors = [b];`, + check(stderr) { + expect(count(stderr, "AggregateError: " + A)).toBe(1); + expect(count(stderr, "AggregateError: " + B)).toBe(1); + expect(stderr.indexOf("AggregateError: " + B)).toBeGreaterThan(stderr.indexOf("AggregateError: " + A)); + expect(stderr).toContain("[errors]: [Circular]"); + }, + }, + { + name: "a member whose cause is the aggregate", + build: + `const c = new Error(["plain", "c"].join("-"));` + + `const e = new AggregateError([c], ["agg", "a"].join("-"));` + + `c.cause = e;`, + check(stderr) { + expect(count(stderr, "AggregateError: " + A)).toBe(1); + expect(count(stderr, "error: " + C)).toBe(1); + expect(stderr).toContain("[cause]: [Circular]"); + }, + }, + { + name: "deleted .errors", + build: `const e = new AggregateError([new Error("x")], ["agg", "a"].join("-")); delete e.errors;`, + check(stderr) { + expect(stderr).toContain("AggregateError: " + A); + expect(stderr).not.toContain("[errors]:"); + }, + }, + { + name: ".errors redefined as a throwing accessor", + build: + `const e = new AggregateError([], ["agg", "a"].join("-"));` + + `Object.defineProperty(e, "errors", { get() { console.log("getter ran"); throw new Error("boom"); } });`, + check(stderr, stdout) { + expect(stderr).toContain("AggregateError: " + A); + expect(stderr).not.toContain("[errors]:"); + expect(stdout).not.toContain("getter ran"); + }, + }, + { + name: ".errors reassigned to a non-array", + build: `const e = new AggregateError([], ["agg", "a"].join("-")); e.errors = 42;`, + check(stderr) { + expect(stderr).toContain("AggregateError: " + A); + expect(stderr).not.toContain("[errors]:"); + }, + }, + { + name: "an .errors element that throws when read", + build: + `const e = new AggregateError([new Error(["plain", "c"].join("-")), new Error("never"), new Error("never")], ["agg", "a"].join("-"));` + + `Object.defineProperty(e.errors, 1, { get() { throw new Error("boom"); } });`, + check(stderr) { + expect(stderr).toContain("AggregateError: " + A); + expect(stderr).toContain("error: " + C); + expect(stderr).not.toContain("error: never"); + expect(stderr).toContain("... 2 more errors"); + }, + }, + ]; + + describe.each([ + ["console.error", e => `console.error(${e});`, 0], + ["uncaught throw", e => `throw ${e};`, 1], + ])("via %s", (_, sink, expectedExitCode) => { + test.concurrent.each(shapes)("$name", async ({ build, check }) => { + const { stdout, stderr, exitCode } = await run(`${build} ${sink("e")}`); + check(stderr, stdout); + expect(exitCode).toBe(expectedExitCode); + }); + }); + + test.concurrent("prints at most 100 members and counts the rest", async () => { + const { stderr, exitCode } = await run( + `const members = Array.from({ length: 103 }, (_, i) => new Error("member" + i));` + + `console.error(new AggregateError(members, ["agg", "a"].join("-")));`, + ); + expect(stderr).toContain("AggregateError: " + A); + expect(count(stderr, "[errors]:")).toBe(100); + expect(stderr).toContain("error: member99\n"); + expect(stderr).not.toContain("error: member100\n"); + expect(stderr).toContain("... 3 more errors"); + expect(exitCode).toBe(0); + }); +}); + +// Nesting deeper than the native stack allows must stop printing instead of +// overflowing it. console.* and Bun.inspect report that as a RangeError; the +// uncaught-exception and unhandled-rejection reporters truncate the output. +describe("deeply nested error chains do not overflow the stack", () => { + const deepAggregate = + `let e = new AggregateError([], "leaf");` + + `for (let i = 0; i < 3000; i++) e = new AggregateError([e], "level" + i);`; + const deepCause = + `let e = new Error("leaf");` + `for (let i = 0; i < 3000; i++) e = new Error("level" + i, { cause: e });`; + + test.concurrent("AggregateError chain via console.error throws a RangeError", async () => { + const { stdout, stderr, exitCode } = await run( + `${deepAggregate} try { console.error(e); } catch (err) { console.log("caught", err.name); }`, + ); + expect(stderr).toContain("AggregateError: level2999"); + expect(stdout).toBe("caught RangeError\n"); + expect(exitCode).toBe(0); + }); + + test.concurrent.each([ + ["AggregateError chain", deepAggregate, "AggregateError: level2999"], + ["cause chain", deepCause, "error: level2999"], + ])("%s via uncaught throw", async (_, build, header) => { + const { stderr, exitCode } = await run(`${build} throw e;`); + expect(stderr).toContain(header); + expect(exitCode).toBe(1); + }); + + test.concurrent.each([ + ["AggregateError chain", deepAggregate, "AggregateError: level2999"], + ["cause chain", deepCause, "error: level2999"], + ])("%s via unhandled rejection", async (_, build, header) => { + const { stderr, exitCode } = await run(`${build} Promise.reject(e);`); + expect(stderr).toContain(header); + expect(exitCode).toBe(1); + }); +}); + +// https://github.com/oven-sh/bun/issues/36963 +// When a second test file imports a module that already failed to build, JSC +// replays the cached failure as an AggregateError that has no .errors property. +test.concurrent("bun test reports a module that failed to build for a second test file", async () => { + using dir = tempDir("aggregate-error-missing-errors", { + "lib.ts": `export function f() { + const v = {b: {},),r,}; +} +`, + "a.test.ts": `import { f } from "./lib";\nf();\n`, + "b.test.ts": `import { f } from "./lib";\nf();\n`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(stderr).toContain('error: Expected identifier but found ")"'); + expect(count(stderr, "AggregateError: 4 errors building ")).toBe(2); + expect(stderr).toContain("a.test.ts:"); + expect(stderr).toContain("b.test.ts:"); + expect(exitCode).toBe(1); +}); From 1e0df48d0d6583e0221c328e0fe38b98a8a10aaa Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 13 Aug 2026 02:52:21 +0000 Subject: [PATCH 07/11] test: printed frames match error.stack after it was read (source-mapped TypeScript fixture) --- test/js/bun/util/inspect-error.test.js | 42 ++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/test/js/bun/util/inspect-error.test.js b/test/js/bun/util/inspect-error.test.js index b57dcb0cee37..5ec801bc8563 100644 --- a/test/js/bun/util/inspect-error.test.js +++ b/test/js/bun/util/inspect-error.test.js @@ -274,6 +274,48 @@ const agg = new AggregateError([m1, m2], ["agg", "msg"].join("-"), { cause }); expect(exitCode).toBe(0); }); + // https://github.com/oven-sh/bun/issues/15859 + test.concurrent("uncaught error printed after error.stack was read keeps the positions of error.stack", async () => { + // Reading .stack makes the printer re-parse the already source-mapped + // string; the non-top frames used to be mapped a second time and the + // frame without a function name used to be dropped. The imports push the + // TypeScript source lines away from the transpiled ones. + using dir = tempDir("inspect-error-stack-reparse", { + "test.ts": `import * as i1 from "util"; +import * as i2 from "util"; +import * as i3 from "util"; +function err() { + throw new Error() +}; +function f1(){ + err() +} +function f2(){ + +} +try { + f1(); +} catch (error: any) { + console.log(error.stack) + throw error +} +`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test.ts"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + const positions = s => [...s.matchAll(/test\.ts:(\d+:\d+)/g)].map(m => m[1]); + expect(positions(stdout)).toEqual(["5:15", "8:5", "14:5"]); + expect(positions(stderr)).toEqual(positions(stdout)); + expect(exitCode).toBe(1); + }); + test.concurrent("Promise.any rejection prints AggregateError header", async () => { const { stderr, exitCode } = await run( `Promise.any([Promise.reject(new Error(${JSON.stringify(M1)})), Promise.reject(new Error(${JSON.stringify(M2)}))]);`, From 95a476fd3135054508b39c98719cf6f1b15164e8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 13 Aug 2026 04:06:13 +0000 Subject: [PATCH 08/11] error printer: only track Error members in the cycle map; reject unbalanced stack frames; review follow-ups A plain-object member of .errors was pre-registered in the formatter's visited map before being handed to formatter.format, which then reported it as [Circular]. Register only ErrorInstance members; the formatter tracks everything else itself. V8StackTraceIterator: a frame with exactly one parenthesis (or ')' before '(') stops parsing as it did before; only a frame with neither is parsed as a bare location. Use handle_oom for the visited-map inserts, trim comments, drain stdout in the two remaining spawns, and make the deep-chain fixtures deep enough to exhaust the stack on release builds too (a Windows release build printed all 3000 levels), asserting that fewer levels than the chain has were printed. --- src/jsc/VirtualMachine.rs | 52 ++++++------------- src/jsc/bindings/ZigException.cpp | 10 ++-- src/runtime/jsc_hooks.rs | 3 +- test/js/bun/util/inspect-error.test.js | 72 +++++++++++++++++++++----- 4 files changed, 82 insertions(+), 55 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index f50c5ba38683..a3179964c9b7 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -4793,13 +4793,8 @@ impl VirtualMachine { } self.has_terminated = true; } - /// Prints an uncaught error (or unhandled rejection value) to `writer`. - /// `exception` is the JSC wrapper when the value was thrown, used to - /// recover the stack trace of internal errors. - /// - /// Note: takes the concrete - /// `bun_core::io::Writer` since every call site passes - /// `Output.errorWriterBuffered()`. + /// Prints an uncaught `value` to `writer`; `exception` is its JSC wrapper + /// when it was thrown. pub fn print_exception( &mut self, value: JSValue, @@ -4809,9 +4804,7 @@ impl VirtualMachine { allow_side_effects: bool, ) { let mut formatter = crate::console_object::Formatter::new(self.global()); - // `Formatter::new` leaves the stack check inert; the `cause` / - // `.errors` recursion in `print_error_instance_js` relies on it to stop - // before a deep chain overflows the native stack. + // `Formatter::new` leaves the stack check inert. formatter.stack_check = bun_core::StackCheck::init(); let colors = bun_core::Output::enable_ansi_colors_stderr(); self.print_errorlike_object( @@ -5837,11 +5830,7 @@ impl VirtualMachine { // 16K-deep Error test (`bun-inspect.test.ts`). bun_paths::MAX_PATH_BYTES * 3 } else { - // The same chain (transpiler plus allocator slow paths) also - // exceeds the 128 KB default here: a debug+ASAN build was measured - // using ~130 KB between a passing check and the guard page, so a - // deep chain overflowed in about one run in four depending on the - // initial stack offset. + // The same chain measured ~130 KB under debug+ASAN, past the 128 KB default. 256 * 1024 }; if !formatter @@ -6380,12 +6369,8 @@ impl VirtualMachine { } } - // `.errors` is DontEnum, so it was not seen by the loop above. It is - // an ordinary writable property: `get_errors_property` (`getDirect`) - // returns empty when it was deleted (JSC's module loader also replays - // a cached load failure as an AggregateError without it), a - // GetterSetter cell when redefined as an accessor, or whatever it was - // reassigned to. + // `.errors` is DontEnum (skipped above) and may be deleted, reassigned, + // or turned into an accessor; `get_errors_property` is a `getDirect`. if error_instance.is_aggregate_error(global_ref) { const MAX_AGGREGATE_ERRORS_PRINTED: u64 = 100; let errors = error_instance.get_errors_property(global_ref); @@ -6450,25 +6435,20 @@ impl VirtualMachine { formatter.map = core::mem::take(data); formatter.map_node = Some(node); } - // A nested error was registered by the level that appended it; the - // outermost one registers itself here so a chain leading back to it - // prints `[Circular]` instead of printing it a second time. - let registered_self = !formatter - .map - .get_or_put(error_instance) - .expect("unreachable") - .found_existing; + // The outermost error is not registered by anyone else. + let registered_self = + !bun_core::handle_oom(formatter.map.get_or_put(error_instance)).found_existing; let mut exception_list = exception_list; for &(err, ref label) in &errors_to_append { - // Set by the writer failing or by the stack check in - // `print_error_instance_js` (which may have thrown a RangeError - // that is still pending): stop instead of formatting siblings. + // Stack check failed (a RangeError may be pending) or the writer failed. if formatter.failed { break; } - let entry = formatter.map.get_or_put(err).expect("unreachable"); - if entry.found_existing { + // Non-error members are rendered by `formatter.format`, which + // tracks them in the same map itself. + let is_error = err.is_cell() && err.js_type() == JSType::ErrorInstance; + if is_error && bun_core::handle_oom(formatter.map.get_or_put(err)).found_existing { writer.write_all(b"\n")?; if !label.is_empty() { pretty_write!(writer, "[{}]: ", label)?; @@ -6490,7 +6470,9 @@ impl VirtualMachine { allow_ansi_color, allow_side_effects, ); - let _ = formatter.map.remove(&err); + if is_error { + let _ = formatter.map.remove(&err); + } } if registered_self { diff --git a/src/jsc/bindings/ZigException.cpp b/src/jsc/bindings/ZigException.cpp index 1f27983043ed..072e55ae207f 100644 --- a/src/jsc/bindings/ZigException.cpp +++ b/src/jsc/bindings/ZigException.cpp @@ -292,12 +292,14 @@ class V8StackTraceIterator { // the proper singular spelling is parenthesis auto openingParentheses = line.reverseFind('('); auto closingParentheses = line.reverseFind(')'); - - if (openingParentheses > closingParentheses) - openingParentheses = WTF::notFound; - bool hasParens = openingParentheses != WTF::notFound && closingParentheses != WTF::notFound; + // One unmatched parenthesis, or `)` before `(`: stop parsing as before. + if (hasParens ? openingParentheses > closingParentheses : openingParentheses != closingParentheses) { + offset = stack.length(); + return false; + } + if (!hasParens) { // Special case: "unknown" frames don't have parentheses but are valid // These appear in stack traces from certain error paths diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index a32ba07bda2d..507eac707f39 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -1235,8 +1235,7 @@ unsafe fn auto_tick_active(vm: *mut VirtualMachine) { unsafe { (*vm).on_after_event_loop() }; } -/// `printException` / `printErrorlikeObject` — formats `value` (or the value -/// carried by the `Exception` it wraps) to stderr. +/// `printException` / `printErrorlikeObject`: formats `value` to stderr. fn print_exception( vm_ref: &mut VirtualMachine, value: JSValue, diff --git a/test/js/bun/util/inspect-error.test.js b/test/js/bun/util/inspect-error.test.js index 5ec801bc8563..6c156337d18c 100644 --- a/test/js/bun/util/inspect-error.test.js +++ b/test/js/bun/util/inspect-error.test.js @@ -1,5 +1,5 @@ import { describe, expect, jest, test } from "bun:test"; -import { bunEnv, bunExe, normalizeBunSnapshot, tempDir } from "harness"; +import { bunEnv, bunExe, isASAN, isDebug, normalizeBunSnapshot, tempDir } from "harness"; test("error.cause", () => { const err = new Error("error 1"); @@ -10,7 +10,7 @@ test("error.cause", () => { .replaceAll(import.meta.dir.replaceAll("\\", "/"), "[dir]"), ).toMatchInlineSnapshot(` "1 | import { describe, expect, jest, test } from "bun:test"; -2 | import { bunEnv, bunExe, normalizeBunSnapshot, tempDir } from "harness"; +2 | import { bunEnv, bunExe, isASAN, isDebug, normalizeBunSnapshot, tempDir } from "harness"; 3 | 4 | test("error.cause", () => { 5 | const err = new Error("error 1"); @@ -21,7 +21,7 @@ error: error 2 [cause]: 1 | import { describe, expect, jest, test } from "bun:test"; -2 | import { bunEnv, bunExe, normalizeBunSnapshot, tempDir } from "harness"; +2 | import { bunEnv, bunExe, isASAN, isDebug, normalizeBunSnapshot, tempDir } from "harness"; 3 | 4 | test("error.cause", () => { 5 | const err = new Error("error 1"); @@ -260,15 +260,19 @@ const agg = new AggregateError([m1, m2], ["agg", "msg"].join("-"), { cause }); test.concurrent("reassigned Error.stack (V8 format) is honored by console.error", async () => { // After `.stack` materializes, overwrite it with another V8-format stack // string that mixes paren-ful, paren-less and `at async /path:l:c` frames. + // A frame with an unbalanced parenthesis is malformed: parsing stops there. const { stderr, exitCode } = await run( `const e = new Error("X"); void e.stack;` + - `e.stack = "Error: X\\n at fn (/fake-one.js:11:22)\\n at /fake-two.js:33:44\\n at async /fake-three.mjs:55:66";` + + `e.stack = "Error: X\\n at fn (/fake-one.js:11:22)\\n at /fake-two.js:33:44\\n at async /fake-three.mjs:55:66` + + `\\n at broken (/fake-four.js:77:88\\n at fine (/fake-five.js:99:11)";` + `console.error(e);`, ); expect(stderr).toContain("at fn (/fake-one.js:11:22)"); expect(stderr).toContain("at /fake-two.js:33:44"); expect(stderr).toContain("/fake-three.mjs:55:66"); expect(stderr).not.toContain("async /fake-three.mjs"); + expect(stderr).not.toContain("fake-four"); + expect(stderr).not.toContain("fake-five"); // Original creation site must not leak through. expect(stderr).not.toContain("[eval]:1"); expect(exitCode).toBe(0); @@ -349,7 +353,8 @@ throw new AggregateError([foo(), bar()], "qux!"); stderr: "pipe", stdout: "pipe", }); - const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe(""); expect(normalizeBunSnapshot(stderr.replace(/^Bun v.*$/m, ""), String(dir))).toMatchInlineSnapshot(` "2 | return new Error("foo!"); 3 | } @@ -432,6 +437,29 @@ describe("AggregateError .errors printing is guarded", () => { expect(stderr).toContain("[cause]: [Circular]"); }, }, + { + // Promise.any([Promise.reject({ code })]) produces this shape. + name: "a member that is a plain object", + build: `const e = new AggregateError([{ code: ["EN", "OENT"].join("") }], ["agg", "a"].join("-"));`, + check(stderr) { + expect(stderr).toContain("AggregateError: " + A); + expect(stderr).toContain("[errors]:"); + expect(stderr).toContain('code: "ENOENT"'); + expect(stderr).not.toContain("[Circular]"); + }, + }, + { + name: "a plain-object member that refers back to the aggregate", + build: + `const o = { code: ["EN", "OENT"].join("") };` + + `const e = new AggregateError([o], ["agg", "a"].join("-"));` + + `o.parent = e;`, + check(stderr) { + expect(count(stderr, "AggregateError: " + A)).toBe(1); + expect(stderr).toContain('code: "ENOENT"'); + expect(stderr).toContain("parent: [Circular]"); + }, + }, { name: "deleted .errors", build: `const e = new AggregateError([new Error("x")], ["agg", "a"].join("-")); delete e.errors;`, @@ -502,36 +530,52 @@ describe("AggregateError .errors printing is guarded", () => { // overflowing it. console.* and Bun.inspect report that as a RangeError; the // uncaught-exception and unhandled-rejection reporters truncate the output. describe("deeply nested error chains do not overflow the stack", () => { + // A debug or ASAN build runs out of stack a few hundred levels down and + // takes tens of microseconds to construct each error; a release build prints + // thousands of levels (about 1500 on Linux, more on Windows, where the + // printer's frames are smaller) and constructs the chain in about a + // microsecond per error. + const DEPTH = isDebug || isASAN ? 3_000 : 50_000; + const TOP = "level" + (DEPTH - 1); const deepAggregate = - `let e = new AggregateError([], "leaf");` + - `for (let i = 0; i < 3000; i++) e = new AggregateError([e], "level" + i);`; + `let e = new AggregateError([], "leaf");\n` + + `for (let i = 0; i < ${DEPTH}; i++)\n` + + ` e = new AggregateError([e], "level" + i);\n`; const deepCause = - `let e = new Error("leaf");` + `for (let i = 0; i < 3000; i++) e = new Error("level" + i, { cause: e });`; + `let e = new Error("leaf");\n` + + `for (let i = 0; i < ${DEPTH}; i++)\n` + + ` e = new Error("level" + i, { cause: e });\n`; + // Each printed level has one `: level` header; the quoted source + // lines contain `"level"` and so do not match. + const printedLevels = stderr => count(stderr, ": level"); test.concurrent("AggregateError chain via console.error throws a RangeError", async () => { const { stdout, stderr, exitCode } = await run( `${deepAggregate} try { console.error(e); } catch (err) { console.log("caught", err.name); }`, ); - expect(stderr).toContain("AggregateError: level2999"); + expect(stderr).toContain("AggregateError: " + TOP); + expect(printedLevels(stderr)).toBeLessThan(DEPTH); expect(stdout).toBe("caught RangeError\n"); expect(exitCode).toBe(0); }); test.concurrent.each([ - ["AggregateError chain", deepAggregate, "AggregateError: level2999"], - ["cause chain", deepCause, "error: level2999"], + ["AggregateError chain", deepAggregate, "AggregateError: " + TOP], + ["cause chain", deepCause, "error: " + TOP], ])("%s via uncaught throw", async (_, build, header) => { const { stderr, exitCode } = await run(`${build} throw e;`); expect(stderr).toContain(header); + expect(printedLevels(stderr)).toBeLessThan(DEPTH); expect(exitCode).toBe(1); }); test.concurrent.each([ - ["AggregateError chain", deepAggregate, "AggregateError: level2999"], - ["cause chain", deepCause, "error: level2999"], + ["AggregateError chain", deepAggregate, "AggregateError: " + TOP], + ["cause chain", deepCause, "error: " + TOP], ])("%s via unhandled rejection", async (_, build, header) => { const { stderr, exitCode } = await run(`${build} Promise.reject(e);`); expect(stderr).toContain(header); + expect(printedLevels(stderr)).toBeLessThan(DEPTH); expect(exitCode).toBe(1); }); }); @@ -555,7 +599,7 @@ test.concurrent("bun test reports a module that failed to build for a second tes stderr: "pipe", stdout: "pipe", }); - const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).toContain('error: Expected identifier but found ")"'); expect(count(stderr, "AggregateError: 4 errors building ")).toBe(2); expect(stderr).toContain("a.test.ts:"); From 39feec5d3c445218eb6408f34c1ee1ccb1d6d9c2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 13 Aug 2026 04:17:51 +0000 Subject: [PATCH 09/11] trim comments --- src/jsc/VirtualMachine.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index a3179964c9b7..ece430a3ad3f 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -4793,8 +4793,7 @@ impl VirtualMachine { } self.has_terminated = true; } - /// Prints an uncaught `value` to `writer`; `exception` is its JSC wrapper - /// when it was thrown. + pub fn print_exception( &mut self, value: JSValue, @@ -6369,8 +6368,7 @@ impl VirtualMachine { } } - // `.errors` is DontEnum (skipped above) and may be deleted, reassigned, - // or turned into an accessor; `get_errors_property` is a `getDirect`. + // `.errors` is DontEnum (not seen above) and may have been deleted or reassigned. if error_instance.is_aggregate_error(global_ref) { const MAX_AGGREGATE_ERRORS_PRINTED: u64 = 100; let errors = error_instance.get_errors_property(global_ref); @@ -6445,8 +6443,7 @@ impl VirtualMachine { if formatter.failed { break; } - // Non-error members are rendered by `formatter.format`, which - // tracks them in the same map itself. + // `formatter.format` tracks non-error members in this map itself. let is_error = err.is_cell() && err.js_type() == JSType::ErrorInstance; if is_error && bun_core::handle_oom(formatter.map.get_or_put(err)).found_existing { writer.write_all(b"\n")?; From cab8f5517dd8c8213d775180a1cd13081ca70c65 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 13 Aug 2026 07:03:19 +0000 Subject: [PATCH 10/11] error printer: pass the exception list to nested errors; singular omitted-members trailer print_error_instance_js handed the caller's exception list to remap_zig_exception and gave the body None, so the cause chain and (since the .errors walk moved here) the AggregateError members never reached the list behind Bun.serve's development error page, and BuildMessage members of a multi-error module no longer reached its build log. Reborrow the list for the remap and pass it on to the body: the page now lists the thrown error, then its cause and members, and the build log is populated as before. --- src/jsc/VirtualMachine.rs | 14 ++++++++----- test/js/bun/http/serve.test.ts | 29 ++++++++++++++++++++++++-- test/js/bun/util/inspect-error.test.js | 9 +++++--- 3 files changed, 42 insertions(+), 10 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index ece430a3ad3f..b4a262b0e3a3 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -5803,7 +5803,7 @@ impl VirtualMachine { fn print_error_instance_js( &mut self, error_instance: JSValue, - exception_list: Option<&mut ExceptionList>, + mut exception_list: Option<&mut ExceptionList>, formatter: &mut crate::console_object::Formatter, writer: &mut bun_core::io::Writer, allow_ansi_color: bool, @@ -5859,7 +5859,7 @@ impl VirtualMachine { // SAFETY: `exception` points into stack-local `exception_holder`. unsafe { &mut *exception }, error_instance, - exception_list, + exception_list.as_deref_mut(), &mut exception_holder.need_to_clear_parser_arena_on_deinit, &mut source_code_slice, formatter.error_display_level != crate::console_object::ErrorDisplayLevel::Warn, @@ -5870,8 +5870,7 @@ impl VirtualMachine { // SAFETY: see above. unsafe { &mut *exception }, error_instance, - None, // Note: `exception_list` was already - // consumed by `remap_zig_exception` above (only writer). + exception_list, formatter, writer, allow_ansi_color, @@ -6478,7 +6477,12 @@ impl VirtualMachine { } if errors_omitted > 0 && !formatter.failed { - pretty_write!(writer, "\n... {} more errors\n", errors_omitted)?; + pretty_write!( + writer, + "\n... {} more error{}\n", + errors_omitted, + if errors_omitted == 1 { "" } else { "s" } + )?; } Ok(()) diff --git a/test/js/bun/http/serve.test.ts b/test/js/bun/http/serve.test.ts index 4e1af432432b..95569dd492d3 100644 --- a/test/js/bun/http/serve.test.ts +++ b/test/js/bun/http/serve.test.ts @@ -2011,11 +2011,17 @@ it.concurrent("dev error page embeds the thrown error, its stack, and build/reso if (pathname === "/throw") inner(); if (pathname === "/syntax") await import("./broken.ts"); if (pathname === "/resolve") await import("./bad-import.ts"); + if (pathname === "/multi") await import("./broken-twice.ts"); + if (pathname === "/aggregate") { + throw new AggregateError([new Error("member one"), new RangeError("member two")], "two members", { + cause: new TypeError("the cause"), + }); + } return new Response("unreachable"); }, }); const out = {}; - for (const path of ["/throw", "/syntax", "/resolve"]) { + for (const path of ["/throw", "/syntax", "/resolve", "/multi", "/aggregate"]) { const res = await fetch(server.url + path.slice(1)); const html = await res.text(); const match = /