From 4e3d3eb1c51d520a75f2efa0263fb1433464a61a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:07:52 +0000 Subject: [PATCH 1/3] error: don't double source-map rethrown frames after .stack is read When user code reads error.stack and then rethrows, JSC clears m_stackTrace after materializing the string. The uncaught-exception printer then falls back to parsing the .stack string via V8StackTraceIterator, which sets remapped=true on every parsed frame because the positions in the string are already source-mapped. remap_zig_exception checked that flag for the top frame but not for the rest, so every non-top frame was fed back through resolve_source_mapping and remapped a second time, producing wrong line numbers. Also teach V8StackTraceIterator to parse V8-style frames with no function name ("at /path:line:col"), which it previously treated as end-of-stack, so the anonymous top-level frame is no longer dropped. Fixes #15859 --- src/jsc/VirtualMachine.rs | 2 +- src/jsc/bindings/ZigException.cpp | 22 +++-- test/regression/issue/15859.test.ts | 121 ++++++++++++++++++++++++++++ 3 files changed, 137 insertions(+), 8 deletions(-) create mode 100644 test/regression/issue/15859.test.ts diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 6f69f5b977f4..68d0031dd01f 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -5507,7 +5507,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(); diff --git a/src/jsc/bindings/ZigException.cpp b/src/jsc/bindings/ZigException.cpp index ea84d276eda9..f0e78a919b17 100644 --- a/src/jsc/bindings/ZigException.cpp +++ b/src/jsc/bindings/ZigException.cpp @@ -296,6 +296,9 @@ class V8StackTraceIterator { if (openingParentheses > closingParentheses) openingParentheses = WTF::notFound; + StringView lineInner; + StringView functionName; + if (openingParentheses == WTF::notFound || closingParentheses == WTF::notFound) { // Special case: "unknown" frames don't have parentheses but are valid // These appear in stack traces from certain error paths @@ -305,13 +308,20 @@ class V8StackTraceIterator { return true; } - // For any other frame without parentheses, terminate parsing as before - offset = stack.length(); - return false; + // V8/Node format for frames without a function name: + // at /path/to/file.js:1:2 + // at async /path/to/file.js:1:2 + lineInner = line; + if (lineInner.startsWith("async "_s)) { + frame.isAsync = true; + lineInner = lineInner.substring(6); + } + functionName = StringView(); + } else { + lineInner = StringView_slice(line, openingParentheses + 1, closingParentheses); + functionName = line.substring(0, openingParentheses - 1); } - auto lineInner = StringView_slice(line, openingParentheses + 1, closingParentheses); - { auto marker1 = 0; auto marker2 = lineInner.find(':', marker1); @@ -383,8 +393,6 @@ class V8StackTraceIterator { } done_block: - StringView functionName = line.substring(0, openingParentheses - 1); - if (functionName == "global code"_s) { functionName = StringView(); frame.isGlobalCode = true; diff --git a/test/regression/issue/15859.test.ts b/test/regression/issue/15859.test.ts new file mode 100644 index 000000000000..0729f911af74 --- /dev/null +++ b/test/regression/issue/15859.test.ts @@ -0,0 +1,121 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; + +// https://github.com/oven-sh/bun/issues/15859 +// +// Reading `error.stack` before rethrowing caused the uncaught-exception printer +// to show wrong line numbers for non-top frames (double source-mapped) and to +// drop frames without a function name. + +const fixture = `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) { + let x = error.stack + throw error +} +`; + +test("uncaught exception frames are not double source-mapped after reading error.stack", async () => { + using dir = tempDir("issue-15859", { + "test.ts": fixture, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "test.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toBe(""); + // f1's body calls err() on line 8. Before the fix this printed line 13 + // (source-mapped twice). + expect(stderr).toContain("at f1 "); + expect(stderr).toMatch(/at f1 \(.*test\.ts:8:5\)/); + expect(stderr).not.toMatch(/at f1 \(.*test\.ts:13:/); + // err() throws on line 5; this frame was already correct (top frame). + expect(stderr).toMatch(/at err \(.*test\.ts:5:/); + expect(exitCode).toBe(1); +}); + +test("uncaught exception printer keeps the anonymous top-level frame after reading error.stack", async () => { + using dir = tempDir("issue-15859-anon", { + "test.ts": fixture, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "test.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toBe(""); + // The try block calls f1() on line 14 from module scope (no function name). + // Before the fix this frame was dropped entirely because the .stack string + // parser could not handle "at ::" frames. + expect(stderr).toMatch(/at .*test\.ts:14:5/); + expect(exitCode).toBe(1); +}); + +test("uncaught exception frames match error.stack after reading it", async () => { + using dir = tempDir("issue-15859-match", { + "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), + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + const extract = (s: string) => + [...s.matchAll(/test\.ts:(\d+):(\d+)/g)].map(m => `${m[1]}:${m[2]}`); + + const stackPositions = extract(stdout); + const printedPositions = extract(stderr); + + expect(stackPositions.length).toBeGreaterThanOrEqual(3); + expect(printedPositions).toEqual(stackPositions); + expect(exitCode).toBe(1); +}); From b8440a8606c8daafd42ef5e93db7721b9f5df71c Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:10:28 +0000 Subject: [PATCH 2/3] [autofix.ci] apply automated fixes --- test/regression/issue/15859.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/regression/issue/15859.test.ts b/test/regression/issue/15859.test.ts index 0729f911af74..8e53416504e1 100644 --- a/test/regression/issue/15859.test.ts +++ b/test/regression/issue/15859.test.ts @@ -109,8 +109,7 @@ try { const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - const extract = (s: string) => - [...s.matchAll(/test\.ts:(\d+):(\d+)/g)].map(m => `${m[1]}:${m[2]}`); + const extract = (s: string) => [...s.matchAll(/test\.ts:(\d+):(\d+)/g)].map(m => `${m[1]}:${m[2]}`); const stackPositions = extract(stdout); const printedPositions = extract(stderr); From 79d52a2254aa6950fb58a448dace6ecda82d7dbb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:13:43 +0000 Subject: [PATCH 3/3] Trim format-example comment to match local style --- src/jsc/bindings/ZigException.cpp | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/jsc/bindings/ZigException.cpp b/src/jsc/bindings/ZigException.cpp index f0e78a919b17..6118d973293a 100644 --- a/src/jsc/bindings/ZigException.cpp +++ b/src/jsc/bindings/ZigException.cpp @@ -308,9 +308,8 @@ class V8StackTraceIterator { return true; } - // V8/Node format for frames without a function name: - // at /path/to/file.js:1:2 - // at async /path/to/file.js:1:2 + // /path/to/file.js:1:2 + // async /path/to/file.js:1:2 lineInner = line; if (lineInner.startsWith("async "_s)) { frame.isAsync = true;