Skip to content

Throw DOMExceptions from Rust JSG - #7951

Draft
bartlomieju wants to merge 7 commits into
mainfrom
biwanczuk/rust-jsg-domexception
Draft

bartlomieju wants to merge 7 commits into
mainfrom
biwanczuk/rust-jsg-domexception

Conversation

@bartlomieju

@bartlomieju bartlomieju commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

Rust JSG offered DOM error types such as AbortError and NotSupportedError but threw them all as a plain Error, and could not express DOMException(SyntaxError) because SyntaxError meant the JS class. Rename the DOM variants to mirror the C++ JSG_REQUIRE error types (DOMSyntaxError, DOMAbortError, ...), throw real DOMExceptions for them, and recognize DOMExceptions in tunneled KJ errors and in FromJS for jsg::Error.

To do that without C++ JSG in Rust JSG's shims, the global DOMException moves to the per-isolate TypeScript bootstrap, and native code creates and recognizes DOMExceptions with V8 alone.

Why land this before any Rust API needs it:

  • The Rust API already exposes DOM error types (new_abort_error, jsg_require!(…, NotSupportedError, …)) that silently throw a plain Error, a trap for every API ported to Rust JSG.
  • A tunneled jsg.DOMException(SyntaxError) from C++ decodes in Rust to the JS SyntaxError, the wrong type.
  • The C++ APIs that would move to Rust JSG next throw DOMExceptions throughout: WebCrypto, filesystem, sockets, WebSocket, RPC, and anything taking an AbortSignal. The Rust port of MemoryCache.read() needs DOMDataCloneError for parity (#6465048856).

Commits

  1. Throw DOMExceptions from Rust JSG / Keep terminations and DOMException names when throwing from Rust JSG: the Rust side (unchanged in approach since the first review).
  2. Merge origin/main: resolves the conflicts with Propagate JavaScript exceptions through Rust JSG function calls #7954/Keep what JavaScript throws during Rust JSG coercions #7961/Write down and check the Rust JSG safety rules #7965 and teaches main's new Lock::exception() about DOM-named internal errors. This commit temporarily bumps the allowlist so it passes on its own; commit 4 removes the bump.
  3. Return a JsObject from jsg::Lock::domException(): every caller only wanted the handle; drops the 16 TypeHandler<Ref<DOMException>> parameters from the filesystem API. No behaviour change.
  4. Brand DOMExceptions and create and recognize them with V8 alone: a DOMException carries its name and message in V8 private properties. Native code recognizes one by them (tryGetDOMExceptionFields()) and creates one (newDOMException()) as a plain object over the context's DOMException prototype, held in a new ContextDataSlot, without running JavaScript (so it still works under DisallowJavascriptExecution, e.g. in the deserializer). The serializers, ExceptionWrapper, reportError and Rust JSG all use the brand. Rust JSG's shims no longer use jsg::Lock/jsg::Ref; the allowlist is back to main's counts.
  5. Implement the global DOMException in the per-isolate bootstrap: src/per_isolate/dom-exception.ts replaces the C++ class as the global, and hands native code its prototype, so runtime-created DOMExceptions are instances of it. It copies the C++ class's shape exactly, including the flag-dependent attributes (set_tostring_tag, immutable_api_prototypes, constant attributes), which it reads off the C++ class rather than recomputing. The C++ class remains for isolates without a bootstrap (JSG and Rust JSG tests) and for the deserializer registration of tags 7/8.

Behaviour

Behaviour that matches C++ JSG:

  • Rust's Lock::throw_exception throws nothing while execution is terminating, and Error::to_local refuses to create a DOMException then. If creating one fails with an exception pending, that exception propagates instead of an internal error.
  • Error::new_dom_exception(name, message) accepts any DOMException name, as jsg::Lock::domException() does. A name without a DOM-prefixed type, such as the legacy IndexSizeError, keeps its name (Error::dom_exception_name()) through FromJS, tunneled errors and Display.
  • An internal jsg-internal.DOMException(<Name>) is thrown as a DOMException with that name and the redacted internal error; reference = <id> message, logged as makeInternalError() logs it.
  • The TS class's instances, constructor and prototype have the same own keys, order and attributes as before; src/workerd/api/tests/dom-exception-test pins the shape and passes against the C++ class too.

Changes:

  • Accepted, ungated: Function.prototype.toString() of DOMException shows its source instead of [native code]. This is the only standard-API-surface difference found.
  • kj::str() of a natively created DOMException now gives Name: message instead of an empty string. MemoryCache.read() relied on the empty string to report an unserializable value as "failed to serialize ", so it now checks for a DOMException explicitly. Its fallback_error span tag for that case becomes DataCloneError: failed to serialize <type> instead of "" — relevant for the parity test of the Rust MemoryCache.read() port.
  • Only an object's own brand counts. C++ JSG also unwrapped an object with a DOMException on its prototype chain (Object.create(new DOMException())); nothing depends on that.
  • A DOMException that user code throws during a Rust coercion and that Rust rethrows stays a DOMException, where it used to become a plain Error (or a JS SyntaxError for DOMException(SyntaxError)). No shipped API reaches this: UrlUtil and DnsUtil only receive values their TypeScript wrappers have already converted.

Fast API callbacks are unaffected: a method returning Result has no fast callback, so a fast callback only throws TypeErrors from its receiver and argument checks (now checked with a debug_assert!).

Not covered

  • The fallback when creating a DOMException fails with an exception pending is not tested: recursing to the stack limit fails the call before it reaches Rust rather than inside the creation.
  • Not checked: a context restored from a snapshot re-runs the bootstrap, so DOMExceptions saved in a snapshot presumably keep the previous class's prototype, as TS streams classes would.

Rust JSG offered DOM error types such as AbortError and
NotSupportedError but threw them all as a plain Error, and could not
express DOMException(SyntaxError) because SyntaxError meant the JS
class. Rename the DOM variants to mirror the C++ JSG_REQUIRE error
types (DOMSyntaxError, DOMAbortError, ...), throw real DOMExceptions
for them, and recognize DOMExceptions in tunneled KJ errors and in
FromJS for jsg::Error. No shipped Rust API throws these types, so
observable JS behaviour is unchanged.
…mexception

# Conflicts:
#	src/rust/jsg-test/tests/mod.rs
#	src/rust/jsg/README.md
#	src/rust/jsg/ffi.c++
#	src/rust/jsg/lib.rs
Creating a DOMException clears V8's pending exception, so a DOM-typed
error returned after a termination replaced it with an exception that
JavaScript could catch. Throwing now does nothing while terminating,
and a failed creation lets V8's pending exception propagate instead of
logging an internal error. DOMException names without a DOM-prefixed
type are kept, and internal DOM errors throw a DOMException with the
redacted message, as C++ JSG does.

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

Direction is right. The two new shims add jsg::Lock/jsg::Ref, which rule 3 normally forbids, but I'm OK with that as an allowlisted exception: DOMException is a C++ JSG resource and Rust has to produce the same class.

Please rebase onto main (#7965), bump the allowlist explicitly, and keep the TODO pointing at moving DOMException out of C++ JSG. Expect a conflict with #7961 in FromJS for Error. Nit: log_internal_error builds the "internal error; reference = " text by hand instead of using renderInternalError.

/// Each DOM-prefixed type with the name and legacy `code` the thrown
/// `DOMException` has (`src/workerd/jsg/dom-exception.h`).
#[rustfmt::skip]
const DOM_TYPES: [(ExceptionType, &str, u16); 13] = [

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We should likely just move DOMException to per-isolate TS. On the native side we just need a function to create a DE instance.

@bartlomieju

Copy link
Copy Markdown
Collaborator Author

Pausing this for now, will wait until per-isolate autogate is gone and then try to do minimal V8 bindings + TS impl

@bartlomieju

Copy link
Copy Markdown
Collaborator Author

A follow-up for this PR: the Rust port of MemoryCache.read() (stacked on #8034, PR to come) depends on it for parity in one place.

When a fallback produces a value that can't be serialized, C++ MemoryCache.read() rejects with a DOMException (DataCloneError, "failed to serialize "). The Rust port calls jsg::Error::new_data_clone_error(), which currently throws a plain Error. That differs in two ways:

  • the read rejects with an Error, not a DOMException
  • the fallback_error tag on the memory_cache_fallback span differs: C++ records an empty string, since kj::str() of a DOMException gives none, while Rust records Error: failed to serialize function

The port's trace parity test (memory-cache-trace-test.c++) leaves an unserializable value out of its scenario, with a TODO(#7951). Once this lands, the port should throw DOMDataCloneError, and the scenario should gain that case and a check that the read rejects with a DOMException. Whichever of the two PRs lands second can make that change.

…mexception

# Conflicts:
#	src/rust/jsg-test/ffi.c++
#	src/rust/jsg-test/ffi.h
#	src/rust/jsg-test/lib.rs
#	src/rust/jsg/ffi.c++
#	src/rust/jsg/ffi.h
#	src/rust/jsg/lib.rs
#	src/rust/jsg/v8.rs
Every caller only wanted the JS handle, and the filesystem API took a
TypeHandler<Ref<DOMException>> on 16 methods just to wrap the Ref. With
a JsObject the DOMException no longer has to be a C++ JSG resource,
which lets it move to the per-isolate TypeScript bootstrap.

No behaviour change.
A DOMException now carries the name and message it was created with in
V8 private properties. Native code recognizes one by them and creates
one as a plain object over the context's DOMException prototype, held
in a new context data slot, with the own `code`, `name`, `message` and
`stack` properties a DOMException has always had. Neither runs
JavaScript, and neither needs the C++ JSG resource, so the TypeScript
class the per-isolate bootstrap will install can share them.

The serializers write a DOMException by its brand. Rust JSG creates
and recognizes DOMExceptions through the same V8-only functions, so its
shims no longer use jsg::Lock or jsg::Ref, and the allowlist is back to
its counts on main.

Only an object's own brand counts. C++ JSG also unwrapped an object
that had a DOMException on its prototype chain, such as
`Object.create(new DOMException())`; nothing depends on that.

kj::str() of a natively created DOMException now gives "Name: message"
rather than an empty string. MemoryCache.read() relied on the empty
string to report a value that cannot be serialized as "failed to
serialize <type>", so it now checks for a DOMException explicitly.
The per-isolate bootstrap replaces the C++ DOMException with a
TypeScript one (src/per_isolate/dom-exception.ts), and hands native code
its prototype, so the DOMExceptions native code and Rust JSG create are
instances of it. Its constructor brands an instance as native code
does, through a new utils.initDOMException(), so both kinds are
recognized alike.

The class copies the C++ class's shape: the global lives on the global
object's prototype with the same attributes; instances have own
read-only `code`, `name` and `message` and an enumerable `stack` in
that order; the legacy constants are on the constructor and the
prototype; and the attributes that compatibility flags choose
(set_tostring_tag, immutable_api_prototypes,
spec_compliant_property_attributes) are copied from the C++ class
rather than recomputed. The stack leaves out the constructor's own
frame. dom-exception-test pins the shape, and passes against the C++
class too.

Compatibility: this changes the standard API surface in one way, which
we accept: Function.prototype.toString() of DOMException shows its
source rather than `[native code]`. It is ungated.

The webfs bootstrap recognizes a DOMException by its brand rather than
with instanceof, and the primordials no longer capture DOMException.
@github-actions

Copy link
Copy Markdown

The generated output of @cloudflare/workers-types has been changed by this PR. If this is intentional, run just generate-types to update the snapshot. Alternatively, you can download the full generated types:

Full Type Diff

@bartlomieju

Copy link
Copy Markdown
Collaborator Author

Not ready for review yet, I'm still iterating on this one


// The message and name are converted as Web IDL converts a DOMString, which a template literal
// does too: a Symbol throws a TypeError.
function DOMException(this: object, ...args: unknown[]): void {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I know you mentioned that you're still iterating.. but I'm curious... why a function and not a class DOMException?

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants