Skip to content

DOMException: make line/column/sourceURL on internally created exceptions non-enumerable - #39311

Open
robobun wants to merge 3 commits into
mainfrom
farm/7f5291ed/domexception-dontenum-error-info
Open

DOMException: make line/column/sourceURL on internally created exceptions non-enumerable#39311
robobun wants to merge 3 commits into
mainfrom
farm/7f5291ed/domexception-dontenum-error-info

Conversation

@robobun

@robobun robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • Every DOMException that Bun creates internally has own enumerable line, column and sourceURL properties: Object.keys(AbortSignal.abort().reason) is ["line", "column", "sourceURL"] (Node: []), and the same holds for AbortController#abort() reasons, structuredClone() DataCloneErrors, atob() InvalidCharacterErrors, WebSocket#close() InvalidAccessErrors and WebCrypto rejections. JSON.stringify(reason) is {"line":1,"column":32,"sourceURL":"/app/index.js"} and { ...reason } copies the three properties.
  • util.isDeepStrictEqual, assert.deepStrictEqual and expect().toEqual() compare those properties as ordinary own enumerable properties, so two abort reasons created on different lines (or against a new DOMException(...) with the same name and message) are not deep equal. They are in Node. test/js/web/abort/abort.test.ts was stripping these keys before comparing a reason with a constructed DOMException.
  • Cause: createDOMException() (src/jsc/bindings/JSDOMExceptionHandling.cpp, default arm) records the creation site on the new wrapper with JSC::addErrorInfo(). JSDOMException is a plain DOM wrapper, not a JSC::ErrorInstance, so addErrorInfo() stores line, column and sourceURL with putDirect() and default (enumerable) attributes (vendor/WebKit/Source/JavaScriptCore/runtime/Error.cpp, addErrorInfo(VM&, Vector<StackFrame>*, JSObject*)); only stack is stored DontEnum there. User-constructed and structured-cloned DOMExceptions do not go through this arm and have no own properties at all.

