From 18cca79bc625efba1cddfefecedb08d82e2e24fe Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 10 Aug 2026 22:08:59 -0300 Subject: [PATCH 1/3] feat: structuredClone global (HTML structured clone, ArrayBuffer transfer) Android port of NativeScript/ios#431, in lockstep with the iOS runtime. Implements the WHATWG structuredClone(value, { transfer }) global as a post-context JS builtin: js/structured-clone.js (shared unchanged with iOS) owns the WebIDL argument coercion, and a thin native binding runs a v8::ValueSerializer -> ValueDeserializer round-trip in the one isolate. The serialization machinery is consolidated into StructuredSerialization.{h,cpp} (tns::serialization) so structuredClone and worker postMessage run on one core: delegate pair, DataCloneError construction (an Error carrying that name -- previously the worker path threw a plain Error with a message prefix), transfer-list validation, and the register->write->claim->detach ordering that V8 14.9 requires (Detach() aborts on non-detachable buffers; Release() must be claimed even after a failed write). WorkerMessage.cpp is deleted and WorkerMessage.h reduced to an alias. postMessage gains the ArrayBuffer transfer list on both entry points. Host objects stay intentionally asymmetric via HostObjectPolicy: structuredClone rejects (spec), postMessage keeps the shipped degrade-to-{} behavior. Tests: shared cross-runtime suite (common-runtime-tests-app#26, 54 specs) wired via shared.runStructuredCloneTests(), plus an unguarded canary in testRuntimeImplementedAPIs.js. Documented in docs/structured-clone.md. --- docs/README.md | 1 + docs/structured-clone.md | 55 ++++ test-app/app/src/main/assets/app/mainpage.js | 1 + test-app/app/src/main/assets/app/shared | 2 +- .../app/tests/testRuntimeImplementedAPIs.js | 9 + test-app/runtime/CMakeLists.txt | 4 +- .../runtime/src/main/cpp/CallbackHandlers.cpp | 33 ++- test-app/runtime/src/main/cpp/Runtime.cpp | 3 + .../runtime/src/main/cpp/StructuredClone.cpp | 67 +++++ .../runtime/src/main/cpp/StructuredClone.h | 20 ++ .../src/main/cpp/StructuredSerialization.cpp | 250 ++++++++++++++++++ .../src/main/cpp/StructuredSerialization.h | 90 +++++++ .../runtime/src/main/cpp/WorkerMessage.cpp | 160 ----------- test-app/runtime/src/main/cpp/WorkerMessage.h | 94 +------ .../runtime/src/main/cpp/js/primordials.js | 4 + .../src/main/cpp/js/structured-clone.js | 110 ++++++++ 16 files changed, 646 insertions(+), 257 deletions(-) create mode 100644 docs/structured-clone.md create mode 100644 test-app/runtime/src/main/cpp/StructuredClone.cpp create mode 100644 test-app/runtime/src/main/cpp/StructuredClone.h create mode 100644 test-app/runtime/src/main/cpp/StructuredSerialization.cpp create mode 100644 test-app/runtime/src/main/cpp/StructuredSerialization.h delete mode 100644 test-app/runtime/src/main/cpp/WorkerMessage.cpp create mode 100644 test-app/runtime/src/main/cpp/js/structured-clone.js diff --git a/docs/README.md b/docs/README.md index cb09a6942..2e8bc411a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,7 @@ # Runtime documentation - [Error handling](error-handling.md) — global `error`/`unhandledrejection` events, `reportError`, catching Java exceptions in JS (`error.nativeException`), forwarding JS throws to Java callers (`interop.escapeException`), JS stacks on Java exceptions (`com.tns.JavaScriptStackTrace`), 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`. - [Implementing additional Chrome DevTools protocol Domains](extending-inspector.md) ## Knowledge diff --git a/docs/structured-clone.md b/docs/structured-clone.md new file mode 100644 index 000000000..4f7877518 --- /dev/null +++ b/docs/structured-clone.md @@ -0,0 +1,55 @@ +# 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: 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. + +`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 (Java proxies and the objects the metadata layer hands out), 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. + +## 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.** 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, 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 `test-app/runtime/src/main/cpp/StructuredSerialization.h` — and unifying the two on rejection is a breaking change that needs the iOS 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"`. 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`. +- **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`). diff --git a/test-app/app/src/main/assets/app/mainpage.js b/test-app/app/src/main/assets/app/mainpage.js index ca13a904d..b0795d190 100644 --- a/test-app/app/src/main/assets/app/mainpage.js +++ b/test-app/app/src/main/assets/app/mainpage.js @@ -18,6 +18,7 @@ shared.runRequireTests(); shared.runWeakRefTests(); shared.runRuntimeTests(); shared.runWorkerTests(); +shared.runStructuredCloneTests(); require("./tests/testWebAssembly"); require("./tests/testMultithreadedJavascript"); require("./tests/testInterfaceDefaultMethods"); diff --git a/test-app/app/src/main/assets/app/shared b/test-app/app/src/main/assets/app/shared index 3a262b979..037f981d1 160000 --- a/test-app/app/src/main/assets/app/shared +++ b/test-app/app/src/main/assets/app/shared @@ -1 +1 @@ -Subproject commit 3a262b979c6b84cdfe69cd495436a7088d016505 +Subproject commit 037f981d1ea5074e4621f79ae503fde700276983 diff --git a/test-app/app/src/main/assets/app/tests/testRuntimeImplementedAPIs.js b/test-app/app/src/main/assets/app/tests/testRuntimeImplementedAPIs.js index 501cf4e61..db176a109 100644 --- a/test-app/app/src/main/assets/app/tests/testRuntimeImplementedAPIs.js +++ b/test-app/app/src/main/assets/app/tests/testRuntimeImplementedAPIs.js @@ -31,3 +31,12 @@ describe("Runtime exposes", function () { expect(ok).toBe(true, "__time delta " + timeDelta + "ms diverged from Date.now delta " + dateDelta + "ms (tolerance " + tolerance + "ms) on all " + attempts + " attempts"); }); }); + +// 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", function () { + it("is implemented by this runtime", function () { + expect(typeof structuredClone).toBe("function"); + }); +}); diff --git a/test-app/runtime/CMakeLists.txt b/test-app/runtime/CMakeLists.txt index f61bbd353..ca0833edb 100644 --- a/test-app/runtime/CMakeLists.txt +++ b/test-app/runtime/CMakeLists.txt @@ -74,6 +74,7 @@ set(RUNTIME_BUILTIN_JS ${RUNTIME_BUILTIN_JS_DIR}/ns-util.js ${RUNTIME_BUILTIN_JS_DIR}/primordials.js ${RUNTIME_BUILTIN_JS_DIR}/require-factory.js + ${RUNTIME_BUILTIN_JS_DIR}/structured-clone.js ${RUNTIME_BUILTIN_JS_DIR}/weak-ref.js ) set(RUNTIME_BUILTINS_GENERATED_DIR ${PROJECT_SOURCE_DIR}/src/main/cpp/generated) @@ -184,11 +185,12 @@ add_library( src/main/cpp/Runtime.cpp src/main/cpp/SimpleAllocator.cpp src/main/cpp/SimpleProfiler.cpp + src/main/cpp/StructuredClone.cpp + src/main/cpp/StructuredSerialization.cpp src/main/cpp/Util.cpp src/main/cpp/V8GlobalHelpers.cpp src/main/cpp/V8StringConstants.cpp src/main/cpp/WeakRef.cpp - src/main/cpp/WorkerMessage.cpp src/main/cpp/WorkerWrapper.cpp src/main/cpp/Timers.cpp src/main/cpp/com_tns_AssetExtractor.cpp diff --git a/test-app/runtime/src/main/cpp/CallbackHandlers.cpp b/test-app/runtime/src/main/cpp/CallbackHandlers.cpp index de1d0b345..84d5623a9 100644 --- a/test-app/runtime/src/main/cpp/CallbackHandlers.cpp +++ b/test-app/runtime/src/main/cpp/CallbackHandlers.cpp @@ -1222,12 +1222,18 @@ CallbackHandlers::WorkerObjectPostMessageCallback(const v8::FunctionCallbackInfo HandleScope scope(isolate); try { - if (args.Length() != 1) { + if (args.Length() < 1) { isolate->ThrowException(ArgConverter::ConvertToV8String(isolate, "Failed to execute 'postMessage' on 'Worker': 1 argument required.")); return; } + if (args.Length() > 2) { + isolate->ThrowException(ArgConverter::ConvertToV8String(isolate, + "Failed to execute 'postMessage' on 'Worker': no more than 2 arguments accepted.")); + return; + } + auto thiz = args.This(); // Worker instance Local jsId; @@ -1249,9 +1255,15 @@ CallbackHandlers::WorkerObjectPostMessageCallback(const v8::FunctionCallbackInfo return; } + Local transferList = args.Length() > 1 + ? args[1] + : v8::Undefined(isolate).As(); auto message = std::make_shared(); - if (message->Serialize(isolate, context, args[0]).IsNothing()) { - // a DataCloneError is already pending on the isolate + if (message->Serialize(isolate, context, args[0], transferList, + 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; } @@ -1283,9 +1295,12 @@ CallbackHandlers::WorkerGlobalPostMessageCallback(const v8::FunctionCallbackInfo TryCatch tc(isolate); // TODO: Pete: Discuss whether this is the way to go - if (args.Length() != 1) { + if (args.Length() < 1) { isolate->ThrowException(ArgConverter::ConvertToV8String(isolate, "Failed to execute 'postMessage' on WorkerGlobalScope: 1 argument required.")); + } else if (args.Length() > 2) { + isolate->ThrowException(ArgConverter::ConvertToV8String(isolate, + "Failed to execute 'postMessage' on WorkerGlobalScope: no more than 2 arguments accepted.")); } if (tc.HasCaught()) { @@ -1301,9 +1316,15 @@ CallbackHandlers::WorkerGlobalPostMessageCallback(const v8::FunctionCallbackInfo } auto context = isolate->GetCurrentContext(); + Local transferList = args.Length() > 1 + ? args[1] + : v8::Undefined(isolate).As(); auto message = std::make_shared(); - if (message->Serialize(isolate, context, args[0]).IsNothing()) { - // a DataCloneError is already pending on the isolate + if (message->Serialize(isolate, context, args[0], transferList, + 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; } diff --git a/test-app/runtime/src/main/cpp/Runtime.cpp b/test-app/runtime/src/main/cpp/Runtime.cpp index b463cb728..69ce59328 100644 --- a/test-app/runtime/src/main/cpp/Runtime.cpp +++ b/test-app/runtime/src/main/cpp/Runtime.cpp @@ -32,6 +32,7 @@ #include "NativeScriptException.h" #include "SimpleAllocator.h" #include "SimpleProfiler.h" +#include "StructuredClone.h" #include "URLImpl.h" #include "URLPatternImpl.h" #include "URLSearchParamsImpl.h" @@ -853,6 +854,8 @@ Isolate* Runtime::PrepareV8Runtime(const string& filesPath, Events::Init(context); ErrorEvents::Init(context); + StructuredClone::Init(context); + // The `interop` global (interop.escapeException), mirroring iOS. Interop::Init(context); diff --git a/test-app/runtime/src/main/cpp/StructuredClone.cpp b/test-app/runtime/src/main/cpp/StructuredClone.cpp new file mode 100644 index 000000000..0c4d17feb --- /dev/null +++ b/test-app/runtime/src/main/cpp/StructuredClone.cpp @@ -0,0 +1,67 @@ +#include "StructuredClone.h" + +#include + +#include "ArgConverter.h" +#include "BuiltinLoader.h" +#include "StructuredSerialization.h" + +using namespace v8; + +namespace tns { + +namespace { + +/* + * binding.clone(value, transferArrayOrUndefined): serialize and deserialize in + * this one isolate, which is what 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(); + 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 (!serialized.Deserialize(isolate, context).ToLocal(&result)) { + return; + } + info.GetReturnValue().Set(result); +} + +} // namespace + +void StructuredClone::Init(Local context) { + Isolate* isolate = Isolate::GetCurrent(); + + Local clone; + bool success = v8::Function::New(context, CloneCallback).ToLocal(&clone); + assert(success); + + Local binding = Object::New(isolate); + success = binding->Set(context, + ArgConverter::ConvertToV8String(isolate, "clone"), + clone) + .FromMaybe(false); + assert(success); + + Local result; + success = BuiltinLoader::RunBuiltin(context, BuiltinId::kStructuredClone, + binding) + .ToLocal(&result); + assert(success); +} + +} // namespace tns diff --git a/test-app/runtime/src/main/cpp/StructuredClone.h b/test-app/runtime/src/main/cpp/StructuredClone.h new file mode 100644 index 000000000..1b3dae31a --- /dev/null +++ b/test-app/runtime/src/main/cpp/StructuredClone.h @@ -0,0 +1,20 @@ +#ifndef STRUCTUREDCLONE_H_ +#define STRUCTUREDCLONE_H_ + +#include "v8.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/test-app/runtime/src/main/cpp/StructuredSerialization.cpp b/test-app/runtime/src/main/cpp/StructuredSerialization.cpp new file mode 100644 index 000000000..74d93594d --- /dev/null +++ b/test-app/runtime/src/main/cpp/StructuredSerialization.cpp @@ -0,0 +1,250 @@ +#include "StructuredSerialization.h" + +#include + +#include "ArgConverter.h" + +using namespace v8; + +namespace tns { +namespace serialization { + +void ThrowDataCloneError(Isolate* isolate, const std::string& message) { + Local context = isolate->GetCurrentContext(); + Local error = + Exception::Error(ArgConverter::ConvertToV8String(isolate, message)); + bool success = + error.As() + ->Set(context, ArgConverter::ConvertToV8String(isolate, "name"), + ArgConverter::ConvertToV8String(isolate, "DataCloneError")) + .FromMaybe(false); + assert(success); + isolate->ThrowException(error); +} + +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_, ArgConverter::ConvertToString(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 = + ArgConverter::ConvertToString(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(ArgConverter::ConvertToV8String( + 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); + assert(buffer_ == nullptr); + + 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(); + // 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)); + } + + 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/test-app/runtime/src/main/cpp/StructuredSerialization.h b/test-app/runtime/src/main/cpp/StructuredSerialization.h new file mode 100644 index 000000000..fac8d20de --- /dev/null +++ b/test-app/runtime/src/main/cpp/StructuredSerialization.h @@ -0,0 +1,90 @@ +#ifndef STRUCTUREDSERIALIZATION_H_ +#define STRUCTUREDSERIALIZATION_H_ + +#include +#include +#include +#include + +#include "v8.h" + +namespace tns { +namespace serialization { + +/* + * What an entry point does with an object backed by native state — a Java + * proxy, an interop wrapper. 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 — the shape the shared cross-runtime suite + * detects clone failures by. + */ +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/test-app/runtime/src/main/cpp/WorkerMessage.cpp b/test-app/runtime/src/main/cpp/WorkerMessage.cpp deleted file mode 100644 index f527c7ed9..000000000 --- a/test-app/runtime/src/main/cpp/WorkerMessage.cpp +++ /dev/null @@ -1,160 +0,0 @@ -#include "WorkerMessage.h" - -#include - -#include "ArgConverter.h" - -using namespace v8; - -namespace tns { -namespace worker { -namespace { - -void ThrowDataCloneException(Local context, Local message) { - Isolate* isolate = v8::Isolate::GetCurrent(); - std::string msg = "DataCloneError: " + ArgConverter::ConvertToString(message); - isolate->ThrowException( - Exception::Error(ArgConverter::ConvertToV8String(isolate, msg))); -} - -class SerializerDelegate : public 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 { - // Host objects (e.g. Java proxies) carry no transferable native state; - // they are recreated as plain objects on the receiving side. - return Just(true); - } - - Maybe GetSharedArrayBufferId( - Isolate* isolate, Local shared_array_buffer) override { - uint32_t i; - for (i = 0; i < seen_shared_array_buffers_.size(); ++i) { - if (seen_shared_array_buffers_[i].Get(isolate) == 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); - } - - ValueSerializer* serializer = nullptr; - -private: - Isolate* isolate_; - Local context_; - Message* msg_; - std::vector> seen_shared_array_buffers_; - - friend class tns::worker::Message; -}; - -class DeserializerDelegate : public ValueDeserializer::Delegate { -public: - DeserializerDelegate( - Message* m, Isolate* isolate, - const std::vector>& shared_array_buffers) - : shared_array_buffers_(shared_array_buffers) {} - - MaybeLocal ReadHostObject(Isolate* isolate) override { - EscapableHandleScope scope(isolate); - Local object = Object::New(isolate); - return scope.Escape(object).As(); - } - - MaybeLocal GetSharedArrayBufferFromId( - Isolate* isolate, uint32_t clone_id) override { - if (clone_id >= shared_array_buffers_.size()) { - return MaybeLocal(); - } - return shared_array_buffers_[clone_id]; - } - - ValueDeserializer* deserializer = nullptr; - -private: - const std::vector>& shared_array_buffers_; -}; - -} // namespace - -Maybe Message::Serialize(Isolate* isolate, Local context, - Local input) { - HandleScope handle_scope(isolate); - Context::Scope context_scope(context); - - // Verify that we're not silently overwriting an existing message. - assert(main_message_buf_.is_empty()); - - SerializerDelegate delegate(isolate, context, this); - ValueSerializer serializer(isolate, &delegate); - delegate.serializer = &serializer; - - serializer.WriteHeader(); - if (serializer.WriteValue(context, input).IsNothing()) { - return Nothing(); - } - - // The serializer gave us a buffer allocated using `malloc()`. - std::pair data = serializer.Release(); - assert(data.first != nullptr); - 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); - EscapableHandleScope handle_scope(isolate); - - // Attach all transferred SharedArrayBuffers to their new Isolate. - std::vector> shared_array_buffers; - 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, shared_array_buffers); - 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 MaybeLocal(); - } - - Local return_value; - if (!deserializer.ReadValue(context).ToLocal(&return_value)) { - return MaybeLocal(); - } - - 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/test-app/runtime/src/main/cpp/WorkerMessage.h b/test-app/runtime/src/main/cpp/WorkerMessage.h index 734fbad35..fcc890160 100644 --- a/test-app/runtime/src/main/cpp/WorkerMessage.h +++ b/test-app/runtime/src/main/cpp/WorkerMessage.h @@ -1,101 +1,17 @@ #ifndef WORKERMESSAGE_H_ #define WORKERMESSAGE_H_ -#include -#include -#include -#include "v8.h" +#include "StructuredSerialization.h" namespace tns { - -template -inline T* Malloc(size_t n) { - // n is an element count (see UncheckedRealloc), not a byte count - return static_cast(malloc(sizeof(T) * n)); -} - -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); - - 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) { - 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; -}; - namespace worker { /* - * A structured-clone payload that can cross isolate/thread boundaries. - * Serialized with v8::ValueSerializer on the sending isolate and - * deserialized with v8::ValueDeserializer on the receiving one. - * SharedArrayBuffer contents are shared via their backing stores. + * 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). */ -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); - - // Called when a new SharedArrayBuffer object is encountered in the - // incoming value's structure. - void AddSharedArrayBuffer(std::shared_ptr backing_store); - - private: - MallocedBuffer main_message_buf_; - std::vector> array_buffers_; - std::vector> shared_array_buffers_; -}; +using Message = tns::serialization::SerializedValue; } // namespace worker } // namespace tns diff --git a/test-app/runtime/src/main/cpp/js/primordials.js b/test-app/runtime/src/main/cpp/js/primordials.js index 119bb4187..1e9d35b73 100644 --- a/test-app/runtime/src/main/cpp/js/primordials.js +++ b/test-app/runtime/src/main/cpp/js/primordials.js @@ -22,6 +22,7 @@ const uncurryThis = FunctionPrototypeBind.bind(FunctionPrototypeCall); const intrinsics = { // Constructors. Date, + Error, Map, Number, Proxy, @@ -29,6 +30,9 @@ const intrinsics = { String, TypeError, + // Well-known symbols. + SymbolIterator: Symbol.iterator, + // Namespaces / prototypes. ObjectPrototype: Object.prototype, diff --git a/test-app/runtime/src/main/cpp/js/structured-clone.js b/test-app/runtime/src/main/cpp/js/structured-clone.js new file mode 100644 index 000000000..3b3243f43 --- /dev/null +++ b/test-app/runtime/src/main/cpp/js/structured-clone.js @@ -0,0 +1,110 @@ +"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"); + } + + // 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 = FunctionPrototypeCall(next, iterator); + 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); +}; From fa4eb20b72156748ddb17849ebd1fef60bbc1550 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 10 Aug 2026 23:14:34 -0300 Subject: [PATCH 2/3] test: pin shared suite to common-runtime-tests-app master Both tests PRs (#25 Performance, #26 StructuredClone) squash-merged upstream; the branch commit this previously pinned is now orphaned. StructuredClone/ content is byte-identical to the tested commit; master additionally carries the Performance suite, which mainpage.js does not invoke. --- test-app/app/src/main/assets/app/shared | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-app/app/src/main/assets/app/shared b/test-app/app/src/main/assets/app/shared index 037f981d1..2eee85b4a 160000 --- a/test-app/app/src/main/assets/app/shared +++ b/test-app/app/src/main/assets/app/shared @@ -1 +1 @@ -Subproject commit 037f981d1ea5074e4621f79ae503fde700276983 +Subproject commit 2eee85b4ad4863b59bc22a356246d2cbe5cb62c4 From 975e724120696fa76df04cb10cc72d8d1d56f1b3 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Tue, 11 Aug 2026 11:59:37 -0300 Subject: [PATCH 3/3] test: bump shared suite to current master (Performance detail-clone specs) Matches the pin on ios main. Only Performance/index.js changes -- not invoked by mainpage.js; StructuredClone/ is unchanged. --- test-app/app/src/main/assets/app/shared | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-app/app/src/main/assets/app/shared b/test-app/app/src/main/assets/app/shared index 2eee85b4a..8be1d9f53 160000 --- a/test-app/app/src/main/assets/app/shared +++ b/test-app/app/src/main/assets/app/shared @@ -1 +1 @@ -Subproject commit 2eee85b4ad4863b59bc22a356246d2cbe5cb62c4 +Subproject commit 8be1d9f539ef48861a889bef7216dad84e05b843