From 84fc2b6c2bf1a3f26d32c96317e17aeb24fb56fc Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Fri, 11 Sep 2026 17:49:48 -0300 Subject: [PATCH] feat: serialize DOMException per Web IDL [Serializable] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/structured-clone.md | 2 +- test-app/app/src/main/assets/app/shared | 2 +- .../app/tests/domExceptionFirstCloneWorker.js | 14 + .../app/tests/testRuntimeImplementedAPIs.js | 41 +++ test-app/runtime/src/main/cpp/LazyGlobals.cpp | 3 +- .../runtime/src/main/cpp/NsBuiltinModules.cpp | 4 +- .../src/main/cpp/StructuredSerialization.cpp | 253 ++++++++++++++++-- .../src/main/cpp/StructuredSerialization.h | 34 +++ .../runtime/src/main/cpp/WorkerWrapper.cpp | 25 +- .../runtime/src/main/cpp/js/dom-exception.js | 12 +- 10 files changed, 361 insertions(+), 29 deletions(-) create mode 100644 test-app/app/src/main/assets/app/tests/domExceptionFirstCloneWorker.js diff --git a/docs/structured-clone.md b/docs/structured-clone.md index d0a0fbb84..80dd045e6 100644 --- a/docs/structured-clone.md +++ b/docs/structured-clone.md @@ -18,7 +18,7 @@ buffer.byteLength; // 0 — the memory now belongs to `moved` - `options` may be `undefined` or `null` (both mean "no transfer"); anything else must be an object, or a `TypeError` is thrown. - `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`. -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`. +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. 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. diff --git a/test-app/app/src/main/assets/app/shared b/test-app/app/src/main/assets/app/shared index 9cc46c06b..aa7f8cfcc 160000 --- a/test-app/app/src/main/assets/app/shared +++ b/test-app/app/src/main/assets/app/shared @@ -1 +1 @@ -Subproject commit 9cc46c06bc918d849a54d089842f5a42ebfbb6e6 +Subproject commit aa7f8cfcc29ac2945e190fafe1259827ab6ab2e6 diff --git a/test-app/app/src/main/assets/app/tests/domExceptionFirstCloneWorker.js b/test-app/app/src/main/assets/app/tests/domExceptionFirstCloneWorker.js new file mode 100644 index 000000000..256105beb --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/domExceptionFirstCloneWorker.js @@ -0,0 +1,14 @@ +// A fresh isolate: the DOMException constructed inside the getter below is +// the first one this isolate has ever seen, and it is born while the clone +// that carries it is already being written. +var graph = { + get inner() { + return new DOMException("first in this isolate", "AbortError"); + }, +}; +var clone = structuredClone(graph); +postMessage({ + isDomException: clone.inner instanceof DOMException, + name: clone.inner.name, + message: clone.inner.message, +}); diff --git a/test-app/app/src/main/assets/app/tests/testRuntimeImplementedAPIs.js b/test-app/app/src/main/assets/app/tests/testRuntimeImplementedAPIs.js index b93266f48..08b07c8f0 100644 --- a/test-app/app/src/main/assets/app/tests/testRuntimeImplementedAPIs.js +++ b/test-app/app/src/main/assets/app/tests/testRuntimeImplementedAPIs.js @@ -62,6 +62,47 @@ describe("DOMException canary", function () { it("is not reachable as a module from app code", function () { expect(function () { require("internal/dom-exception"); }).toThrow(); }); + + it("serializes through structuredClone on this runtime", function () { + var clone = structuredClone(new DOMException("x", "AbortError")); + expect(clone instanceof DOMException).toBe(true); + expect(clone.name).toBe("AbortError"); + }); + + it("clones the isolate's first DOMException even when a getter creates it mid-clone", function (done) { + var worker = new Worker("./domExceptionFirstCloneWorker.js"); + worker.onmessage = function (event) { + expect(event.data.isDomException).toBe(true); + expect(event.data.name).toBe("AbortError"); + expect(event.data.message).toBe("first in this isolate"); + worker.terminate(); + done(); + }; + worker.onerror = function (event) { + fail("worker error: " + event.message); + worker.terminate(); + done(); + return true; + }; + }); + + // Once an isolate holds a DOMException the serializer claims host objects + // itself, and V8 then stops detecting native wrappers on its own. These are + // the shapes that would silently clone as {} if the claim missed them. + it("still rejects native wrappers once a DOMException exists", function () { + new DOMException("x", "AbortError"); + var wrappers = [new java.lang.Object(), new URL("https://example.com/")]; + for (var i = 0; i < wrappers.length; i++) { + var error; + try { + structuredClone(wrappers[i]); + } catch (e) { + error = e; + } + expect(error instanceof DOMException).toBe(true); + expect(error.name).toBe("DataCloneError"); + } + }); }); describe("CustomEvent canary", function () { diff --git a/test-app/runtime/src/main/cpp/LazyGlobals.cpp b/test-app/runtime/src/main/cpp/LazyGlobals.cpp index c812eebd4..9b174c3ba 100644 --- a/test-app/runtime/src/main/cpp/LazyGlobals.cpp +++ b/test-app/runtime/src/main/cpp/LazyGlobals.cpp @@ -3,6 +3,7 @@ #include "ArgConverter.h" #include "Base64.h" #include "BuiltinLoader.h" +#include "StructuredSerialization.h" #include "TextEncoding.h" using namespace v8; @@ -38,7 +39,7 @@ constexpr LazyGlobalEntry kLazyGlobals[] = { {"TextDecoder", "TextDecoder", TextEncoding::GetExports}, {"atob", "atob", Base64::GetExports}, {"btoa", "btoa", Base64::GetExports}, - {"DOMException", "DOMException", BuiltinExports}, + {"DOMException", "DOMException", serialization::GetDomExceptionExports}, // events.js is an eager builtin (Events::Init), so this row never runs // a file: the read hits the exports cache and only the placement is // lazy. diff --git a/test-app/runtime/src/main/cpp/NsBuiltinModules.cpp b/test-app/runtime/src/main/cpp/NsBuiltinModules.cpp index 467917d93..1e1d2bba3 100644 --- a/test-app/runtime/src/main/cpp/NsBuiltinModules.cpp +++ b/test-app/runtime/src/main/cpp/NsBuiltinModules.cpp @@ -10,6 +10,7 @@ #include "NativeScriptAssert.h" #include "Runtime.h" #include "RuntimeState.h" +#include "StructuredSerialization.h" #include "TextEncoding.h" #include "TraceLog.h" #include "console/Console.h" @@ -58,7 +59,8 @@ constexpr Registration kRegistry[] = { {"node:module", BuiltinId::kNodeModule, nullptr}, {"node:url", BuiltinId::kNodeUrl, nullptr}, {"node:util", BuiltinId::kNodeUtil, nullptr}, - {"internal/dom-exception", BuiltinId::kDomException, nullptr, true}, + {"internal/dom-exception", BuiltinId::kDomException, serialization::DomExceptionBinding, + true}, {"internal/events", BuiltinId::kEvents, nullptr, true}, }; diff --git a/test-app/runtime/src/main/cpp/StructuredSerialization.cpp b/test-app/runtime/src/main/cpp/StructuredSerialization.cpp index 3da795333..75a5c2877 100644 --- a/test-app/runtime/src/main/cpp/StructuredSerialization.cpp +++ b/test-app/runtime/src/main/cpp/StructuredSerialization.cpp @@ -4,12 +4,77 @@ #include "ArgConverter.h" #include "BuiltinLoader.h" +#include "RuntimeState.h" using namespace v8; namespace tns { namespace serialization { +namespace { + +/* + * The private symbol markCloneable stamps on every DOMException instance. + * Private, so app code can neither forge the brand onto an impostor nor strip + * it; per isolate because a worker's instances are branded and checked on its + * own isolate, and only bytes cross between them. Every instance passes + * through markCloneable — deserialization rebuilds via the constructor. + */ +struct DomExceptionBrandState { + Persistent brand; +}; + +Local BrandOf(Isolate* isolate, DomExceptionBrandState* state) { + if (state->brand.IsEmpty()) { + state->brand.Reset(isolate, + Private::New(isolate, ArgConverter::ConvertToV8String( + isolate, "domExceptionCloneable"))); + } + return state->brand.Get(isolate); +} + +// Empty once teardown has begun — callers bail to their fallback. +Local DomExceptionBrand(Isolate* isolate) { + auto* state = RuntimeState::For(isolate); + if (state == nullptr) { + return Local(); + } + return BrandOf(isolate, state); +} + +void MarkCloneableCallback(const FunctionCallbackInfo& info) { + Isolate* isolate = info.GetIsolate(); + if (info.Length() < 1 || !info[0]->IsObject()) { + return; + } + auto* state = RuntimeState::For(isolate); + if (state == nullptr) { + return; + } + Local brand = BrandOf(isolate, state); + (void)info[0].As()->SetPrivate(isolate->GetCurrentContext(), brand, + v8::True(isolate)); +} + +} // namespace + +MaybeLocal DomExceptionBinding(Local context) { + Isolate* isolate = v8::Isolate::GetCurrent(); + Local binding = Object::New(isolate); + Local markCloneable; + if (!v8::Function::New(context, MarkCloneableCallback).ToLocal(&markCloneable) || + !binding->Set(context, ArgConverter::ConvertToV8String(isolate, "markCloneable"), + markCloneable) + .FromMaybe(false)) { + return MaybeLocal(); + } + return binding; +} + +MaybeLocal GetDomExceptionExports(Local context) { + return BuiltinLoader::GetExports(context, BuiltinId::kDomException, DomExceptionBinding); +} + void ThrowDataCloneError(Isolate* isolate, const std::string& message) { Local context = isolate->GetCurrentContext(); @@ -25,8 +90,7 @@ void ThrowDataCloneError(Isolate* isolate, const std::string& message) { TryCatch tc(isolate); Local exports; Local ctor; - if (BuiltinLoader::GetExports(context, BuiltinId::kDomException, nullptr) - .ToLocal(&exports) && + if (GetDomExceptionExports(context).ToLocal(&exports) && exports->Get(context, ArgConverter::ConvertToV8String(isolate, "DOMException")) .ToLocal(&ctor) && ctor->IsFunction()) { @@ -56,24 +120,73 @@ void ThrowDataCloneError(Isolate* isolate, const std::string& message) { namespace { +/* + * Every host object's payload starts with one of these, so the reader can + * dispatch. kHostObjectDegraded carries nothing further; kHostObjectDomException + * carries a uint32 index into the SerializedValue's out-of-band payload list. + * The bytes never outlive the process (structuredClone round-trips in one + * isolate, worker messages cross isolates in the same binary), so the format can + * evolve freely with this file. + */ +constexpr uint32_t kHostObjectDegraded = 0; +constexpr uint32_t kHostObjectDomException = 1; + class SerializerDelegate : public ValueSerializer::Delegate { public: SerializerDelegate(Isolate* isolate, HostObjectPolicy hostObjectPolicy, - std::vector>* sharedBuffers) + std::vector>* sharedBuffers, + std::vector* domExceptions) : isolate_(isolate), hostObjectPolicy_(hostObjectPolicy), - sharedBuffers_(sharedBuffers) {} + sharedBuffers_(sharedBuffers), + domExceptions_(domExceptions), + domExceptionBrand_(DomExceptionBrand(isolate)) {} + + void SetSerializer(ValueSerializer* serializer) { serializer_ = serializer; } void ThrowDataCloneError(Local message) override { serialization::ThrowDataCloneError( isolate_, ArgConverter::ConvertToString(message)); } + /* + * Always claimed: V8 samples this once per ValueSerializer and never again, + * so a gate on "this isolate holds a DOMException" would miss the isolate's + * first instance when a getter constructs it during the very clone that + * carries it. The price is one IsHostObject call per plain JS object in a + * graph, the same Node pays for its JSTransferable protocol. + */ + bool HasCustomHostObject(Isolate* isolate) override { return true; } + + Maybe IsHostObject(Isolate* isolate, Local object) override { + // Claiming custom host objects REPLACES V8's own embedder-field + // detection rather than adding to it, so anything with a native half + // has to be claimed here too — otherwise a Java proxy would be written + // out as a plain object, silently losing the half that mattered. + if (object->InternalFieldCount() > 0) { + return Just(true); + } + if (domExceptionBrand_.IsEmpty()) { + return Just(false); + } + return object->HasPrivate(isolate->GetCurrentContext(), domExceptionBrand_); + } + Maybe WriteHostObject(Isolate* isolate, Local object) override { + // DOMException serializes under both policies: it is [Serializable] in + // the IDL, and it is a plain JS object with no native half to lose. + bool isDomException = false; + if (!domExceptionBrand_.IsEmpty() && + !object->HasPrivate(isolate->GetCurrentContext(), domExceptionBrand_) + .To(&isDomException)) { + return Nothing(); + } + if (isDomException) { + return WriteDomException(isolate, object); + } if (hostObjectPolicy_ == HostObjectPolicy::kDegrade) { - // V8 has already written the kHostObject tag; writing no payload is - // what the zero-byte ReadHostObject below expects, and the value - // surfaces as an empty object. + // Tag only, no payload: the value surfaces as an empty object. + serializer_->WriteUint32(kHostObjectDegraded); return Just(true); } std::string name = @@ -109,21 +222,83 @@ class SerializerDelegate : public ValueSerializer::Delegate { } private: + /* + * Web IDL's DOMException serialization steps (name and message), plus the + * stack, matching Node. The payload travels out-of-band and only an index + * enters the stream: the receiving side must construct instances before + * ReadValue runs, because V8 forbids JS execution during deserialization. + */ + Maybe WriteDomException(Isolate* isolate, Local object) { + Local context = isolate->GetCurrentContext(); + Local name, message, stack; + if (!object->Get(context, ArgConverter::ConvertToV8String(isolate, "name")) + .ToLocal(&name) || + !object->Get(context, ArgConverter::ConvertToV8String(isolate, "message")) + .ToLocal(&message) || + !object->Get(context, ArgConverter::ConvertToV8String(isolate, "stack")) + .ToLocal(&stack)) { + return Nothing(); + } + SerializedValue::DomExceptionPayload payload; + payload.name = ArgConverter::ToString(isolate, name); + payload.message = ArgConverter::ToString(isolate, message); + // The stack can legitimately be absent or tampered into a non-string; + // carry it only when it is the string captureStackTrace left. + payload.hasStack = stack->IsString(); + if (payload.hasStack) { + payload.stack = ArgConverter::ToString(isolate, stack); + } + serializer_->WriteUint32(kHostObjectDomException); + serializer_->WriteUint32(static_cast(domExceptions_->size())); + domExceptions_->push_back(std::move(payload)); + return Just(true); + } + Isolate* isolate_; HostObjectPolicy hostObjectPolicy_; std::vector>* sharedBuffers_; + std::vector* domExceptions_; + // Resolved once per serializer: V8 asks about every object in the graph, + // and each lookup would otherwise re-resolve the state slot and push a + // fresh handle into the caller's scope. + Local domExceptionBrand_; + ValueSerializer* serializer_ = nullptr; }; class DeserializerDelegate : public ValueDeserializer::Delegate { public: - explicit DeserializerDelegate( - const std::vector>* sharedBuffers) - : sharedBuffers_(sharedBuffers) {} + DeserializerDelegate(const std::vector>* sharedBuffers, + const std::vector>* domExceptions) + : sharedBuffers_(sharedBuffers), domExceptions_(domExceptions) {} - // Counterpart of the kDegrade branch: consumes no bytes, so the stream - // stays balanced. Unreachable for a value written under kReject. + void SetDeserializer(ValueDeserializer* deserializer) { + deserializer_ = deserializer; + } + + // No JS may run in here (V8 forbids it during a read); DOMException + // instances were constructed by Deserialize before ReadValue started, and + // this only hands them out. MaybeLocal ReadHostObject(Isolate* isolate) override { - return Object::New(isolate); + uint32_t tag; + if (!deserializer_->ReadUint32(&tag)) { + return MaybeLocal(); + } + switch (tag) { + case kHostObjectDegraded: + // Counterpart of the kDegrade branch: tag only, so the value + // arrives as an empty object. Unreachable for a value written + // under kReject. + return Object::New(isolate); + case kHostObjectDomException: { + uint32_t index; + if (!deserializer_->ReadUint32(&index) || index >= domExceptions_->size()) { + return MaybeLocal(); + } + return (*domExceptions_)[index]; + } + default: + return MaybeLocal(); + } } MaybeLocal GetSharedArrayBufferFromId( @@ -136,6 +311,8 @@ class DeserializerDelegate : public ValueDeserializer::Delegate { private: const std::vector>* sharedBuffers_; + const std::vector>* domExceptions_; + ValueDeserializer* deserializer_ = nullptr; }; /* @@ -207,8 +384,9 @@ Maybe SerializedValue::Serialize(Isolate* isolate, Local context, return Nothing(); } - SerializerDelegate delegate(isolate, hostObjectPolicy, &sharedBuffers_); + SerializerDelegate delegate(isolate, hostObjectPolicy, &sharedBuffers_, &domExceptions_); ValueSerializer serializer(isolate, &delegate); + delegate.SetSerializer(&serializer); for (size_t i = 0; i < transfers.size(); i++) { serializer.TransferArrayBuffer(static_cast(i), transfers[i]); } @@ -258,9 +436,54 @@ MaybeLocal SerializedValue::Deserialize(Isolate* isolate, sharedBuffers.push_back(SharedArrayBuffer::New(isolate, backingStore)); } - DeserializerDelegate delegate(&sharedBuffers); + /* + * Construct every DOMException the payload names before the read begins: + * JS is allowed here and forbidden inside ReadHostObject. Construction goes + * through the real constructor — on a worker isolate that never touched + * DOMException this runs the builtin on demand — so each instance is + * branded again and re-serializes on the next hop. + */ + std::vector> domExceptions; + if (!domExceptions_.empty()) { + Local exports; + Local ctor; + if (!GetDomExceptionExports(context).ToLocal(&exports) || + !exports->Get(context, ArgConverter::ConvertToV8String(isolate, "DOMException")) + .ToLocal(&ctor) || + !ctor->IsFunction()) { + // Only reached with nothing pending (a builtin that cannot load + // during teardown); the caller must see a failure, not an undefined + // result. + if (!isolate->HasPendingException()) { + ThrowDataCloneError(isolate, + "DOMException could not be rebuilt on this isolate."); + } + return MaybeLocal(); + } + Local stackKey = ArgConverter::ConvertToV8String(isolate, "stack"); + for (const DomExceptionPayload& payload : domExceptions_) { + Local args[] = {ArgConverter::ConvertToV8String(isolate, payload.message), + ArgConverter::ConvertToV8String(isolate, payload.name)}; + Local exception; + if (!ctor.As()->NewInstance(context, 2, args).ToLocal(&exception)) { + return MaybeLocal(); + } + // The sender's stack replaces the one captured just now for the + // receiving side's constructor frame, matching Node. + if (payload.hasStack && + !exception->Set(context, stackKey, + ArgConverter::ConvertToV8String(isolate, payload.stack)) + .FromMaybe(false)) { + return MaybeLocal(); + } + domExceptions.push_back(exception); + } + } + + DeserializerDelegate delegate(&sharedBuffers, &domExceptions); ValueDeserializer deserializer(isolate, buffer_.get(), bufferSize_, &delegate); + delegate.SetDeserializer(&deserializer); for (size_t i = 0; i < transferredBuffers_.size(); i++) { deserializer.TransferArrayBuffer( diff --git a/test-app/runtime/src/main/cpp/StructuredSerialization.h b/test-app/runtime/src/main/cpp/StructuredSerialization.h index 43c1c0fbb..ec7f4fb2c 100644 --- a/test-app/runtime/src/main/cpp/StructuredSerialization.h +++ b/test-app/runtime/src/main/cpp/StructuredSerialization.h @@ -34,6 +34,23 @@ enum class HostObjectPolicy { */ void ThrowDataCloneError(v8::Isolate* isolate, const std::string& message); +/* + * The dom-exception builtin's native half: `markCloneable`, which stamps a + * per-isolate private brand on every instance the constructor makes. The brand + * is what the serialization delegates answer IsHostObject from, so DOMException + * travels through structuredClone and worker postMessage (Web IDL + * [Serializable]). Lives here, next to those delegates. + */ +v8::MaybeLocal DomExceptionBinding(v8::Local context); + +/* + * The dom-exception builtin's exports with its binding attached. GetExports + * consults the factory only on the run that populates the cache, so every call + * site for this builtin must go through here — a site passing a different + * factory would win or lose by init order. + */ +v8::MaybeLocal GetDomExceptionExports(v8::Local context); + /* * A value serialized out of one isolate, plus the memory that travels with it. * Serializing and deserializing are separate halves because a worker message @@ -68,6 +85,20 @@ class SerializedValue { v8::MaybeLocal Deserialize(v8::Isolate* isolate, v8::Local context); + /* + * Web IDL's DOMException serialization steps (name, message) plus the + * stack, matching Node. Kept out-of-band because V8 forbids JS while a + * value is being read: Deserialize constructs every instance up front and + * ReadHostObject only hands them out by index (Node's host_objects_ + * design). + */ + struct DomExceptionPayload { + std::string name; + std::string message; + std::string stack; + bool hasStack = false; + }; + private: struct FreeDeleter { void operator()(void* pointer) const { std::free(pointer); } @@ -82,6 +113,9 @@ class SerializedValue { std::vector> transferredBuffers_; // Backing stores shared with — not moved from — the sending isolate. std::vector> sharedBuffers_; + // One entry per distinct DOMException in the graph, in write order (a + // repeated reference is an object id in the stream, not a second entry). + std::vector domExceptions_; }; } // namespace serialization diff --git a/test-app/runtime/src/main/cpp/WorkerWrapper.cpp b/test-app/runtime/src/main/cpp/WorkerWrapper.cpp index 04abc68fd..f53648480 100644 --- a/test-app/runtime/src/main/cpp/WorkerWrapper.cpp +++ b/test-app/runtime/src/main/cpp/WorkerWrapper.cpp @@ -343,13 +343,26 @@ void WorkerWrapper::FireMessageOnParentWorkerObject(int workerId, } Local data; - if (message->Deserialize(isolate, context).ToLocal(&data)) { - auto event = Object::New(isolate); - event->DefineOwnProperty(context, ArgConverter::ConvertToV8String(isolate, "data"), - data, PropertyAttribute::ReadOnly); - Local args[] = {event}; - callback.As()->Call(context, Undefined(isolate), 1, args); + { + // Reading runs JS (a DOMException is rebuilt through its + // constructor), so a failure here must not stay pending on the + // isolate past this callout. + TryCatch tc(isolate); + if (!message->Deserialize(isolate, context).ToLocal(&data)) { + if (!tc.HasTerminated() && tc.HasCaught()) { + DEBUG_WRITE_FORCE("MAIN: worker(id=%d) message could not be read: %s", + workerId, + ArgConverter::ToString(isolate, tc.Exception()).c_str()); + } + return; + } } + + auto event = Object::New(isolate); + event->DefineOwnProperty(context, ArgConverter::ConvertToV8String(isolate, "data"), data, + PropertyAttribute::ReadOnly); + Local args[] = {event}; + callback.As()->Call(context, Undefined(isolate), 1, args); } catch (NativeScriptException& ex) { ex.ReThrowToV8(); } diff --git a/test-app/runtime/src/main/cpp/js/dom-exception.js b/test-app/runtime/src/main/cpp/js/dom-exception.js index e8821d6fa..64eb25754 100644 --- a/test-app/runtime/src/main/cpp/js/dom-exception.js +++ b/test-app/runtime/src/main/cpp/js/dom-exception.js @@ -9,10 +9,11 @@ // constructor through require("internal/dom-exception") at throw time, so // this file never runs in an app that never touches a DOMException. // -// Not implemented: the spec's [Serializable] slot. structuredClone and worker -// postMessage go through v8::ValueSerializer, which has no hook for a plain -// JS class, so a DOMException inside a cloned graph degrades the same way any -// custom Error subclass does. +// [Serializable]: the constructor stamps every instance with a native private +// brand (binding.markCloneable), which the serialization delegates in +// StructuredSerialization.cpp claim through V8's IsHostObject hook — name, +// message and stack travel across structuredClone and worker postMessage, +// Node's JSTransferable approach reduced to the one class. const { ErrorCaptureStackTrace, ErrorPrototype, @@ -21,6 +22,8 @@ const { SymbolToStringTag, } = primordials; +const { markCloneable } = binding; + // Web IDL §4.3.4: the closed table of names with a legacy code. Any name // outside it — including every post-table spec name — has code 0. const nameToCode = { @@ -62,6 +65,7 @@ class DOMException { this.#message = `${message}`; this.#name = `${name}`; ErrorCaptureStackTrace(this, DOMException); + markCloneable(this); } get name() {