Create implementation exceptions in the correct realm
Posted by GPT-6 Astra High
Problem
jsdom implementations commonly throw exceptions with throw DOMException.create(this._globalObject, ...). This uses the object's realm, which can differ from the realm required for the exception. Helpers sometimes use the global of an argument or parent node instead, introducing the same problem even without borrowing a method from another window.
Web IDL's exception rules require newly created exceptions to use the current realm: for an ordinary operation call, the realm of the invoked method. For example, inner.Range.prototype.insertNode.call(outerRange, invalidNode) must create its exception in inner's realm even though the receiver belongs to outer.
This issue extracts exception-realm handling from the broader tracking in #2727. The goal is a uniform solution across implementations and shared helpers, with brief throw sites, no per-method annotations, and reasonable performance and memory overhead. The design should accommodate built-in errors such as TypeError and RangeError as well as DOMException.
Concrete example
The WPT dom/ranges/Range-insertNode.html remains disabled for exceptions from the wrong realm. Its fixture adopts cloned nodes into an iframe. Range insertion delegates to the parent node's pre-insertion validation, which creates exceptions using the parent's original global. Adoption updates ownerDocument but correctly preserves the node's JavaScript realm, so the exception has the right name and the wrong prototype.
A smaller reproduction:
const { JSDOM } = require("jsdom");
const outer = new JSDOM("<iframe></iframe>").window;
const inner = outer.document.querySelector("iframe").contentWindow;
const parent = outer.document.createElement("div");
inner.document.body.append(parent);
const range = inner.document.createRange();
range.selectNodeContents(parent);
try {
range.insertNode(inner.document);
} catch (error) {
console.log(error.name); // HierarchyRequestError
console.log(error instanceof inner.DOMException); // false; should be true
console.log(error instanceof outer.DOMException); // true; should be false
}
outer.close();Using the range's own global would fix that example, but would still fail the borrowed-method case above.
Recommendation
Explore a synchronous active-realm scope established by the binding infrastructure first. This keeps throw sites short and constructs real exceptions in the correct realm immediately. Internal exception descriptions materialized at the binding boundary are a serious alternative, especially if avoiding realm-state writes on successful calls proves important.
The proposals below are design sketches, not measured performance claims. No benchmarks have been run to compare them.
Option A: Track the active realm in a synchronous scope (recommended)Every generated binding saves the previous active realm, establishes its own realm, and restores the previous value on exit. Illustrative generated code:
const previous = exceptionContext.globalObject;
exceptionContext.globalObject = globalObject;
try {
return impl.insertNode(node);
} finally {
exceptionContext.globalObject = previous;
}Implementations and helpers can uniformly write:
throw domException("HierarchyRequestError", "Invalid start node.");The factory reads the active realm and creates the actual exception immediately. Equivalent factories could cover built-in error types.
Advantages:
- No realm parameters need to be threaded through internal helpers.
- Internal implementation calls retain the invoking binding's realm. Reentrant calls through another window's bindings temporarily establish that window's realm, then restore the previous one.
- Implementation code can catch, inspect, or store ordinary exception objects immediately.
- The scope itself needs no additional per-node or per-window object. The previous realm can live in a local variable.
Costs and complications:
- Every covered binding invocation, including successful getters, saves, sets, and restores realm state and uses
finally. - Entry-point coverage must include constructors, accessors, operations, and hand-written bindings. Reentrant callbacks and other ways of entering implementation code need deliberate treatment.
- Work running after the binding returns must establish the appropriate realm at its entry point, or capture a realm where the relevant algorithm requires it. Automatically propagating the initiating realm through all asynchronous work would not necessarily be correct.
Keep the same brief throw-site syntax, but make the helper return a privately branded description containing the exception type, name, and message. Bindings catch those descriptions and construct the actual exception in their realm:
try {
return impl.insertNode(node);
} catch (error) {
if (isExceptionDescription(error)) {
throw createException(globalObject, error);
}
throw error;
}Advantages:
- No ambient realm state or parameter threading.
- Successful calls perform no realm-state writes; descriptions are allocated only when needed.
- Existing exceptions pass through unchanged. An exception materialized by an inner binding keeps its identity and realm when crossing an outer binding.
Costs and complications:
- Internal catches encounter descriptions instead of actual exceptions, so those catch sites need auditing.
- Any path exposing an error before it reaches the binding, such as storing an abort reason, rejecting a promise, or reporting an error, needs appropriate materialization. Descriptions must never escape to application code.
- Stack traces need deliberate preservation: constructing the exception at the boundary otherwise loses the original throw location.
- Failures allocate both a description and the final exception. Successful calls still cross a catch boundary.
This requires a distinct internal representation. Catching and recreating every DOMException would incorrectly replace exceptions thrown by user callbacks or stored as abort reasons.
Change the implementation calling convention universally, with the generator always supplying a context and no per-method annotations:
insertNode(context, node) {
// ...
throw context.domException("HierarchyRequestError", "Invalid start node.");
}Contexts could be shared per realm, avoiding per-call allocation. Dependencies are explicit, and asynchronous code can capture a context when appropriate.
The costs are extensive parameter propagation and implementation API changes. JavaScript getters cannot accept a context parameter, and setters cannot accept an extra one, so implementation accessors would need a new calling convention. This option is less attractive given the goal of concise implementation and helper code.
Constraints common to any solution
- Distinguish creating an exception from propagating an existing exception. Exceptions from user callbacks and existing reasons thrown by
signal.throwIfAborted()must preserve their identity and realm. - Preserve
_globalObjectas the object's original global. Making it context-dependent would change object creation and other realm-sensitive behavior beyond exception throwing. - Handle explicit exception creation and asynchronous delivery according to the relevant algorithm; a synchronous throw convention alone does not cover those paths.
- Cover adopted-node cases, borrowed methods across windows, nested/reentrant binding calls, preservation of existing exceptions, and restoration of context after both successful and throwing calls.
Source: jsdom/jsdom