Skip to content

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

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

feat(runtime): serialize DOMException per Web IDL [Serializable]#453
edusperoni wants to merge 2 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.

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

Base automatically changed from feat/dom-exception to main August 25, 2026 22:16
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.
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.
@edusperoni
edusperoni force-pushed the feat/dom-exception-serializable branch from d4eab63 to 0e243e6 Compare August 26, 2026 12:21
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