From 052ec783dc8cd6492c80cd7fdebb2a94788b85ee Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:52:56 +0000 Subject: [PATCH 1/5] Take a parser SyntaxError's sourceURL from the same source-mapped frame as its line The `` block of formatStackTrace() maps the parser's line through the source map and stores the mapped line. It left sourceURL as the file that was loaded. Under a source map that names another file (the output of `bun build --sourcemap`), `sourceURL:line` named a line that the loaded file does not have. The block now stores both from the one remapped frame, as the path for an error with frames does. exceptionFromString() printed an object that has the properties of an error at `sourceURL:originalLine`. `sourceURL` is the mapped file and `originalLine` is the line before the map, so it now prints `sourceURL:line`. --- src/jsc/bindings/FormatStackTraceForJS.cpp | 4 +- src/jsc/bindings/ZigException.cpp | 12 +- test/bundler/bundler_compile.test.ts | 28 +++++ test/js/bun/test/stack.test.ts | 129 ++++++++++++++++++++- 4 files changed, 158 insertions(+), 15 deletions(-) diff --git a/src/jsc/bindings/FormatStackTraceForJS.cpp b/src/jsc/bindings/FormatStackTraceForJS.cpp index b1734eb34d4b..553d526cd853 100644 --- a/src/jsc/bindings/FormatStackTraceForJS.cpp +++ b/src/jsc/bindings/FormatStackTraceForJS.cpp @@ -285,12 +285,14 @@ WTF::String formatStackTrace( sb.append(" at ("_s); - sb.append(remappedFrame.source_url.toWTFString()); + WTF::String remappedSourceURL = remappedFrame.source_url.toWTFString(); + sb.append(remappedSourceURL); if (remappedFrame.remapped) { errorInstance->putDirect(vm, builtinNames(vm).originalLinePublicName(), jsNumber(originalLine.oneBasedInt()), PropertyAttribute::DontEnum | 0); hasSet = true; line = remappedFrame.position.line(); + sourceURL = WTF::move(remappedSourceURL); } if (remappedFrame.remapped) { diff --git a/src/jsc/bindings/ZigException.cpp b/src/jsc/bindings/ZigException.cpp index 160fad449ee9..f2f97a5d9d30 100644 --- a/src/jsc/bindings/ZigException.cpp +++ b/src/jsc/bindings/ZigException.cpp @@ -800,18 +800,8 @@ void exceptionFromString(ZigException& except, JSC::JSValue value, JSC::JSGlobal } if (line) { if (line.isNumber()) { + // Not marked as remapped, even with an `originalLine`: the printer reads the file that a remapped frame names. except.stack.frames_ptr[0].position.line_zero_based = OrdinalNumber::fromOneBasedInt(JSC::toInt32(line.asNumber())).zeroBasedInt(); - - // TODO: don't sourcemap it twice - auto originalLine = obj->getIfPropertyExists(global, builtinNames(vm).originalLinePublicName()); - if (scope.exception()) [[unlikely]] { - scope.clearExceptionExceptTermination(); - } - if (originalLine) { - if (originalLine.isNumber()) { - except.stack.frames_ptr[0].position.line_zero_based = OrdinalNumber::fromOneBasedInt(JSC::toInt32(originalLine.asNumber())).zeroBasedInt(); - } - } except.stack.frames_len = 1; } } diff --git a/test/bundler/bundler_compile.test.ts b/test/bundler/bundler_compile.test.ts index ca33edde4b84..7e4e8dbfb8d6 100644 --- a/test/bundler/bundler_compile.test.ts +++ b/test/bundler/bundler_compile.test.ts @@ -1398,6 +1398,34 @@ error: Hello World`, }, }, }); + // JavaScriptCore's parser is the one that rejects second.ts: the bundler passes a regular expression literal through. + // The source map names second.ts, which is not the embedded module that is loaded. + itBundled("compile/SourceMapParserSyntaxErrorLocation", { + backend: "cli", + target: "bun", + compile: true, + files: { + "/entry.ts": /* js */ ` + const specifier = "./second" + ".js"; + try { + require(specifier); + } catch (e) { + // The stack names a file too, in its "at " line. + const stack = e.stack.split("\\n")[1].trim().replace(e.sourceURL, ""); + console.log(JSON.stringify({ sourceURL: require("node:path").basename(e.sourceURL), line: e.line, stack })); + } + `, + "/second.ts": "export const a = 1;\nexport const b = 2;\nexport const re = /(/;\n", + }, + entryPointsRaw: ["./entry.ts", "./second.ts"], + sourceMap: "external", + outfile: "dist/out", + run: { + stdout: JSON.stringify({ sourceURL: "second.ts", line: 3, stack: "at (:3)" }), + file: "dist/out", + setCwd: true, + }, + }); itBundled("compile/BunBeBunEnvVar", { compile: true, files: { diff --git a/test/js/bun/test/stack.test.ts b/test/js/bun/test/stack.test.ts index 5445e109bf8e..34e95531b472 100644 --- a/test/js/bun/test/stack.test.ts +++ b/test/js/bun/test/stack.test.ts @@ -1,7 +1,7 @@ import { $ } from "bun"; -import { expect, test } from "bun:test"; -import { bunEnv, bunExe, bunRun, normalizeBunSnapshot } from "harness"; -import { join } from "node:path"; +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, bunRun, normalizeBunSnapshot, tempDir } from "harness"; +import { join, sep } from "node:path"; test("name property is used for function calls in Error.stack", () => { function WRONG() { @@ -85,6 +85,129 @@ test("err.line and err.column are set", async () => { ); }); +// JavaScriptCore's parser is the one that rejects src.ts: the bundler passes a regular expression literal through. +// The source map of a bundle names src.ts, which is not the file that is loaded. +describe.concurrent("a SyntaxError from JavaScriptCore's parser in a file with a source map", () => { + const source = "export const a = 1;\nexport const b = 2;\nexport const re = /(/;\n"; + + /** Builds src.ts into `name`/src.js. Returns the line of the bundle that has the regular expression. */ + async function bundle(root: string, name: string, options: Pick) { + const { outputs } = await Bun.build({ + entrypoints: [join(root, "src.ts")], + outdir: join(root, name), + target: "bun", + ...options, + }); + const text = await outputs.find(output => output.kind === "entry-point")!.text(); + return text.split("\n").findIndex(line => line.includes("/(/")) + 1; + } + + async function run(cwd: string) { + await using proc = Bun.spawn({ cmd: [bunExe(), "main.cjs"], env: bunEnv, cwd, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + } + + test("has the line and the sourceURL of one file", async () => { + using dir = tempDir("parser-error-location", { + "src.ts": source, + "unmapped.js": "// @bun\nconst a = 1;\nconst b = 2;\nconst re = /(/;\n", + "main.cjs": /* js */ ` + const { relative } = require("node:path"); + const locations = {}; + function locate(file, e) { + const sourceURL = relative(__dirname, e.sourceURL); + // The stack names a file too, in its "at " line. + const stack = e.stack.split("\\n")[1].trim().replace(e.sourceURL, sourceURL); + locations[file] = { sourceURL, line: e.line, originalLine: e.originalLine, stack }; + } + for (const file of ["linked/src.js", "inline/src.js", "cjs/src.js", "unmapped.js"]) { + try { + require("./" + file); + } catch (e) { + locate(file, e); + } + } + // A module is parsed with no JS frame on the stack. Its error is located once it has frames. + import("./imported/src.js") + .catch(e => { + Error.captureStackTrace(e); + locate("imported/src.js", e); + }) + .then(() => console.log(JSON.stringify(locations))); + `, + }); + const root = String(dir); + const generatedLine = { + linked: await bundle(root, "linked", { sourcemap: "linked" }), + inline: await bundle(root, "inline", { sourcemap: "inline" }), + cjs: await bundle(root, "cjs", { sourcemap: "linked", format: "cjs" }), + imported: await bundle(root, "imported", { sourcemap: "linked" }), + }; + + const { stdout, stderr, exitCode } = await run(root); + + // `line` is a line of src.ts, so `sourceURL` is src.ts. `originalLine` is the line in the file that was loaded. + const inSource = (originalLine: number) => ({ + sourceURL: "src.ts", + line: 3, + originalLine, + stack: "at (src.ts:3)", + }); + expect({ locations: stdout && JSON.parse(stdout), stderr, exitCode }).toEqual({ + locations: { + "linked/src.js": inSource(generatedLine.linked), + "inline/src.js": inSource(generatedLine.inline), + "cjs/src.js": inSource(generatedLine.cjs), + "imported/src.js": inSource(generatedLine.imported), + "unmapped.js": { sourceURL: "unmapped.js", line: 4, originalLine: 4, stack: "at (unmapped.js:4)" }, + }, + stderr: "", + exitCode: 0, + }); + }); + + test("is printed at that location when the printer has only its properties to read", async () => { + using dir = tempDir("parser-error-printed", { + "src.ts": source, + "main.cjs": /* js */ ` + try { + require("./out/src.js"); + } catch (e) { + // Neither the error without its stack nor an object with its properties has frames to print. + const copy = { name: e.name, message: e.message, sourceURL: e.sourceURL, line: e.line, originalLine: e.originalLine }; + e.stack = ""; + console.log(Bun.inspect(e, { colors: false })); + throw copy; + } + `, + }); + const root = String(dir); + await bundle(root, "out", { sourcemap: "linked" }); + + const { stdout, stderr, exitCode } = await run(root); + + // The source lines above the message, and the frame below it. + const shown = (printed: string) => { + const lines = printed.replaceAll(root + sep, "").split("\n"); + const message = lines.findIndex(line => line.startsWith("SyntaxError: ")); + return { + lines: lines.slice(0, Math.max(message, 0)), + at: lines.filter(line => line.trimStart().startsWith("at ")), + }; + }; + expect({ error: shown(stdout), copy: shown(stderr), exitCode }).toEqual({ + error: { + lines: ["1 | export const a = 1;", "2 | export const b = 2;", "3 | export const re = /(/;", " ^"], + at: [" at src.ts:3:1"], + }, + // The printer does not read the file that an object names, only the one that an Error names. + copy: { lines: [], at: [" at src.ts:3"] }, + exitCode: 1, + }); + }); +}); + test("throwing inside an error suppresses the error and prints the stack", async () => { $.throws(false); $.env(bunEnv); From 194261cce5b9be98871d86855674adcd98fd5abf Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 1 Nov 2026 23:21:56 +0000 Subject: [PATCH 2/5] Test the parser SyntaxError location in bundles of two modules and in the arms that stay as they are The bundles in the tests now have two modules, with the error in the second one: the expected sourceURL is the source of the mapped line, not the only source of the map. New rows: a structured clone that gets frames, a file that Bun transpiles, a map with no mapping for the line, an error with frames, a module error before it has frames, and Error.prepareStackTrace. A minified bundle names the first module of the generated line. That case is a failing test with the reason. The printer tests throw objects that real code makes: one that inherits from the error, and one with the property descriptors of an error of a TypeScript file. --- src/jsc/bindings/ZigException.cpp | 3 +- test/bundler/bundler_compile.test.ts | 11 +- test/js/bun/test/stack.test.ts | 228 ++++++++++++++++++++------- 3 files changed, 175 insertions(+), 67 deletions(-) diff --git a/src/jsc/bindings/ZigException.cpp b/src/jsc/bindings/ZigException.cpp index f2f97a5d9d30..b31701d26070 100644 --- a/src/jsc/bindings/ZigException.cpp +++ b/src/jsc/bindings/ZigException.cpp @@ -800,7 +800,8 @@ void exceptionFromString(ZigException& except, JSC::JSValue value, JSC::JSGlobal } if (line) { if (line.isNumber()) { - // Not marked as remapped, even with an `originalLine`: the printer reads the file that a remapped frame names. + // The position is what the object says. It has no column, so a source map that is already parsed does not map it again. + // It is not marked as remapped: the printer reads the file of a remapped frame, and an object that is not an Error can name any file. except.stack.frames_ptr[0].position.line_zero_based = OrdinalNumber::fromOneBasedInt(JSC::toInt32(line.asNumber())).zeroBasedInt(); except.stack.frames_len = 1; } diff --git a/test/bundler/bundler_compile.test.ts b/test/bundler/bundler_compile.test.ts index 7e4e8dbfb8d6..b7af9912e0a6 100644 --- a/test/bundler/bundler_compile.test.ts +++ b/test/bundler/bundler_compile.test.ts @@ -1399,7 +1399,7 @@ error: Hello World`, }, }); // JavaScriptCore's parser is the one that rejects second.ts: the bundler passes a regular expression literal through. - // The source map names second.ts, which is not the embedded module that is loaded. + // The source map names second.ts for the line of the error, and the module that is loaded is the embedded one. itBundled("compile/SourceMapParserSyntaxErrorLocation", { backend: "cli", target: "bun", @@ -1410,18 +1410,17 @@ error: Hello World`, try { require(specifier); } catch (e) { - // The stack names a file too, in its "at " line. - const stack = e.stack.split("\\n")[1].trim().replace(e.sourceURL, ""); - console.log(JSON.stringify({ sourceURL: require("node:path").basename(e.sourceURL), line: e.line, stack })); + console.log(JSON.stringify({ sourceURL: e.sourceURL, line: e.line, stack: e.stack.split("\\n")[1].trim() })); } `, - "/second.ts": "export const a = 1;\nexport const b = 2;\nexport const re = /(/;\n", + "/first.ts": "export const first = 1;\n", + "/second.ts": 'import { first } from "./first.ts";\nexport const second = first;\nexport const re = /(/;\n', }, entryPointsRaw: ["./entry.ts", "./second.ts"], sourceMap: "external", outfile: "dist/out", run: { - stdout: JSON.stringify({ sourceURL: "second.ts", line: 3, stack: "at (:3)" }), + stdout: JSON.stringify({ sourceURL: "second.ts", line: 3, stack: "at (second.ts:3)" }), file: "dist/out", setCwd: true, }, diff --git a/test/js/bun/test/stack.test.ts b/test/js/bun/test/stack.test.ts index 34e95531b472..082599b6911d 100644 --- a/test/js/bun/test/stack.test.ts +++ b/test/js/bun/test/stack.test.ts @@ -85,21 +85,53 @@ test("err.line and err.column are set", async () => { ); }); -// JavaScriptCore's parser is the one that rejects src.ts: the bundler passes a regular expression literal through. -// The source map of a bundle names src.ts, which is not the file that is loaded. +/** What the error printer shows of one error: the source lines above the message, and the frames below it. */ +function shown(printed: string, root: string) { + const lines = printed.replaceAll(root + sep, "").split("\n"); + let sourceLines = 0; + while (sourceLines < lines.length && /^\s*(\d+ \| .*|\^)$/.test(lines[sourceLines])) sourceLines++; + return { + lines: lines.slice(0, sourceLines), + at: lines.filter(line => line.trimStart().startsWith("at ")), + }; +} + +// JavaScriptCore's parser is the one that rejects second.ts: the bundler passes a regular expression literal through. +// The source map of a bundle names second.ts for the line of the error, and the file that is loaded is the bundle. describe.concurrent("a SyntaxError from JavaScriptCore's parser in a file with a source map", () => { - const source = "export const a = 1;\nexport const b = 2;\nexport const re = /(/;\n"; + const sources = { + "first.ts": "export const first = 1;\n", + "second.ts": "export const a = 1;\nexport const b = 2;\nexport const re = /(/;\n", + "entry.ts": 'import { first } from "./first.ts";\nimport { re } from "./second.ts";\nconsole.log(first, re);\n', + }; + // What an error says about where it is: its properties, and the "at " line of its stack if it has one. + const locate = /* js */ ` + const { relative } = require("node:path"); + function locate(e) { + const sourceURL = relative(__dirname, e.sourceURL).replaceAll("\\\\", "/"); + const parse = String(e.stack).split("\\n").find(line => line.includes("at ")); + return { sourceURL, line: e.line, originalLine: e.originalLine, stack: parse?.trim().replace(e.sourceURL, sourceURL) }; + } + function thrownBy(file) { + try { + require("./" + file); + } catch (e) { + return e; + } + } + `; - /** Builds src.ts into `name`/src.js. Returns the line of the bundle that has the regular expression. */ - async function bundle(root: string, name: string, options: Pick) { + type Options = Pick & { entry?: string }; + /** Builds entry.ts into the folder `name`. Returns the line of the bundle that has `text`. */ + async function bundle(root: string, name: string, { entry = "entry.ts", ...options }: Options, text = "/(/") { const { outputs } = await Bun.build({ - entrypoints: [join(root, "src.ts")], + entrypoints: [join(root, entry)], outdir: join(root, name), target: "bun", ...options, }); - const text = await outputs.find(output => output.kind === "entry-point")!.text(); - return text.split("\n").findIndex(line => line.includes("/(/")) + 1; + const output = await outputs.find(output => output.kind === "entry-point")!.text(); + return output.split("\n").findIndex(line => line.includes(text)) + 1; } async function run(cwd: string) { @@ -110,75 +142,132 @@ describe.concurrent("a SyntaxError from JavaScriptCore's parser in a file with a test("has the line and the sourceURL of one file", async () => { using dir = tempDir("parser-error-location", { - "src.ts": source, + ...sources, + "throws.ts": 'export const a = 1;\nexport const b = 2;\nthrow new TypeError("thrown");\n', + "transpiled.ts": + "type T = { a: number };\ninterface I { b: string }\nconst a: T = { a: 1 };\nconst re = /(/;\nexport { a, re };\n", "unmapped.js": "// @bun\nconst a = 1;\nconst b = 2;\nconst re = /(/;\n", + // The source map has a mapping for line 2 and none for the line of the error. + "partly-mapped.js": "// @bun\nconst a = 1;\nconst b = 2;\nconst re = /(/;\n", + "partly-mapped.js.map": JSON.stringify({ + version: 3, + sources: ["other.ts"], + sourcesContent: [""], + names: [], + mappings: ";AAAA", + }), "main.cjs": /* js */ ` - const { relative } = require("node:path"); - const locations = {}; - function locate(file, e) { - const sourceURL = relative(__dirname, e.sourceURL); - // The stack names a file too, in its "at " line. - const stack = e.stack.split("\\n")[1].trim().replace(e.sourceURL, sourceURL); - locations[file] = { sourceURL, line: e.line, originalLine: e.originalLine, stack }; - } - for (const file of ["linked/src.js", "inline/src.js", "cjs/src.js", "unmapped.js"]) { - try { - require("./" + file); - } catch (e) { - locate(file, e); - } - } - // A module is parsed with no JS frame on the stack. Its error is located once it has frames. - import("./imported/src.js") - .catch(e => { - Error.captureStackTrace(e); - locate("imported/src.js", e); - }) - .then(() => console.log(JSON.stringify(locations))); + ${locate} + (async () => { + const locations = {}; + const files = ["linked/entry.js", "inline/entry.js", "cjs/entry.js", "unmapped.js", "partly-mapped.js", "transpiled.ts", "throws/throws.js"]; + const errors = Object.fromEntries(files.map(file => [file, thrownBy(file)])); + for (const file of files) locations[file] = locate(errors[file]); + + const imported = await import("./imported/entry.js").catch(e => e); + locations["imported/entry.js"] = locate(imported); + // The frames are alive while this function runs, so no collection formats them first. + Error.captureStackTrace(imported); + locations["imported/entry.js, with frames"] = locate(imported); + + const clone = structuredClone(errors["linked/entry.js"]); + Error.captureStackTrace(clone); + locations["a clone of linked/entry.js, with frames"] = locate(clone); + + Error.prepareStackTrace = () => ""; + const prepared = thrownBy("prepared/entry.js"); + Error.prepareStackTrace = undefined; + locations["prepared/entry.js, under Error.prepareStackTrace"] = locate(prepared); + + console.log(JSON.stringify(locations)); + })(); `, }); const root = String(dir); - const generatedLine = { + // The line of each bundle that has the error. + const line = { linked: await bundle(root, "linked", { sourcemap: "linked" }), inline: await bundle(root, "inline", { sourcemap: "inline" }), cjs: await bundle(root, "cjs", { sourcemap: "linked", format: "cjs" }), imported: await bundle(root, "imported", { sourcemap: "linked" }), + prepared: await bundle(root, "prepared", { sourcemap: "linked" }), + throws: await bundle(root, "throws", { sourcemap: "linked", entry: "throws.ts" }, "thrown"), }; const { stdout, stderr, exitCode } = await run(root); - // `line` is a line of src.ts, so `sourceURL` is src.ts. `originalLine` is the line in the file that was loaded. - const inSource = (originalLine: number) => ({ - sourceURL: "src.ts", + // `line` is a line of second.ts, so `sourceURL` is second.ts. `originalLine` is the line before the source map. + const inSecond = (originalLine: number) => ({ + sourceURL: "second.ts", line: 3, originalLine, - stack: "at (src.ts:3)", + stack: "at (second.ts:3)", + }); + // No source map gives another file for the line: both are the ones of the file that was loaded. + const inLoaded = (file: string, line: number) => ({ + sourceURL: file, + line, + originalLine: line, + stack: `at (${file}:${line})`, }); expect({ locations: stdout && JSON.parse(stdout), stderr, exitCode }).toEqual({ locations: { - "linked/src.js": inSource(generatedLine.linked), - "inline/src.js": inSource(generatedLine.inline), - "cjs/src.js": inSource(generatedLine.cjs), - "imported/src.js": inSource(generatedLine.imported), - "unmapped.js": { sourceURL: "unmapped.js", line: 4, originalLine: 4, stack: "at (unmapped.js:4)" }, + "linked/entry.js": inSecond(line.linked), + "inline/entry.js": inSecond(line.inline), + "cjs/entry.js": inSecond(line.cjs), + "unmapped.js": inLoaded("unmapped.js", 4), + "partly-mapped.js": inLoaded("partly-mapped.js", 4), + // Bun's own source map of a file that it transpiles names that file. + "transpiled.ts": { + sourceURL: "transpiled.ts", + line: 4, + originalLine: 2, + stack: "at (transpiled.ts:4)", + }, + // An error that has frames takes both from its first frame. + "throws/throws.js": { sourceURL: "throws.ts", line: 3, originalLine: line.throws }, + // A module is parsed with no JS frame on the stack. Nothing maps its error until it has frames. + "imported/entry.js": { sourceURL: "imported/entry.js", line: line.imported }, + "imported/entry.js, with frames": inSecond(line.imported), + // A structured clone starts from the line and the sourceURL of the error. They are not mapped a second time. + "a clone of linked/entry.js, with frames": inSecond(3), + // A callable Error.prepareStackTrace makes the stack, and nothing maps the error. + "prepared/entry.js, under Error.prepareStackTrace": { sourceURL: "prepared/entry.js", line: line.prepared }, }, stderr: "", exitCode: 0, }); }); + // The line is looked up at column 0. A minified bundle has its modules on one line, and the first mapping of that + // line is in first.ts, so the error says first.ts:1. The parser knows the column: https://github.com/oven-sh/bun/issues/5192 + test.failing("names the module that has the error when the bundle is minified", async () => { + using dir = tempDir("parser-error-minified", { + ...sources, + "main.cjs": `${locate}\nconsole.log(JSON.stringify(locate(thrownBy("minified/entry.js"))));`, + }); + const root = String(dir); + const line = await bundle(root, "minified", { sourcemap: "linked", minify: true }); + + const { stdout, stderr, exitCode } = await run(root); + expect({ location: stdout && JSON.parse(stdout), stderr, exitCode }).toEqual({ + location: { sourceURL: "second.ts", line: 3, originalLine: line, stack: "at (second.ts:3)" }, + stderr: "", + exitCode: 0, + }); + }); + test("is printed at that location when the printer has only its properties to read", async () => { using dir = tempDir("parser-error-printed", { - "src.ts": source, + ...sources, "main.cjs": /* js */ ` try { - require("./out/src.js"); + require("./out/entry.js"); } catch (e) { - // Neither the error without its stack nor an object with its properties has frames to print. - const copy = { name: e.name, message: e.message, sourceURL: e.sourceURL, line: e.line, originalLine: e.originalLine }; + // Neither has frames to print: the error has no stack now, and an object made from it is not an Error. e.stack = ""; console.log(Bun.inspect(e, { colors: false })); - throw copy; + throw Object.create(e); } `, }); @@ -186,28 +275,47 @@ describe.concurrent("a SyntaxError from JavaScriptCore's parser in a file with a await bundle(root, "out", { sourcemap: "linked" }); const { stdout, stderr, exitCode } = await run(root); - - // The source lines above the message, and the frame below it. - const shown = (printed: string) => { - const lines = printed.replaceAll(root + sep, "").split("\n"); - const message = lines.findIndex(line => line.startsWith("SyntaxError: ")); - return { - lines: lines.slice(0, Math.max(message, 0)), - at: lines.filter(line => line.trimStart().startsWith("at ")), - }; - }; - expect({ error: shown(stdout), copy: shown(stderr), exitCode }).toEqual({ + expect({ error: shown(stdout, root), object: shown(stderr, root), exitCode }).toEqual({ error: { lines: ["1 | export const a = 1;", "2 | export const b = 2;", "3 | export const re = /(/;", " ^"], - at: [" at src.ts:3:1"], + at: [" at second.ts:3:1"], }, - // The printer does not read the file that an object names, only the one that an Error names. - copy: { lines: [], at: [" at src.ts:3"] }, + // The printer reads the file that an Error names, and not the file that another object names. + object: { lines: [], at: [" at second.ts:3"] }, exitCode: 1, }); }); }); +test("an object with the properties of an error is printed at the line of the error", async () => { + // The lines of types are not in the text that runs. The error is on line 6, which is line 2 before the source map. + using dir = tempDir("error-properties-location", { + "thrower.ts": `type A = { a: number }; +interface B { + b: string; +} +const value: A = { a: 1 }; +const error = new Error("boom " + value.a); +throw Object.create(Error.prototype, Object.getOwnPropertyDescriptors(error)); +`, + }); + const root = String(dir); + await using proc = Bun.spawn({ + cmd: [bunExe(), "thrower.ts"], + env: bunEnv, + cwd: root, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect({ stdout, shown: shown(stderr, root), exitCode }).toEqual({ + stdout: "", + shown: { lines: [], at: [" at thrower.ts:6"] }, + exitCode: 1, + }); +}); + test("throwing inside an error suppresses the error and prints the stack", async () => { $.throws(false); $.env(bunEnv); From 3f8171804e3ee1cd41acc35b68c5bc70efabf1b8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 2 Nov 2026 01:54:06 +0000 Subject: [PATCH 3/5] Say why an object's frame is not marked as remapped, and keep a collection out of one test row An `originalLine` on an object that is not an Error does not show that its `line` and `sourceURL` are mapped: Error.captureStackTrace() puts one on any object, and a DOMException keeps the line that JavaScriptCore gave it. The comment in exceptionFromString() now says so. The row for an error with frames compared `originalLine`, which the error has only if no collection formats its stack first. It now compares `sourceURL` and `line`. --- src/jsc/bindings/ZigException.cpp | 3 ++- test/js/bun/test/stack.test.ts | 16 ++++++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/jsc/bindings/ZigException.cpp b/src/jsc/bindings/ZigException.cpp index b31701d26070..4b7fd2287cd7 100644 --- a/src/jsc/bindings/ZigException.cpp +++ b/src/jsc/bindings/ZigException.cpp @@ -801,7 +801,8 @@ void exceptionFromString(ZigException& except, JSC::JSValue value, JSC::JSGlobal if (line) { if (line.isNumber()) { // The position is what the object says. It has no column, so a source map that is already parsed does not map it again. - // It is not marked as remapped: the printer reads the file of a remapped frame, and an object that is not an Error can name any file. + // It is not marked as remapped, even with an `originalLine`: Error.captureStackTrace() puts one on any object, and a + // DOMException keeps a `line` from before the source map. The printer also reads the file that a remapped frame names. except.stack.frames_ptr[0].position.line_zero_based = OrdinalNumber::fromOneBasedInt(JSC::toInt32(line.asNumber())).zeroBasedInt(); except.stack.frames_len = 1; } diff --git a/test/js/bun/test/stack.test.ts b/test/js/bun/test/stack.test.ts index 082599b6911d..b2884abf9206 100644 --- a/test/js/bun/test/stack.test.ts +++ b/test/js/bun/test/stack.test.ts @@ -122,8 +122,8 @@ describe.concurrent("a SyntaxError from JavaScriptCore's parser in a file with a `; type Options = Pick & { entry?: string }; - /** Builds entry.ts into the folder `name`. Returns the line of the bundle that has `text`. */ - async function bundle(root: string, name: string, { entry = "entry.ts", ...options }: Options, text = "/(/") { + /** Builds entry.ts into the folder `name`. Returns the line of the bundle that has the regular expression. */ + async function bundle(root: string, name: string, { entry = "entry.ts", ...options }: Options) { const { outputs } = await Bun.build({ entrypoints: [join(root, entry)], outdir: join(root, name), @@ -131,7 +131,7 @@ describe.concurrent("a SyntaxError from JavaScriptCore's parser in a file with a ...options, }); const output = await outputs.find(output => output.kind === "entry-point")!.text(); - return output.split("\n").findIndex(line => line.includes(text)) + 1; + return output.split("\n").findIndex(line => line.includes("/(/")) + 1; } async function run(cwd: string) { @@ -160,10 +160,14 @@ describe.concurrent("a SyntaxError from JavaScriptCore's parser in a file with a ${locate} (async () => { const locations = {}; - const files = ["linked/entry.js", "inline/entry.js", "cjs/entry.js", "unmapped.js", "partly-mapped.js", "transpiled.ts", "throws/throws.js"]; + const files = ["linked/entry.js", "inline/entry.js", "cjs/entry.js", "unmapped.js", "partly-mapped.js", "transpiled.ts"]; const errors = Object.fromEntries(files.map(file => [file, thrownBy(file)])); for (const file of files) locations[file] = locate(errors[file]); + // An error with frames has an originalLine only if no collection formats its stack first. + const { sourceURL, line } = locate(thrownBy("throws/throws.js")); + locations["throws/throws.js"] = { sourceURL, line }; + const imported = await import("./imported/entry.js").catch(e => e); locations["imported/entry.js"] = locate(imported); // The frames are alive while this function runs, so no collection formats them first. @@ -191,8 +195,8 @@ describe.concurrent("a SyntaxError from JavaScriptCore's parser in a file with a cjs: await bundle(root, "cjs", { sourcemap: "linked", format: "cjs" }), imported: await bundle(root, "imported", { sourcemap: "linked" }), prepared: await bundle(root, "prepared", { sourcemap: "linked" }), - throws: await bundle(root, "throws", { sourcemap: "linked", entry: "throws.ts" }, "thrown"), }; + await bundle(root, "throws", { sourcemap: "linked", entry: "throws.ts" }); const { stdout, stderr, exitCode } = await run(root); @@ -225,7 +229,7 @@ describe.concurrent("a SyntaxError from JavaScriptCore's parser in a file with a stack: "at (transpiled.ts:4)", }, // An error that has frames takes both from its first frame. - "throws/throws.js": { sourceURL: "throws.ts", line: 3, originalLine: line.throws }, + "throws/throws.js": { sourceURL: "throws.ts", line: 3 }, // A module is parsed with no JS frame on the stack. Nothing maps its error until it has frames. "imported/entry.js": { sourceURL: "imported/entry.js", line: line.imported }, "imported/entry.js, with frames": inSecond(line.imported), From b7c0e46a7bea66f42a54d3f0bc16b68805eb2450 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 2 Nov 2026 01:57:00 +0000 Subject: [PATCH 4/5] Shorten the comment in exceptionFromString to one line --- src/jsc/bindings/ZigException.cpp | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/jsc/bindings/ZigException.cpp b/src/jsc/bindings/ZigException.cpp index 4b7fd2287cd7..0fcaab51076f 100644 --- a/src/jsc/bindings/ZigException.cpp +++ b/src/jsc/bindings/ZigException.cpp @@ -800,9 +800,7 @@ void exceptionFromString(ZigException& except, JSC::JSValue value, JSC::JSGlobal } if (line) { if (line.isNumber()) { - // The position is what the object says. It has no column, so a source map that is already parsed does not map it again. - // It is not marked as remapped, even with an `originalLine`: Error.captureStackTrace() puts one on any object, and a - // DOMException keeps a `line` from before the source map. The printer also reads the file that a remapped frame names. + // Not marked as remapped, even with an `originalLine`: Error.captureStackTrace() puts one on any object. except.stack.frames_ptr[0].position.line_zero_based = OrdinalNumber::fromOneBasedInt(JSC::toInt32(line.asNumber())).zeroBasedInt(); except.stack.frames_len = 1; } From fef4bf974cd1c83c38b1aaf1830b98d77d7d12ea Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 2 Nov 2026 02:14:17 +0000 Subject: [PATCH 5/5] Make the error-properties test in stack.test.ts concurrent --- test/js/bun/test/stack.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/js/bun/test/stack.test.ts b/test/js/bun/test/stack.test.ts index b2884abf9206..a4dbaa1039de 100644 --- a/test/js/bun/test/stack.test.ts +++ b/test/js/bun/test/stack.test.ts @@ -291,7 +291,7 @@ describe.concurrent("a SyntaxError from JavaScriptCore's parser in a file with a }); }); -test("an object with the properties of an error is printed at the line of the error", async () => { +test.concurrent("an object with the properties of an error is printed at the line of the error", async () => { // The lines of types are not in the text that runs. The error is on line 6, which is line 2 before the source map. using dir = tempDir("error-properties-location", { "thrower.ts": `type A = { a: number };