DOMException: inherit from ErrorInstance for [[ErrorData]] and .stack
What
DOMException now derives from JSC::ErrorInstance, so it carries the [[ErrorData]] internal slot and a captured stack trace like native Error types. WebIDL §3.14.1 specifies this, and Node.js, Chrome and Firefox all do it.
Repro
const d = new DOMException("boom", "AbortError");
console.log(Error.isError(d)); // was false, now true
console.log(typeof d.stack); // was undefined, now string
console.log(typeof AbortSignal.abort().reason.stack); // was undefined, now string
console.log(typeof structuredClone(d).stack); // was undefined, now string
console.log(d); // was a 25-line legacy-constant object dump, now formats as an error
Bun was also internally inconsistent: AbortController#abort() produced a reason with a .stack (because createDOMException called addErrorInfo afterwards), while AbortSignal.abort() and user-constructed DOMExceptions had none.
Cause
JSDOMException was a plain JSDOMWrapper<DOMException> with JSType::ObjectType. Error.isError checks type() == ErrorInstanceType, and nothing on the wrapper-creation path captured a stack.
Fix
JSDOMException now inherits from JSC::ErrorInstance while keeping its Ref<DOMException> wrapped impl and the existing prototype (accessor-based name/message/code, legacy constants):
createStructureusesErrorInstanceType.finishCreationcallsErrorInstance::finishCreationwith a null message/cause so a stack trace is captured butname/messagestay as prototype accessors backed by the wrapped DOMException.visitChildrenkeeps the captured stack frames' callee/codeBlock alive.ErrorInstancenormally relies on Heap'sfinalizeUnconditionallysweep overerrorInstanceSpace, which our DOM subspace is not part of, so without this a lazy.stackread after GC would touch freedCodeBlocks.- New
IsoHeapCellTypeinJSHeapDataso the larger cell destructs itsRef<DOMException>andErrorInstancestate correctly. - When the captured trace is empty (a native entry with no JS frames, e.g.
AbortSignal.timeout()firing from a native timer),ErrorInstancenever materializes astack, sofinishCreationeagerly puts thename: messageheader. A DOMException.stackis now always a string. This improves #25182 and #21900 (no longerundefined/"") but does not close them: Node shows real async frames there and Bun's native timer has none to capture.
Callers that branched on ErrorInstance before JSDOMException now check the DOMException case first: the .stack header in FormatStackTraceForJS, the except.name in ZigException, and the terminal-dump path in SerializedScriptValue, so structuredClone keeps emitting DOMExceptionTag instead of falling through to ErrorInstanceTag. The redundant addErrorInfo in createDOMException is removed now that the wrapper captures a Bun-formatted stack on construction; internally-thrown DOMExceptions now have the same stack format as regular errors instead of JSC's native foo@file format.
Latent exception-check bug this exposed
The BUN_JSC_validateExceptionChecks CI lane caught an unchecked exception in getNonObservable (ZigException.cpp):
This scope can throw a JS exception: getNonIndexPropertySlot @ JSObjectInlines.h:251
But the exception was unchecked as of this scope: get @ JSDOMAttribute.h:83
getNonObservable called a throwable getNonIndexPropertySlot with no exception check, and its slot.isAccessor() guard only filters JS getter/setter pairs, not native custom accessors, so slot.getValue() would invoke them. That was unreachable while fromErrorInstance only saw plain ErrorInstances, whose prototypes have no custom accessors. With JSDOMException now an ErrorInstance, the lookup for code lands on DOMException.prototype.code and invokes the getter. Fixed by adding a ThrowScope with RETURN_IF_EXCEPTION (the caller already clears after each call) and restricting the helper to plain data properties, which is what its name promises. This also stops the DOM legacy code (20) from being misread as a node system_code.
structuredClone round-trips the stack (wire format version 15)
test/js/node/test/parallel/test-structuredClone-domexception.js asserts clone.stack === e.stack. That passed vacuously before (both undefined); once DOMExceptions had stacks, the clone captured a fresh one at the structuredClone call site. Node serializes the stack, so DOMExceptionTag now carries it: the serializer reads it through [[Get]] (a throwing prepareStackTrace propagates out of the clone, as in the ErrorInstanceTag branch) and the deserializer installs it on the new wrapper, drops the frames the wrapper captured at the clone site (so GC does not keep rooting them and .line cannot materialize the clone site's position over the copy), and pins the stack property. CurrentVersion is bumped to 15 and the read is gated on it; older payloads still deserialize.
Header stack is put after the subclass structure swap
The header-only .stack fallback (empty trace, e.g. Error.stackTraceLimit = 0 or a native timer firing with no JS frames) was put from finishCreation. JSDOMException has no inline slots, so that allocated a butterfly, and setSubclassStructureIfNeeded then swapped class Sub extends DOMException instances onto a capacity-0 structure, tripping the setStructure butterfly assertion on debug builds. finishCreation now puts nothing; the toJSNewlyCreated free function (every internal creation path) puts the header after createWrapper, and the constructor builds the wrapper directly so the swap happens before the header and cause are put.
Verification
test/js/node/domexception-node.test.js adds coverage for Error.isError, .stack capture (including the AbortSignal.abort(), AbortSignal.timeout(), and structuredClone paths), stack equality through two structuredClones, the subclass + stackTraceLimit = 0 case, prototype-accessor invariants, util.inspect formatting, and a GC stress loop over lazy stack materialization. test-structuredClone-domexception.js, structured-clone.test.ts (including its version 13 payload and cross-process cases) and bun-jsc.test.ts pass. The existing .failing test for .stack is un-failed. fetch.test.ts and abort.test.ts pass under BUN_JSC_validateExceptionChecks=1.
Fixes #15821
Fixes #17877
Fixes #37419
Refs #25182
Refs #21900
[review] gate passed · iteration 1 · 10 files touched
fails on main (without fix)
ASAN without fix: 14 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/domexception-node.test.js
bun test v1.4.0 (d8661f2c1)
test/js/node/domexception-node.test.js:
(pass) DOMException in Node.js environment > exists globally [5.14ms]
(pass) DOMException in Node.js environment > creates instance with message and name [2.99ms]
(pass) DOMException in Node.js environment > uses default name when only message is provided [1.97ms]
(pass) DOMException in Node.js environment > creates instance with options object [2.48ms]
(pass) DOMException in Node.js environment > has standard error constants [12.29ms]
59 | });
60 |
61 | it("inherits prototype properties from Error", () => {
62 | const error = new DOMException("Test error");
63 | expect(error.toString()).toBe("Error: Test error");
64 | expect(error.stack).toBeDefined();
^
error: expect(received).toBeDefined()
Received: undefined
at <anonymous> (/workspace/bun/test/js/node/domexception-node.test.js:64:25)
(fail) DOMException in Node.js environment > inherits prototype properties from Error [6.2
... (truncated)
release without fix: 14 FAILED
bun test v1.4.0-canary.1 (9008ae7ab)
test/js/node/domexception-node.test.js:
(pass) DOMException in Node.js environment > exists globally [0.05ms]
(pass) DOMException in Node.js environment > creates instance with message and name [0.04ms]
(pass) DOMException in Node.js environment > uses default name when only message is provided [0.02ms]
(pass) DOMException in Node.js environment > creates instance with options object [0.03ms]
(pass) DOMException in Node.js environment > has standard error constants [0.09ms]
59 | });
60 |
61 | it("inherits prototype properties from Error", () => {
62 | const error = new DOMException("Test error");
63 | expect(error.toString()).toBe("Error: Test error");
64 | expect(error.stack).toBeDefined();
^
error: expect(received).toBeDefined()
Received: undefined
at <anonymous> (/workspace/bun/test/js/node/domexception-node.test.js:64:25)
(fail) DOMException in Node.js environment > inherits prototype properties from Error [0.22ms]
64 | expect(error.stack).toBeDefined();
65 | });
66 |
67 | it("has [[ErrorData]] internal slot", () => {
68 | const error = new DOMException("boom
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/domexception-node.test.js
bun test v1.4.0 (d8661f2c1)
test/js/node/domexception-node.test.js:
(pass) DOMException in Node.js environment > exists globally [5.89ms]
(pass) DOMException in Node.js environment > creates instance with message and name [5.23ms]
(pass) DOMException in Node.js environment > uses default name when only message is provided [3.14ms]
(pass) DOMException in Node.js environment > creates instance with options object [5.01ms]
(pass) DOMException in Node.js environment > has standard error constants [15.38ms]
(pass) DOMException in Node.js environment > inherits prototype properties from Error [5.43ms]
(pass) DOMException in Node.js environment > has [[ErrorData]] internal slot [4.84ms]
(pass) DOMException in Node.js environment > captures a stack trace [7.83ms]
(pass) DOMException in Node.js environment > keeps name/message/code as prototype accessors [8.57ms]
(pass) DOMException in Node.js environment > AbortSignal.abort().reason is a DOMException with a stack [7.24ms]
(pass) DOMException in Node.js e
... (truncated)
release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 740ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/127] gen generated_host_exports.rs
generated_host_exports.rs: 93 exports (host=3, lazy=10, generic=80, rust=0); 239 extern-C blocks audited
[2/127] gen cpp.rs (cppbind)
[3/127] gen JS modules (bundle-modules)
Preprocess modules (9568ms)
Bundle modules (69ms)
Postprocesss modules (26ms)
Bundle Functions (637ms)
Generate Code (31ms)
[10.35s] Bundled "src/js" for production
2610 kb
197 internal modules
13 native modules
91 internal functions across 17 files
[3/126] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)
[1m[92m Compiling[0m bun_parsers v0.0.0 (/workspace/bun/src/parsers)
[1m[92m Compiling[0m bun_http v0.0.0 (/workspace/bun/src/http)
[1m[92m Compiling[0m bun_resolver v0.0.0 (/workspace/bun/src/resolver)
[1m[92m Compiling[0m bun_sourcemap v0.0.0 (/workspace/bun/src/sourcemap)
[1m[92m Compiling[0m bun_ini v0.0.0 (/workspace/bun/src/ini)
[1m[92
... (truncated)
diff hotspot
src/jsc/bindings/BunClientData.cpp | 2 +
src/jsc/bindings/BunClientData.h | 1 +
src/jsc/bindings/FormatStackTraceForJS.cpp | 25 ++-
src/jsc/bindings/JSDOMExceptionHandling.cpp | 5 +-
src/jsc/bindings/JSDOMWrapperCache.h | 12 +-
src/jsc/bindings/ZigException.cpp | 28 +--
src/jsc/bindings/webcore/JSDOMException.cpp | 104 ++++++++++--
src/jsc/bindings/webcore/JSDOMException.h | 35 +++-
src/jsc/bindings/webcore/SerializedScriptValue.cpp | 46 ++---
test/js/node/domexception-node.test.js | 188 ++++++++++++++++++++-
10 files changed, 376 insertions(+), 70 deletions(-)
gate history · 3 passed · 0 rejected · iteration 1
evidence per changed file
file reads edits tests
src/jsc/bindings/BunClientData.cpp 1 2 18
src/jsc/bindings/BunClientData.h 1 1 18
src/jsc/bindings/FormatStackTraceForJS.cpp 4 8 18
src/jsc/bindings/JSDOMExceptionHandling.cpp 5 7 18
src/jsc/bindings/JSDOMWrapperCache.h 1 2 18
src/jsc/bindings/ZigException.cpp 4 6 18
src/jsc/bindings/webcore/JSDOMException.cpp 10 18 18
src/jsc/bindings/webcore/JSDOMException.h 5 5 18
src/jsc/bindings/webcore/SerializedScriptValue.cpp 5 7 18
test/js/node/domexception-node.test.js 13 18 18