From 212cfc4bf3577a7f961097b7046e1bb3ef845451 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 01:56:32 +0000 Subject: [PATCH 1/4] error printer: keep async and frame names once error.stack has been read Frames parsed back out of the error.stack string were left with code type None, and the frame name formatter only renders the async prefix and the placeholder for Function frames. Mark parsed frames that had a function name as Function frames, as the structured path does, so that Bun.inspect, console.error and the uncaught error output print the same frames whether or not error.stack was materialized first. --- src/jsc/bindings/ZigException.cpp | 7 +++ test/js/bun/test/stack.test.ts | 101 +++++++++++++++++++++++++++++- 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/src/jsc/bindings/ZigException.cpp b/src/jsc/bindings/ZigException.cpp index ea84d276eda9..f8159d85213e 100644 --- a/src/jsc/bindings/ZigException.cpp +++ b/src/jsc/bindings/ZigException.cpp @@ -251,6 +251,9 @@ class V8StackTraceIterator { bool isConstructor = false; bool isGlobalCode = false; bool isAsync = false; + // The frame was printed as "name (url)" or " (url)" rather + // than as a bare "url" (module top-level code, native frames). + bool isFunction = false; }; WTF::StringView stack; @@ -400,6 +403,8 @@ class V8StackTraceIterator { functionName = functionName.substring(4); } + frame.isFunction = !functionName.isEmpty(); + if (functionName == ""_s) { functionName = StringView(); } @@ -619,6 +624,8 @@ static void fromErrorInstance(ZigException& except, JSC::JSGlobalObject* global, current.code_type = ZigStackFrameCodeConstructor; } else if (frame.isGlobalCode) { current.code_type = ZigStackFrameCodeGlobal; + } else if (frame.isFunction) { + current.code_type = ZigStackFrameCodeFunction; } except.stack.frames_len += 1; diff --git a/test/js/bun/test/stack.test.ts b/test/js/bun/test/stack.test.ts index 63b28630a3f6..a9e4e9430ac0 100644 --- a/test/js/bun/test/stack.test.ts +++ b/test/js/bun/test/stack.test.ts @@ -1,5 +1,5 @@ import { $ } from "bun"; -import { expect, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, bunRun, normalizeBunSnapshot } from "harness"; import { join } from "node:path"; @@ -149,3 +149,102 @@ test("Async functions frame should be included in stack trace", async () => { at async (file:NN:NN)" `); }); + +// Once error.stack has been read (or assigned), JSC no longer has the frames of +// the error, and Bun.inspect / console.error / the uncaught error output print +// the frames parsed back out of that string instead. +describe("printing the frames parsed back out of error.stack", () => { + const printedFrames = (text: string) => + text + .split("\n") + .map(line => line.trim()) + .filter(line => line.startsWith("at ")); + + // "at async outer (/x.js:1:2)" -> "at async outer": where the frames point is + // not what is being tested here. + const withoutLocation = (line: string) => line.replace(/ \(.*$/, ""); + + test("async, , new and bare frames print as they do in error.stack", () => { + const err = new Error("boom"); + err.stack = [ + "Error: boom", + " at async fetchUser (/fake/api.js:10:9)", + " at async (/fake/app.js:4:3)", + " at (/fake/app.js:9:1)", + " at new Client (/fake/client.js:2:11)", + " at run (/fake/main.js:7:5)", + " at global code (/fake/main.js:12:1)", + " at unknown", + ].join("\n"); + + const expected = [ + "at async fetchUser (/fake/api.js:10:9)", + "at async (/fake/app.js:4:3)", + "at (/fake/app.js:9:1)", + "at new Client (/fake/client.js:2:11)", + "at run (/fake/main.js:7:5)", + "at /fake/main.js:12:1", + expect.stringMatching(/^at unknown\b/), + ]; + expect(printedFrames(Bun.inspect(err, { colors: false }))).toEqual(expected); + expect(printedFrames(Bun.stripANSI(Bun.inspect(err, { colors: true })))).toEqual(expected); + }); + + test("Bun.inspect prints the same frames before and after error.stack has been read", async () => { + async function inner() { + await 1; + throw new Error("boom"); + } + async function outer() { + await inner(); + } + const err: Error = await (async () => { + await outer(); + })().catch(e => e); + + const ownFrames = (text: string) => + printedFrames(text) + .filter(line => line.includes("stack.test.ts")) + .map(withoutLocation); + const before = ownFrames(Bun.inspect(err)); + const stack = ownFrames(err.stack!); + const after = ownFrames(Bun.inspect(err)); + + expect(before).toEqual(["at inner", "at async outer", "at async "]); + expect({ stack, after }).toEqual({ stack: before, after: before }); + }); + + test("console.error and the unhandled rejection output keep the async frames", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + async function inner() { + await 1; + throw new Error("boom"); + } + async function outer() { + await inner(); + } + (async () => { + await outer(); + })().catch(e => { + e.stack; // e.g. a logger reading it + console.error(e); + throw e; + }); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + + // Printed once by console.error(e) and once as an unhandled rejection. + const expected = ["at inner", "at async outer", "at async "]; + expect(printedFrames(stderr).map(withoutLocation)).toEqual([...expected, ...expected]); + expect(exitCode).toBe(1); + }); +}); From 8e1f917a99b5a3957b47cf216336aea2f3187c65 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 02:04:48 +0000 Subject: [PATCH 2/4] test: drain stdout of the spawned process and assert it is empty --- test/js/bun/test/stack.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/js/bun/test/stack.test.ts b/test/js/bun/test/stack.test.ts index a9e4e9430ac0..1c9a40eb675d 100644 --- a/test/js/bun/test/stack.test.ts +++ b/test/js/bun/test/stack.test.ts @@ -240,11 +240,12 @@ describe("printing the frames parsed back out of error.stack", () => { stdout: "pipe", stderr: "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]); // Printed once by console.error(e) and once as an unhandled rejection. const expected = ["at inner", "at async outer", "at async "]; expect(printedFrames(stderr).map(withoutLocation)).toEqual([...expected, ...expected]); + expect(stdout).toBe(""); expect(exitCode).toBe(1); }); }); From f02aa480951066c60e0978549cccaf8daa56979a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 02:06:34 +0000 Subject: [PATCH 3/4] Shorten the isFunction comment --- src/jsc/bindings/ZigException.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/jsc/bindings/ZigException.cpp b/src/jsc/bindings/ZigException.cpp index f8159d85213e..11e2a6c40899 100644 --- a/src/jsc/bindings/ZigException.cpp +++ b/src/jsc/bindings/ZigException.cpp @@ -251,8 +251,7 @@ class V8StackTraceIterator { bool isConstructor = false; bool isGlobalCode = false; bool isAsync = false; - // The frame was printed as "name (url)" or " (url)" rather - // than as a bare "url" (module top-level code, native frames). + // Printed as "name (url)" or " (url)", not as a bare "url". bool isFunction = false; }; From 950510c9be46b6ce6563d5ec4af443ed120818f8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 02:49:25 +0000 Subject: [PATCH 4/4] test: error.stack doesnt lose frames now prints identically with and without reading error.stack The inline snapshot for the error.stack case encoded the last frame being printed as a bare location; it is now printed as (...) like the other case, so the two outputs are compared in full. --- test/cli/inspect/inspect.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/test/cli/inspect/inspect.test.ts b/test/cli/inspect/inspect.test.ts index 04719466ad3e..d791068a0d56 100644 --- a/test/cli/inspect/inspect.test.ts +++ b/test/cli/inspect/inspect.test.ts @@ -596,10 +596,9 @@ test("error.stack doesnt lose frames", () => { at middle (/inspect.test.ts::) at IGNORE_ME_BEFORE_THIS_LINE (/inspect.test.ts::) at accessErrorStackProperty (/inspect.test.ts::) - at /inspect.test.ts:: + at (/inspect.test.ts::) " `); - // We allow it to differ by the existence of as a string. But that's it. - expect(no.split("\n").slice(0, -2).join("\n").trim()).toBe(yes.split("\n").slice(0, -2).join("\n").trim()); + expect(yes).toBe(no); });