From 282b7cbe7cf208929a2c40ec09b76391d60fd302 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 16 Aug 2026 05:42:46 +0000 Subject: [PATCH 1/3] DOMException: make line/column/sourceURL on internally created exceptions non-enumerable createDOMException() attached the creation site to the new JSDOMException with JSC::addErrorInfo(). JSDOMException is not an ErrorInstance, so that helper used plain putDirect() and line, column and sourceURL ended up as own enumerable properties, unlike user-constructed DOMExceptions, Bun's own Error objects (DontEnum) and Node (no such properties). Object.keys, JSON.stringify, spread and deep equality of AbortSignal reasons, DataCloneErrors and every other DOMException Bun creates exposed them, so two abort reasons created on different lines were not deep equal. Put the same properties with DontEnum instead. The values are unchanged: the uncaught error printer still reads them to point at the creation site. abort.test.ts no longer needs to strip these keys before comparing a signal's reason with a constructed DOMException. --- src/jsc/bindings/JSDOMExceptionHandling.cpp | 30 +++++- test/js/node/domexception-node.test.js | 100 ++++++++++++++++++++ test/js/web/abort/abort.test.ts | 1 - 3 files changed, 129 insertions(+), 2 deletions(-) diff --git a/src/jsc/bindings/JSDOMExceptionHandling.cpp b/src/jsc/bindings/JSDOMExceptionHandling.cpp index 9db6f0f283de..cc8db4cdf24f 100644 --- a/src/jsc/bindings/JSDOMExceptionHandling.cpp +++ b/src/jsc/bindings/JSDOMExceptionHandling.cpp @@ -28,9 +28,11 @@ #include "JSDOMExceptionHandling.h" #include "JSDOMPromiseDeferred.h" +#include #include #include #include +#include #include #include #include "headers.h" @@ -115,6 +117,32 @@ String retrieveErrorMessage(JSGlobalObject& lexicalGlobalObject, VM& vm, JSValue return errorMessage; } +// JSC::addErrorInfo(), except every property is DontEnum: JSDOMException is not an +// ErrorInstance, so addErrorInfo() would make line/column/sourceURL enumerable. +// The error printer (ZigException.cpp) reads them back to locate uncaught DOMExceptions. +static void addDOMExceptionErrorInfo(JSGlobalObject* lexicalGlobalObject, JSObject* exception) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto stackTrace = getStackTrace(vm, exception, /* useCurrentFrame */ true); + if (!stackTrace) + return; + + constexpr unsigned attributes = JSC::PropertyAttribute::DontEnum | 0; + if (stackTrace->isEmpty()) { + exception->putDirect(vm, vm.propertyNames->stack, vm.smallStrings.emptyString(), attributes); + return; + } + + LineColumn lineColumn; + String sourceURL; + getLineColumnAndSource(vm, stackTrace.get(), lineColumn, sourceURL); + exception->putDirect(vm, vm.propertyNames->line, jsNumber(lineColumn.line), attributes); + exception->putDirect(vm, vm.propertyNames->column, jsNumber(lineColumn.column), attributes); + if (!sourceURL.isEmpty()) + exception->putDirect(vm, vm.propertyNames->sourceURL, jsString(vm, WTF::move(sourceURL)), attributes); + exception->putDirect(vm, vm.propertyNames->stack, jsString(vm, Interpreter::stackTraceAsString(vm, *stackTrace)), attributes); +} + JSValue createDOMException(JSGlobalObject* lexicalGlobalObject, ExceptionCode ec, const String& message, const String& extra) { auto& vm = JSC::getVM(lexicalGlobalObject); @@ -184,7 +212,7 @@ JSValue createDOMException(JSGlobalObject* lexicalGlobalObject, ExceptionCode ec JSValue errorObject = toJS(lexicalGlobalObject, globalObject, DOMException::create(ec, message)); ASSERT(errorObject); - addErrorInfo(lexicalGlobalObject, asObject(errorObject), true); + addDOMExceptionErrorInfo(lexicalGlobalObject, asObject(errorObject)); return errorObject; } } diff --git a/test/js/node/domexception-node.test.js b/test/js/node/domexception-node.test.js index 6896119ebbac..8058166c2cd8 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, normalizeBunSnapshot, tempDir } from "harness"; +import { isDeepStrictEqual } from "node:util"; describe("DOMException in Node.js environment", () => { it("exists globally", () => { @@ -72,3 +74,101 @@ describe("DOMException in Node.js environment", () => { expect(abortError.code).toBe(20); // ABORT_ERR }); }); + +function thrownBy(fn) { + try { + fn(); + } catch (e) { + return e; + } + throw new Error("expected the callback to throw"); +} + +async function rejectionOf(promise) { + try { + await promise; + } catch (e) { + return e; + } + throw new Error("expected the promise to reject"); +} + +async function timeoutReason() { + const signal = AbortSignal.timeout(0); + const { promise, resolve } = Promise.withResolvers(); + signal.addEventListener("abort", resolve, { once: true }); + await promise; + return signal.reason; +} + +// As in Node, no DOMException has own enumerable properties. The creation site Bun +// records on internally created ones (line/column/sourceURL/stack) is non-enumerable. +describe("DOMException own properties", () => { + it.each([ + ["new DOMException()", "AbortError", () => new DOMException("The operation was aborted.", "AbortError")], + ["structuredClone(domException)", "AbortError", () => structuredClone(new DOMException("cloned", "AbortError"))], + ["AbortSignal.abort().reason", "AbortError", () => AbortSignal.abort().reason], + [ + "AbortController#abort() reason", + "AbortError", + () => { + const controller = new AbortController(); + controller.abort(); + return controller.signal.reason; + }, + ], + ["AbortSignal.timeout() reason", "TimeoutError", timeoutReason], + ["structuredClone() DataCloneError", "DataCloneError", () => thrownBy(() => structuredClone(() => {}))], + ["atob() InvalidCharacterError", "InvalidCharacterError", () => thrownBy(() => atob("!"))], + [ + "crypto.subtle rejection", + "DataError", + () => rejectionOf(crypto.subtle.importKey("raw", new Uint8Array(3), { name: "AES-GCM" }, false, ["encrypt"])), + ], + ])("%s has no own enumerable properties", async (_, name, create) => { + const exception = await create(); + expect(exception).toBeInstanceOf(DOMException); + expect(exception.name).toBe(name); + + expect({ + keys: Object.keys(exception), + json: JSON.stringify(exception), + spread: { ...exception }, + }).toEqual({ keys: [], json: "{}", spread: {} }); + }); + + it("internally created DOMExceptions from different call sites are deep equal", () => { + const first = AbortSignal.abort().reason; + const second = AbortSignal.abort().reason; + const constructed = new DOMException(first.message, first.name); + + expect(isDeepStrictEqual(first, second)).toBe(true); + expect(isDeepStrictEqual(first, constructed)).toBe(true); + expect(first).toEqual(second); + expect(first).toEqual(constructed); + }); + + it("the uncaught error printer still reports where an internally created DOMException came from", async () => { + using dir = tempDir("domexception-uncaught", { + "reject.js": "Promise.reject(AbortSignal.abort().reason);\n", + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "reject.js"], + 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 locations = normalizeBunSnapshot(stderr, String(dir)) + .split("\n") + .map(line => line.trim()) + .filter(line => line.startsWith("at ")); + expect({ stdout, locations, exitCode }).toEqual({ + stdout: "", + locations: ["at /reject.js:1"], + exitCode: 1, + }); + }); +}); diff --git a/test/js/web/abort/abort.test.ts b/test/js/web/abort/abort.test.ts index c07b5856bd83..0f9deab0f51c 100644 --- a/test/js/web/abort/abort.test.ts +++ b/test/js/web/abort/abort.test.ts @@ -103,7 +103,6 @@ describe("AbortSignal", () => { function fmt(value: any) { const res = {}; for (const key in value) { - if (key === "column" || key === "line" || key === "sourceURL") continue; res[key] = value[key]; } return res; From 6a40cbf660dac7a349e5aa6241f73b20fb952267 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 16 Aug 2026 06:07:32 +0000 Subject: [PATCH 2/3] test: compare the printed DOMException location case-insensitively on Windows The sourceURL JSC records for a module spells the drive letter in lowercase on Windows, so the printed location does not match the tempDir path byte for byte there. --- test/js/node/domexception-node.test.js | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/test/js/node/domexception-node.test.js b/test/js/node/domexception-node.test.js index 8058166c2cd8..53937038e418 100644 --- a/test/js/node/domexception-node.test.js +++ b/test/js/node/domexception-node.test.js @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test"; -import { bunEnv, bunExe, normalizeBunSnapshot, tempDir } from "harness"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; +import { join } from "node:path"; import { isDeepStrictEqual } from "node:util"; describe("DOMException in Node.js environment", () => { @@ -161,13 +162,17 @@ describe("DOMException own properties", () => { }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - const locations = normalizeBunSnapshot(stderr, String(dir)) + // The location comes from the sourceURL JSC recorded, which spells the + // drive letter in lowercase on Windows. + const normalizeCase = line => (isWindows ? line.toLowerCase() : line); + const locations = stderr .split("\n") .map(line => line.trim()) - .filter(line => line.startsWith("at ")); + .filter(line => line.startsWith("at ")) + .map(normalizeCase); expect({ stdout, locations, exitCode }).toEqual({ stdout: "", - locations: ["at /reject.js:1"], + locations: [normalizeCase(`at ${join(String(dir), "reject.js")}:1`)], exitCode: 1, }); }); From 69587b765d57c75e3bd6dac1b6497982b5333c45 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 16 Aug 2026 06:09:21 +0000 Subject: [PATCH 3/3] Shorten the addDOMExceptionErrorInfo comment --- src/jsc/bindings/JSDOMExceptionHandling.cpp | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/jsc/bindings/JSDOMExceptionHandling.cpp b/src/jsc/bindings/JSDOMExceptionHandling.cpp index cc8db4cdf24f..0eae38c0707c 100644 --- a/src/jsc/bindings/JSDOMExceptionHandling.cpp +++ b/src/jsc/bindings/JSDOMExceptionHandling.cpp @@ -117,9 +117,7 @@ String retrieveErrorMessage(JSGlobalObject& lexicalGlobalObject, VM& vm, JSValue return errorMessage; } -// JSC::addErrorInfo(), except every property is DontEnum: JSDOMException is not an -// ErrorInstance, so addErrorInfo() would make line/column/sourceURL enumerable. -// The error printer (ZigException.cpp) reads them back to locate uncaught DOMExceptions. +// JSC::addErrorInfo() would make these enumerable (JSDOMException is not an ErrorInstance); ZigException.cpp reads them back. static void addDOMExceptionErrorInfo(JSGlobalObject* lexicalGlobalObject, JSObject* exception) { auto& vm = JSC::getVM(lexicalGlobalObject);