DOMException: inherit from ErrorInstance for [[ErrorData]] and .stack

Closed
RO
2d ago
+376-70

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):

  • createStructure uses ErrorInstanceType.
  • finishCreation calls ErrorInstance::finishCreation with a null message/cause so a stack trace is captured but name/message stay as prototype accessors backed by the wrapped DOMException.
  • visitChildren keeps the captured stack frames' callee/codeBlock alive. ErrorInstance normally relies on Heap's finalizeUnconditionally sweep over errorInstanceSpace, which our DOM subspace is not part of, so without this a lazy .stack read after GC would touch freed CodeBlocks.
  • New IsoHeapCellType in JSHeapData so the larger cell destructs its Ref<DOMException> and ErrorInstance state correctly.
  • When the captured trace is empty (a native entry with no JS frames, e.g. AbortSignal.timeout() firing from a native timer), ErrorInstance never materializes a stack, so finishCreation eagerly puts the name: message header. A DOMException .stack is now always a string. This improves #25182 and #21900 (no longer undefined / "") 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)

   Compiling bun_parsers v0.0.0 (/workspace/bun/src/parsers)
   Compiling bun_http v0.0.0 (/workspace/bun/src/http)
   Compiling bun_resolver v0.0.0 (/workspace/bun/src/resolver)
   Compiling bun_sourcemap v0.0.0 (/workspace/bun/src/sourcemap)
   Compiling bun_ini v0.0.0 (/workspace/bun/src/ini)
[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