diff --git a/NativeScript/runtime/LazyGlobals.cpp b/NativeScript/runtime/LazyGlobals.cpp index 98cd3a1b..148e7ec2 100644 --- a/NativeScript/runtime/LazyGlobals.cpp +++ b/NativeScript/runtime/LazyGlobals.cpp @@ -3,6 +3,7 @@ #include "Base64.h" #include "BuiltinLoader.h" #include "Helpers.h" +#include "StructuredSerialization.h" #include "TextEncoding.h" using namespace v8; @@ -34,7 +35,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. {"CustomEvent", "CustomEvent", BuiltinExports}, diff --git a/NativeScript/runtime/NsBuiltinModules.cpp b/NativeScript/runtime/NsBuiltinModules.cpp index 7cd7458f..cbfc747e 100644 --- a/NativeScript/runtime/NsBuiltinModules.cpp +++ b/NativeScript/runtime/NsBuiltinModules.cpp @@ -8,6 +8,7 @@ #include "Helpers.h" #include "ModuleInternalCallbacks.h" #include "Runtime.h" +#include "StructuredSerialization.h" #include "TextEncoding.h" using namespace v8; @@ -47,7 +48,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/NativeScript/runtime/StructuredSerialization.cpp b/NativeScript/runtime/StructuredSerialization.cpp index ae62cd93..c016a10d 100644 --- a/NativeScript/runtime/StructuredSerialization.cpp +++ b/NativeScript/runtime/StructuredSerialization.cpp @@ -1,6 +1,7 @@ #include "StructuredSerialization.h" #include "BuiltinLoader.h" +#include "Caches.h" #include "Helpers.h" #include "NativeScriptException.h" @@ -9,6 +10,82 @@ 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. `anyInstances` flips when +// the first instance is branded and gates HasCustomHostObject: claiming host +// objects makes V8 consult IsHostObject for every plain JS object in a +// serialized graph (~25ns each, ~+12% on an object-heavy clone), and an +// isolate that never constructed a DOMException cannot be holding one, so it +// keeps serializing on the exact pre-claim path. Every instance passes +// through markCloneable — deserialization rebuilds via the constructor — so +// the flag cannot miss one. +struct DomExceptionBrandState { + Persistent brand; + bool anyInstances = false; +}; + +// Empty once teardown has begun — callers bail to their fallback. +Local DomExceptionBrand(Isolate* isolate) { + auto* state = Caches::StateFor(isolate); + if (state == nullptr) { + return Local(); + } + if (state->brand.IsEmpty()) { + state->brand.Reset( + isolate, Private::New(isolate, tns::ToV8String( + isolate, "domExceptionCloneable"))); + } + return state->brand.Get(isolate); +} + +bool AnyDomExceptionInstances(Isolate* isolate) { + auto* state = Caches::StateFor(isolate); + return state != nullptr && state->anyInstances; +} + +void MarkCloneableCallback(const FunctionCallbackInfo& info) { + Isolate* isolate = info.GetIsolate(); + if (info.Length() < 1 || !info[0]->IsObject()) { + return; + } + Local brand = DomExceptionBrand(isolate); + if (brand.IsEmpty()) { + return; + } + if (info[0] + .As() + ->SetPrivate(isolate->GetCurrentContext(), brand, v8::True(isolate)) + .FromMaybe(false)) { + Caches::StateFor(isolate)->anyInstances = true; + } +} + +} // 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, tns::ToV8String(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) { // The spec's DataCloneError is a DOMException; build it through the // builtin's exports cache so native and JS throw sites produce the same @@ -21,8 +98,7 @@ void ThrowDataCloneError(Isolate* isolate, const std::string& message) { Local context = isolate->GetCurrentContext(); Local exports; Local ctor; - if (BuiltinLoader::GetExports(context, BuiltinId::kDomException, nullptr) - .ToLocal(&exports) && + if (GetDomExceptionExports(context).ToLocal(&exports) && exports->Get(context, tns::ToV8String(isolate, "DOMException")) .ToLocal(&ctor) && ctor->IsFunction()) { @@ -46,24 +122,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) + SerializerDelegate( + Isolate* isolate, HostObjectPolicy hostObjectPolicy, + std::vector>* sharedBuffers, + std::vector* domExceptions) : isolate_(isolate), hostObjectPolicy_(hostObjectPolicy), - sharedBuffers_(sharedBuffers) {} + sharedBuffers_(sharedBuffers), + domExceptions_(domExceptions) {} + + void SetSerializer(ValueSerializer* serializer) { serializer_ = serializer; } void ThrowDataCloneError(Local message) override { serialization::ThrowDataCloneError(isolate_, tns::ToString(isolate_, message)); } + // With this returning true, V8 asks IsHostObject about every plain JS + // object in the graph — the cost of claiming a plain-JS class as a host + // object is one private-symbol lookup per object (Node pays the same for + // its JSTransferable protocol). Claimed only once this isolate has actually + // constructed a DOMException; until then serialization runs the pre-claim + // path untouched. V8 samples this once per ValueSerializer. Accepted edge: + // a getter invoked during this very clone could construct the isolate's + // FIRST DOMException and return it into the graph after a false sample — + // that one instance degrades to a plain object (the pre-feature behavior) + // instead of cloning; every later serialization sees the flag. + bool HasCustomHostObject(Isolate* isolate) override { + return AnyDomExceptionInstances(isolate); + } + + Maybe IsHostObject(Isolate* isolate, Local object) override { + // Only branded DOMException instances are claimed; native-backed wrappers + // keep reaching WriteHostObject through V8's embedder-field detection. + Local brand = DomExceptionBrand(isolate); + if (brand.IsEmpty()) { + return Just(false); + } + return object->HasPrivate(isolate->GetCurrentContext(), brand); + } + 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. + Local brand = DomExceptionBrand(isolate); + bool isDomException = false; + if (!brand.IsEmpty() && + !object->HasPrivate(isolate->GetCurrentContext(), brand) + .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 = tns::ToString(isolate, object->GetConstructorName()); @@ -98,21 +223,78 @@ 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, tns::ToV8String(isolate, "name")) + .ToLocal(&name) || + !object->Get(context, tns::ToV8String(isolate, "message")) + .ToLocal(&message) || + !object->Get(context, tns::ToV8String(isolate, "stack")) + .ToLocal(&stack)) { + return Nothing(); + } + SerializedValue::DomExceptionPayload payload; + payload.name = tns::ToString(isolate, name); + payload.message = tns::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 = tns::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_; + 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( @@ -125,6 +307,8 @@ class DeserializerDelegate : public ValueDeserializer::Delegate { private: const std::vector>* sharedBuffers_; + const std::vector>* domExceptions_; + ValueDeserializer* deserializer_ = nullptr; }; // Validates the transfer list and collects it in registration order. The @@ -193,8 +377,10 @@ 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]); } @@ -243,9 +429,47 @@ 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, tns::ToV8String(isolate, "DOMException")) + .ToLocal(&ctor) || + !ctor->IsFunction()) { + return MaybeLocal(); + } + Local stackKey = tns::ToV8String(isolate, "stack"); + for (const DomExceptionPayload& payload : domExceptions_) { + Local args[] = {tns::ToV8String(isolate, payload.message), + tns::ToV8String(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, tns::ToV8String(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/NativeScript/runtime/StructuredSerialization.h b/NativeScript/runtime/StructuredSerialization.h index 156db4b7..ac176fdf 100644 --- a/NativeScript/runtime/StructuredSerialization.h +++ b/NativeScript/runtime/StructuredSerialization.h @@ -31,6 +31,20 @@ enum class HostObjectPolicy { // carrying that name when the dom-exception builtin cannot run. 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 is // read back on a different isolate than it was written on, while @@ -58,6 +72,18 @@ 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); } @@ -72,6 +98,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/NativeScript/runtime/js/dom-exception.js b/NativeScript/runtime/js/dom-exception.js index e8821d6f..64eb2575 100644 --- a/NativeScript/runtime/js/dom-exception.js +++ b/NativeScript/runtime/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() { diff --git a/TestRunner/app/shared b/TestRunner/app/shared index 9cc46c06..67da5fc0 160000 --- a/TestRunner/app/shared +++ b/TestRunner/app/shared @@ -1 +1 @@ -Subproject commit 9cc46c06bc918d849a54d089842f5a42ebfbb6e6 +Subproject commit 67da5fc0f692aafc6342c6e87b05ced3ee770c09 diff --git a/TestRunner/app/tests/RuntimeImplementedAPIs.js b/TestRunner/app/tests/RuntimeImplementedAPIs.js index 74b31290..946f799d 100644 --- a/TestRunner/app/tests/RuntimeImplementedAPIs.js +++ b/TestRunner/app/tests/RuntimeImplementedAPIs.js @@ -81,6 +81,12 @@ describe("DOMException canary", () => { it("is not reachable as a module from app code", () => { expect(() => require("internal/dom-exception")).toThrow(); }); + + it("serializes through structuredClone on this runtime", () => { + const clone = structuredClone(new DOMException("x", "AbortError")); + expect(clone instanceof DOMException).toBe(true); + expect(clone.name).toBe("AbortError"); + }); }); describe("CustomEvent canary", () => { diff --git a/docs/structured-clone.md b/docs/structured-clone.md index 2dda58e3..cd20129f 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.