From 985606e42e1279b271245f21851a48a01f037cc5 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 10 Aug 2026 19:30:20 -0300 Subject: [PATCH 1/7] feat: structuredClone global (HTML structured clone, ArrayBuffer transfer) Adds the WHATWG structuredClone(value, { transfer }) global, following the post-context builtin architecture: all spec-level argument coercion lives in the portable internal/structured-clone.js, and the native side is one binding function so the Android runtime can reuse the JS unchanged. StructuredClone.cpp round-trips the value through v8::ValueSerializer and v8::ValueDeserializer inside the one isolate, which is what StructuredDeserialize(StructuredSerializeWithTransfer(...)) reduces to when there is no second agent. Transferred buffers are validated up front (ArrayBuffer, not detached, detachable, no duplicates) so a rejected call never leaves a half-transferred graph, registered with the serializer before the write, then detached and re-wrapped around their original backing store for the deserializer. There is no DOMException in this runtime, so clone failures throw an Error whose name is "DataCloneError", matching how native exceptions are surfaced. GetSharedArrayBufferId and AdoptSharedValueConveyor are overridden purely to preserve that name: with a delegate installed V8's defaults throw a plain Error straight onto the isolate instead of routing through ThrowDataCloneError. Host objects are rejected. A native/interop wrapper serialized without its native counterpart would deserialize into a wrapper around nothing, so WriteHostObject reports it as uncloneable. The transfer list is converted per WebIDL sequence semantics, so any iterable works and a string primitive does not. ArrayBuffer membership is brand-checked through the captured byteLength getter, which also excludes SharedArrayBuffer -- correctly, since it is not transferable. Adds the SymbolIterator primordial and documents the surface, the transfer semantics and the deviations in docs/structured-clone.md. --- NativeScript/runtime/Runtime.mm | 2 + NativeScript/runtime/StructuredClone.cpp | 203 ++++++++++++++++++++ NativeScript/runtime/StructuredClone.h | 18 ++ NativeScript/runtime/js/primordials.js | 1 + NativeScript/runtime/js/structured-clone.js | 103 ++++++++++ TestRunner/app/shared | 2 +- TestRunner/app/tests/index.js | 3 + docs/README.md | 2 + docs/structured-clone.md | 38 ++++ tools/js2c-inputs.xcfilelist | 1 + v8ios.xcodeproj/project.pbxproj | 6 + 11 files changed, 378 insertions(+), 1 deletion(-) create mode 100644 NativeScript/runtime/StructuredClone.cpp create mode 100644 NativeScript/runtime/StructuredClone.h create mode 100644 NativeScript/runtime/js/structured-clone.js create mode 100644 docs/structured-clone.md diff --git a/NativeScript/runtime/Runtime.mm b/NativeScript/runtime/Runtime.mm index c7fdead9..76af2cdb 100644 --- a/NativeScript/runtime/Runtime.mm +++ b/NativeScript/runtime/Runtime.mm @@ -17,6 +17,7 @@ #include "RuntimeConfig.h" #include "SimpleAllocator.h" #include "SpinLock.h" +#include "StructuredClone.h" #include "TSHelpers.h" #include "WeakRef.h" #include "Worker.h" @@ -362,6 +363,7 @@ void DisposeIsolateWhenPossible(Isolate* isolate) { PromiseProxy::Init(context); Events::Init(context); ErrorEvents::Init(context); + StructuredClone::Init(context); Console::Init(context); WeakRef::Init(context); diff --git a/NativeScript/runtime/StructuredClone.cpp b/NativeScript/runtime/StructuredClone.cpp new file mode 100644 index 00000000..657e6561 --- /dev/null +++ b/NativeScript/runtime/StructuredClone.cpp @@ -0,0 +1,203 @@ +#include "StructuredClone.h" + +#include +#include +#include +#include + +#include "BuiltinLoader.h" +#include "Helpers.h" + +using namespace v8; + +namespace tns { + +namespace { + +// There is no DOMException in this runtime, so a clone failure surfaces as an +// Error carrying the spec's exception name — the same shape the +// native-exception bridge uses (docs/error-handling.md). Failing to attach the +// name is ignored: throwing something is more useful than throwing nothing. +void ThrowDataCloneError(Isolate* isolate, Local message) { + Local error = Exception::Error(message); + Local context = isolate->GetCurrentContext(); + bool named = error.As() + ->Set(context, tns::ToV8String(isolate, "name"), + tns::ToV8String(isolate, "DataCloneError")) + .FromMaybe(false); + (void)named; + isolate->ThrowException(error); +} + +void ThrowDataCloneError(Isolate* isolate, const std::string& message) { + ThrowDataCloneError(isolate, tns::ToV8String(isolate, message)); +} + +class CloneDelegate : public ValueSerializer::Delegate { + public: + explicit CloneDelegate(Isolate* isolate) : isolate_(isolate) {} + + void ThrowDataCloneError(Local message) override { + tns::ThrowDataCloneError(isolate_, message); + } + + // Objects backed by native state — ObjC wrappers, interop pointers, function + // references — reach the serializer as host objects. They have no + // serialization form, and cloning the JS shell without its native counterpart + // would hand back a wrapper pointing at nothing. + Maybe WriteHostObject(Isolate* isolate, Local object) override { + std::string name = tns::ToString(isolate, object->GetConstructorName()); + ThrowDataCloneError( + tns::ToV8String(isolate, "#<" + name + "> could not be cloned.")); + return Nothing(); + } + + // Shared memory has no owner to hand it to in a single-agent clone. Both + // hooks below are overridden only to keep the DataCloneError name: V8's + // defaults throw a plain Error directly on the isolate instead of going + // through ThrowDataCloneError. + Maybe GetSharedArrayBufferId( + Isolate* isolate, Local sharedArrayBuffer) override { + ThrowDataCloneError( + tns::ToV8String(isolate, "# could not be cloned.")); + return Nothing(); + } + + bool AdoptSharedValueConveyor(Isolate* isolate, + SharedValueConveyor&& conveyor) override { + ThrowDataCloneError( + tns::ToV8String(isolate, "shared value could not be cloned.")); + return false; + } + + private: + Isolate* isolate_; +}; + +// Validates the transfer list the builtin materialized and collects it in +// registration order. Returns false with an exception pending. +bool CollectTransferList(Isolate* isolate, Local context, + Local transferValue, + std::vector>& transfers) { + if (!transferValue->IsArray()) { + return true; + } + + Local list = transferValue.As(); + uint32_t length = list->Length(); + for (uint32_t i = 0; i < length; i++) { + Local item; + if (!list->Get(context, i).ToLocal(&item)) { + return false; + } + if (!item->IsArrayBuffer()) { + ThrowDataCloneError(isolate, + "structuredClone: value in transfer list is not " + "transferable"); + return false; + } + + Local buffer = item.As(); + for (const Local& existing : transfers) { + if (existing == buffer) { + ThrowDataCloneError(isolate, + "structuredClone: transfer list contains the same " + "ArrayBuffer twice"); + return false; + } + } + if (buffer->WasDetached() || !buffer->IsDetachable()) { + ThrowDataCloneError(isolate, + "structuredClone: an ArrayBuffer in the transfer " + "list is detached and cannot be transferred"); + return false; + } + + transfers.push_back(buffer); + } + return true; +} + +// binding.clone(value, transferArrayOrUndefined): serialize and deserialize in +// this one isolate, which is what the spec's StructuredDeserialize( +// StructuredSerializeWithTransfer(...)) amounts to when there is no second +// agent involved. +void CloneCallback(const FunctionCallbackInfo& info) { + Isolate* isolate = info.GetIsolate(); + Local context = isolate->GetCurrentContext(); + Local value = + info.Length() > 0 ? info[0] : v8::Undefined(isolate).As(); + + std::vector> transfers; + if (info.Length() > 1 && + !CollectTransferList(isolate, context, info[1], transfers)) { + return; + } + + CloneDelegate delegate(isolate); + ValueSerializer serializer(isolate, &delegate); + for (size_t i = 0; i < transfers.size(); i++) { + serializer.TransferArrayBuffer(static_cast(i), transfers[i]); + } + + serializer.WriteHeader(); + bool written = serializer.WriteValue(context, value).FromMaybe(false); + + // Release() hands over ownership of a buffer the default delegate grew with + // realloc(), so it is free()d rather than deleted — and it must be released + // even after a failed write, or the buffer leaks with the serializer. + std::pair data = serializer.Release(); + std::unique_ptr owned(data.first, std::free); + if (!written) { + return; + } + + ValueDeserializer deserializer(isolate, data.first, data.second); + + // Transferred memory changes hands here, after serialization succeeded: the + // backing store is claimed before the source is detached (detaching drops the + // buffer's own reference to it) and handed to a fresh ArrayBuffer under the + // same id the serializer wrote. + for (size_t i = 0; i < transfers.size(); i++) { + std::shared_ptr backingStore = + transfers[i]->GetBackingStore(); + if (transfers[i]->Detach(Local()).IsNothing()) { + return; + } + deserializer.TransferArrayBuffer(static_cast(i), + ArrayBuffer::New(isolate, backingStore)); + } + + if (deserializer.ReadHeader(context).IsNothing()) { + return; + } + + Local result; + if (!deserializer.ReadValue(context).ToLocal(&result)) { + return; + } + info.GetReturnValue().Set(result); +} + +} // namespace + +void StructuredClone::Init(Local context) { + Isolate* isolate = v8::Isolate::GetCurrent(); + + Local clone; + bool success = v8::Function::New(context, CloneCallback).ToLocal(&clone); + tns::Assert(success, isolate); + + Local binding = Object::New(isolate); + success = binding->Set(context, tns::ToV8String(isolate, "clone"), clone) + .FromMaybe(false); + tns::Assert(success, isolate); + + Local result; + success = + BuiltinLoader::RunBuiltin(context, BuiltinId::kStructuredClone, binding) + .ToLocal(&result); + tns::Assert(success, isolate); +} + +} // namespace tns diff --git a/NativeScript/runtime/StructuredClone.h b/NativeScript/runtime/StructuredClone.h new file mode 100644 index 00000000..071cfc46 --- /dev/null +++ b/NativeScript/runtime/StructuredClone.h @@ -0,0 +1,18 @@ +#ifndef StructuredClone_h +#define StructuredClone_h + +#include "Common.h" + +namespace tns { + +class StructuredClone { + public: + // Installs the structuredClone global (internal/structured-clone.js). The + // builtin owns the argument coercion and hands the native side a value plus + // an already-materialized array of ArrayBuffers to transfer. + static void Init(v8::Local context); +}; + +} // namespace tns + +#endif /* StructuredClone_h */ diff --git a/NativeScript/runtime/js/primordials.js b/NativeScript/runtime/js/primordials.js index 368d2a26..c1dcbcc7 100644 --- a/NativeScript/runtime/js/primordials.js +++ b/NativeScript/runtime/js/primordials.js @@ -29,6 +29,7 @@ const intrinsics = { String, TypeError, SymbolHasInstance: Symbol.hasInstance, + SymbolIterator: Symbol.iterator, // Namespaces / prototypes. ObjectPrototype: Object.prototype, diff --git a/NativeScript/runtime/js/structured-clone.js b/NativeScript/runtime/js/structured-clone.js new file mode 100644 index 00000000..3d7b43af --- /dev/null +++ b/NativeScript/runtime/js/structured-clone.js @@ -0,0 +1,103 @@ +"use strict"; + +// WHATWG structuredClone(value, { transfer }): the argument coercion and the +// WebIDL sequence handling for `transfer`; the clone itself is native +// (v8::ValueSerializer round-tripped in this isolate). +// +// Deviations from the HTML spec, both forced by the platform: +// - There is no DOMException here, so a clone failure throws an Error whose +// `name` is "DataCloneError" (same shape as the native-exception errors in +// docs/error-handling.md). `instanceof DOMException` checks cannot work. +// - Only ArrayBuffers are transferable. MessagePort, ImageBitmap and the +// native/interop wrapper objects have no serialization form in this runtime, +// so they are rejected rather than half-supported. + +const { clone } = binding; +const { + ArrayBufferPrototypeGetByteLength, + ArrayPrototypePush, + Error, + FunctionPrototypeCall, + SymbolIterator, + TypeError, +} = primordials; + +var g = globalThis; + +function dataCloneError(message) { + var e = new Error(message); + e.name = "DataCloneError"; + return e; +} + +// Brand check through the captured byteLength getter: it is the one thing only +// a real ArrayBuffer has, and it cannot be faked by a `Symbol.toStringTag` or a +// forged prototype. SharedArrayBuffer has its own getter and so fails here, +// which is what the spec wants — a SAB is not transferable. +function isArrayBuffer(value) { + if (value === null || typeof value !== "object") { + return false; + } + try { + ArrayBufferPrototypeGetByteLength(value); + return true; + } catch (notAnArrayBuffer) { + return false; + } +} + +// WebIDL `sequence` conversion: only an object with a callable +// @@iterator qualifies, which is why a string primitive is a TypeError even +// though strings are iterable. +function toTransferList(value) { + if (value === null || (typeof value !== "object" && typeof value !== "function")) { + throw new TypeError("structuredClone: transfer is not iterable"); + } + var method = value[SymbolIterator]; + if (typeof method !== "function") { + throw new TypeError("structuredClone: transfer is not iterable"); + } + + var iterator = FunctionPrototypeCall(method, value); + if (iterator === null || typeof iterator !== "object") { + throw new TypeError("structuredClone: transfer is not iterable"); + } + + var list = []; + for (;;) { + var step = iterator.next(); + if (step === null || typeof step !== "object") { + throw new TypeError("structuredClone: transfer iterator returned a non-object"); + } + if (step.done) { + break; + } + var item = step.value; + if (!isArrayBuffer(item)) { + throw dataCloneError("structuredClone: value in transfer list is not transferable"); + } + ArrayPrototypePush(list, item); + } + return list; +} + +// `options` is defaulted rather than merely optional so that the function's +// reported arity is 1, as the IDL requires. +g.structuredClone = function structuredClone(value, options = undefined) { + if (arguments.length < 1) { + throw new TypeError("structuredClone: 1 argument required, but only 0 present"); + } + + var transfer; + if (options !== undefined && options !== null) { + if (typeof options !== "object" && typeof options !== "function") { + throw new TypeError("structuredClone: options is not an object"); + } + var requested = options.transfer; + if (requested !== undefined) { + transfer = toTransferList(requested); + } + } + + return clone(value, transfer); +}; diff --git a/TestRunner/app/shared b/TestRunner/app/shared index 3a262b97..a7492cec 160000 --- a/TestRunner/app/shared +++ b/TestRunner/app/shared @@ -1 +1 @@ -Subproject commit 3a262b979c6b84cdfe69cd495436a7088d016505 +Subproject commit a7492cecc9e2be95c7eb58591a5cbbb5dbb0267a diff --git a/TestRunner/app/tests/index.js b/TestRunner/app/tests/index.js index 7fdd3869..d858fa43 100644 --- a/TestRunner/app/tests/index.js +++ b/TestRunner/app/tests/index.js @@ -175,6 +175,9 @@ require("./ExtendedClassNamingTests"); // Tests common for all runtimes (git submodule of NativeScript/common-runtime-tests-app). require("../shared/index").runAllTests(); +// Opt-in shared suite: structuredClone is not shipped by every runtime yet. +require("../shared/index").runStructuredCloneTests(); + // (Optional) Custom testing for various optional sdk's and frameworks // These can be turned on manually to verify if needed anytime //require("./sdks/MusicKit"); diff --git a/docs/README.md b/docs/README.md index 19842bf8..9668902c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,6 +2,8 @@ - [Error handling](error-handling.md) — global `error`/`unhandledrejection` events, `reportError`, catching native exceptions in JS (`error.nativeException`), forwarding JS throws to native (`interop.escapeException`), JS stacks on `NSException`, configuration flags, and crash-reporter integration. +- [structuredClone](structured-clone.md) — the WHATWG `structuredClone(value, { transfer })` global: what clones, how graph identity and cycles are preserved, `ArrayBuffer` transfer, and the `DataCloneError`-named `Error` that stands in for `DOMException`. + ## Knowledge Notes on work that is done, kept because the reasoning is expensive to diff --git a/docs/structured-clone.md b/docs/structured-clone.md new file mode 100644 index 00000000..74dac3ad --- /dev/null +++ b/docs/structured-clone.md @@ -0,0 +1,38 @@ +# structuredClone + +The runtime exposes the WHATWG [`structuredClone(value, options)`](https://html.spec.whatwg.org/multipage/structured-data.html#dom-structuredclone) global. It performs a deep, structure-preserving copy of `value` using V8's structured clone serializer — the same one worker `postMessage` uses — optionally taking ownership of `ArrayBuffer`s named in `options.transfer`. + +```js +const clone = structuredClone({ when: new Date(), tags: new Set(["a"]) }); + +const buffer = new ArrayBuffer(1024); +const moved = structuredClone(buffer, { transfer: [buffer] }); +buffer.byteLength; // 0 — the memory now belongs to `moved` +``` + +## Surface + +`structuredClone(value)` returns a clone of `value`. `structuredClone(value, { transfer })` additionally transfers every `ArrayBuffer` in `transfer`. + +- `value` is required; calling with no arguments throws a `TypeError`. +- `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: all primitives including `BigInt`, `undefined` and `-0`; plain objects and arrays; `Date`, `RegExp`, `Map`, `Set`, `Error`; `Boolean`/`String`/`Number` wrapper objects; `ArrayBuffer`, every typed array and `DataView`. + +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. + +Not cloneable — each throws (see the deviation below): functions, symbols, `WeakMap`/`WeakSet`/`WeakRef`, `Promise`, and every native/interop object (ObjC wrappers, pointers, function references), which have no serialized form. + +## Transfer semantics + +Listed buffers are validated before anything is serialized: each entry must be an `ArrayBuffer`, must not already be detached, must be detachable, and must appear at most once. A violation throws before the source buffers are touched, so a rejected call never leaves a half-transferred graph behind. + +On success the memory changes hands rather than being copied: the source buffer is detached (`byteLength` becomes 0, and every typed array over it becomes zero-length) and the clone receives the original backing store. A transferred buffer need not appear inside `value` at all; a buffer reached through a typed array in `value` is transferred as a unit, so the cloned view sees the original bytes. + +## Deviations from the specification + +- **`DataCloneError` is an `Error`, not a `DOMException`.** This runtime has no `DOMException`, so failures throw an `Error` whose `name` is set to `"DataCloneError"` — the same shape used for native exceptions (see [Error handling](error-handling.md)). Detect failures with `e.name === "DataCloneError"`; `instanceof DOMException` cannot work. +- **Only `ArrayBuffer` is transferable.** The spec's other transferable types — `MessagePort`, `ImageBitmap`, `ReadableStream` and friends — do not exist here. A non-`ArrayBuffer` in the transfer list is a `DataCloneError`. +- **`SharedArrayBuffer` is not supported** and clones to a `DataCloneError`, whether it appears in the value or in the transfer list. +- **Host objects are never cloneable.** The spec leaves platform objects to each host; here every native/interop wrapper is rejected with a `DataCloneError`, because a JavaScript copy detached from its native counterpart would be a wrapper around nothing. diff --git a/tools/js2c-inputs.xcfilelist b/tools/js2c-inputs.xcfilelist index b76a9d8a..ce4fff7e 100644 --- a/tools/js2c-inputs.xcfilelist +++ b/tools/js2c-inputs.xcfilelist @@ -11,5 +11,6 @@ $(SRCROOT)/NativeScript/runtime/js/ns-runtime.js $(SRCROOT)/NativeScript/runtime/js/ns-util.js $(SRCROOT)/NativeScript/runtime/js/promise-proxy.js $(SRCROOT)/NativeScript/runtime/js/require-factory.js +$(SRCROOT)/NativeScript/runtime/js/structured-clone.js $(SRCROOT)/NativeScript/runtime/js/ts-helpers.js $(SRCROOT)/NativeScript/runtime/js/weak-ref.js diff --git a/v8ios.xcodeproj/project.pbxproj b/v8ios.xcodeproj/project.pbxproj index 67b56f0a..88e409ac 100644 --- a/v8ios.xcodeproj/project.pbxproj +++ b/v8ios.xcodeproj/project.pbxproj @@ -294,6 +294,7 @@ 4A5C201A2E2B000100000006 /* BuiltinLoader.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 4A5C201A2E2B000100000001 /* BuiltinLoader.cpp */; }; 4A5C201A2E2B000100000007 /* RuntimeBuiltins.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 4A5C201A2E2B000100000003 /* RuntimeBuiltins.cpp */; }; 4A5C201A2E2B000200000006 /* NsBuiltinModules.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 4A5C201A2E2B000200000001 /* NsBuiltinModules.cpp */; }; + 4A5C201A2E2B000400000003 /* StructuredClone.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 4A5C201A2E2B000400000001 /* StructuredClone.cpp */; }; C2DDEB93229EAC8300345BFE /* ArgConverter.mm in Sources */ = {isa = PBXBuildFile; fileRef = C2DDEB6A229EAC8200345BFE /* ArgConverter.mm */; }; C2DDEB94229EAC8300345BFE /* Console.cpp in Sources */ = {isa = PBXBuildFile; fileRef = C2DDEB6B229EAC8200345BFE /* Console.cpp */; }; C2DDEB95229EAC8300345BFE /* SetTimeout.cpp in Sources */ = {isa = PBXBuildFile; fileRef = C2DDEB6C229EAC8200345BFE /* SetTimeout.cpp */; }; @@ -815,6 +816,8 @@ 4A5C201A2E2B000100000003 /* RuntimeBuiltins.cpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.cpp; name = RuntimeBuiltins.cpp; path = generated/RuntimeBuiltins.cpp; sourceTree = ""; }; 4A5C201A2E2B000100000004 /* RuntimeBuiltins.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = RuntimeBuiltins.h; path = generated/RuntimeBuiltins.h; sourceTree = ""; }; 4A5C201A2E2B000100000005 /* js */ = {isa = PBXFileReference; lastKnownFileType = folder; path = js; sourceTree = ""; }; + 4A5C201A2E2B000400000001 /* StructuredClone.cpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.cpp; path = StructuredClone.cpp; sourceTree = ""; }; + 4A5C201A2E2B000400000002 /* StructuredClone.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = StructuredClone.h; sourceTree = ""; }; C2DDEB6A229EAC8200345BFE /* ArgConverter.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = ArgConverter.mm; sourceTree = ""; }; C2DDEB6B229EAC8200345BFE /* Console.cpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.cpp; path = Console.cpp; sourceTree = ""; }; C2DDEB6C229EAC8200345BFE /* SetTimeout.cpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.cpp; path = SetTimeout.cpp; sourceTree = ""; }; @@ -1516,6 +1519,8 @@ C2C8EE7922CF64E4001F8CEC /* SimpleAllocator.h */, C2C8EE7822CF64E4001F8CEC /* SimpleAllocator.cpp */, C2DDEB87229EAC8300345BFE /* StringHasher.h */, + 4A5C201A2E2B000400000002 /* StructuredClone.h */, + 4A5C201A2E2B000400000001 /* StructuredClone.cpp */, C2F4D0AC232F85E20008A2EB /* SymbolIterator.h */, C2F4D0AB232F85E20008A2EB /* SymbolIterator.mm */, C2DDEB7F229EAC8200345BFE /* SymbolLoader.h */, @@ -2288,6 +2293,7 @@ C2DDEB92229EAC8300345BFE /* WeakRef.cpp in Sources */, 4A5C201A2E2B000100000006 /* BuiltinLoader.cpp in Sources */, 4A5C201A2E2B000200000006 /* NsBuiltinModules.cpp in Sources */, + 4A5C201A2E2B000400000003 /* StructuredClone.cpp in Sources */, 4A5C201A2E2B000100000007 /* RuntimeBuiltins.cpp in Sources */, C2A5F86A2359AEB600074AFA /* ExtVector.cpp in Sources */, AA8C47B22E27114300649BF5 /* ModuleInternalCallbacks.mm in Sources */, From 5b200a1389b38c1c04adbbf31f59802220056627 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 10 Aug 2026 19:49:10 -0300 Subject: [PATCH 2/7] fix(structured-clone): capture the iterator next method once per WebIDL Converting `options.transfer` re-read `next` off the iterator on every step. WebIDL creates the iterator record once and captures `next` with it, so a `next` that changes mid-iteration must not be observed; read it once after creating the iterator, reject a non-callable one as a TypeError, and invoke it through the captured reference. Also reconciles the cloneable-types list in the docs with the error list: symbols throw a DataCloneError, so "all primitives" was wrong. --- NativeScript/runtime/js/structured-clone.js | 9 ++++++++- TestRunner/app/shared | 2 +- docs/structured-clone.md | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/NativeScript/runtime/js/structured-clone.js b/NativeScript/runtime/js/structured-clone.js index 3d7b43af..3b3243f4 100644 --- a/NativeScript/runtime/js/structured-clone.js +++ b/NativeScript/runtime/js/structured-clone.js @@ -63,9 +63,16 @@ function toTransferList(value) { throw new TypeError("structuredClone: transfer is not iterable"); } + // The iterator record captures `next` once, when it is created — re-reading + // it per step would expose a `next` that changes mid-iteration. + var next = iterator.next; + if (typeof next !== "function") { + throw new TypeError("structuredClone: transfer is not iterable"); + } + var list = []; for (;;) { - var step = iterator.next(); + var step = FunctionPrototypeCall(next, iterator); if (step === null || typeof step !== "object") { throw new TypeError("structuredClone: transfer iterator returned a non-object"); } diff --git a/TestRunner/app/shared b/TestRunner/app/shared index a7492cec..5058d193 160000 --- a/TestRunner/app/shared +++ b/TestRunner/app/shared @@ -1 +1 @@ -Subproject commit a7492cecc9e2be95c7eb58591a5cbbb5dbb0267a +Subproject commit 5058d1932d1dbcb5726ed93725398c4c735df266 diff --git a/docs/structured-clone.md b/docs/structured-clone.md index 74dac3ad..f0fc6f67 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: all primitives including `BigInt`, `undefined` and `-0`; 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`. 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. From f310c63ac92f14e3710b68b9d81ca934be74e2c8 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 10 Aug 2026 20:14:31 -0300 Subject: [PATCH 3/7] refactor(serialization): one structured-clone core for structuredClone and worker postMessage structuredClone and worker postMessage each carried their own serializer and deserializer delegates, so the two could drift on everything the structured clone algorithm leaves to the embedder. StructuredSerialization now owns that machinery once and both call it. SerializedValue keeps serializing and deserializing as separate halves over a neutral buffer plus its backing-store lists, because a worker message is read back on a different isolate than it was written on while structuredClone round-trips on one. Transfer-list validation, id registration and the claim-then-detach ordering move in with it, so the rule that a buffer is detached only after the value is safely written holds for both callers. Consolidating settles three inconsistencies: - SharedArrayBuffer is now shared by structuredClone rather than rejected, which is what the spec asks for and what the worker path already did. - Worker postMessage no longer ignores a failed Serialize; it returns with the exception pending instead of posting an unwritten message. - Both paths raise DataCloneError through NativeScriptException. The two mechanisms built the same object -- Error with name "DataCloneError" -- except that the worker's also carried `fullMessage`, so unifying on it keeps every property worker callers can already see today. Host objects are the one place the callers still differ, and HostObjectPolicy is now the only place that is written down: structuredClone rejects them per spec, postMessage keeps delivering an empty object as it always has. The degrade branch writes no payload and its ReadHostObject counterpart consumes none, so the stream stays balanced. Drops the Node message-port scaffolding the core supersedes along with Worker::Serialize, the pre-structured-clone JSON path whose only remaining callers were commented out. --- NativeScript/runtime/Message.cpp | 490 ------------------ NativeScript/runtime/Message.hpp | 125 +---- NativeScript/runtime/StructuredClone.cpp | 164 +----- .../runtime/StructuredSerialization.cpp | 229 ++++++++ .../runtime/StructuredSerialization.h | 80 +++ NativeScript/runtime/Worker.h | 4 +- NativeScript/runtime/Worker.mm | 57 +- v8ios.xcodeproj/project.pbxproj | 10 +- 8 files changed, 351 insertions(+), 808 deletions(-) delete mode 100644 NativeScript/runtime/Message.cpp create mode 100644 NativeScript/runtime/StructuredSerialization.cpp create mode 100644 NativeScript/runtime/StructuredSerialization.h diff --git a/NativeScript/runtime/Message.cpp b/NativeScript/runtime/Message.cpp deleted file mode 100644 index efd3a4c4..00000000 --- a/NativeScript/runtime/Message.cpp +++ /dev/null @@ -1,490 +0,0 @@ -// -// Message.cpp -// NativeScript -// -// Created by Eduardo Speroni on 11/22/23. -// Copyright © 2023 Progress. All rights reserved. -// - -#include "Message.hpp" - -#include "Helpers.h" -#include "NativeScriptException.h" - -using namespace v8; - -namespace tns { -namespace worker { -namespace { -void ThrowDataCloneException(Local context, - Local message) { - Isolate* isolate = v8::Isolate::GetCurrent(); - // Local argv[] = {message, - // FIXED_ONE_BYTE_STRING(isolate, "DataCloneError")}; - NativeScriptException except(isolate, tns::ToString(isolate, message), - "DataCloneError"); - except.ReThrowToV8(isolate); -} -class SerializerDelegate : public v8::ValueSerializer::Delegate { - public: - SerializerDelegate(Isolate* isolate, Local context, Message* m) - : isolate_(isolate), context_(context), msg_(m) {} - - void ThrowDataCloneError(Local message) override { - ThrowDataCloneException(context_, message); - } - - Maybe WriteHostObject(Isolate* isolate, Local object) override { - return Just(true); - // if (BaseObject::IsBaseObject(object)) { - // return WriteHostObject( - // BaseObjectPtr { Unwrap(object) }); - // } - // - // // Convert process.env to a regular object. - // auto env_proxy_ctor_template = env_->env_proxy_ctor_template(); - // if (!env_proxy_ctor_template.IsEmpty() && - // env_proxy_ctor_template->HasInstance(object)) { - // HandleScope scope(isolate); - // // TODO(bnoordhuis) Prototype-less object in case process.env - // contains - // // a "__proto__" key? process.env has a prototype with concomitant - // // methods like toString(). It's probably confusing if that gets - // lost - // // in transmission. - // Local normal_object = Object::New(isolate); - // env_->env_vars()->AssignToObject(isolate, env_->context(), - // normal_object); serializer->WriteUint32(kNormalObject); // Instead - // of a BaseObject. return serializer->WriteValue(env_->context(), - // normal_object); - // } - // - // ThrowDataCloneError(env_->clone_unsupported_type_str()); - // return Nothing(); - } - - Maybe GetSharedArrayBufferId( - Isolate* isolate, Local shared_array_buffer) override { - uint32_t i; - for (i = 0; i < seen_shared_array_buffers_.size(); ++i) { - if (PersistentToLocal::Strong(seen_shared_array_buffers_[i]) == - shared_array_buffer) { - return Just(i); - } - } - - seen_shared_array_buffers_.emplace_back( - Global{isolate, shared_array_buffer}); - msg_->AddSharedArrayBuffer(shared_array_buffer->GetBackingStore()); - return Just(i); - } - - // Maybe GetWasmModuleTransferId( - // Isolate* isolate, Local module) override { - // return Just(msg_->AddWASMModule(module->GetCompiledModule())); - // } - - // bool AdoptSharedValueConveyor(Isolate* isolate, - // SharedValueConveyor&& conveyor) override { - // msg_->AdoptSharedValueConveyor(std::move(conveyor)); - // return true; - // } - - // Maybe Finish(Local context) { - // for (uint32_t i = 0; i < host_objects_.size(); i++) { - // BaseObjectPtr host_object = std::move(host_objects_[i]); - // std::unique_ptr data; - // if (i < first_cloned_object_index_) - // data = host_object->TransferForMessaging(); - // if (!data) - // data = host_object->CloneForMessaging(); - // if (!data) return Nothing(); - // if (data->FinalizeTransferWrite(context, serializer).IsNothing()) - // return Nothing(); - // msg_->AddTransferable(std::move(data)); - // } - // return Just(true); - // } - - // inline void AddHostObject(BaseObjectPtr host_object) { - // // Make sure we have not started serializing the value itself yet. - // CHECK_EQ(first_cloned_object_index_, SIZE_MAX); - // host_objects_.emplace_back(std::move(host_object)); - // } - // - // // Some objects in the transfer list may register sub-objects that can be - // // transferred. This could e.g. be a public JS wrapper object, such as a - // // FileHandle, that is registering its C++ handle for transfer. - // inline Maybe AddNestedHostObjects() { - // for (size_t i = 0; i < host_objects_.size(); i++) { - // std::vector> nested_transferables; - // if - // (!host_objects_[i]->NestedTransferables().To(&nested_transferables)) - // return Nothing(); - // for (auto& nested_transferable : nested_transferables) { - // if (std::find(host_objects_.begin(), - // host_objects_.end(), - // nested_transferable) == host_objects_.end()) { - // AddHostObject(nested_transferable); - // } - // } - // } - // return Just(true); - // } - - ValueSerializer* serializer = nullptr; - - private: - // Maybe WriteHostObject(BaseObjectPtr host_object) { - // BaseObject::TransferMode mode = host_object->GetTransferMode(); - // if (mode == BaseObject::TransferMode::kUntransferable) { - // ThrowDataCloneError(env_->clone_unsupported_type_str()); - // return Nothing(); - // } - // - // for (uint32_t i = 0; i < host_objects_.size(); i++) { - // if (host_objects_[i] == host_object) { - // serializer->WriteUint32(i); - // return Just(true); - // } - // } - // - // if (mode == BaseObject::TransferMode::kTransferable) { - // THROW_ERR_MISSING_TRANSFERABLE_IN_TRANSFER_LIST(env_); - // return Nothing(); - // } - // - // CHECK_EQ(mode, BaseObject::TransferMode::kCloneable); - // uint32_t index = host_objects_.size(); - // if (first_cloned_object_index_ == SIZE_MAX) - // first_cloned_object_index_ = index; - // serializer->WriteUint32(index); - // host_objects_.push_back(host_object); - // return Just(true); - // } - - __unused Isolate* isolate_; - __unused Local context_; - Message* msg_; - std::vector> seen_shared_array_buffers_; - // std::vector> host_objects_; - __unused size_t first_cloned_object_index_ = SIZE_MAX; - - friend class tns::worker::Message; -}; - -class DeserializerDelegate : public ValueDeserializer::Delegate { - public: - DeserializerDelegate( - Message* m, Isolate* isolate, - // const std::vector>& host_objects, - const std::vector>& shared_array_buffers - // const std::vector& wasm_modules, - // const std::optional& shared_value_conveyor - ) - : // host_objects_(host_objects), - shared_array_buffers_(shared_array_buffers) - // wasm_modules_(wasm_modules), - // shared_value_conveyor_(shared_value_conveyor) - {} - - MaybeLocal ReadHostObject(Isolate* isolate) override { - EscapableHandleScope scope(isolate); - Local object = Object::New(isolate); - return scope.Escape(object).As(); - // // Identifying the index in the message's BaseObject array is - // sufficient. uint32_t id; if (!deserializer->ReadUint32(&id)) - // return MaybeLocal(); - // if (id != kNormalObject) { - // CHECK_LT(id, host_objects_.size()); - // return host_objects_[id]->object(isolate); - // } - // EscapableHandleScope scope(isolate); - // Local context = isolate->GetCurrentContext(); - // Local object; - // if (!deserializer->ReadValue(context).ToLocal(&object)) - // return MaybeLocal(); - // CHECK(object->IsObject()); - // return scope.Escape(object.As()); - } - - MaybeLocal GetSharedArrayBufferFromId( - Isolate* isolate, uint32_t clone_id) override { - // CHECK_LT(clone_id, shared_array_buffers_.size()); - return shared_array_buffers_[clone_id]; - } - - // MaybeLocal GetWasmModuleFromId( - // Isolate* isolate, uint32_t transfer_id) override { - //// CHECK_LT(transfer_id, wasm_modules_.size()); - // return WasmModuleObject::FromCompiledModule( - // isolate, wasm_modules_[transfer_id]); - // } - - // const SharedValueConveyor* GetSharedValueConveyor(Isolate* isolate) - // override { - //// CHECK(shared_value_conveyor_.has_value()); - // return &shared_value_conveyor_.value(); - // } - - ValueDeserializer* deserializer = nullptr; - - private: - // const std::vector>& host_objects_; - const std::vector>& shared_array_buffers_; - // const std::vector& wasm_modules_; - // const std::optional& shared_value_conveyor_; -}; -}; // namespace - -v8::Maybe Message::Serialize(v8::Isolate* isolate, - v8::Local context, - v8::Local input) { - HandleScope handle_scope(isolate); - v8::Context::Scope context_scope(context); - - // Verify that we're not silently overwriting an existing message. - tns::Assert(main_message_buf_.is_empty()); - - SerializerDelegate delegate(isolate, context, this); - ValueSerializer serializer(isolate, &delegate); - delegate.serializer = &serializer; - - std::vector> array_buffers; - // for (uint32_t i = 0; i < transfer_list_v.length(); ++i) { - // Local entry = transfer_list_v[i]; - // if (entry->IsObject()) { - // // See - // https://github.com/nodejs/node/pull/30339#issuecomment-552225353 - // // for details. - // bool untransferable; - // if (!entry.As()->HasPrivate( - // context, - // env->untransferable_object_private_symbol()) - // .To(&untransferable)) { - // return Nothing(); - // } - // if (untransferable) { - // ThrowDataCloneException(context, - // env->transfer_unsupported_type_str()); return Nothing(); - // } - // } - // - // // Currently, we support ArrayBuffers and BaseObjects for which - // // GetTransferMode() returns kTransferable. - // if (entry->IsArrayBuffer()) { - // Local ab = entry.As(); - // // If we cannot render the ArrayBuffer unusable in this Isolate, - // // copying the buffer will have to do. - // // Note that we can currently transfer ArrayBuffers even if they - // were - // // not allocated by Node’s ArrayBufferAllocator in the first - // place, - // // because we pass the underlying v8::BackingStore around rather - // than - // // raw data *and* an Isolate with a non-default ArrayBuffer - // allocator - // // is always going to outlive any Workers it creates, and so will - // its - // // allocator along with it. - // if (!ab->IsDetachable() || ab->WasDetached()) { - // ThrowDataCloneException(context, - // env->transfer_unsupported_type_str()); return Nothing(); - // } - // if (std::find(array_buffers.begin(), array_buffers.end(), ab) != - // array_buffers.end()) { - // ThrowDataCloneException( - // context, - // FIXED_ONE_BYTE_STRING( - // env->isolate(), - // "Transfer list contains duplicate ArrayBuffer")); - // return Nothing(); - // } - // // We simply use the array index in the `array_buffers` list as - // the - // // ID that we write into the serialized buffer. - // uint32_t id = array_buffers.size(); - // array_buffers.push_back(ab); - // serializer.TransferArrayBuffer(id, ab); - // continue; - // } else if (entry->IsObject() && - // BaseObject::IsBaseObject(entry.As())) { - // // Check if the source MessagePort is being transferred. - // if (!source_port.IsEmpty() && entry == source_port) { - // ThrowDataCloneException( - // context, - // FIXED_ONE_BYTE_STRING(env->isolate(), - // "Transfer list contains source - // port")); - // return Nothing(); - // } - // BaseObjectPtr host_object { - // Unwrap(entry.As()) }; - // if (env->message_port_constructor_template()->HasInstance(entry) - // && - // (!host_object || - // static_cast(host_object.get())->IsDetached())) - // { - // ThrowDataCloneException( - // context, - // FIXED_ONE_BYTE_STRING( - // env->isolate(), - // "MessagePort in transfer list is already detached")); - // return Nothing(); - // } - // if (std::find(delegate.host_objects_.begin(), - // delegate.host_objects_.end(), - // host_object) != delegate.host_objects_.end()) { - // ThrowDataCloneException( - // context, - // String::Concat(env->isolate(), - // FIXED_ONE_BYTE_STRING( - // env->isolate(), - // "Transfer list contains duplicate "), - // entry.As()->GetConstructorName())); - // return Nothing(); - // } - // if (host_object && host_object->GetTransferMode() == - // BaseObject::TransferMode::kTransferable) { - // delegate.AddHostObject(host_object); - // continue; - // } - // } - // - // THROW_ERR_INVALID_TRANSFER_OBJECT(env); - // return Nothing(); - // } - // if (delegate.AddNestedHostObjects().IsNothing()) - // return Nothing(); - - serializer.WriteHeader(); - if (serializer.WriteValue(context, input).IsNothing()) { - return Nothing(); - } - - for (Local ab : array_buffers) { - // If serialization succeeded, we render it inaccessible in this Isolate. - std::shared_ptr backing_store = ab->GetBackingStore(); - // A null key is accepted for buffers without a detach key. The result is - // deliberately discarded: the void Detach() this replaced could not report - // failure either, and a detach-key mismatch must not abort the process. - ab->Detach(v8::Local()).FromMaybe(false); - - array_buffers_.emplace_back(std::move(backing_store)); - } - - // if (delegate.Finish(context).IsNothing()) - // return Nothing(); - - // The serializer gave us a buffer allocated using `malloc()`. - std::pair data = serializer.Release(); - tns::Assert(data.first != NULL, isolate); - main_message_buf_ = - MallocedBuffer(reinterpret_cast(data.first), data.second); - return Just(true); -} - -MaybeLocal Message::Deserialize(Isolate* isolate, - Local context) { - Context::Scope context_scope(context); - - // CHECK(!IsCloseMessage()); - // if (port_list != nullptr && !transferables_.empty()) { - // // Need to create this outside of the EscapableHandleScope, but inside - // // the Context::Scope. - // *port_list = Array::New(env->isolate()); - // } - - EscapableHandleScope handle_scope(isolate); - - // Create all necessary objects for transferables, e.g. MessagePort handles. - // std::vector> - // host_objects(transferables_.size()); auto cleanup = OnScopeLeave([&]() { - // for (BaseObjectPtr object : host_objects) { - // if (!object) continue; - // - // // If the function did not finish successfully, host_objects will - // contain - // // a list of objects that will never be passed to JS. Therefore, we - // // destroy them here. - // object->Detach(); - // } - // }); - - // for (uint32_t i = 0; i < transferables_.size(); ++i) { - // HandleScope handle_scope(env->isolate()); - // TransferData* data = transferables_[i].get(); - // host_objects[i] = data->Deserialize( - // env, context, std::move(transferables_[i])); - // if (!host_objects[i]) return {}; - // if (port_list != nullptr) { - // // If we gather a list of all message ports, and this transferred - // object - // // is a message port, add it to that list. This is a bit of an odd - // case - // // of special handling for MessagePorts (as opposed to applying to all - // // transferables), but it's required for spec compliance. - // DCHECK((*port_list)->IsArray()); - // Local port_list_array = port_list->As(); - // Local obj = host_objects[i]->object(); - // if (env->message_port_constructor_template()->HasInstance(obj)) { - // if (port_list_array->Set(context, - // port_list_array->Length(), - // obj).IsNothing()) { - // return {}; - // } - // } - // } - // } - // transferables_.clear(); - - std::vector> shared_array_buffers; - // Attach all transferred SharedArrayBuffers to their new Isolate. - for (uint32_t i = 0; i < shared_array_buffers_.size(); ++i) { - Local sab = - SharedArrayBuffer::New(isolate, shared_array_buffers_[i]); - shared_array_buffers.push_back(sab); - } - - DeserializerDelegate delegate( - this, isolate, - // host_objects, - shared_array_buffers - // wasm_modules_, - // shared_value_conveyor_ - ); - ValueDeserializer deserializer( - isolate, reinterpret_cast(main_message_buf_.data), - main_message_buf_.size, &delegate); - delegate.deserializer = &deserializer; - - // Attach all transferred ArrayBuffers to their new Isolate. - for (uint32_t i = 0; i < array_buffers_.size(); ++i) { - Local ab = - ArrayBuffer::New(isolate, std::move(array_buffers_[i])); - deserializer.TransferArrayBuffer(i, ab); - } - - if (deserializer.ReadHeader(context).IsNothing()) return {}; - Local return_value; - if (!deserializer.ReadValue(context).ToLocal(&return_value)) return {}; - - // for (BaseObjectPtr base_object : host_objects) { - // if (base_object->FinalizeTransferRead(context, - // &deserializer).IsNothing()) - // return {}; - // } - - // host_objects.clear(); - return handle_scope.Escape(return_value); -} - -void Message::AddSharedArrayBuffer( - std::shared_ptr backing_store) { - shared_array_buffers_.emplace_back(std::move(backing_store)); -} - -Message::Message(MallocedBuffer&& payload) - : main_message_buf_(std::move(payload)) {} -}; // namespace worker -}; // namespace tns diff --git a/NativeScript/runtime/Message.hpp b/NativeScript/runtime/Message.hpp index e90ec00d..6771ee9a 100644 --- a/NativeScript/runtime/Message.hpp +++ b/NativeScript/runtime/Message.hpp @@ -8,129 +8,18 @@ #ifndef Message_hpp #define Message_hpp -#include "v8.h" -namespace tns { - -template -inline T* Malloc(size_t n) { - T* ret = malloc(n); - return ret; -} - -template -T* UncheckedRealloc(T* pointer, size_t n) { - size_t full_size = sizeof(T) * n; - - if (full_size == 0) { - free(pointer); - return nullptr; - } - - void* allocated = realloc(pointer, full_size); - - // if (UNLIKELY(allocated == nullptr)) { - // // Tell V8 that memory is low and retry. - // LowMemoryNotification(); - // allocated = realloc(pointer, full_size); - // } - - return static_cast(allocated); -} - -template -struct MallocedBuffer { - T* data; - size_t size; - - T* release() { - T* ret = data; - data = nullptr; - return ret; - } - - void Truncate(size_t new_size) { - CHECK_LE(new_size, size); - size = new_size; - } - - void Realloc(size_t new_size) { - Truncate(new_size); - data = UncheckedRealloc(data, new_size); - } - - bool is_empty() const { return data == nullptr; } - - MallocedBuffer() : data(nullptr), size(0) {} - explicit MallocedBuffer(size_t size) : data(Malloc(size)), size(size) {} - MallocedBuffer(T* data, size_t size) : data(data), size(size) {} - MallocedBuffer(MallocedBuffer&& other) : data(other.data), size(other.size) { - other.data = nullptr; - } - MallocedBuffer& operator=(MallocedBuffer&& other) { - this->~MallocedBuffer(); - return *new (this) MallocedBuffer(std::move(other)); - } - ~MallocedBuffer() { free(data); } - MallocedBuffer(const MallocedBuffer&) = delete; - MallocedBuffer& operator=(const MallocedBuffer&) = delete; -}; +#include "StructuredSerialization.h" +namespace tns { namespace worker { -class Message { - public: - Message(MallocedBuffer&& payload = MallocedBuffer()); - Message(Message&& other) = default; - Message& operator=(Message&& other) = default; - Message& operator=(const Message&) = delete; - Message(const Message&) = delete; - v8::Maybe Serialize(v8::Isolate* isolate, - v8::Local context, - v8::Local input); - v8::MaybeLocal Deserialize(v8::Isolate* isolate, - v8::Local context); - // Internal method of Message that is called when a new SharedArrayBuffer - // object is encountered in the incoming value's structure. - void AddSharedArrayBuffer(std::shared_ptr backing_store); - // Internal method of Message that is called once serialization finishes - // and that transfers ownership of `data` to this message. - // void AddTransferable(std::unique_ptr&& data); - // Internal method of Message that is called when a new WebAssembly.Module - // object is encountered in the incoming value's structure. - // uint32_t AddWASMModule(v8::CompiledWasmModule&& mod); - // Internal method of Message that is called when a shared value is - // encountered for the first time in the incoming value's structure. - // void AdoptSharedValueConveyor(v8::SharedValueConveyor&& conveyor); - - // The host objects that will be transferred, as recorded by Serialize() - // (e.g. MessagePorts). - // Used for warning user about posting the target MessagePort to itself, - // which will as a side effect destroy the communication channel. - // const std::vector>& transferables() - // const { - // return transferables_; - // } - // bool has_transferables() const { - // return !transferables_.empty() || !array_buffers_.empty(); - // } +// What a worker posts: a value serialized on the sending isolate and read back +// on the receiving one. The mechanism is shared with structuredClone; only the +// host-object policy differs (see HostObjectPolicy). +using Message = tns::serialization::SerializedValue; - // void MemoryInfo(MemoryTracker* tracker) const override; - // - // SET_MEMORY_INFO_NAME(Message) - // SET_SELF_SIZE(Message) - private: - MallocedBuffer main_message_buf_; - // TODO(addaleax): Make this a std::variant to save storage size in the common - // case (which is that all of these vectors are empty) once that is available - // with C++17. - std::vector> array_buffers_; - std::vector> shared_array_buffers_; - // std::vector> transferables_; - // std::vector wasm_modules_; - // std::optional shared_value_conveyor_; -}; -}; // namespace worker +} // namespace worker } // namespace tns #endif /* Message_hpp */ diff --git a/NativeScript/runtime/StructuredClone.cpp b/NativeScript/runtime/StructuredClone.cpp index 657e6561..7385b228 100644 --- a/NativeScript/runtime/StructuredClone.cpp +++ b/NativeScript/runtime/StructuredClone.cpp @@ -1,12 +1,8 @@ #include "StructuredClone.h" -#include -#include -#include -#include - #include "BuiltinLoader.h" #include "Helpers.h" +#include "StructuredSerialization.h" using namespace v8; @@ -14,112 +10,8 @@ namespace tns { namespace { -// There is no DOMException in this runtime, so a clone failure surfaces as an -// Error carrying the spec's exception name — the same shape the -// native-exception bridge uses (docs/error-handling.md). Failing to attach the -// name is ignored: throwing something is more useful than throwing nothing. -void ThrowDataCloneError(Isolate* isolate, Local message) { - Local error = Exception::Error(message); - Local context = isolate->GetCurrentContext(); - bool named = error.As() - ->Set(context, tns::ToV8String(isolate, "name"), - tns::ToV8String(isolate, "DataCloneError")) - .FromMaybe(false); - (void)named; - isolate->ThrowException(error); -} - -void ThrowDataCloneError(Isolate* isolate, const std::string& message) { - ThrowDataCloneError(isolate, tns::ToV8String(isolate, message)); -} - -class CloneDelegate : public ValueSerializer::Delegate { - public: - explicit CloneDelegate(Isolate* isolate) : isolate_(isolate) {} - - void ThrowDataCloneError(Local message) override { - tns::ThrowDataCloneError(isolate_, message); - } - - // Objects backed by native state — ObjC wrappers, interop pointers, function - // references — reach the serializer as host objects. They have no - // serialization form, and cloning the JS shell without its native counterpart - // would hand back a wrapper pointing at nothing. - Maybe WriteHostObject(Isolate* isolate, Local object) override { - std::string name = tns::ToString(isolate, object->GetConstructorName()); - ThrowDataCloneError( - tns::ToV8String(isolate, "#<" + name + "> could not be cloned.")); - return Nothing(); - } - - // Shared memory has no owner to hand it to in a single-agent clone. Both - // hooks below are overridden only to keep the DataCloneError name: V8's - // defaults throw a plain Error directly on the isolate instead of going - // through ThrowDataCloneError. - Maybe GetSharedArrayBufferId( - Isolate* isolate, Local sharedArrayBuffer) override { - ThrowDataCloneError( - tns::ToV8String(isolate, "# could not be cloned.")); - return Nothing(); - } - - bool AdoptSharedValueConveyor(Isolate* isolate, - SharedValueConveyor&& conveyor) override { - ThrowDataCloneError( - tns::ToV8String(isolate, "shared value could not be cloned.")); - return false; - } - - private: - Isolate* isolate_; -}; - -// Validates the transfer list the builtin materialized and collects it in -// registration order. Returns false with an exception pending. -bool CollectTransferList(Isolate* isolate, Local context, - Local transferValue, - std::vector>& transfers) { - if (!transferValue->IsArray()) { - return true; - } - - Local list = transferValue.As(); - uint32_t length = list->Length(); - for (uint32_t i = 0; i < length; i++) { - Local item; - if (!list->Get(context, i).ToLocal(&item)) { - return false; - } - if (!item->IsArrayBuffer()) { - ThrowDataCloneError(isolate, - "structuredClone: value in transfer list is not " - "transferable"); - return false; - } - - Local buffer = item.As(); - for (const Local& existing : transfers) { - if (existing == buffer) { - ThrowDataCloneError(isolate, - "structuredClone: transfer list contains the same " - "ArrayBuffer twice"); - return false; - } - } - if (buffer->WasDetached() || !buffer->IsDetachable()) { - ThrowDataCloneError(isolate, - "structuredClone: an ArrayBuffer in the transfer " - "list is detached and cannot be transferred"); - return false; - } - - transfers.push_back(buffer); - } - return true; -} - // binding.clone(value, transferArrayOrUndefined): serialize and deserialize in -// this one isolate, which is what the spec's StructuredDeserialize( +// this one isolate, which is what StructuredDeserialize( // StructuredSerializeWithTransfer(...)) amounts to when there is no second // agent involved. void CloneCallback(const FunctionCallbackInfo& info) { @@ -127,53 +19,19 @@ void CloneCallback(const FunctionCallbackInfo& info) { Local context = isolate->GetCurrentContext(); Local value = info.Length() > 0 ? info[0] : v8::Undefined(isolate).As(); - - std::vector> transfers; - if (info.Length() > 1 && - !CollectTransferList(isolate, context, info[1], transfers)) { - return; - } - - CloneDelegate delegate(isolate); - ValueSerializer serializer(isolate, &delegate); - for (size_t i = 0; i < transfers.size(); i++) { - serializer.TransferArrayBuffer(static_cast(i), transfers[i]); - } - - serializer.WriteHeader(); - bool written = serializer.WriteValue(context, value).FromMaybe(false); - - // Release() hands over ownership of a buffer the default delegate grew with - // realloc(), so it is free()d rather than deleted — and it must be released - // even after a failed write, or the buffer leaks with the serializer. - std::pair data = serializer.Release(); - std::unique_ptr owned(data.first, std::free); - if (!written) { - return; - } - - ValueDeserializer deserializer(isolate, data.first, data.second); - - // Transferred memory changes hands here, after serialization succeeded: the - // backing store is claimed before the source is detached (detaching drops the - // buffer's own reference to it) and handed to a fresh ArrayBuffer under the - // same id the serializer wrote. - for (size_t i = 0; i < transfers.size(); i++) { - std::shared_ptr backingStore = - transfers[i]->GetBackingStore(); - if (transfers[i]->Detach(Local()).IsNothing()) { - return; - } - deserializer.TransferArrayBuffer(static_cast(i), - ArrayBuffer::New(isolate, backingStore)); - } - - if (deserializer.ReadHeader(context).IsNothing()) { + Local transferList = + info.Length() > 1 ? info[1] : v8::Undefined(isolate).As(); + + serialization::SerializedValue serialized; + if (serialized + .Serialize(isolate, context, value, transferList, + serialization::HostObjectPolicy::kReject) + .IsNothing()) { return; } Local result; - if (!deserializer.ReadValue(context).ToLocal(&result)) { + if (!serialized.Deserialize(isolate, context).ToLocal(&result)) { return; } info.GetReturnValue().Set(result); diff --git a/NativeScript/runtime/StructuredSerialization.cpp b/NativeScript/runtime/StructuredSerialization.cpp new file mode 100644 index 00000000..48eddcca --- /dev/null +++ b/NativeScript/runtime/StructuredSerialization.cpp @@ -0,0 +1,229 @@ +#include "StructuredSerialization.h" + +#include "Helpers.h" +#include "NativeScriptException.h" + +using namespace v8; + +namespace tns { +namespace serialization { + +void ThrowDataCloneError(Isolate* isolate, const std::string& message) { + NativeScriptException exception(isolate, message, "DataCloneError"); + exception.ReThrowToV8(isolate); +} + +namespace { + +class SerializerDelegate : public ValueSerializer::Delegate { + public: + SerializerDelegate(Isolate* isolate, HostObjectPolicy hostObjectPolicy, + std::vector>* sharedBuffers) + : isolate_(isolate), + hostObjectPolicy_(hostObjectPolicy), + sharedBuffers_(sharedBuffers) {} + + void ThrowDataCloneError(Local message) override { + serialization::ThrowDataCloneError(isolate_, + tns::ToString(isolate_, message)); + } + + Maybe WriteHostObject(Isolate* isolate, Local object) override { + 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. + return Just(true); + } + std::string name = tns::ToString(isolate, object->GetConstructorName()); + serialization::ThrowDataCloneError(isolate, + "#<" + name + "> could not be cloned."); + return Nothing(); + } + + // Shared memory is shared, not copied: the receiving isolate builds a new + // SharedArrayBuffer over this same backing store. + Maybe GetSharedArrayBufferId( + Isolate* isolate, Local sharedArrayBuffer) override { + std::shared_ptr backingStore = + sharedArrayBuffer->GetBackingStore(); + for (size_t i = 0; i < sharedBuffers_->size(); i++) { + if ((*sharedBuffers_)[i] == backingStore) { + return Just(static_cast(i)); + } + } + uint32_t id = static_cast(sharedBuffers_->size()); + sharedBuffers_->push_back(std::move(backingStore)); + return Just(id); + } + + // Overridden only to keep the DataCloneError name: with a delegate installed + // V8's default throws a plain Error straight onto the isolate. + bool AdoptSharedValueConveyor(Isolate* isolate, + SharedValueConveyor&& conveyor) override { + serialization::ThrowDataCloneError(isolate, + "shared value could not be cloned."); + return false; + } + + private: + Isolate* isolate_; + HostObjectPolicy hostObjectPolicy_; + std::vector>* sharedBuffers_; +}; + +class DeserializerDelegate : public ValueDeserializer::Delegate { + public: + explicit DeserializerDelegate( + const std::vector>* sharedBuffers) + : sharedBuffers_(sharedBuffers) {} + + // Counterpart of the kDegrade branch: consumes no bytes, so the stream stays + // balanced. Unreachable for a value written under kReject. + MaybeLocal ReadHostObject(Isolate* isolate) override { + return Object::New(isolate); + } + + MaybeLocal GetSharedArrayBufferFromId( + Isolate* isolate, uint32_t cloneId) override { + if (cloneId >= sharedBuffers_->size()) { + return MaybeLocal(); + } + return (*sharedBuffers_)[cloneId]; + } + + private: + const std::vector>* sharedBuffers_; +}; + +// Validates the transfer list and collects it in registration order. The +// detached and detachable checks are load-bearing rather than defensive: +// ArrayBuffer::Detach() aborts the process on a non-detachable buffer instead +// of reporting failure. +bool CollectTransferList(Isolate* isolate, Local context, + Local transferList, + std::vector>& transfers) { + if (transferList.IsEmpty() || transferList->IsUndefined() || + transferList->IsNull()) { + return true; + } + + if (!transferList->IsArray()) { + isolate->ThrowException(Exception::TypeError(tns::ToV8String( + isolate, "The transfer list must be an array of ArrayBuffers"))); + return false; + } + + Local list = transferList.As(); + uint32_t length = list->Length(); + for (uint32_t i = 0; i < length; i++) { + Local item; + if (!list->Get(context, i).ToLocal(&item)) { + return false; + } + if (!item->IsArrayBuffer()) { + ThrowDataCloneError(isolate, + "A value in the transfer list is not transferable"); + return false; + } + + Local buffer = item.As(); + for (const Local& existing : transfers) { + if (existing == buffer) { + ThrowDataCloneError( + isolate, "The transfer list contains the same ArrayBuffer twice"); + return false; + } + } + if (buffer->WasDetached() || !buffer->IsDetachable()) { + ThrowDataCloneError(isolate, + "An ArrayBuffer in the transfer list is detached and " + "cannot be transferred"); + return false; + } + + transfers.push_back(buffer); + } + return true; +} + +} // namespace + +Maybe SerializedValue::Serialize(Isolate* isolate, Local context, + Local input, + Local transferList, + HostObjectPolicy hostObjectPolicy) { + HandleScope handleScope(isolate); + Context::Scope contextScope(context); + tns::Assert(buffer_ == nullptr, isolate); + + std::vector> transfers; + if (!CollectTransferList(isolate, context, transferList, transfers)) { + return Nothing(); + } + + SerializerDelegate delegate(isolate, hostObjectPolicy, &sharedBuffers_); + ValueSerializer serializer(isolate, &delegate); + for (size_t i = 0; i < transfers.size(); i++) { + serializer.TransferArrayBuffer(static_cast(i), transfers[i]); + } + + serializer.WriteHeader(); + bool written = serializer.WriteValue(context, input).FromMaybe(false); + + // Release() hands ownership over whether or not the write succeeded, so the + // buffer is claimed either way rather than leaking with the serializer. + std::pair data = serializer.Release(); + std::unique_ptr owned(data.first); + if (!written) { + return Nothing(); + } + + // Only once the value is safely written does the memory change hands: claim + // each backing store before detaching, since detaching drops the buffer's own + // reference to it. + for (Local buffer : transfers) { + std::shared_ptr backingStore = buffer->GetBackingStore(); + // A null key is accepted for buffers without a detach key. The result is + // deliberately discarded: a key mismatch must not abort the process. + buffer->Detach(Local()).FromMaybe(false); + transferredBuffers_.push_back(std::move(backingStore)); + } + + buffer_ = std::move(owned); + bufferSize_ = data.second; + return Just(true); +} + +MaybeLocal SerializedValue::Deserialize(Isolate* isolate, + Local context) { + Context::Scope contextScope(context); + EscapableHandleScope handleScope(isolate); + + std::vector> sharedBuffers; + for (const std::shared_ptr& backingStore : sharedBuffers_) { + sharedBuffers.push_back(SharedArrayBuffer::New(isolate, backingStore)); + } + + DeserializerDelegate delegate(&sharedBuffers); + ValueDeserializer deserializer(isolate, buffer_.get(), bufferSize_, + &delegate); + + for (size_t i = 0; i < transferredBuffers_.size(); i++) { + deserializer.TransferArrayBuffer( + static_cast(i), + ArrayBuffer::New(isolate, std::move(transferredBuffers_[i]))); + } + + if (deserializer.ReadHeader(context).IsNothing()) { + return MaybeLocal(); + } + Local result; + if (!deserializer.ReadValue(context).ToLocal(&result)) { + return MaybeLocal(); + } + return handleScope.Escape(result); +} + +} // namespace serialization +} // namespace tns diff --git a/NativeScript/runtime/StructuredSerialization.h b/NativeScript/runtime/StructuredSerialization.h new file mode 100644 index 00000000..860655e1 --- /dev/null +++ b/NativeScript/runtime/StructuredSerialization.h @@ -0,0 +1,80 @@ +#ifndef StructuredSerialization_h +#define StructuredSerialization_h + +#include +#include +#include +#include + +#include "Common.h" + +namespace tns { +namespace serialization { + +// What an entry point does with an object backed by native state — an ObjC +// wrapper, an interop pointer, a function reference. The two callers +// deliberately disagree, and this enum is the only place that disagreement is +// encoded. +enum class HostObjectPolicy { + // structuredClone: a DataCloneError, as the HTML spec requires. A clone whose + // native half was left behind would be a wrapper around nothing. + kReject, + // Worker postMessage: the value arrives as an empty object. This is what the + // runtime has always shipped and what the cross-runtime worker suite asserts; + // moving it to kReject is a breaking change both runtimes have to make + // together. + kDegrade, +}; + +// Throws the runtime's DataCloneError. There is no DOMException here, so it is +// an Error carrying that name — routed through NativeScriptException so the +// object is shaped like every other error the runtime raises. +void ThrowDataCloneError(v8::Isolate* isolate, const std::string& message); + +// 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 +// structuredClone round-trips on a single one. +class SerializedValue { + public: + SerializedValue() = default; + SerializedValue(SerializedValue&&) = default; + SerializedValue& operator=(SerializedValue&&) = default; + SerializedValue(const SerializedValue&) = delete; + SerializedValue& operator=(const SerializedValue&) = delete; + + // Serializes `input`, moving out of this isolate every ArrayBuffer named by + // `transferList` (an Array, or undefined/null for none). Returns Nothing with + // an exception pending: a TypeError when the transfer list is not an Array, a + // DataCloneError for anything wrong with its entries or with the value. + v8::Maybe Serialize(v8::Isolate* isolate, + v8::Local context, + v8::Local input, + v8::Local transferList, + HostObjectPolicy hostObjectPolicy); + + // Reads the value back into `context`. Transferred buffers are consumed, so + // this runs once per serialized value. + v8::MaybeLocal Deserialize(v8::Isolate* isolate, + v8::Local context); + + private: + struct FreeDeleter { + void operator()(void* pointer) const { std::free(pointer); } + }; + + // The serializer grows this with realloc() through its delegate's default + // allocator, so it is free()d rather than deleted. + std::unique_ptr buffer_; + size_t bufferSize_ = 0; + // Backing stores moved out of the sending isolate. Each is re-wrapped in a + // fresh ArrayBuffer under the same transfer id on the receiving side. + std::vector> transferredBuffers_; + // Backing stores shared with — not moved from — the sending isolate. + std::vector> sharedBuffers_; +}; + +} // namespace serialization +} // namespace tns + +#endif /* StructuredSerialization_h */ diff --git a/NativeScript/runtime/Worker.h b/NativeScript/runtime/Worker.h index f1663336..c532dddd 100644 --- a/NativeScript/runtime/Worker.h +++ b/NativeScript/runtime/Worker.h @@ -17,8 +17,8 @@ class Worker { static void TerminateCallback(const v8::FunctionCallbackInfo& info); static void OnMessageCallback(v8::Isolate* isolate, v8::Local receiver, std::shared_ptr message); static void PostMessageToMainCallback(const v8::FunctionCallbackInfo& info); - static void CloseWorkerCallback(const v8::FunctionCallbackInfo& info); - static v8::Local Serialize(v8::Isolate* isolate, v8::Local value, v8::Local& error); + static void CloseWorkerCallback( + const v8::FunctionCallbackInfo& info); static void SetWorkerId(v8::Isolate* isolate, int workerId); static int GetWorkerId(v8::Isolate* isolate, v8::Local global); }; diff --git a/NativeScript/runtime/Worker.mm b/NativeScript/runtime/Worker.mm index f59d4357..62bf7cc6 100644 --- a/NativeScript/runtime/Worker.mm +++ b/NativeScript/runtime/Worker.mm @@ -283,13 +283,6 @@ throw NativeScriptException( return; } - // Local error; - // Local result = Worker::Serialize(isolate, info[0], error); - // if (result.IsEmpty()) { - // isolate->ThrowException(error); - // return; - // } - auto context = Caches::Get(isolate)->GetContext(); auto message = std::make_shared(); Local objTemplate = ObjectTemplate::New(isolate); @@ -300,8 +293,14 @@ throw NativeScriptException( success = obj->Set(context, tns::ToV8String(isolate, "data"), info[0]).FromMaybe(false); tns::Assert(success, isolate); - message->Serialize(isolate, context, obj); - // std::string message = tns::ToString(isolate, result); + if (message + ->Serialize(isolate, context, obj, v8::Undefined(isolate), + serialization::HostObjectPolicy::kDegrade) + .IsNothing()) { + // The transfer list was rejected or the value could not be cloned; the + // exception is already pending and nothing may be posted. + return; + } auto runtime = static_cast(state->GetIsolate()->GetData(Constants::RUNTIME_SLOT)); if (runtime == nullptr) { @@ -353,15 +352,15 @@ throw NativeScriptException( success = obj->Set(context, tns::ToV8String(isolate, "data"), info[0]).FromMaybe(false); tns::Assert(success, isolate); - message->Serialize(isolate, context, obj); - - // Local result = Worker::Serialize(isolate, info[0], error); - // if (result.IsEmpty()) { - // isolate->ThrowException(error); - // return; - // } + if (message + ->Serialize(isolate, context, obj, v8::Undefined(isolate), + serialization::HostObjectPolicy::kDegrade) + .IsNothing()) { + // The transfer list was rejected or the value could not be cloned; the + // exception is already pending and nothing may be posted. + return; + } - // std::string message = tns::ToString(isolate, result); worker->PostMessage(message); } catch (NativeScriptException& ex) { ex.ReThrowToV8(isolate); @@ -434,30 +433,6 @@ throw NativeScriptException( worker->Terminate(); } -Local Worker::Serialize(Isolate* isolate, Local value, Local& error) { - Local context = isolate->GetCurrentContext(); - Local objTemplate = ObjectTemplate::New(isolate); - - Local obj; - bool success = objTemplate->NewInstance(context).ToLocal(&obj); - tns::Assert(success, isolate); - - success = obj->Set(context, tns::ToV8String(isolate, "data"), value).FromMaybe(false); - tns::Assert(success, isolate); - - Local result; - TryCatch tc(isolate); - success = v8::JSON::Stringify(context, obj).ToLocal(&result); - if (!success && tc.HasCaught()) { - error = tc.Exception(); - return Local(); - } - - tns::Assert(success, isolate); - - return result.As(); -} - void Worker::SetWorkerId(Isolate* isolate, int workerId) { // Runs on the worker thread right after Runtime::Init(), whose Isolate::Scope // has already been unwound -- so this has to enter the isolate itself, and diff --git a/v8ios.xcodeproj/project.pbxproj b/v8ios.xcodeproj/project.pbxproj index 88e409ac..4b633daa 100644 --- a/v8ios.xcodeproj/project.pbxproj +++ b/v8ios.xcodeproj/project.pbxproj @@ -15,7 +15,6 @@ 3C1850542A6DCB2D002ACC81 /* Timers.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 3C1850522A6DCB2D002ACC81 /* Timers.cpp */; }; 3C1850552A6DCB2D002ACC81 /* Timers.hpp in Headers */ = {isa = PBXBuildFile; fileRef = 3C1850532A6DCB2D002ACC81 /* Timers.hpp */; }; 3C48F68D2F57905500C14231 /* json.hpp in Headers */ = {isa = PBXBuildFile; fileRef = 3C48F68B2F57905500C14231 /* json.hpp */; }; - 3C5333342B0E683100BE0C47 /* Message.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 3C5333322B0E683100BE0C47 /* Message.cpp */; }; 3C5333352B0E683100BE0C47 /* Message.hpp in Headers */ = {isa = PBXBuildFile; fileRef = 3C5333332B0E683100BE0C47 /* Message.hpp */; }; 3C78BA5C2A0D600100C20A88 /* ModuleBinding.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 3C78BA5A2A0D600100C20A88 /* ModuleBinding.cpp */; }; 3C78BA5D2A0D600100C20A88 /* ModuleBinding.hpp in Headers */ = {isa = PBXBuildFile; fileRef = 3C78BA5B2A0D600100C20A88 /* ModuleBinding.hpp */; }; @@ -295,6 +294,7 @@ 4A5C201A2E2B000100000007 /* RuntimeBuiltins.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 4A5C201A2E2B000100000003 /* RuntimeBuiltins.cpp */; }; 4A5C201A2E2B000200000006 /* NsBuiltinModules.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 4A5C201A2E2B000200000001 /* NsBuiltinModules.cpp */; }; 4A5C201A2E2B000400000003 /* StructuredClone.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 4A5C201A2E2B000400000001 /* StructuredClone.cpp */; }; + 4A5C201A2E2B000500000003 /* StructuredSerialization.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 4A5C201A2E2B000500000001 /* StructuredSerialization.cpp */; }; C2DDEB93229EAC8300345BFE /* ArgConverter.mm in Sources */ = {isa = PBXBuildFile; fileRef = C2DDEB6A229EAC8200345BFE /* ArgConverter.mm */; }; C2DDEB94229EAC8300345BFE /* Console.cpp in Sources */ = {isa = PBXBuildFile; fileRef = C2DDEB6B229EAC8200345BFE /* Console.cpp */; }; C2DDEB95229EAC8300345BFE /* SetTimeout.cpp in Sources */ = {isa = PBXBuildFile; fileRef = C2DDEB6C229EAC8200345BFE /* SetTimeout.cpp */; }; @@ -467,7 +467,6 @@ 3C1850522A6DCB2D002ACC81 /* Timers.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = Timers.cpp; sourceTree = ""; }; 3C1850532A6DCB2D002ACC81 /* Timers.hpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = Timers.hpp; sourceTree = ""; }; 3C48F68B2F57905500C14231 /* json.hpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = json.hpp; sourceTree = ""; }; - 3C5333322B0E683100BE0C47 /* Message.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = Message.cpp; sourceTree = ""; }; 3C5333332B0E683100BE0C47 /* Message.hpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = Message.hpp; sourceTree = ""; }; 3C78BA5A2A0D600100C20A88 /* ModuleBinding.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = ModuleBinding.cpp; sourceTree = ""; }; 3C78BA5B2A0D600100C20A88 /* ModuleBinding.hpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = ModuleBinding.hpp; sourceTree = ""; }; @@ -818,6 +817,8 @@ 4A5C201A2E2B000100000005 /* js */ = {isa = PBXFileReference; lastKnownFileType = folder; path = js; sourceTree = ""; }; 4A5C201A2E2B000400000001 /* StructuredClone.cpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.cpp; path = StructuredClone.cpp; sourceTree = ""; }; 4A5C201A2E2B000400000002 /* StructuredClone.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = StructuredClone.h; sourceTree = ""; }; + 4A5C201A2E2B000500000001 /* StructuredSerialization.cpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.cpp; path = StructuredSerialization.cpp; sourceTree = ""; }; + 4A5C201A2E2B000500000002 /* StructuredSerialization.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = StructuredSerialization.h; sourceTree = ""; }; C2DDEB6A229EAC8200345BFE /* ArgConverter.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = ArgConverter.mm; sourceTree = ""; }; C2DDEB6B229EAC8200345BFE /* Console.cpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.cpp; path = Console.cpp; sourceTree = ""; }; C2DDEB6C229EAC8200345BFE /* SetTimeout.cpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.cpp; path = SetTimeout.cpp; sourceTree = ""; }; @@ -1521,6 +1522,8 @@ C2DDEB87229EAC8300345BFE /* StringHasher.h */, 4A5C201A2E2B000400000002 /* StructuredClone.h */, 4A5C201A2E2B000400000001 /* StructuredClone.cpp */, + 4A5C201A2E2B000500000002 /* StructuredSerialization.h */, + 4A5C201A2E2B000500000001 /* StructuredSerialization.cpp */, C2F4D0AC232F85E20008A2EB /* SymbolIterator.h */, C2F4D0AB232F85E20008A2EB /* SymbolIterator.mm */, C2DDEB7F229EAC8200345BFE /* SymbolLoader.h */, @@ -1555,7 +1558,6 @@ 3C78BA5B2A0D600100C20A88 /* ModuleBinding.hpp */, 3C1850522A6DCB2D002ACC81 /* Timers.cpp */, 3C1850532A6DCB2D002ACC81 /* Timers.hpp */, - 3C5333322B0E683100BE0C47 /* Message.cpp */, 3C5333332B0E683100BE0C47 /* Message.hpp */, ); path = runtime; @@ -2294,6 +2296,7 @@ 4A5C201A2E2B000100000006 /* BuiltinLoader.cpp in Sources */, 4A5C201A2E2B000200000006 /* NsBuiltinModules.cpp in Sources */, 4A5C201A2E2B000400000003 /* StructuredClone.cpp in Sources */, + 4A5C201A2E2B000500000003 /* StructuredSerialization.cpp in Sources */, 4A5C201A2E2B000100000007 /* RuntimeBuiltins.cpp in Sources */, C2A5F86A2359AEB600074AFA /* ExtVector.cpp in Sources */, AA8C47B22E27114300649BF5 /* ModuleInternalCallbacks.mm in Sources */, @@ -2343,7 +2346,6 @@ F1F30E752B58FC74006A62C0 /* URLSearchParamsImpl.cpp in Sources */, F1F30E742B58FC74006A62C0 /* URLImpl.cpp in Sources */, C2DDEBA3229EAC8300345BFE /* ArrayAdapter.mm in Sources */, - 3C5333342B0E683100BE0C47 /* Message.cpp in Sources */, C2C8EE7422CE3266001F8CEC /* ConcurrentQueue.cpp in Sources */, C2DDEBA0229EAC8300345BFE /* Interop.mm in Sources */, C2D7E9D623F42C1100DB289C /* PromiseProxy.cpp in Sources */, From 3d6d1d958adfe8364fa0b80aa4414373269aba53 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 10 Aug 2026 20:16:22 -0300 Subject: [PATCH 4/7] feat(worker): ArrayBuffer transfer on postMessage Both postMessage entry points -- Worker.prototype.postMessage and the worker global -- accepted a second argument and dropped it, so buffers could only ever be copied into a worker. They now hand it to the shared serialization core as the transfer list, which moves each buffer's memory instead: the sender's ArrayBuffer is detached as the message is written, and the receiving isolate wraps the same backing store. The list must be a plain array; anything else is a TypeError. The WebIDL iterable-to-sequence conversion that lets structuredClone accept a Set or any iterable belongs to its JavaScript wrapper, and postMessage has none. Everything past that point -- duplicate detection, the detached and detachable checks, and detaching only after a successful write -- is the same code structuredClone runs. Documents the transfer semantics alongside structuredClone's, including the one behavior the two entry points do not share: a posted native object still arrives as an empty object rather than raising a DataCloneError. --- NativeScript/runtime/Worker.mm | 10 ++++++---- TestRunner/app/shared | 2 +- docs/structured-clone.md | 23 ++++++++++++++++++++--- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/NativeScript/runtime/Worker.mm b/NativeScript/runtime/Worker.mm index 62bf7cc6..88fce2c2 100644 --- a/NativeScript/runtime/Worker.mm +++ b/NativeScript/runtime/Worker.mm @@ -271,7 +271,7 @@ throw NativeScriptException( throw NativeScriptException("Not enough arguments."); } - if (info.Length() > 1) { + if (info.Length() > 2) { throw NativeScriptException("Too many arguments passed."); } @@ -293,8 +293,9 @@ throw NativeScriptException( success = obj->Set(context, tns::ToV8String(isolate, "data"), info[0]).FromMaybe(false); tns::Assert(success, isolate); + Local transferList = info.Length() > 1 ? info[1] : v8::Undefined(isolate).As(); if (message - ->Serialize(isolate, context, obj, v8::Undefined(isolate), + ->Serialize(isolate, context, obj, transferList, serialization::HostObjectPolicy::kDegrade) .IsNothing()) { // The transfer list was rejected or the value could not be cloned; the @@ -329,7 +330,7 @@ throw NativeScriptException( return; } - if (info.Length() > 1) { + if (info.Length() > 2) { throw NativeScriptException("Too many arguments passed."); return; } @@ -352,8 +353,9 @@ throw NativeScriptException( success = obj->Set(context, tns::ToV8String(isolate, "data"), info[0]).FromMaybe(false); tns::Assert(success, isolate); + Local transferList = info.Length() > 1 ? info[1] : v8::Undefined(isolate).As(); if (message - ->Serialize(isolate, context, obj, v8::Undefined(isolate), + ->Serialize(isolate, context, obj, transferList, serialization::HostObjectPolicy::kDegrade) .IsNothing()) { // The transfer list was rejected or the value could not be cloned; the diff --git a/TestRunner/app/shared b/TestRunner/app/shared index 5058d193..63748281 160000 --- a/TestRunner/app/shared +++ b/TestRunner/app/shared @@ -1 +1 @@ -Subproject commit 5058d1932d1dbcb5726ed93725398c4c735df266 +Subproject commit 63748281fb46b32a29b5d90b3ae4cbcdf4e9c9cb diff --git a/docs/structured-clone.md b/docs/structured-clone.md index f0fc6f67..81a1ee8c 100644 --- a/docs/structured-clone.md +++ b/docs/structured-clone.md @@ -22,7 +22,9 @@ Cloneable: every primitive value except symbols — numbers (including `-0`, `Na 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. -Not cloneable — each throws (see the deviation below): functions, symbols, `WeakMap`/`WeakSet`/`WeakRef`, `Promise`, and every native/interop object (ObjC wrappers, pointers, function references), which have no serialized form. +`SharedArrayBuffer` is **shared, not copied**: the clone is a second `SharedArrayBuffer` over the same memory, so writes through either are visible through the other. + +Not cloneable — each throws (see the deviations below): functions, symbols, `WeakMap`/`WeakSet`/`WeakRef`, `Promise`, and every native/interop object (ObjC wrappers, pointers, function references), which have no serialized form. ## Transfer semantics @@ -30,9 +32,24 @@ Listed buffers are validated before anything is serialized: each entry must be a On success the memory changes hands rather than being copied: the source buffer is detached (`byteLength` becomes 0, and every typed array over it becomes zero-length) and the clone receives the original backing store. A transferred buffer need not appear inside `value` at all; a buffer reached through a typed array in `value` is transferred as a unit, so the cloned view sees the original bytes. +## Worker `postMessage` + +`structuredClone` and worker `postMessage` run on the same serialization core, so everything above — which types clone, graph identity, cycles, `SharedArrayBuffer` sharing — holds for messages too. `postMessage` takes the same transfer list as a second argument: + +```js +worker.postMessage({ pixels: buffer }, [buffer]); // buffer is detached here, + // its memory now in the worker +``` + +Two differences are intentional: + +- **The transfer list must be an array.** Nothing else is accepted (anything non-array is a `TypeError`). The WebIDL iterable-to-sequence conversion that lets `structuredClone` take a `Set` or any iterable lives in the JavaScript wrapper around `structuredClone`; `postMessage` is native all the way down and has no such wrapper. +- **Host objects degrade instead of throwing.** Posting a native/interop object delivers an empty object to the receiver rather than raising a `DataCloneError`. This is long-standing shipped behavior that predates the V8 port, and app code relies on it; `structuredClone`, being new, follows the spec and rejects. The asymmetry is encoded in exactly one place — the `HostObjectPolicy` enum in `NativeScript/runtime/StructuredSerialization.h` — and unifying the two on rejection is a breaking change that needs the Android runtime to move at the same time. + ## Deviations from the specification - **`DataCloneError` is an `Error`, not a `DOMException`.** This runtime has no `DOMException`, so failures throw an `Error` whose `name` is set to `"DataCloneError"` — the same shape used for native exceptions (see [Error handling](error-handling.md)). Detect failures with `e.name === "DataCloneError"`; `instanceof DOMException` cannot work. - **Only `ArrayBuffer` is transferable.** The spec's other transferable types — `MessagePort`, `ImageBitmap`, `ReadableStream` and friends — do not exist here. A non-`ArrayBuffer` in the transfer list is a `DataCloneError`. -- **`SharedArrayBuffer` is not supported** and clones to a `DataCloneError`, whether it appears in the value or in the transfer list. -- **Host objects are never cloneable.** The spec leaves platform objects to each host; here every native/interop wrapper is rejected with a `DataCloneError`, because a JavaScript copy detached from its native counterpart would be a wrapper around nothing. +- **Host objects are not cloneable by `structuredClone`.** The spec leaves platform objects to each host; here every native/interop wrapper is rejected with a `DataCloneError`, because a JavaScript copy detached from its native counterpart would be a wrapper around nothing. Worker `postMessage` deliberately differs — see above. + +`SharedArrayBuffer` follows the spec: it is shared rather than copied, and it is not transferable (listing one throws a `DataCloneError`). From 7636c059269457a2400fddbf50e746e6d6f953c3 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 10 Aug 2026 20:28:56 -0300 Subject: [PATCH 5/7] test: run the shared structuredClone suite via runAllTests with a canary The shared suite now gates itself on the global being present, so the explicit opt-in call is redundant: runAllTests() picks it up and the suite skips itself on runtimes that do not implement structuredClone. That gate would also turn this runtime losing structuredClone into a green run, so RuntimeImplementedAPIs.js gets an unguarded spec asserting the global exists. The shared suite is free to skip; this one is not. --- TestRunner/app/shared | 2 +- TestRunner/app/tests/RuntimeImplementedAPIs.js | 9 +++++++++ TestRunner/app/tests/index.js | 3 --- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/TestRunner/app/shared b/TestRunner/app/shared index 63748281..037f981d 160000 --- a/TestRunner/app/shared +++ b/TestRunner/app/shared @@ -1 +1 @@ -Subproject commit 63748281fb46b32a29b5d90b3ae4cbcdf4e9c9cb +Subproject commit 037f981d1ea5074e4621f79ae503fde700276983 diff --git a/TestRunner/app/tests/RuntimeImplementedAPIs.js b/TestRunner/app/tests/RuntimeImplementedAPIs.js index b3d581e2..d282f436 100644 --- a/TestRunner/app/tests/RuntimeImplementedAPIs.js +++ b/TestRunner/app/tests/RuntimeImplementedAPIs.js @@ -78,3 +78,12 @@ describe("queueMicrotask", () => { }, 0); }); }); + +// The shared StructuredClone suite skips itself where the API is missing, which +// would turn this runtime losing structuredClone into a green run. This spec is +// deliberately unguarded so that regression fails instead. +describe("structuredClone canary", () => { + it("is implemented by this runtime", () => { + expect(typeof structuredClone).toBe("function"); + }); +}); diff --git a/TestRunner/app/tests/index.js b/TestRunner/app/tests/index.js index d858fa43..7fdd3869 100644 --- a/TestRunner/app/tests/index.js +++ b/TestRunner/app/tests/index.js @@ -175,9 +175,6 @@ require("./ExtendedClassNamingTests"); // Tests common for all runtimes (git submodule of NativeScript/common-runtime-tests-app). require("../shared/index").runAllTests(); -// Opt-in shared suite: structuredClone is not shipped by every runtime yet. -require("../shared/index").runStructuredCloneTests(); - // (Optional) Custom testing for various optional sdk's and frameworks // These can be turned on manually to verify if needed anytime //require("./sdks/MusicKit"); From a63821c9721704e1d420cd4352a24d7445c031a6 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 10 Aug 2026 20:47:50 -0300 Subject: [PATCH 6/7] fix(serialization): propagate Detach failures and resolve the postMessage target before serializing Two ways a transfer could take a buffer's memory and give nothing back. Serialize discarded the result of ArrayBuffer::Detach, so a buffer that refused the null key was recorded as transferred while its contents stayed where they were; the receiver would have got an empty buffer and no error. The failure now propagates. Only a buffer carrying an [[ArrayBufferDetachKey]] can refuse, which nothing here produces -- script cannot set one, this runtime never calls SetDetachKey, and the WebAssembly memory buffers that have one are already turned away as non-detachable -- so the branch is unreachable and stays untested by design; the comment says so, and V8's TypeError is passed along unchanged because it names the mismatch. The worker-to-main path also looked up the target runtime after serializing and returned silently when it was gone -- by which point the caller's transfer-list buffers were already detached. The lookup moves above the serialize call, alongside the IsRunning check, so a message that cannot be delivered costs the caller nothing. The main-to-worker path already resolved its target first and is unchanged. Also documents that postMessage treats a missing, undefined or null transfer list as "transfer nothing", and rejects every other non-array. --- NativeScript/runtime/StructuredSerialization.cpp | 13 ++++++++++--- NativeScript/runtime/Worker.mm | 12 ++++++++---- docs/structured-clone.md | 2 +- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/NativeScript/runtime/StructuredSerialization.cpp b/NativeScript/runtime/StructuredSerialization.cpp index 48eddcca..a6c6602d 100644 --- a/NativeScript/runtime/StructuredSerialization.cpp +++ b/NativeScript/runtime/StructuredSerialization.cpp @@ -184,9 +184,16 @@ Maybe SerializedValue::Serialize(Isolate* isolate, Local context, // reference to it. for (Local buffer : transfers) { std::shared_ptr backingStore = buffer->GetBackingStore(); - // A null key is accepted for buffers without a detach key. The result is - // deliberately discarded: a key mismatch must not abort the process. - buffer->Detach(Local()).FromMaybe(false); + // Detach rejects a null key only for a buffer carrying an + // [[ArrayBufferDetachKey]]: script cannot set one, this runtime never calls + // SetDetachKey, and the WebAssembly memory buffers that have one are + // already turned away as non-detachable above. Unreachable, then — but + // claiming success without moving the memory would hand the receiver an + // empty buffer, so the failure propagates carrying V8's TypeError, which + // names the key mismatch. + if (buffer->Detach(Local()).IsNothing()) { + return Nothing(); + } transferredBuffers_.push_back(std::move(backingStore)); } diff --git a/NativeScript/runtime/Worker.mm b/NativeScript/runtime/Worker.mm index 88fce2c2..dbb02997 100644 --- a/NativeScript/runtime/Worker.mm +++ b/NativeScript/runtime/Worker.mm @@ -283,6 +283,14 @@ throw NativeScriptException( return; } + // Resolved before anything is serialized: serializing a transfer list + // detaches the caller's buffers, so bailing out afterwards would destroy + // their contents without ever delivering the message. + auto runtime = static_cast(state->GetIsolate()->GetData(Constants::RUNTIME_SLOT)); + if (runtime == nullptr) { + return; + } + auto context = Caches::Get(isolate)->GetContext(); auto message = std::make_shared(); Local objTemplate = ObjectTemplate::New(isolate); @@ -303,10 +311,6 @@ throw NativeScriptException( return; } - auto runtime = static_cast(state->GetIsolate()->GetData(Constants::RUNTIME_SLOT)); - if (runtime == nullptr) { - return; - } tns::ExecuteOnRunLoop(runtime->RuntimeLoop(), [state, message]() { Isolate* isolate = state->GetIsolate(); v8::Locker locker(isolate); diff --git a/docs/structured-clone.md b/docs/structured-clone.md index 81a1ee8c..1ed42734 100644 --- a/docs/structured-clone.md +++ b/docs/structured-clone.md @@ -43,7 +43,7 @@ worker.postMessage({ pixels: buffer }, [buffer]); // buffer is detached here, Two differences are intentional: -- **The transfer list must be an array.** Nothing else is accepted (anything non-array is a `TypeError`). The WebIDL iterable-to-sequence conversion that lets `structuredClone` take a `Set` or any iterable lives in the JavaScript wrapper around `structuredClone`; `postMessage` is native all the way down and has no such wrapper. +- **The transfer list must be an array.** Omitting it, or passing `undefined` or `null`, means "transfer nothing"; every other non-array value is a `TypeError`. The WebIDL iterable-to-sequence conversion that lets `structuredClone` take a `Set` or any iterable lives in the JavaScript wrapper around `structuredClone`; `postMessage` is native all the way down and has no such wrapper. - **Host objects degrade instead of throwing.** Posting a native/interop object delivers an empty object to the receiver rather than raising a `DataCloneError`. This is long-standing shipped behavior that predates the V8 port, and app code relies on it; `structuredClone`, being new, follows the spec and rejects. The asymmetry is encoded in exactly one place — the `HostObjectPolicy` enum in `NativeScript/runtime/StructuredSerialization.h` — and unifying the two on rejection is a breaking change that needs the Android runtime to move at the same time. ## Deviations from the specification From e712ceb8b8814c2dc6e2bfab83570d00021defac Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 10 Aug 2026 23:04:49 -0300 Subject: [PATCH 7/7] chore: point shared tests at master with the merged suites The structuredClone suite is merged (#26), so the pointer moves off the feature branch entirely and onto master, where the work now lives alongside the Performance suite merged as #25. master's runAllTests() calls both. Nothing is lost in the move: the suite on master is byte-identical to what the branch carried. The Performance suite arrives with it and gates itself the same way this one does, so on this runtime -- which has no PerformanceObserver -- it reports a single pending spec rather than failures. --- TestRunner/app/shared | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TestRunner/app/shared b/TestRunner/app/shared index 037f981d..2eee85b4 160000 --- a/TestRunner/app/shared +++ b/TestRunner/app/shared @@ -1 +1 @@ -Subproject commit 037f981d1ea5074e4621f79ae503fde700276983 +Subproject commit 2eee85b4ad4863b59bc22a356246d2cbe5cb62c4