Skip to content

Commit 84fc2b6

Browse files
committed
feat: serialize DOMException per Web IDL [Serializable]
DOMException carries the [Serializable] slot in Web IDL, so it must survive structuredClone and worker postMessage rather than degrading the way a custom Error subclass does. Node reaches that with its JSTransferable protocol; this is the same mechanism reduced to the one class. The dom-exception builtin gains a native half: binding.markCloneable stamps every instance with a per-isolate v8::Private held in the runtime's RuntimeState, unforgeable and invisible from JS. Every GetExports call site for that builtin now goes through serialization::GetDomExceptionExports, because GetExports consults the binding factory only on the run that populates the cache. The serializer delegate claims host objects unconditionally and answers IsHostObject from the brand. That claim replaces V8's own embedder-field detection instead of extending it, so objects with internal fields — Java proxies, URL, URLSearchParams, ObjectManager wrappers — are claimed first and keep their existing behavior: a DataCloneError under structuredClone, an empty object over postMessage. V8 forbids JS execution while a value is being read, so the payload travels out-of-band: WriteHostObject pushes {name, message, stack} onto the SerializedValue and writes a tag plus an index, and Deserialize constructs every instance through the real constructor before ReadValue starts — running the builtin on demand on a worker isolate that never touched DOMException. Construction re-brands, so a forwarded exception serializes on the next hop. Host objects now start with a uint32 tag (0 = degraded native wrapper, 1 = DOMException index); the bytes never outlive the process.
1 parent 0ffff1b commit 84fc2b6

10 files changed

Lines changed: 361 additions & 29 deletions

File tree

docs/structured-clone.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ buffer.byteLength; // 0 — the memory now belongs to `moved`
1818
- `options` may be `undefined` or `null` (both mean "no transfer"); anything else must be an object, or a `TypeError` is thrown.
1919
- `options.transfer` is a WebIDL sequence: any object with a callable `Symbol.iterator` works (an array, a `Set`, a generator). A non-iterable value — including a string primitive — throws a `TypeError`.
2020

21-
Cloneable: every primitive value except symbols — numbers (including `-0`, `NaN` and the infinities), strings, booleans, `BigInt`, `null` and `undefined`; plain objects and arrays; `Date`, `RegExp`, `Map`, `Set`, `Error`; `Boolean`/`String`/`Number` wrapper objects; `ArrayBuffer`, every typed array and `DataView`.
21+
Cloneable: every primitive value except symbols — numbers (including `-0`, `NaN` and the infinities), strings, booleans, `BigInt`, `null` and `undefined`; plain objects and arrays; `Date`, `RegExp`, `Map`, `Set`, `Error`; `Boolean`/`String`/`Number` wrapper objects; `ArrayBuffer`, every typed array and `DataView`; and `DOMException`, per its Web IDL `[Serializable]` slot — `name`, `message` and `stack` round-trip through `structuredClone` and worker `postMessage`, and object identity within a graph is preserved.
2222

2323
The clone preserves the shape of the graph, not just the values: an object referenced twice in the input is a single object referenced twice in the output, and cycles round-trip. Prototypes do not survive — a class instance clones to a plain object with the same own properties. Getters are invoked during cloning and their result is stored as a plain data property. Property insertion order is preserved.
2424

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// A fresh isolate: the DOMException constructed inside the getter below is
2+
// the first one this isolate has ever seen, and it is born while the clone
3+
// that carries it is already being written.
4+
var graph = {
5+
get inner() {
6+
return new DOMException("first in this isolate", "AbortError");
7+
},
8+
};
9+
var clone = structuredClone(graph);
10+
postMessage({
11+
isDomException: clone.inner instanceof DOMException,
12+
name: clone.inner.name,
13+
message: clone.inner.message,
14+
});

test-app/app/src/main/assets/app/tests/testRuntimeImplementedAPIs.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,47 @@ describe("DOMException canary", function () {
6262
it("is not reachable as a module from app code", function () {
6363
expect(function () { require("internal/dom-exception"); }).toThrow();
6464
});
65+
66+
it("serializes through structuredClone on this runtime", function () {
67+
var clone = structuredClone(new DOMException("x", "AbortError"));
68+
expect(clone instanceof DOMException).toBe(true);
69+
expect(clone.name).toBe("AbortError");
70+
});
71+
72+
it("clones the isolate's first DOMException even when a getter creates it mid-clone", function (done) {
73+
var worker = new Worker("./domExceptionFirstCloneWorker.js");
74+
worker.onmessage = function (event) {
75+
expect(event.data.isDomException).toBe(true);
76+
expect(event.data.name).toBe("AbortError");
77+
expect(event.data.message).toBe("first in this isolate");
78+
worker.terminate();
79+
done();
80+
};
81+
worker.onerror = function (event) {
82+
fail("worker error: " + event.message);
83+
worker.terminate();
84+
done();
85+
return true;
86+
};
87+
});
88+
89+
// Once an isolate holds a DOMException the serializer claims host objects
90+
// itself, and V8 then stops detecting native wrappers on its own. These are
91+
// the shapes that would silently clone as {} if the claim missed them.
92+
it("still rejects native wrappers once a DOMException exists", function () {
93+
new DOMException("x", "AbortError");
94+
var wrappers = [new java.lang.Object(), new URL("https://example.com/")];
95+
for (var i = 0; i < wrappers.length; i++) {
96+
var error;
97+
try {
98+
structuredClone(wrappers[i]);
99+
} catch (e) {
100+
error = e;
101+
}
102+
expect(error instanceof DOMException).toBe(true);
103+
expect(error.name).toBe("DataCloneError");
104+
}
105+
});
65106
});
66107

67108
describe("CustomEvent canary", function () {

test-app/runtime/src/main/cpp/LazyGlobals.cpp

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
#include "ArgConverter.h"
44
#include "Base64.h"
55
#include "BuiltinLoader.h"
6+
#include "StructuredSerialization.h"
67
#include "TextEncoding.h"
78

89
using namespace v8;
@@ -38,7 +39,7 @@ constexpr LazyGlobalEntry kLazyGlobals[] = {
3839
{"TextDecoder", "TextDecoder", TextEncoding::GetExports},
3940
{"atob", "atob", Base64::GetExports},
4041
{"btoa", "btoa", Base64::GetExports},
41-
{"DOMException", "DOMException", BuiltinExports<BuiltinId::kDomException>},
42+
{"DOMException", "DOMException", serialization::GetDomExceptionExports},
4243
// events.js is an eager builtin (Events::Init), so this row never runs
4344
// a file: the read hits the exports cache and only the placement is
4445
// lazy.

test-app/runtime/src/main/cpp/NsBuiltinModules.cpp

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
#include "NativeScriptAssert.h"
1111
#include "Runtime.h"
1212
#include "RuntimeState.h"
13+
#include "StructuredSerialization.h"
1314
#include "TextEncoding.h"
1415
#include "TraceLog.h"
1516
#include "console/Console.h"
@@ -58,7 +59,8 @@ constexpr Registration kRegistry[] = {
5859
{"node:module", BuiltinId::kNodeModule, nullptr},
5960
{"node:url", BuiltinId::kNodeUrl, nullptr},
6061
{"node:util", BuiltinId::kNodeUtil, nullptr},
61-
{"internal/dom-exception", BuiltinId::kDomException, nullptr, true},
62+
{"internal/dom-exception", BuiltinId::kDomException, serialization::DomExceptionBinding,
63+
true},
6264
{"internal/events", BuiltinId::kEvents, nullptr, true},
6365
};
6466

0 commit comments

Comments
 (0)