From e6faff59cac340d5d8c35284ad03969d2d093dc3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 16 Aug 2026 06:43:48 +0000 Subject: [PATCH 1/4] DOMException: record source-mapped positions and a Bun-format stack on internally created exceptions createDOMException() attached line/column/sourceURL/stack with JSC's addErrorInfo(), so they held positions in Bun's transpiled output and a JSC-format stack string, unlike Errors, whose positions go through the source map when they are materialized. Replace it with Bun::addErrorInfoWithSourceMap(), which formats the captured frames with Bun::formatStackTrace() and stores the mapped position (plus the originalLine/originalColumn an Error gets) as DontEnum properties. The error printer fallback for objects that are not ErrorInstances now also reads column and treats a present originalLine as meaning the position is already mapped, instead of substituting the unmapped line, so uncaught DOMExceptions print the line they were created on. --- src/jsc/bindings/FormatStackTraceForJS.cpp | 22 +++ src/jsc/bindings/FormatStackTraceForJS.h | 7 + src/jsc/bindings/JSDOMExceptionHandling.cpp | 6 +- src/jsc/bindings/ZigException.cpp | 23 ++- test/js/node/domexception-node.test.js | 170 ++++++++++++++++++++ 5 files changed, 219 insertions(+), 9 deletions(-) diff --git a/src/jsc/bindings/FormatStackTraceForJS.cpp b/src/jsc/bindings/FormatStackTraceForJS.cpp index d622679f31aa..fb7541ae5b2c 100644 --- a/src/jsc/bindings/FormatStackTraceForJS.cpp +++ b/src/jsc/bindings/FormatStackTraceForJS.cpp @@ -393,6 +393,28 @@ WTF::String formatStackTrace( return sb.toString(); } +void addErrorInfoWithSourceMap(JSC::JSGlobalObject* lexicalGlobalObject, JSC::JSObject* object, const WTF::String& name, const WTF::String& message) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + std::unique_ptr> stackTrace = JSC::getStackTrace(vm, object, /* useCurrentFrame */ true); + if (!stackTrace) + return; + + OrdinalNumber line = OrdinalNumber::beforeFirst(); + OrdinalNumber column = OrdinalNumber::beforeFirst(); + WTF::String sourceURL; + WTF::String stack = formatStackTrace(vm, defaultGlobalObject(lexicalGlobalObject), lexicalGlobalObject, name, message, line, column, sourceURL, *stackTrace, object); + + constexpr unsigned attributes = JSC::PropertyAttribute::DontEnum | 0; + if (line != OrdinalNumber::beforeFirst()) { + object->putDirect(vm, vm.propertyNames->line, jsNumber(line.oneBasedInt()), attributes); + object->putDirect(vm, vm.propertyNames->column, jsNumber(column.oneBasedInt()), attributes); + } + if (!sourceURL.isEmpty()) + object->putDirect(vm, vm.propertyNames->sourceURL, jsString(vm, WTF::move(sourceURL)), attributes); + object->putDirect(vm, vm.propertyNames->stack, jsString(vm, WTF::move(stack)), attributes); +} + // error.stack calls this function static String computeErrorInfoWithoutPrepareStackTrace( JSC::VM& vm, diff --git a/src/jsc/bindings/FormatStackTraceForJS.h b/src/jsc/bindings/FormatStackTraceForJS.h index fc9515a4668b..a0155777786e 100644 --- a/src/jsc/bindings/FormatStackTraceForJS.h +++ b/src/jsc/bindings/FormatStackTraceForJS.h @@ -70,6 +70,13 @@ WTF::String formatStackTrace( WTF::Vector& stackTrace, JSC::JSObject* errorInstance); +// Replacement for JSC::addErrorInfo() for objects that are not ErrorInstances (DOMException). +// Captures the current stack and puts the same DontEnum line/column/sourceURL/stack +// properties an ErrorInstance materializes: positions are run through the source maps +// and `stack` uses Bun's format, with originalLine/originalColumn recording the +// unmapped position so the error printer does not remap them a second time. +void addErrorInfoWithSourceMap(JSC::JSGlobalObject*, JSC::JSObject*, const WTF::String& name, const WTF::String& message); + // JSC Host Functions - Error constructor methods JSC_DECLARE_HOST_FUNCTION(errorConstructorFuncCaptureStackTrace); JSC_DECLARE_HOST_FUNCTION(errorConstructorFuncAppendStackTrace); diff --git a/src/jsc/bindings/JSDOMExceptionHandling.cpp b/src/jsc/bindings/JSDOMExceptionHandling.cpp index 9db6f0f283de..42a8c5d1bc94 100644 --- a/src/jsc/bindings/JSDOMExceptionHandling.cpp +++ b/src/jsc/bindings/JSDOMExceptionHandling.cpp @@ -24,6 +24,7 @@ #include "ErrorCode.h" #include "DOMException.h" +#include "FormatStackTraceForJS.h" #include "JSDOMException.h" #include "JSDOMExceptionHandling.h" #include "JSDOMPromiseDeferred.h" @@ -181,10 +182,11 @@ JSValue createDOMException(JSGlobalObject* lexicalGlobalObject, ExceptionCode ec // frames[0].document.createElement(null, null); // throws an exception which should have the subframe's prototypes. // https://bugs.webkit.org/show_bug.cgi?id=222229 JSDOMGlobalObject* globalObject = deprecatedGlobalObjectForPrototype(lexicalGlobalObject); - JSValue errorObject = toJS(lexicalGlobalObject, globalObject, DOMException::create(ec, message)); + auto domException = DOMException::create(ec, message); + JSValue errorObject = toJS(lexicalGlobalObject, globalObject, domException.get()); ASSERT(errorObject); - addErrorInfo(lexicalGlobalObject, asObject(errorObject), true); + Bun::addErrorInfoWithSourceMap(lexicalGlobalObject, asObject(errorObject), domException->name(), domException->message()); return errorObject; } } diff --git a/src/jsc/bindings/ZigException.cpp b/src/jsc/bindings/ZigException.cpp index ea84d276eda9..fc3ae81ba00d 100644 --- a/src/jsc/bindings/ZigException.cpp +++ b/src/jsc/bindings/ZigException.cpp @@ -770,19 +770,28 @@ void exceptionFromString(ZigException& except, JSC::JSValue value, JSC::JSGlobal } if (line) { if (line.isNumber()) { - except.stack.frames_ptr[0].position.line_zero_based = OrdinalNumber::fromOneBasedInt(line.toInt32(global)).zeroBasedInt(); + ZigStackFrame& frame = except.stack.frames_ptr[0]; + frame.position.line_zero_based = OrdinalNumber::fromOneBasedInt(line.toInt32(global)).zeroBasedInt(); + except.stack.frames_len = 1; - // TODO: don't sourcemap it twice - auto originalLine = obj->getIfPropertyExists(global, builtinNames(vm).originalLinePublicName()); + auto column = obj->getIfPropertyExists(global, vm.propertyNames->column); if (scope.exception()) [[unlikely]] { scope.clearExceptionExceptTermination(); } - if (originalLine) { - if (originalLine.isNumber()) { - except.stack.frames_ptr[0].position.line_zero_based = OrdinalNumber::fromOneBasedInt(originalLine.toInt32(global)).zeroBasedInt(); + if (column) { + if (column.isNumber()) { + frame.position.column_zero_based = OrdinalNumber::fromOneBasedInt(column.toInt32(global)).zeroBasedInt(); } } - except.stack.frames_len = 1; + + // Bun::addErrorInfoWithSourceMap() puts originalLine next to a line/column + // that already went through the source map, so the printer must not remap + // them again. Same as the ErrorInstance path in fromErrorInstance(). + auto originalLine = obj->getIfPropertyExists(global, builtinNames(vm).originalLinePublicName()); + if (scope.exception()) [[unlikely]] { + scope.clearExceptionExceptTermination(); + } + frame.remapped = !!originalLine; } } } diff --git a/test/js/node/domexception-node.test.js b/test/js/node/domexception-node.test.js index 6896119ebbac..c7ee100bd4db 100644 --- a/test/js/node/domexception-node.test.js +++ b/test/js/node/domexception-node.test.js @@ -1,4 +1,6 @@ import { describe, expect, it } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; +import { realpathSync } from "node:fs"; describe("DOMException in Node.js environment", () => { it("exists globally", () => { @@ -72,3 +74,171 @@ describe("DOMException in Node.js environment", () => { expect(abortError.code).toBe(20); // ABORT_ERR }); }); + +// The transpiler drops these comment lines (and reformats what follows), so the positions +// JSC reports for the code below them are not the positions in the file. Positions Bun +// records on a DOMException it creates have to go through the source map like an Error's. +const padded = source => Array.from({ length: 10 }, (_, i) => `// padding ${i + 1}`).join("\n") + "\n" + source + "\n"; + +// 1-based line of the (unique) line of `source` containing `needle`. +function lineContaining(source, needle) { + const matches = source.split("\n").flatMap((line, i) => (line.includes(needle) ? [i + 1] : [])); + expect(matches).toHaveLength(1); + return matches[0]; +} + +async function runFixture(name, source) { + using dir = tempDir("domexception-creation-site", { [name]: source }); + await using proc = Bun.spawn({ + cmd: [bunExe(), name], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + let [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + for (const path of new Set([realpathSync.native(String(dir)), String(dir)])) { + stderr = stderr.replaceAll(path, ""); + } + return { stdout, stderr: stderr.replaceAll("\\", "/"), exitCode }; +} + +describe.concurrent("DOMExceptions created by Bun record where they were created", () => { + it("line, column, sourceURL and stack are positions in the source file, like an Error's", async () => { + // The callbacks passed to caught() have block bodies so that the call inside them is + // not a tail call, which strict mode code would otherwise drop from the stack. + const source = padded(` + function caught(fn) { try { fn(); } catch (e) { return e; } } + function nested() { + const error = new Error("reference"), reason = AbortSignal.abort().reason; + return { error, reason }; + } + const controller = new AbortController(); + controller.abort(); + const fromNested = nested(); + const exceptions = { + "AbortSignal.abort().reason": AbortSignal.abort().reason, + "AbortController#abort() reason": controller.signal.reason, + "atob() InvalidCharacterError": caught(() => { atob("!"); }), + "structuredClone() DataCloneError": caught(() => { structuredClone(() => {}); }), + "created inside a function": fromNested.reason, + }; + const dump = e => ({ + isDOMException: e instanceof DOMException, + name: e.name, + message: e.message, + line: e.line, + column: e.column, + sourceURL: e.sourceURL?.replaceAll(import.meta.path, ""), + stack: e.stack?.replaceAll(import.meta.path, ""), + enumerableKeys: Object.keys(e), + }); + for (const name in exceptions) exceptions[name] = dump(exceptions[name]); + console.log(JSON.stringify({ exceptions, reference: dump(fromNested.error) })); + `); + const { stdout, stderr, exitCode } = await runFixture("creation-site.js", source); + expect(stderr).toBe(""); + const { exceptions, reference } = JSON.parse(stdout); + + // [line, column] of each frame of `stack` that is in the fixture file. + const framePositions = stack => + stack + .split("\n") + .slice(1) + .filter(frame => frame.includes("")) + .map(frame => + frame + .match(/:(\d+):(\d+)\)?$/) + .slice(1) + .map(Number), + ); + const summarize = ({ stack, line, column, ...rest }) => ({ + ...rest, + header: stack.split("\n")[0], + line, + topFrameIsAtLineAndColumn: Bun.deepEquals(framePositions(stack)[0], [line, column]), + frameLines: framePositions(stack).map(([line]) => line), + }); + const expected = (name, message, frameLines) => ({ + isDOMException: true, + name, + message, + header: `${name}: ${message}`, + line: frameLines[0], + sourceURL: "", + topFrameIsAtLineAndColumn: true, + frameLines, + enumerableKeys: [], + }); + + const nestedLine = lineContaining(source, 'new Error("reference")'); + const nestedCallLine = lineContaining(source, "= nested()"); + const caughtLine = lineContaining(source, "function caught"); + const atobLine = lineContaining(source, 'atob("!")'); + const structuredCloneLine = lineContaining(source, "structuredClone("); + expect(Object.fromEntries(Object.entries(exceptions).map(([name, e]) => [name, summarize(e)]))).toEqual({ + // AbortSignal.abort() stores the default reason as an enum; the DOMException is + // created when .reason is first read, which here is this line. + "AbortSignal.abort().reason": expected("AbortError", "The operation was aborted.", [ + lineContaining(source, '"AbortSignal.abort().reason":'), + ]), + // AbortController#abort() creates the reason while aborting. + "AbortController#abort() reason": expected("AbortError", "The operation was aborted.", [ + lineContaining(source, "controller.abort();"), + ]), + "atob() InvalidCharacterError": expected("InvalidCharacterError", "The string contains invalid characters.", [ + atobLine, + caughtLine, + atobLine, + ]), + "structuredClone() DataCloneError": expected("DataCloneError", "The object can not be cloned.", [ + structuredCloneLine, + caughtLine, + structuredCloneLine, + ]), + "created inside a function": expected("AbortError", "The operation was aborted.", [nestedLine, nestedCallLine]), + }); + // The Error created on the same line as the nested DOMException reports the same frames. + expect({ + sourceURL: reference.sourceURL, + frameLines: framePositions(reference.stack).map(([line]) => line), + }).toEqual({ sourceURL: "", frameLines: [nestedLine, nestedCallLine] }); + expect(exitCode).toBe(0); + }); + + it("a DOMException created with no JavaScript on the stack gets a header-only stack", async () => { + const signal = AbortSignal.timeout(0); + const { promise, resolve } = Promise.withResolvers(); + signal.addEventListener("abort", resolve, { once: true }); + await promise; + + const { reason } = signal; + expect({ stack: reason.stack, line: reason.line, column: reason.column, sourceURL: reason.sourceURL }).toEqual({ + stack: "TimeoutError: The operation timed out.", + line: undefined, + column: undefined, + sourceURL: undefined, + }); + }); + + it.each([ + ["unhandled rejection", `Promise.reject(AbortSignal.abort().reason);`, "Promise.reject("], + ["uncaught exception", `function fail() {\n atob("!");\n}\nfail();`, 'atob("!")'], + ])("the %s printer reports the source line the DOMException was created on", async (_, body, needle) => { + const source = padded(body); + const expectedLine = lineContaining(source, needle); + const { stdout, stderr, exitCode } = await runFixture("uncaught.js", source); + + const stackLines = stderr + .split("\n") + .map(line => line.trim()) + .filter(line => line.startsWith("at ")); + expect({ stdout, stackLines }).toEqual({ + stdout: "", + stackLines: [expect.stringMatching(new RegExp(`^at /uncaught\\.js:${expectedLine}:\\d+$`))], + }); + // The code excerpt above the error is taken from the source file at that line. + expect(stderr).toContain(`${expectedLine} | ${source.split("\n")[expectedLine - 1]}`); + expect(exitCode).toBe(1); + }); +}); From a4fe2e6f03de414cc00ec2e2178ba892bc3e81a1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 16 Aug 2026 07:14:39 +0000 Subject: [PATCH 2/4] webcrypto: check for an exception after building a rejection's cause rejectWithCause() called makeCause(), whose ML import variant declares a ThrowScope, and then createDOMException() without checking the scope. createDOMException() now declares exception scopes of its own while formatting the stack, so the exception-check validator flagged every ML-DSA / ML-KEM import failure. Check after makeCause(), and cover the promise rejection creation paths in the DOMException position tests. --- src/jsc/bindings/webcrypto/SubtleCrypto.cpp | 2 ++ test/js/node/domexception-node.test.js | 34 +++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/src/jsc/bindings/webcrypto/SubtleCrypto.cpp b/src/jsc/bindings/webcrypto/SubtleCrypto.cpp index 6345449e9a02..6560172b5307 100644 --- a/src/jsc/bindings/webcrypto/SubtleCrypto.cpp +++ b/src/jsc/bindings/webcrypto/SubtleCrypto.cpp @@ -686,7 +686,9 @@ static void rejectWithCause(Ref&& promise, ExceptionCode ec, co { promise->rejectWithCallback([&](JSDOMGlobalObject& globalObject) -> JSC::JSValue { auto& vm = globalObject.vm(); + auto scope = DECLARE_THROW_SCOPE(vm); JSC::JSValue cause = makeCause(globalObject); + RETURN_IF_EXCEPTION(scope, {}); auto exception = createDOMException(&globalObject, ec, message); if (auto* exceptionObject = exception.getObject(); exceptionObject && cause) exceptionObject->putDirect(vm, vm.propertyNames->cause, cause); diff --git a/test/js/node/domexception-node.test.js b/test/js/node/domexception-node.test.js index c7ee100bd4db..591f6601328c 100644 --- a/test/js/node/domexception-node.test.js +++ b/test/js/node/domexception-node.test.js @@ -116,11 +116,13 @@ describe.concurrent("DOMExceptions created by Bun record where they were created const controller = new AbortController(); controller.abort(); const fromNested = nested(); + const rejectionOf = promise => promise.then(() => { throw new Error("expected a rejection"); }, e => e); const exceptions = { "AbortSignal.abort().reason": AbortSignal.abort().reason, "AbortController#abort() reason": controller.signal.reason, "atob() InvalidCharacterError": caught(() => { atob("!"); }), "structuredClone() DataCloneError": caught(() => { structuredClone(() => {}); }), + "crypto.subtle.importKey() rejection": await rejectionOf(crypto.subtle.importKey("raw", new Uint8Array(3), "AES-GCM", false, ["encrypt"])), "created inside a function": fromNested.reason, }; const dump = e => ({ @@ -196,6 +198,12 @@ describe.concurrent("DOMExceptions created by Bun record where they were created caughtLine, structuredCloneLine, ]), + // Rejections are created while the API is being called, so they point at the call too. + "crypto.subtle.importKey() rejection": expected( + "DataError", + "Data provided to an operation does not meet requirements", + [lineContaining(source, '"crypto.subtle.importKey() rejection":')], + ), "created inside a function": expected("AbortError", "The operation was aborted.", [nestedLine, nestedCallLine]), }); // The Error created on the same line as the nested DOMException reports the same frames. @@ -206,6 +214,32 @@ describe.concurrent("DOMExceptions created by Bun record where they were created expect(exitCode).toBe(0); }); + // ML key imports reject through SubtleCrypto's rejectWithCause(), which builds the cause + // and then the DOMException, while the import call is still on the stack. The reference + // Error is created on the same line as the call. + it("a rejection that carries a cause is recorded at the call site too", async () => { + // prettier-ignore + const reference = new Error("reference"), pending = crypto.subtle.importKey("pkcs8", new Uint8Array(64), "ML-DSA-44", false, ["sign"]); + const rejection = await pending.then( + () => null, + e => e, + ); + + expect({ + isDOMException: rejection instanceof DOMException, + causeIsError: rejection?.cause instanceof Error, + header: rejection?.stack.split("\n")[0], + line: rejection?.line, + sourceURL: rejection?.sourceURL, + }).toEqual({ + isDOMException: true, + causeIsError: true, + header: "DataError: Invalid keyData", + line: reference.line, + sourceURL: reference.sourceURL, + }); + }); + it("a DOMException created with no JavaScript on the stack gets a header-only stack", async () => { const signal = AbortSignal.timeout(0); const { promise, resolve } = Promise.withResolvers(); From 0ec6735de08350a0320c286c2d53ead0174a8b85 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 16 Aug 2026 07:16:53 +0000 Subject: [PATCH 3/4] Shorten the addErrorInfoWithSourceMap and originalLine comments --- src/jsc/bindings/FormatStackTraceForJS.h | 8 +++----- src/jsc/bindings/ZigException.cpp | 4 +--- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/jsc/bindings/FormatStackTraceForJS.h b/src/jsc/bindings/FormatStackTraceForJS.h index a0155777786e..e31679f246e6 100644 --- a/src/jsc/bindings/FormatStackTraceForJS.h +++ b/src/jsc/bindings/FormatStackTraceForJS.h @@ -70,11 +70,9 @@ WTF::String formatStackTrace( WTF::Vector& stackTrace, JSC::JSObject* errorInstance); -// Replacement for JSC::addErrorInfo() for objects that are not ErrorInstances (DOMException). -// Captures the current stack and puts the same DontEnum line/column/sourceURL/stack -// properties an ErrorInstance materializes: positions are run through the source maps -// and `stack` uses Bun's format, with originalLine/originalColumn recording the -// unmapped position so the error printer does not remap them a second time. +// JSC::addErrorInfo() for objects that are not ErrorInstances (DOMException): puts the properties +// an ErrorInstance materializes, i.e. source-mapped line/column/sourceURL, a Bun-format stack, +// and originalLine/originalColumn (which tell the error printer the position is already mapped). void addErrorInfoWithSourceMap(JSC::JSGlobalObject*, JSC::JSObject*, const WTF::String& name, const WTF::String& message); // JSC Host Functions - Error constructor methods diff --git a/src/jsc/bindings/ZigException.cpp b/src/jsc/bindings/ZigException.cpp index fc3ae81ba00d..7bbdda000fad 100644 --- a/src/jsc/bindings/ZigException.cpp +++ b/src/jsc/bindings/ZigException.cpp @@ -784,9 +784,7 @@ void exceptionFromString(ZigException& except, JSC::JSValue value, JSC::JSGlobal } } - // Bun::addErrorInfoWithSourceMap() puts originalLine next to a line/column - // that already went through the source map, so the printer must not remap - // them again. Same as the ErrorInstance path in fromErrorInstance(). + // originalLine means line/column are already source-mapped, as in fromErrorInstance(). auto originalLine = obj->getIfPropertyExists(global, builtinNames(vm).originalLinePublicName()); if (scope.exception()) [[unlikely]] { scope.clearExceptionExceptTermination(); From 03d96c67fad7a2f582908623ebc93c3f461f458f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 16 Aug 2026 07:18:14 +0000 Subject: [PATCH 4/4] Make the addErrorInfoWithSourceMap comment a one-liner --- src/jsc/bindings/FormatStackTraceForJS.h | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/jsc/bindings/FormatStackTraceForJS.h b/src/jsc/bindings/FormatStackTraceForJS.h index e31679f246e6..2082de758ef6 100644 --- a/src/jsc/bindings/FormatStackTraceForJS.h +++ b/src/jsc/bindings/FormatStackTraceForJS.h @@ -70,9 +70,7 @@ WTF::String formatStackTrace( WTF::Vector& stackTrace, JSC::JSObject* errorInstance); -// JSC::addErrorInfo() for objects that are not ErrorInstances (DOMException): puts the properties -// an ErrorInstance materializes, i.e. source-mapped line/column/sourceURL, a Bun-format stack, -// and originalLine/originalColumn (which tell the error printer the position is already mapped). +// JSC::addErrorInfo() for objects that are not ErrorInstances (DOMException), putting what an ErrorInstance materializes: source-mapped positions and a Bun-format stack. void addErrorInfoWithSourceMap(JSC::JSGlobalObject*, JSC::JSObject*, const WTF::String& name, const WTF::String& message); // JSC Host Functions - Error constructor methods