Skip to content

DOMException: record source-mapped positions and a Bun-format stack on internally created exceptions - #39320

Open
robobun wants to merge 4 commits into
mainfrom
farm/166f2bd4/domexception-remap-error-info
Open

DOMException: record source-mapped positions and a Bun-format stack on internally created exceptions#39320
robobun wants to merge 4 commits into
mainfrom
farm/166f2bd4/domexception-remap-error-info

Conversation

@robobun

@robobun robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • The DOMExceptions Bun creates itself (AbortSignal.abort().reason, AbortController#abort(), AbortSignal.timeout(), atob()'s InvalidCharacterError, structuredClone()'s DataCloneError, everything else that goes through WebCore::createDOMException) carry line, column, sourceURL and stack values that point into Bun's transpiled output, not into the user's file. With 10 comment lines above const r = AbortSignal.abort().reason;, r.line is 1 and r.stack is module code@/x/f.js:1:30; a new Error() on the same line reports line 11.
  • The uncaught exception / unhandled rejection printer prints that position as if it were a source position: at /x/f.js:1, with the code excerpt to match. This is the wrong-line half of DOMException (AbortError) prints raw error object and an incorrect stack frame #37419 (the property dump half is not touched here).
  • Cause 1: createDOMException() (src/jsc/bindings/JSDOMExceptionHandling.cpp:187) attaches the properties with JSC's addErrorInfo(), which stores the raw JSC position and Interpreter::stackTraceAsString(). Errors get theirs from Bun's computeErrorInfo hook, which runs them through the source map.
  • Cause 2: the printer's fallback for thrown objects that are not ErrorInstances (exceptionFromString, src/jsc/bindings/ZigException.cpp:751) reads line but never column, so the printer's own source map lookup (which needs both) never matches, and when an originalLine is present it substitutes it for line instead of treating the position as already mapped.

Fix

  • Bun::addErrorInfoWithSourceMap() (new, FormatStackTraceForJS.cpp) captures the frames the same way as before (JSC::getStackTrace, so Error.stackTraceLimit is honored as before), formats them with Bun::formatStackTrace(), and puts the mapped line/column/sourceURL and the Bun-format stack on the object as DontEnum properties. formatStackTrace() also records the unmapped position as DontEnum originalLine/originalColumn, as it does for Errors. createDOMException() calls it with the DOMException's name and message, so stack is AbortError: The operation was aborted. followed by frames in the usual format. util.inspect() of these now prints DOMException [AbortError]: ... plus frames, as node does, instead of [@/x/f.js:1:30] { line: 1, ... }.
  • exceptionFromString() reads column as well and marks the frame remapped when originalLine is present, which is what fromErrorInstance() already does for Errors whose stack has been materialized. The printer then shows the recorded position and takes the excerpt from the source file.
  • Why this is the right fix: a DOMException now gets exactly what ErrorInstance::materializeErrorInfoIfNeeded gives an Error in Bun (the same four properties, attributes and format, and the line/originalLine convention that test/js/bun/test/stack.test.ts pins), and the printer reads both kinds of object the same way. Two details fall out of using the Error path: a DOMException created with no JS frames (AbortSignal.timeout() firing from the timer) gets the header line as its stack instead of "" (node's behavior for a frameless error, and what error: give errors created with no JS frames a .stack property #38074 does for Errors), and with Error.stackTraceLimit deleted nothing is attached, as for an Error. Error.prepareStackTrace is not consulted, as before; DOMException: inherit from ErrorInstance for [[ErrorData]] and .stack #32898 is the change that would make these stacks lazy.
  • Overlap with other open PRs: DOMException: make line/column/sourceURL on internally created exceptions non-enumerable #39311 changes the same line to make the properties non-enumerable, which this does as well; whichever lands second is a one-line rebase. DOMException: inherit from ErrorInstance for [[ErrorData]] and .stack #32898 (make JSDOMException an ErrorInstance) would delete this call together with the old one; until it lands this is the fix for the positions. One visible consequence: Bun's console.log object dump of these DOMExceptions, which prints non-enumerable own properties, now also lists originalLine and originalColumn; console, error printer: render instanceof-Error objects as errors, not object dumps #35723 and DOMException: inherit from ErrorInstance for [[ErrorData]] and .stack #32898 replace that dump with error-style output.
  • SubtleCrypto.cpp rejectWithCause() (ML-DSA / ML-KEM import failures and the oversized ML-DSA context rejection) called makeCause(), whose import variant declares a ThrowScope, and then createDOMException() without checking for an exception. That was invisible while createDOMException() declared no exception scopes of its own; formatting the stack now does, so the exception-check validator (the ASAN CI lane runs every test with BUN_JSC_validateExceptionChecks=1) aborted test-webcrypto-export-import-ml-dsa.js and -ml-kem.js. The lambda now checks after makeCause(); that was the only site the validated suite found. DeferredPromise::reject(), propagateException() and throwDataCloneError(), which cover the other callers, already check or open a scope right before the call.
  • Verified with test/js/node/domexception-node.test.js (new describe): positions and frames of six creation sites (abort reasons, atob, structuredClone, a crypto.subtle.importKey() rejection, one created inside a function) checked against the line numbers of the fixture source and against an Error created on the same line, enumerability, the ML-DSA rejection-with-cause path compared against an Error created on the same line, the header-only case, and the unhandled rejection and uncaught exception printer output (frame line and excerpt). The five new tests fail on the unfixed binary (line 16 instead of 21, JSC-format stack, printer at <dir>/uncaught.js:1) and pass with the fix, also under BUN_JSC_validateExceptionChecks=1 (where the rejection-with-cause test aborts without the SubtleCrypto.cpp change).
  • Also run with the fix: test/js/web/abort/, structured-clone.test.ts, stack.test.ts (pins the user-object fallback, at http://example.com/test.js:42, unchanged), capture-stack-trace.test.js, inspect-error.test.js (its two minified-file failures are the pre-existing debug-only at require frame noted in error printer: remap frames when the original source is unavailable, and not twice after error.stack #38296), timers.promises, web-globals, globals, reportError, the node:stream: ensure stack traces are good #23022 regression test, node's test-domexception-cause, test-global-domexception and test-structuredClone-domexception, and the abort-related fetch tests.
  • Cost: both the old and the new path format the stack eagerly. controller.abort() in a debug build: 551us/op before, 511 to 526us/op after; the abortsignal-leak-fixture timings are unchanged.

Background

  • createDOMException() is where a WebCore ExceptionCode becomes a JS value. For the codes that are DOMExceptions it creates a JSDOMException, which is a plain DOM wrapper object and not a JSC::ErrorInstance, so none of the Error machinery below applies to it and its error properties have to be put on it explicitly.
  • Bun transpiles every module it loads, so the positions JSC reports are positions in the transpiled text. For Errors, Bun installs JSC's onComputeErrorInfo hook (FormatStackTraceForJS.cpp); when stack, line or column is first read, formatStackTrace() runs the frames through the source maps (Bun__remapStackFramePositions), the mapped position becomes line/column, and the unmapped one is kept as DontEnum originalLine/originalColumn.
  • The error printer (uncaught exceptions, unhandled rejections, console.error(err)) converts the thrown value into a ZigException in ZigException.cpp, then remap_zig_exception (src/jsc/VirtualMachine.rs) maps each frame through the source maps and fetches the source for the excerpt. A frame's remapped flag means its position is already a source position, so the mapping step is skipped for it. fromErrorInstance() handles ErrorInstances; exceptionFromString() is the fallback for every other thrown object, DOMExceptions included, and builds a single frame from the object's sourceURL/line properties.
Repro
{ for i in $(seq 10); do echo "// c$i"; done
  echo 'const r = AbortSignal.abort().reason;'
  echo 'console.log(r.line, JSON.stringify(r.stack));'
  echo 'Promise.reject(r);'; } > f.js && bun f.js

Before (1.4.0):

1 "module code@/x/f.js:1:30"
AbortError: The operation was aborted.
DOMException { line: 1, column: 30, sourceURL: "/x/f.js", stack: "module code@/x/f.js:1:30", ... }
      at /x/f.js:1

After:

11 "AbortError: The operation was aborted.\n    at /x/f.js:11:29"
 6 | // c6
 ...
11 | const r = AbortSignal.abort().reason;
                                 ^
AbortError: The operation was aborted.
DOMException { originalLine: 1, originalColumn: 30, line: 11, column: 29, sourceURL: "/x/f.js", stack: "AbortError: ...", ... }
      at /x/f.js:11:29

Column 29 comes from the same source map lookup Error frames go through (the nearest mapping at or before the JSC position).

Refs #37419

…n 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.
@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: 8 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: 7ac4c9f3-9f29-4ded-8b59-f51c313c2de4

📥 Commits

Reviewing files that changed from the base of the PR and between 23d535a and 03d96c6.

📒 Files selected for processing (6)
  • src/jsc/bindings/FormatStackTraceForJS.cpp
  • src/jsc/bindings/FormatStackTraceForJS.h
  • src/jsc/bindings/JSDOMExceptionHandling.cpp
  • src/jsc/bindings/ZigException.cpp
  • src/jsc/bindings/webcrypto/SubtleCrypto.cpp
  • test/js/node/domexception-node.test.js

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

@robobun

robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 12:49 AM PT - Aug 16th, 2026

@robobun, your commit 03d96c67fad7a2f582908623ebc93c3f461f458f passed in Build #99278! 🎉


🧪   To try this PR locally:

bunx bun-pr 39320

That installs a local version of the PR into your bun-39320 executable, so you can run:

bun-39320 --bun

@robobun

robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced and fixed.

  • Reproduced on 1.4.0 and on main (83d65fa): with 10 comment lines above const r = AbortSignal.abort().reason;, r.line is 1, r.stack is module code@/x/f.js:1:30, and rejecting or throwing r prints at /x/f.js:1. A new Error() on the same line reports line 11.
  • Fix is in this PR: createDOMException() now attaches source-mapped line/column/sourceURL and a Bun-format stack (Bun::addErrorInfoWithSourceMap), and the error printer's non-Error fallback reads column and honors originalLine as "already mapped".
  • The exception-check CI lane then flagged rejectWithCause() in SubtleCrypto.cpp, which called createDOMException() right after a throw-capable call without checking; it checks now (a4fe2e6), and the ML-DSA rejection path is covered by a new test. The two long comments flagged by the comment lint were shortened.
  • Tests: test/js/node/domexception-node.test.js, new describe (5 tests fail without the fix, pass with it, also under BUN_JSC_validateExceptionChecks=1).
  • Related: DOMException: make line/column/sourceURL on internally created exceptions non-enumerable #39311 (same line, enumerability only), DOMException: inherit from ErrorInstance for [[ErrorData]] and .stack #32898 (DOMException as ErrorInstance, would supersede the C++ helper), DOMException (AbortError) prints raw error object and an incorrect stack frame #37419 (the wrong-line half of that issue).

@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.

I reviewed this PR and didn't find any bugs. Because it changes user-visible .stack formatting on internally-created DOMExceptions and interacts with several open PRs (#39311, #32898, #35723), a maintainer look at the sequencing would still be worthwhile.

What was reviewed:

  • addErrorInfoWithSourceMap reuses the existing formatStackTrace path that Errors already go through; the Ref<DOMException> local keeps the C++ object alive across the name()/message() reads.
  • The exceptionFromString change mirrors the originalLineremapped handling in fromErrorInstance; the pinned user-object case in stack.test.ts (at http://example.com/test.js:42, no originalLine, no column) is unaffected.
  • No new GC hazard from holding the captured Vector<StackFrame> locally — the frames' cells are still on the live VM stack, same as the JSC::addErrorInfo this replaces.
Extended reasoning...

Overview

Replaces JSC's raw addErrorInfo() with a Bun-side addErrorInfoWithSourceMap() in createDOMException(), so DOMExceptions Bun creates get source-mapped line/column/sourceURL and a Bun-format stack, matching what ErrorInstances get via the computeErrorInfo hook. Also fixes exceptionFromString() to read column and treat an originalLine property as the already-remapped signal (mirroring fromErrorInstance()), rather than overwriting line with the unmapped position. Adds a thorough describe.concurrent block to the existing DOMException test file covering five creation sites, a frameless case, and the uncaught/unhandled printer output.

Security risks

None. This is error-formatting/diagnostics code; no auth, crypto, parsing of untrusted input, or filesystem writes.

Level of scrutiny

Medium-high. C++ JSC bindings touching stack capture and property attachment is an area where GC rooting, exception-scope discipline, and finalizer safety matter. However, the new function is a thin wrapper around the existing, well-trodden formatStackTrace() path (same one computeErrorInfoWithoutPrepareStackTrace uses), and the ZigException.cpp change removes a TODO: don't sourcemap it twice by aligning with the sibling fromErrorInstance path. The captured frames are used synchronously while the corresponding JS frames are still live on the VM stack, so no new rooting is needed — same as the JSC::addErrorInfo being replaced.

Other factors

  • Tests are comprehensive: they compute expected line numbers from the fixture source (not hardcoded), compare against an Error on the same line, assert non-enumerability, cover the no-JS-on-stack case, and verify both the unhandled-rejection and uncaught-exception printer paths (frame line + code excerpt). Tests use tempDir + bunEnv + concurrent subprocess spawning with pipes drained via Promise.all, per repo conventions.
  • The PR description explicitly verified the change under BUN_JSC_validateExceptionChecks=1 and re-ran the abort/structuredClone/stack/capture-stack-trace/inspect-error suites.
  • The user-visible .stack format on DOMExceptions changes from JSC's module code@... to Bun's Name: message\n at .... This is a correctness fix (matches Node and Bun's own Error format) but is observable, and the PR notes it interacts with three other open PRs — a maintainer should confirm the landing order.

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.
Comment thread src/jsc/bindings/FormatStackTraceForJS.h Outdated
Comment thread src/jsc/bindings/ZigException.cpp Outdated
Comment thread src/jsc/bindings/FormatStackTraceForJS.h 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.

I reviewed this PR and didn't find any bugs. Because it adds a new C++ helper that runs source-map remapping and putDirect from inside createDOMException()'s many callers, changes the exceptionFromString() fallback semantics, and alters user-visible stack format / property enumerability on DOMExceptions, a human look would still be worthwhile.

What was reviewed

  • addErrorInfoWithSourceMap(): getStackTrace returning null (stackTraceLimit deleted) short-circuits; line == beforeFirst() guards the frameless case; formatStackTrace() puts originalLine/originalColumn on the passed object, matching the Error path.
  • exceptionFromString(): previously swapped originalLine in for line; now keeps line+column and sets frame.remapped, which mirrors fromErrorInstance()'s handling and is what remap_zig_exception expects. stack.test.ts's user-object case (line: 42, no originalLine) still yields remapped=false → unchanged.
  • rejectWithCause(): the added RETURN_IF_EXCEPTION returns {} into rejectWithCallback, whose caller handles an empty JSValue.
  • Comment-cop lint fired on earlier revisions; the latest commit reduced both to one-liners.
Extended reasoning...

Overview

This PR replaces JSC's addErrorInfo() in createDOMException() with a new Bun::addErrorInfoWithSourceMap() that captures the stack via JSC::getStackTrace, runs it through Bun's existing formatStackTrace() (which source-maps positions and records originalLine/originalColumn), and putDirects DontEnum line/column/sourceURL/stack on the DOMException wrapper. It also updates exceptionFromString() to read column and treat originalLine as an already-mapped marker (setting frame.remapped) rather than substituting it for line, and adds a ThrowScope + RETURN_IF_EXCEPTION in SubtleCrypto's rejectWithCause() because the new path opens exception scopes inside createDOMException(). A ~200-line test describe covers six creation sites, the frameless case, the rejection-with-cause path, and the uncaught/unhandled printer output.

Security risks

None identified. The change is confined to error-position reporting and stack formatting; no auth, crypto correctness, or untrusted-input parsing is touched (the SubtleCrypto.cpp edit only adds an exception check).

Level of scrutiny

Medium-high. This is C++ in the JSC bindings layer where exception-scope discipline, GC safety, and source-map interaction are all in play. createDOMException() is called from many sites (abort signals, atob, structuredClone, WebCrypto rejections, propagateException, throwDataCloneError, DeferredPromise::reject), so a latent unchecked-exception path or a wrong putDirect would surface widely. The exceptionFromString() semantic change also affects any thrown non-Error object with a line property. The PR description enumerates the caller audit and the validated-exception-check run, and the tests are thorough, but the surface area and the interaction with three other open PRs (#39311, #32898, #35723) warrant a maintainer's eye.

Other factors

  • The CI failure on e6faff5 (unchecked exception in rejectWithCause) was addressed by a4fe2e6; the robobun status comment has not yet updated for the two later commits.
  • The comment-cop bot fired three times on earlier revisions; commits 0ec6735 and 03d96c6 shortened the flagged comments to one-liners, so those appear resolved.
  • User-visible behavior changes: DOMException .stack format switches from JSC's module code@... to Bun's Name: message\n at ..., the properties become DontEnum, and console.log object dumps now include originalLine/originalColumn. These are documented in the description as intentional and covered by tests, but are the kind of visible-output change a maintainer should sign off on.

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