Fix

  • Replace the addErrorInfo() call with a local addDOMExceptionErrorInfo() that stores the same four properties (line, column, sourceURL, stack; just stack: "" when no JS frame is on the stack, as for AbortSignal.timeout()) with DontEnum. Values, stack contents and the empty-trace case are unchanged; only the attribute differs.
  • Why non-enumerable rather than dropping the properties: the uncaught error / unhandled rejection printer reads sourceURL and line back off the object to print the at file:line location of an internally created DOMException (ZigException.cpp, exceptionFromString()), since a rejection reason was never thrown and has no JSC::Exception stack. Keeping them DontEnum keeps that output and reason.line reads working while removing them from everything enumeration-based. This is also how Bun's own Error objects already carry line/column/sourceURL (own, non-enumerable), and it gives the shape Node and WebIDL define for a DOMException: no own enumerable properties.
  • console.log(reason) output is unchanged (Bun.inspect lists a DOM wrapper's own properties regardless of enumerability, and it already listed the non-enumerable stack).
  • abort.test.ts: removed the line that skipped column/line/sourceURL in its fmt() helper. The two tests using it now fail without this change and pass with it.
  • Verified with:
    • test/js/node/domexception-node.test.js: new DOMException own properties block. An it.each over constructed, structured-cloned, AbortSignal.abort(), AbortController#abort(), AbortSignal.timeout(), structuredClone() DataCloneError, atob() and a WebCrypto rejection asserts Object.keys / JSON.stringify / spread are empty; a deep-equality test covers util.isDeepStrictEqual and toEqual() across call sites and against a constructed DOMException; a spawned fixture asserts the printer still prints at <fixture path>:1 for an unhandled abort reason (compared case-insensitively on Windows, where the sourceURL JSC records spells the drive letter in lowercase; that is pre-existing and unchanged here). On the released binary (USE_SYSTEM_BUN=1, checked on Linux and Windows x64) the five internally created paths and the deep-equality test fail; everything passes with the fix. Every assertion in the block also holds under Node v26.3.0 (output below).
    • test/js/web/abort/abort.test.ts (16 pass; .signal.reason should be a DOMException fails on the released binary), plus globals.test.js, web-globals.test.js, abort-controller-gc-reason.test.ts, websocket-close-code.test.ts, structured-clone.test.ts, timers.promises.test.ts, the deno encoding.test.ts, and the upstream test-global-domexception.js, test-domexception-cause.js, test-structuredClone-domexception.js.
    • The new tests pass under BUN_JSC_validateExceptionChecks=1 (the helper makes the same non-throwing calls addErrorInfo() made).
  • Related: deepEquals: compare DOMException name, message and cause #39308 and assert.partialDeepStrictEqual: compare DOMExceptions by name, message and cause #39306 make the deep-equality helpers compare DOMException name/message/cause and list this enumerable-property issue as the remaining difference; the tests here hold with or without them. DOMException: inherit from ErrorInstance for [[ErrorData]] and .stack #32898 re-parents JSDOMException onto JSC::ErrorInstance and deletes this call altogether; this change is the small fix for the shape problem independent of that refactor.

Background

  • DOMException in Bun is a WebCore DOM wrapper (JSDOMException): name, message and code are getters on the prototype that read the wrapped C++ object, so an instance normally has no own properties. JSC::ErrorInstance is the class behind new Error(); it stores line/column/sourceURL/stack in internal fields and materializes them as non-enumerable own properties, which is why the same data is invisible to Object.keys on a regular Error.
  • JSC::addErrorInfo(globalObject, object, useCurrentFrame) captures the current JS stack and writes line, column, sourceURL and stack onto an arbitrary object. Upstream WebCore calls it from createDOMException(); createDOMException() is the single place all of Bun's internally created DOMExceptions come from (abort reasons via toJS(CommonAbortReason), throwDataCloneError(), propagateException() for WebIDL ExceptionCodes, and DeferredPromise rejections).
  • DontEnum is JSC's name for enumerable: false. Object.keys, JSON.stringify, spread, for...in and Bun's and Node's deep-equality walks only look at enumerable own properties; [[Get]]-based reads such as the error printer's still see a DontEnum property.
Node v26.3.0 on the same scenarios
$ node nodecheck.mjs
new DOMException()                 DOMException AbortError {"keys":[],"json":"{}","spread":{}}
structuredClone(domException)      DOMException AbortError {"keys":[],"json":"{}","spread":{}}
AbortSignal.abort().reason         DOMException AbortError {"keys":[],"json":"{}","spread":{}}
AbortController#abort()            DOMException AbortError {"keys":[],"json":"{}","spread":{}}
structuredClone() DataCloneError   DOMException DataCloneError {"keys":[],"json":"{}","spread":{}}
atob()                             DOMException InvalidCharacterError {"keys":[],"json":"{}","spread":{}}
subtle rejection                   DOMException DataError {"keys":[],"json":"{}","spread":{}}
isDeepStrictEqual(first, second): true
isDeepStrictEqual(first, new DOMException(first.message, first.name)): true

Bun 1.4.0 on the same script reports "keys":["line","column","sourceURL"] for the last five creation paths and false for both deep-equality lines; this branch matches the Node output above (Node's own property set differs only in the non-enumerable stack / creation-site properties, which none of these operations observe).

Before / after for the printer and for util.inspect
$ cat reject.js
Promise.reject(AbortSignal.abort().reason);

$ bun reject.js            # identical before and after: the `at` line comes from the retained line/sourceURL properties
AbortError: The operation was aborted.
DOMException { ... }
      at /tmp/x/reject.js:1

$ bun -e 'console.log(require("util").inspect(AbortSignal.abort().reason))'
# before (1.4.0)
[global code@/tmp/x/[eval]:1:56] {
  line: 1,
  column: 56,
  sourceURL: '/tmp/x/[eval]'
}
# after
[global code@/tmp/x/[eval]:1:56]

The missing AbortError: ... header in that util.inspect output comes from the JSC-format stack string, which this change does not touch; #32898 replaces it with a Bun-formatted stack.

…ions 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.
@coderabbitai

coderabbitai Bot commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 2 minutes

Limit details: You’ve used all 5 included reviews currently available under your plan.

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 256d71f6-4f31-4942-a5a9-df3dc5af3f0b

📥 Commits

Reviewing files that changed from the base of the PR and between 11678f5 and 69587b7.

📒 Files selected for processing (3)
  • src/jsc/bindings/JSDOMExceptionHandling.cpp
  • test/js/node/domexception-node.test.js
  • test/js/web/abort/abort.test.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:09 PM PT - Aug 15th, 2026

@robobun, your commit 69587b7 is building: #99209

@robobun

robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced on Bun 1.4.0 and on main (83d65fa) with bun -e 'console.log(Object.keys(AbortSignal.abort().reason))', which prints [ "line", "column", "sourceURL" ] (Node v26.3.0 prints []); the same applies to the DataCloneError from structuredClone(() => {}), atob("!") and WebCrypto rejections. With this branch all of them print [], and test/js/node/domexception-node.test.js plus the two abort.test.ts reason tests fail on the released binary and pass with the fix. Running a self-review pass next; will update here if it turns anything up.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — focused compat fix that only flips property enumerability, with thorough coverage.

What was reviewed:

  • addDOMExceptionErrorInfo() mirrors JSC's addErrorInfo path (null/empty stackTrace branches, getLineColumnAndSource, stackTraceAsString) with DontEnum on all four puts; no user-JS re-entry, so no exception scope needed beyond what the replaced call had.
  • New it.each covers constructed, cloned, and six internally-created DOMException paths; the spawned-fixture test proves line/sourceURL remain readable by the unhandled-rejection printer.
  • abort.test.ts workaround removal turns two existing tests into regression tests; WTF::move and helper placement match local convention.
Extended reasoning...

Overview

The PR changes one call site in src/jsc/bindings/JSDOMExceptionHandling.cpp: the default arm of createDOMException() now calls a new file-local addDOMExceptionErrorInfo() instead of JSC::addErrorInfo(). The helper reproduces the upstream addErrorInfo(VM&, Vector<StackFrame>*, JSObject*) body — getStackTrace → null return / empty→stack:"" / otherwise getLineColumnAndSource + four putDirects — but passes DontEnum for line, column, and sourceURL (upstream only marks stack DontEnum on non-ErrorInstance objects). Two test files are updated: a new describe block in test/js/node/domexception-node.test.js and a one-line workaround removal in test/js/web/abort/abort.test.ts.

Security risks

None. The change alters property attributes on a freshly created DOM wrapper; no untrusted input, no auth/crypto, no memory ownership changes. putDirect on a brand-new JSDOMException cannot re-enter user JS, and the helper makes the same non-throwing JSC calls the replaced function made (verified per the description under BUN_JSC_validateExceptionChecks=1).

Level of scrutiny

Low-to-medium. This is a Node/WebIDL compat fix in a shared C++ path that every internally-created DOMException flows through, so I checked that the helper is a faithful structural copy of addErrorInfo (including the !stackTrace early return and the sourceURL.isEmpty() guard) and that nothing but enumerability changes. The values, the empty-trace fallback, and the stack string format are all identical. WTF::move matches the ~1500 existing uses in src/jsc/bindings.

Other factors

Test coverage is strong for the size of the change: an eight-case it.each (constructed, structured-cloned, AbortSignal.abort, AbortController#abort, AbortSignal.timeout, DataCloneError, atob, WebCrypto rejection) asserts Object.keys/JSON.stringify/spread are empty; a deep-equality test covers util.isDeepStrictEqual and toEqual; a spawned subprocess asserts the unhandled-rejection printer still emits at <dir>/reject.js:1, proving the retained non-enumerable line/sourceURL are still consumed by ZigException.cpp. The abort.test.ts fmt() helper no longer skips these keys, converting two existing assertions into regression tests. Tests follow harness conventions (tempDir, bunEnv, concurrent pipe drain, await using, event-awaited timeoutReason with no sleep). No CODEOWNERS covers the touched paths and there are no outstanding human review comments.

… 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.
Comment thread src/jsc/bindings/JSDOMExceptionHandling.cpp Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — targeted attribute change with thorough coverage.

What was reviewed:

  • addDOMExceptionErrorInfo() mirrors JSC's addErrorInfo() internals (getStackTracegetLineColumnAndSourceputDirect, empty-trace → stack: "") with only the DontEnum flag added; no new throwing calls, WTF::move matches file convention.
  • New tests cover 8 DOMException creation paths, isDeepStrictEqual/toEqual across call sites, and a spawned fixture confirming the unhandled-rejection printer still reads back sourceURL/line.
  • The abort.test.ts fmt() workaround removal tightens the existing assertion; comment-cop feedback was addressed in 69587b7.
Extended reasoning...

Overview

Replaces the addErrorInfo(lexicalGlobalObject, asObject(errorObject), true) call in the default arm of createDOMException() with a local addDOMExceptionErrorInfo() helper that stores the same four properties (line, column, sourceURL, stack) via putDirect() but with PropertyAttribute::DontEnum. Adds a DOMException own properties describe block to test/js/node/domexception-node.test.js covering constructed, structured-cloned, AbortSignal.abort(), AbortController#abort(), AbortSignal.timeout(), DataCloneError, atob(), and WebCrypto rejection paths, plus deep-equality and a spawned printer-output regression guard. Removes the column/line/sourceURL skip from abort.test.ts's fmt() helper.

Security risks

None. This changes only the enumerability attribute of diagnostic properties on internally-created DOMException wrappers. No user input parsing, no auth/crypto logic, no new allocation or lifetime paths.

Level of scrutiny

Medium — the code path runs for every internally-created DOMException, but the change is a near-verbatim inline of JSC::addErrorInfo()'s non-ErrorInstance branch with only the attribute flag changed. The helper makes the same non-throwing calls (getStackTrace, getLineColumnAndSource, Interpreter::stackTraceAsString, putDirect on a fresh wrapper), so no new exception-scope handling is needed; the PR confirms it passes under BUN_JSC_validateExceptionChecks=1. The empty-stack-trace and empty-sourceURL branches match the original's behavior. WTF::move is already the idiom used elsewhere in this file.

Other factors

Tests follow harness conventions (tempDir, bunEnv/bunExe, await using, concurrent pipe drain, Windows drive-letter case normalization) and the PR description documents USE_SYSTEM_BUN=1 failure on the five internally-created paths and the deep-equality test. The removed fmt() skip in abort.test.ts turns two existing tests into regression guards for this change. The github-actions comment-cop feedback about the long comment was addressed in 69587b7 (trimmed to one line) and the thread is resolved. No CODEOWNERS entry covers this file and there are no outstanding human reviewer comments.

@robobun

robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator Author

Heads up on an overlap: #39320 replaces the same addErrorInfo() call in createDOMException() to fix a different problem (the recorded line/column/stack are positions in the transpiled output and the uncaught printer shows the wrong line). It also ends up making the properties DontEnum, so whichever of the two lands second needs a one-line rebase at that call site; the tests in both PRs are compatible except that the uncaught-printer test here would see at <dir>/reject.js:1:<column> once #39320 is in.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant