Skip to content

feat(runtime): serialize DOMException per Web IDL [Serializable] - #453

Draft
edusperoni wants to merge 4 commits into
mainfrom
feat/dom-exception-serializable
Draft

feat(runtime): serialize DOMException per Web IDL [Serializable]#453
edusperoni wants to merge 4 commits into
mainfrom
feat/dom-exception-serializable

Conversation

@edusperoni

@edusperoni edusperoni commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #452. Implements the [Serializable] slot that PR deliberately left out, so DOMException survives structuredClone and worker postMessage instead of degrading like a custom Error subclass.

Mechanism (Node's JSTransferable protocol, reduced to one class)

  • Branding. The dom-exception builtin gains a native half: binding.markCloneable stamps every instance with a per-isolate v8::Private (stored via Caches::StateFor), unforgeable and invisible from JS. All three GetExports call sites for the builtin now share one binding factory (serialization::DomExceptionBinding) — GetExports consults the factory only on the run that populates the cache, so a site passing a different one would win or lose by init order.
  • Claiming. The serializer delegate turns on HasCustomHostObject and answers IsHostObject with a private-symbol check, V8's escape hatch for treating a plain JS object as a host object. Cost: one private-symbol lookup per plain JS object in a serialized graph — the same price Node pays.
  • Two-phase payload. V8 forbids JS execution while a value is being read (calling the constructor inside ReadHostObject is a V8_Fatal, found the hard way), so this mirrors Node's host_objects_ design: WriteHostObject pushes {name, message, stack} onto an out-of-band list on the SerializedValue and writes only a tag + index into the stream; Deserialize constructs every instance through the real constructor before ReadValue starts, and ReadHostObject hands them out by index. Construction re-brands the instance, so a forwarded exception serializes again on the next hop — and on a worker isolate that never touched DOMException, the pre-construction step runs the builtin on demand.
  • Wire format. Host objects now start with a uint32 tag (0 = degraded native wrapper, unchanged empty-object semantics; 1 = DOMException index). The bytes never outlive the process, so the format is free to evolve with the file.
  • Policies. DOMException serializes under both kReject (structuredClone) and kDegrade (worker postMessage): the reject policy exists to refuse objects whose native half would be left behind, and a DOMException has none. Graph identity is preserved by V8's object-id machinery — one payload per distinct instance.

Tests

  • Shared suites (common-runtime-tests-app@67da5fc, submodule bumped): structuredClone round-trip (name/message/code/instanceof, stack where stacks exist), identity within a graph, nesting, and worker postMessage in both directions — main→worker exercises the on-demand builtin run in a fresh isolate.
  • Unguarded serialization canary in RuntimeImplementedAPIs.js.
  • Full suite: 1499 specs, 0 failures.

Benchmarks

structuredClone medians, iPad Pro simulator, 12 runs after warmup (temporary in-suite benchmark, not committed). "Claim off" is an isolate that has never constructed a DOMException — the gated HasCustomHostObject (second commit) keeps it on the exact pre-change path; "claim on" is after the first instance exists.

workload base (818aca3) claim off claim on
object-heavy graph, ~100k JS objects 20.7ms 18.4ms 23.3ms (+12%, ~25ns/object)
200k-number array 4.7ms 4.0ms 4.0ms
20k-string array 1.2ms 0.9ms 0.9ms
tiny object × 20k clones 12.0ms 13.2ms 14.1ms (+~0.1µs/clone)
1000 DOMExceptions in one graph 0.11ms (degraded to {}) 4.3ms (~4.3µs each, the feature working)

Claim-off vs base differences are run-to-run noise. So: zero cost for apps that never touch DOMException, ~25ns per plain object on serialization-heavy paths afterward — the price Node pays unconditionally. Accepted edge (documented in-source): a getter constructing the isolate's first DOMException during the very clone that contains it degrades that one instance to a plain object.

DOMException (Web IDL §4.3) arrives as a new lazy builtin: a class grafted
onto Error.prototype with branded accessor attributes, the legacy code
table, and the constants on interface object and prototype. CustomEvent is
defined in events.js next to the Event it extends and placed by the lazy
tier through the shared exports cache, so only the placement is deferred —
Events::Init now runs the file via GetExports and reads the backing
EventTarget from the exports bag.

Builtins reach each other through a new internal require tier: registry
rows marked internal-only resolve for the require builtins receive and
nowhere else, the Node internal-module idiom the js README planned for.
The four name-patched-Error stand-ins (abort-signal, performance,
structured-clone, base64) now throw real DOMExceptions, required at first
throw so the builtin never runs on a clean path, and the native serializer
builds the same class for its DataCloneError with the old shape kept as a
teardown fallback.

With the tier in place the interim internals parameter loses its only two
users: kListenerChanged and setListenerErrorReporter move into events.js's
exports behind internal/events, and the builtin wrapper drops back to
Node's five parameters (exports, require, module, binding, primordials).
A consumer that runs before its producer now fails loudly at the require
instead of silently reading a missing key.

Not implemented: the spec's [Serializable] slot — a DOMException inside a
cloned graph still degrades like any custom Error subclass, since
v8::ValueSerializer has no hook for a plain JS class.

Shared suites (self-gating, skip where the APIs are absent) land in the
tests submodule; unguarded canaries on this runtime keep a regression from
turning them into silent skips.
…racy

detail is a readonly attribute in the IDL, unlike the base Event's fields
that mutate during dispatch, so define it non-writable. The internal
require tier's misdescribed failure mode is corrected in the README and
abort-signal comment: a cache miss runs the producer on demand, so a
consumer can never observe a missing capability. The docs that still
described the pre-DOMException stand-ins (abort-signal, performance,
structured-clone, index) now describe the real class.
The dom-exception builtin gains a native half: markCloneable stamps every
instance with a per-isolate private brand (Caches::StateFor), and the
serialization delegates claim branded objects through V8's
HasCustomHostObject/IsHostObject hooks — the same escape hatch Node's
JSTransferable protocol uses, reduced to the one class.

The payload (name, message, stack) travels out-of-band on the
SerializedValue with only a tag and index in the stream, because V8 forbids
JS execution while a value is being read: Deserialize constructs every
instance through the real constructor before ReadValue starts — on a worker
isolate that never touched DOMException that runs the builtin on demand —
and ReadHostObject hands them out by index, Node's host_objects_ design.
Rebuilding through the constructor re-brands the instance, so a forwarded
exception serializes again on the next hop. The degraded-wrapper path now
writes an explicit tag where it wrote nothing; the bytes never outlive the
process, so the format is free to change with the file.

DOMException serializes under both host-object policies: structuredClone's
kReject only refuses objects with a native half to lose, and a DOMException
has none. Cost of the claim: with HasCustomHostObject on, V8 asks
IsHostObject about every plain JS object in a graph — one private-symbol
lookup each, the price Node pays for the same protocol.
@coderabbitai

coderabbitai Bot commented Aug 25, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

HasCustomHostObject makes V8 consult IsHostObject for every plain JS
object in a serialized graph — measured ~25ns each, ~+12% on an
object-heavy structuredClone. An isolate that never constructed a
DOMException cannot be holding one, so the claim is gated on a
per-isolate flag markCloneable flips with the first instance;
until then serialization runs the pre-claim path untouched.

Accepted edge, documented at the sample site: a getter running during
the very clone could construct the isolate's first DOMException after a
false sample — that one instance degrades to a plain object, the
pre-feature behavior, and every later serialization sees the flag.
Base automatically changed from feat/dom-exception to main August 25, 2026 22:16
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.

1 participant