From e694faa2b365ab7f68cd117fd00b4529341d22b7 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 26 Aug 2026 15:09:16 -0300 Subject: [PATCH 01/18] feat(runtime): native MessagePort, MessageChannel, BroadcastChannel, and node:worker_threads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports are Node's three-way split without libuv: an isolate-free PortData (mutex-guarded queue, sibling-group entanglement) under a per-isolate NativeMessagePort whose wake is a coalesced EventLoop::PostInternal — producers never take a foreign isolate's Locker. Pairwise channels and named BroadcastChannel groups share one SiblingGroup mechanism; close sentinels are empty messages ordered behind queued traffic. Ports transfer through postMessage and structuredClone as host-object tag 2 on the DOMException wire format: index in-stream, PortData out-of-band, nothing detached until the whole graph has written, and received ports pre-constructed before ReadValue since no JS may run inside ReadHostObject. In passing this fixes claimed host objects suppressing V8's embedder-field detection (ObjC wrappers were written as plain objects once any DOMException existed) and a dangling handle in Deserialize's ports out-parameter. Worker and the worker global scope are now real EventTargets: delivery dispatches MessageEvents through defineEventHandler-backed onmessage attributes (position-fixed HTML handler semantics), replacing the direct property calls. Handler wrappers live on the target's own listener bag — a WeakMap keyed by ObjectManager-registered objects corrupts the heap when the finalizer resurrects them. node:worker_threads ships the real MessageChannel/MessagePort/ BroadcastChannel/receiveMessageOnPort/threadId/environment-data surface with documented shims for the rest (docs/worker-threads.md). New globals ride the lazy tier: MessagePort, MessageChannel, BroadcastChannel, MessageEvent. Suite: 1664/0 (+154 messaging specs in the shared submodule). --- NativeScript/runtime/LazyGlobals.cpp | 6 + NativeScript/runtime/Messaging.cpp | 1090 +++++++++++++++++ NativeScript/runtime/Messaging.h | 224 ++++ NativeScript/runtime/NsBuiltinModules.cpp | 8 + NativeScript/runtime/Runtime.mm | 8 + .../runtime/StructuredSerialization.cpp | 261 +++- .../runtime/StructuredSerialization.h | 53 +- NativeScript/runtime/Worker.h | 12 +- NativeScript/runtime/Worker.mm | 102 +- NativeScript/runtime/WorkerWrapper.mm | 16 +- NativeScript/runtime/js/README.md | 29 +- NativeScript/runtime/js/broadcast-channel.js | 119 ++ NativeScript/runtime/js/events.js | 171 ++- NativeScript/runtime/js/message-channel.js | 246 ++++ NativeScript/runtime/js/message-event.js | 151 +++ .../runtime/js/node-worker-threads.js | 272 ++++ NativeScript/runtime/js/primordials.js | 10 + NativeScript/runtime/js/structured-clone.js | 24 +- NativeScript/runtime/js/worker-events.js | 71 ++ TestRunner/app/shared | 2 +- .../app/tests/RuntimeImplementedAPIs.js | 24 + docs/README.md | 2 + docs/ns-builtin-modules.md | 53 +- docs/structured-clone.md | 14 +- docs/worker-threads.md | 247 ++++ eslint.config.mjs | 2 +- tools/js2c-inputs.xcfilelist | 5 + v8ios.xcodeproj/project.pbxproj | 8 + 28 files changed, 3080 insertions(+), 150 deletions(-) create mode 100644 NativeScript/runtime/Messaging.cpp create mode 100644 NativeScript/runtime/Messaging.h create mode 100644 NativeScript/runtime/js/broadcast-channel.js create mode 100644 NativeScript/runtime/js/message-channel.js create mode 100644 NativeScript/runtime/js/message-event.js create mode 100644 NativeScript/runtime/js/node-worker-threads.js create mode 100644 NativeScript/runtime/js/worker-events.js create mode 100644 docs/worker-threads.md diff --git a/NativeScript/runtime/LazyGlobals.cpp b/NativeScript/runtime/LazyGlobals.cpp index 148e7ec25..81ac7dd94 100644 --- a/NativeScript/runtime/LazyGlobals.cpp +++ b/NativeScript/runtime/LazyGlobals.cpp @@ -3,6 +3,7 @@ #include "Base64.h" #include "BuiltinLoader.h" #include "Helpers.h" +#include "Messaging.h" #include "StructuredSerialization.h" #include "TextEncoding.h" @@ -39,6 +40,11 @@ constexpr LazyGlobalEntry kLazyGlobals[] = { // events.js is an eager builtin (Events::Init), so this row never runs a // file: the read hits the exports cache and only the placement is lazy. {"CustomEvent", "CustomEvent", BuiltinExports}, + {"MessageEvent", "MessageEvent", BuiltinExports}, + {"MessagePort", "MessagePort", messaging::GetMessageChannelExports}, + {"MessageChannel", "MessageChannel", messaging::GetMessageChannelExports}, + {"BroadcastChannel", "BroadcastChannel", + messaging::GetBroadcastChannelExports}, }; void LazyGlobalGetter(Local property, diff --git a/NativeScript/runtime/Messaging.cpp b/NativeScript/runtime/Messaging.cpp new file mode 100644 index 000000000..08b154e7f --- /dev/null +++ b/NativeScript/runtime/Messaging.cpp @@ -0,0 +1,1090 @@ +#include "Messaging.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "BuiltinLoader.h" +#include "Caches.h" +#include "EventLoop.h" +#include "Helpers.h" +#include "NativeScriptException.h" +#include "Runtime.h" +#include "StructuredSerialization.h" + +using namespace v8; + +namespace tns { +namespace messaging { + +using Message = serialization::SerializedValue; + +namespace { + +// Per-isolate state. `livePorts` is the strong reference that keeps a port and +// its wrapper alive until it is closed; everything else is registered once by +// the JS tier or built on first use. +struct MessagingState { + ~MessagingState(); + + Isolate* isolate = nullptr; + std::unordered_set> livePorts; + Global portTemplate; + Global emitMessage; + // The tier's per-wrapper setup, read off the builtin's exports. A wrapper is + // built from a template, so no JS constructor ever ran on it. + Global adoptPort; + Global untransferableBrand; + Global uncloneableBrand; + // Gates the serializer's host-object claim, which costs a delegate call per + // plain object in every graph. An isolate that has neither created a port + // nor stamped a brand cannot be holding either, so it keeps serializing on + // the cheap path. + bool claimHostObjects = false; +}; + +// The isolate's Caches is invalidated long before ~Runtime reaches the point +// where ports must be force-closed, and Caches::StateFor answers null from +// then on. This registry is the teardown sweep's way back to the state. +std::mutex g_statesMutex; +std::unordered_map g_states; + +// Null once the isolate's Caches has been invalidated — callers bail rather +// than recreate state that would never be destroyed. +MessagingState* State(Isolate* isolate) { + MessagingState* state = Caches::StateFor(isolate); + if (state == nullptr || state->isolate != nullptr) { + return state; + } + state->isolate = isolate; + std::lock_guard lock(g_statesMutex); + g_states[isolate] = state; + return state; +} + +MessagingState::~MessagingState() { + if (this->isolate != nullptr) { + std::lock_guard lock(g_statesMutex); + g_states.erase(this->isolate); + } + // Detach the set first so a port's teardown cannot mutate it mid-walk. + std::unordered_set> survivors = + std::move(this->livePorts); + this->livePorts.clear(); +} + +// Values set with setEnvironmentData, shared by every isolate in the process. +// Cloned on the way in and read back per isolate, so nothing but bytes is +// shared. Documented deviation from Node: a write after a worker spawned is +// visible to it, because there is no per-thread snapshot. +std::mutex g_environmentDataMutex; +std::unordered_map> + g_environmentData; + +void IllegalConstructorCallback(const FunctionCallbackInfo& info) { + Isolate* isolate = info.GetIsolate(); + isolate->ThrowException( + Exception::TypeError(tns::ToV8String(isolate, "Illegal constructor"))); +} + +// The template every port wrapper is built from. It doubles as the brand: a +// wrapper is recognised by HasInstance, and the port itself lives in the one +// internal field. +Local PortTemplate(Isolate* isolate) { + MessagingState* state = State(isolate); + if (state == nullptr) { + return Local(); + } + if (state->portTemplate.IsEmpty()) { + Local tmpl = + FunctionTemplate::New(isolate, IllegalConstructorCallback); + tmpl->SetClassName(tns::ToV8String(isolate, "MessagePort")); + tmpl->InstanceTemplate()->SetInternalFieldCount(1); + state->portTemplate.Reset(isolate, tmpl); + } + return state->portTemplate.Get(isolate); +} + +// A port can be created on an isolate that never touched MessagePort — a +// worker receiving a transferred one — so the builtin that registers the +// delivery function and exports the wrapper setup is run on demand rather than +// assumed. +bool EnsureJsTier(Local context) { + Isolate* isolate = v8::Isolate::GetCurrent(); + MessagingState* state = State(isolate); + if (state == nullptr) { + return false; + } + if (!state->emitMessage.IsEmpty() && !state->adoptPort.IsEmpty()) { + return true; + } + Local exports; + Local adopt; + if (!GetMessageChannelExports(context).ToLocal(&exports) || + !exports->Get(context, tns::ToV8String(isolate, "adoptPort")) + .ToLocal(&adopt)) { + return false; + } + if (!adopt->IsFunction() || state->emitMessage.IsEmpty()) { + return false; + } + state->adoptPort.Reset(isolate, adopt.As()); + return true; +} + +Local UntransferableBrand(Isolate* isolate, bool create) { + MessagingState* state = State(isolate); + if (state == nullptr) { + return Local(); + } + if (state->untransferableBrand.IsEmpty()) { + if (!create) { + return Local(); + } + state->untransferableBrand.Reset( + isolate, + Private::New(isolate, + tns::ToV8String(isolate, "messagingUntransferable"))); + } + return state->untransferableBrand.Get(isolate); +} + +Local UncloneableBrand(Isolate* isolate, bool create) { + MessagingState* state = State(isolate); + if (state == nullptr) { + return Local(); + } + if (state->uncloneableBrand.IsEmpty()) { + if (!create) { + return Local(); + } + state->uncloneableBrand.Reset( + isolate, Private::New(isolate, tns::ToV8String( + isolate, "messagingUncloneable"))); + } + return state->uncloneableBrand.Get(isolate); +} + +// Private, not a plain Symbol: app code can neither discover a brand nor forge +// one onto a value the sender never marked. +void StampBrand(const FunctionCallbackInfo& info, + Local (*brandFor)(Isolate*, bool)) { + Isolate* isolate = info.GetIsolate(); + if (info.Length() < 1 || !info[0]->IsObject()) { + return; + } + Local brand = brandFor(isolate, true); + if (brand.IsEmpty()) { + return; + } + if (info[0] + .As() + ->SetPrivate(isolate->GetCurrentContext(), brand, v8::True(isolate)) + .FromMaybe(false)) { + State(isolate)->claimHostObjects = true; + } +} + +} // namespace + +// The process-wide set of ports that can reach each other. An anonymous group +// is one channel's two ends; a named one is every BroadcastChannel sharing a +// name, across every isolate in the process. +class SiblingGroup final : public std::enable_shared_from_this { + public: + static std::shared_ptr Get(const std::string& name); + + SiblingGroup() = default; + explicit SiblingGroup(std::string name) : name_(std::move(name)) {} + ~SiblingGroup(); + + SiblingGroup(const SiblingGroup&) = delete; + SiblingGroup& operator=(const SiblingGroup&) = delete; + + DispatchResult Dispatch(PortData* source, std::shared_ptr message, + std::string* error); + void Entangle(std::initializer_list ports); + void Entangle(PortData* port); + void Disentangle(PortData* data); + + private: + const std::string name_; + std::shared_mutex mutex_; + std::set ports_; +}; + +namespace { + +std::mutex g_groupsMutex; +std::unordered_map> g_groups; + +} // namespace + +std::shared_ptr SiblingGroup::Get(const std::string& name) { + std::lock_guard lock(g_groupsMutex); + auto entry = g_groups.find(name); + if (entry != g_groups.end()) { + std::shared_ptr existing = entry->second.lock(); + if (existing != nullptr) { + return existing; + } + } + std::shared_ptr group = std::make_shared(name); + g_groups[name] = group; + return group; +} + +SiblingGroup::~SiblingGroup() { + if (this->name_.empty()) { + return; + } + std::lock_guard lock(g_groupsMutex); + auto entry = g_groups.find(this->name_); + if (entry != g_groups.end() && entry->second.expired()) { + g_groups.erase(entry); + } +} + +DispatchResult SiblingGroup::Dispatch(PortData* source, + std::shared_ptr message, + std::string* error) { + std::shared_lock lock(this->mutex_); + + if (this->ports_.find(source) == this->ports_.end()) { + if (error != nullptr) { + *error = "Source MessagePort is not entangled with this group."; + } + return DispatchResult::kFailed; + } + if (this->ports_.size() <= 1) { + return DispatchResult::kNoDestination; + } + // Nothing that can only be handed over once may fan out. + if (this->ports_.size() > 2 && message->HasTransferables()) { + if (error != nullptr) { + *error = "Transferables cannot be used with multiple destinations."; + } + return DispatchResult::kFailed; + } + + for (PortData* port : this->ports_) { + if (port == source) { + continue; + } + // Only reachable with a single destination, since a fan-out message can + // carry no transferables at all. + if (message->TransfersPort(port)) { + if (error != nullptr) { + *error = + "The target port was posted to itself, and the communication " + "channel was lost"; + } + return DispatchResult::kDelivered; + } + // One message object shared by every destination: legal only because a + // fan-out carries nothing that a destination could consume. + port->AddToIncomingQueue(message); + } + return DispatchResult::kDelivered; +} + +void SiblingGroup::Entangle(PortData* port) { this->Entangle({port}); } + +void SiblingGroup::Entangle(std::initializer_list ports) { + std::unique_lock lock(this->mutex_); + for (PortData* data : ports) { + this->ports_.insert(data); + // group_ is written under the port's own mutex, which is what lets + // PortData::Dispatch read it without racing a disentangle. Taken here in + // the only legal order: this group's lock is already held. + std::lock_guard dataLock(data->mutex_); + tns::Assert(data->group_ == nullptr); + data->group_ = this->shared_from_this(); + } +} + +void SiblingGroup::Disentangle(PortData* data) { + // Keeps the group alive past the last member dropping its reference. + std::shared_ptr self = this->shared_from_this(); + std::unique_lock lock(this->mutex_); + this->ports_.erase(data); + { + std::lock_guard dataLock(data->mutex_); + data->group_.reset(); + } + + // Queued rather than delivered: a close orders behind everything already + // sent, on both ends. + data->AddToIncomingQueue(std::make_shared()); + if (this->ports_.size() == 1 && this->name_.empty()) { + // A channel with one end left is a channel no more; a named group outlives + // any number of members joining and leaving. + (*this->ports_.begin())->AddToIncomingQueue(std::make_shared()); + } +} + +PortData::PortData(NativeMessagePort* owner) : owner_(owner) {} + +PortData::~PortData() { + tns::Assert(this->owner_ == nullptr); + this->Disentangle(); +} + +void PortData::AddToIncomingQueue(std::shared_ptr message) { + std::lock_guard lock(this->mutex_); + this->incoming_.push_back(std::move(message)); + if (this->owner_ != nullptr) { + // Still holding the mutex: an owner read outside it could be detached by + // the time the wake reaches it. + this->owner_->TriggerAsync(); + } +} + +DispatchResult PortData::Dispatch(std::shared_ptr message, + std::string* error) { + std::shared_ptr group; + { + std::lock_guard lock(this->mutex_); + group = this->group_; + } + // The group's lock is taken with this port's mutex released: the two are + // always acquired group first. + if (group == nullptr) { + if (error != nullptr) { + *error = "MessagePortData is not entangled."; + } + return DispatchResult::kFailed; + } + return group->Dispatch(this, std::move(message), error); +} + +void PortData::Entangle(PortData* a, PortData* b) { + std::make_shared()->Entangle({a, b}); +} + +void PortData::Disentangle() { + std::shared_ptr group; + { + std::lock_guard lock(this->mutex_); + group = this->group_; + } + if (group != nullptr) { + group->Disentangle(this); + } +} + +NativeMessagePort::NativeMessagePort(Isolate* isolate, Local wrapper) + : wrapper_(isolate, wrapper), isolateWrapper_(isolate) { + Runtime* runtime = Runtime::GetRuntime(isolate); + if (runtime != nullptr) { + this->loop_ = runtime->GetEventLoop(); + } +} + +NativeMessagePort::~NativeMessagePort() { this->OrphanData(); } + +std::shared_ptr NativeMessagePort::New( + Local context, std::unique_ptr data, + std::shared_ptr group) { + Isolate* isolate = v8::Isolate::GetCurrent(); + MessagingState* state = State(isolate); + if (state == nullptr || !EnsureJsTier(context)) { + return nullptr; + } + Local tmpl = PortTemplate(isolate); + Local wrapper; + if (tmpl.IsEmpty() || + !tmpl->InstanceTemplate()->NewInstance(context).ToLocal(&wrapper)) { + return nullptr; + } + + std::shared_ptr port( + new NativeMessagePort(isolate, wrapper)); + wrapper->SetAlignedPointerInInternalField(0, port.get(), + v8::kEmbedderDataTypeTagDefault); + state->livePorts.insert(port); + state->claimHostObjects = true; + + if (data != nullptr) { + port->data_ = std::move(data); + std::lock_guard lock(port->data_->mutex_); + port->data_->owner_ = port.get(); + // Whatever queued up while the port was in flight drains on a later turn, + // never inside the read that produced this port. + port->TriggerAsync(); + } else { + port->data_ = std::make_unique(port.get()); + if (group != nullptr) { + group->Entangle(port->data_.get()); + } + } + + // The tier installs whatever a MessagePort instance needs before the wrapper + // is handed out. A failure leaves a live channel behind, so take it down — + // without the close event, which would dispatch on a wrapper that never + // became a MessagePort. + Local arg = wrapper; + if (state->adoptPort.Get(isolate) + ->Call(context, v8::Undefined(isolate), 1, &arg) + .IsEmpty()) { + port->OrphanData(); + port->CloseHandle(); + return nullptr; + } + return port; +} + +void NativeMessagePort::TriggerAsync() { + // The caller holds this port's data mutex, which is what makes "the port is + // still owned" and "a drain is posted" one indivisible step against a + // concurrent detach. Never takes the receiving isolate's Locker: the posted + // entry runs under the home loop's own ceremony. + if (this->loop_ == nullptr || this->scheduled_.exchange(true)) { + return; + } + std::shared_ptr self = this->shared_from_this(); + // A dropped post (the loop already stopped) leaves scheduled_ set on + // purpose: nothing will ever run on that loop again, and the flag keeps + // producers from posting into it. + this->loop_->PostInternal([self]() { + if (!self->isolateWrapper_.IsValid()) { + return; + } + self->Drain(); + }); +} + +void NativeMessagePort::Start() { + if (this->data_ == nullptr) { + return; + } + this->receiving_ = true; + std::lock_guard lock(this->data_->mutex_); + if (!this->data_->incoming_.empty()) { + this->TriggerAsync(); + } +} + +void NativeMessagePort::Stop() { this->receiving_ = false; } + +std::unique_ptr NativeMessagePort::Detach() { + // owner_ drops under the data mutex, so a producer either wakes this port + // before the detach or never sees an owner at all. Node carries a separate + // "closing" flag because libuv tears its handle down asynchronously; here + // the detach IS the close, so a null data_ is the whole [[Detached]] state. + std::lock_guard lock(this->data_->mutex_); + this->data_->owner_ = nullptr; + return std::move(this->data_); +} + +void NativeMessagePort::CloseHandle() { + Isolate* isolate = this->isolateWrapper_.Isolate(); + if (!this->wrapper_.IsEmpty()) { + HandleScope handleScope(isolate); + // The wrapper outlives the port whenever JS still holds it; clearing the + // field is what makes PortFromWrapper report a closed port instead of + // handing out a pointer to freed memory. + this->wrapper_.Get(isolate)->SetAlignedPointerInInternalField( + 0, nullptr, v8::kEmbedderDataTypeTagDefault); + this->wrapper_.Reset(); + } + MessagingState* state = State(isolate); + if (state != nullptr) { + state->livePorts.erase(this->shared_from_this()); + } +} + +void NativeMessagePort::Close() { + // Keeps this object alive across the registry erase in CloseHandle. + std::shared_ptr self = this->shared_from_this(); + if (this->wrapper_.IsEmpty() && this->data_ == nullptr) { + return; + } + Isolate* isolate = this->isolateWrapper_.Isolate(); + HandleScope handleScope(isolate); + Local wrapper = this->Wrapper(isolate); + + std::unique_ptr data; + if (this->data_ != nullptr) { + data = this->Detach(); + } + this->CloseHandle(); + if (data != nullptr) { + // Sequential, never nested: Detach released the data mutex before the + // group's lock is taken here. + data->Disentangle(); + data.reset(); + } + // Last, on the wrapper the port has just let go of, so a listener finds an + // already-detached port and a close() from inside one is a no-op rather than + // a recursion. + if (!wrapper.IsEmpty()) { + this->EmitClose(wrapper); + } +} + +void NativeMessagePort::EmitClose(Local wrapper) { + Isolate* isolate = this->isolateWrapper_.Isolate(); + std::shared_ptr cache = Caches::Get(isolate); + if (cache == nullptr || !cache->IsValid() || !cache->HasContext()) { + return; + } + Local context = cache->GetContext(); + Context::Scope contextScope(context); + if (!EnsureJsTier(context)) { + return; + } + Local undefined = v8::Undefined(isolate); + this->Emit(context, wrapper, State(isolate)->emitMessage.Get(isolate), + undefined, undefined, "close"); +} + +std::unique_ptr NativeMessagePort::TransferForMessaging() { + std::shared_ptr self = this->shared_from_this(); + std::unique_ptr data = this->Detach(); + // Deliberately not disentangled: the group membership and the queue are + // exactly what the receiving port adopts, and senders keep queueing into the + // data while it is in flight. + this->CloseHandle(); + return data; +} + +void NativeMessagePort::OrphanData() { + if (this->data_ == nullptr) { + return; + } + std::unique_ptr data = this->Detach(); + data->Disentangle(); +} + +Local NativeMessagePort::Wrapper(Isolate* isolate) const { + if (this->wrapper_.IsEmpty()) { + return Local(); + } + return this->wrapper_.Get(isolate); +} + +std::shared_ptr NativeMessagePort::TakeMessage(bool force) { + std::lock_guard lock(this->data_->mutex_); + if (this->data_->incoming_.empty()) { + return nullptr; + } + // A port that was never started still learns that its sibling died: the + // close sentinel is honoured with the message queue disabled. + if (!this->receiving_ && !force && + !this->data_->incoming_.front()->IsCloseMessage()) { + return nullptr; + } + std::shared_ptr message = std::move(this->data_->incoming_.front()); + this->data_->incoming_.pop_front(); + return message; +} + +Maybe NativeMessagePort::ReceiveOne(Local context, + Local* out) { + Isolate* isolate = v8::Isolate::GetCurrent(); + std::shared_ptr received = this->TakeMessage(true); + if (received == nullptr) { + return Just(false); + } + if (received->IsCloseMessage()) { + this->Close(); + return Just(false); + } + return received->Deserialize(isolate, context).ToLocal(out) ? Just(true) + : Nothing(); +} + +bool NativeMessagePort::Emit(Local context, Local receiver, + Local emitMessage, Local data, + Local ports, const char* type) { + Isolate* isolate = v8::Isolate::GetCurrent(); + if (receiver.IsEmpty()) { + return false; + } + Local argv[] = {data, ports, tns::ToV8String(isolate, type)}; + TryCatch tc(isolate); + if (!emitMessage->Call(context, receiver, 3, argv).IsEmpty()) { + return true; + } + if (tc.HasTerminated() || !tc.CanContinue()) { + return false; + } + // There is no event-loop frame to unwind into, so a listener that throws is + // an uncaught error, reported where a timer callback's would be. + NativeScriptException::ReportToJsHandlersAndLog(isolate, tc.Exception(), + tc.Message()); + tc.Reset(); + return false; +} + +void NativeMessagePort::Drain() { + // Cleared first: a message arriving from here on must schedule a fresh + // drain rather than be left for this one, which may already be past its + // queue read. + this->scheduled_.store(false); + if (this->data_ == nullptr) { + return; + } + Isolate* isolate = this->isolateWrapper_.Isolate(); + std::shared_ptr cache = Caches::Get(isolate); + if (cache == nullptr || !cache->IsValid() || !cache->HasContext()) { + return; + } + HandleScope handleScope(isolate); + Local context = cache->GetContext(); + Context::Scope contextScope(context); + if (!EnsureJsTier(context)) { + return; + } + MessagingState* state = State(isolate); + if (state == nullptr || state->emitMessage.IsEmpty()) { + return; + } + Local emitMessage = state->emitMessage.Get(isolate); + Local wrapper = this->Wrapper(isolate); + + size_t budget; + { + std::lock_guard lock(this->data_->mutex_); + budget = std::max(this->data_->incoming_.size(), static_cast(1000)); + } + + bool reschedule = false; + // data_ is written only on this thread, but the callout below can transfer + // or close this very port, so it is re-checked every iteration. + while (this->data_ != nullptr) { + if (budget-- == 0) { + // Hand the runloop back rather than starve it; the repost carries + // whatever is left. + reschedule = true; + break; + } + HandleScope messageScope(isolate); + std::shared_ptr received = this->TakeMessage(false); + if (received == nullptr) { + break; + } + if (received->IsCloseMessage()) { + this->Close(); + return; + } + + Local payload; + Local ports = v8::Undefined(isolate); + bool read; + { + // Failures reading the value are the port's 'messageerror' event, not + // the isolate's uncaught-error path. Never holds the data mutex: the + // read runs arbitrary JS. + TryCatch tc(isolate); + read = received->Deserialize(isolate, context, &ports).ToLocal(&payload); + if (!read) { + if (tc.HasTerminated() || !tc.CanContinue()) { + return; + } + payload = tc.HasCaught() ? tc.Exception() + : v8::Undefined(isolate).As(); + tc.Reset(); + } + } + if (!read) { + this->Emit(context, wrapper, emitMessage, payload, v8::Undefined(isolate), + "messageerror"); + reschedule = true; + break; + } + if (!this->Emit(context, wrapper, emitMessage, payload, ports, "message")) { + reschedule = true; + break; + } + // Per message, not per drain: a handler's microtasks run before the next + // message arrives, which is what both browsers and Node observe. + isolate->PerformMicrotaskCheckpoint(); + } + + if (reschedule && this->data_ != nullptr) { + std::lock_guard lock(this->data_->mutex_); + this->TriggerAsync(); + } +} + +NativeMessagePort* PortFromWrapper(Isolate* isolate, Local object) { + if (!IsPortWrapper(isolate, object)) { + return nullptr; + } + return static_cast( + object->GetAlignedPointerFromInternalField( + 0, v8::kEmbedderDataTypeTagDefault)); +} + +bool IsPortWrapper(Isolate* isolate, Local object) { + MessagingState* state = State(isolate); + if (state == nullptr || state->portTemplate.IsEmpty()) { + return false; + } + return state->portTemplate.Get(isolate)->HasInstance(object); +} + +MaybeLocal AdoptPort(Local context, + std::unique_ptr data) { + std::shared_ptr port = + NativeMessagePort::New(context, std::move(data)); + if (port == nullptr) { + return MaybeLocal(); + } + return port->Wrapper(v8::Isolate::GetCurrent()); +} + +bool AnyPortsOrBrands(Isolate* isolate) { + MessagingState* state = State(isolate); + return state != nullptr && state->claimHostObjects; +} + +Maybe IsMarkedUntransferable(Isolate* isolate, Local object) { + Local brand = UntransferableBrand(isolate, false); + if (brand.IsEmpty()) { + return Just(false); + } + return object->HasPrivate(isolate->GetCurrentContext(), brand); +} + +Maybe IsMarkedUncloneable(Isolate* isolate, Local object) { + Local brand = UncloneableBrand(isolate, false); + if (brand.IsEmpty()) { + return Just(false); + } + return object->HasPrivate(isolate->GetCurrentContext(), brand); +} + +Local UncloneableBrandIfAny(Isolate* isolate) { + return UncloneableBrand(isolate, false); +} + +void CloseAllPorts(Isolate* isolate) { + MessagingState* state = nullptr; + { + std::lock_guard lock(g_statesMutex); + auto entry = g_states.find(isolate); + if (entry == g_states.end()) { + return; + } + state = entry->second; + } + // Orphaning every port's data drops the owner — so nothing can be woken on a + // loop that has stopped — and takes the data out of its group, which both + // sentinels the siblings on other isolates and puts the data beyond the + // reach of their sender threads. The ports themselves die with this + // isolate's Caches, by which time their data is inert. + for (const std::shared_ptr& port : state->livePorts) { + port->OrphanData(); + } +} + +namespace { + +// The wrapper argument, or false after throwing. A closed port passes: its +// wrapper is still a MessagePort, and every native here tolerates one. +bool PortArg(const FunctionCallbackInfo& info, int index, + Local* wrapper) { + Isolate* isolate = info.GetIsolate(); + if (info.Length() <= index || !info[index]->IsObject() || + !IsPortWrapper(isolate, info[index].As())) { + isolate->ThrowException(Exception::TypeError(tns::ToV8String( + isolate, "The \"port\" argument must be a MessagePort instance"))); + return false; + } + *wrapper = info[index].As(); + return true; +} + +void CreateChannelCallback(const FunctionCallbackInfo& info) { + Isolate* isolate = info.GetIsolate(); + Local context = isolate->GetCurrentContext(); + std::shared_ptr port1 = NativeMessagePort::New(context); + if (port1 == nullptr) { + return; + } + std::shared_ptr port2 = NativeMessagePort::New(context); + if (port2 == nullptr) { + port1->Close(); + return; + } + PortData::Entangle(port1->Data(), port2->Data()); + + Local pair = v8::Array::New(isolate, 2); + if (!pair->Set(context, 0, port1->Wrapper(isolate)).FromMaybe(false) || + !pair->Set(context, 1, port2->Wrapper(isolate)).FromMaybe(false)) { + return; + } + info.GetReturnValue().Set(pair); +} + +void CreateBroadcastPortCallback(const FunctionCallbackInfo& info) { + Isolate* isolate = info.GetIsolate(); + Local context = isolate->GetCurrentContext(); + if (info.Length() < 1) { + isolate->ThrowException(Exception::TypeError( + tns::ToV8String(isolate, "The \"name\" argument must be a string"))); + return; + } + std::shared_ptr port = NativeMessagePort::New( + context, nullptr, SiblingGroup::Get(tns::ToString(isolate, info[0]))); + if (port == nullptr) { + return; + } + // A BroadcastChannel has no port-enable step: it receives from the moment it + // exists. + port->Start(); + info.GetReturnValue().Set(port->Wrapper(isolate)); +} + +void PostMessageCallback(const FunctionCallbackInfo& info) { + Isolate* isolate = info.GetIsolate(); + Local wrapper; + if (!PortArg(info, 0, &wrapper)) { + return; + } + Local context = isolate->GetCurrentContext(); + Local value = + info.Length() > 1 ? info[1] : v8::Undefined(isolate).As(); + Local transferList = + info.Length() > 2 ? info[2] : v8::Undefined(isolate).As(); + + // Serialization runs even for a port that can no longer deliver: the + // transfer list's side effects, and its errors, do not depend on delivery. + std::shared_ptr message = std::make_shared(); + if (message + ->Serialize(isolate, context, value, transferList, + serialization::HostObjectPolicy::kReject, wrapper) + .IsNothing()) { + return; + } + // Re-read: serializing runs user getters, which may have closed the port. + NativeMessagePort* port = PortFromWrapper(isolate, wrapper); + if (port == nullptr || port->IsDetached()) { + return; + } + + std::string error; + port->Data()->Dispatch(std::move(message), &error); + if (!error.empty()) { + Log("MessagePort: %s", error.c_str()); + } +} + +void StartCallback(const FunctionCallbackInfo& info) { + Local wrapper; + if (!PortArg(info, 0, &wrapper)) { + return; + } + NativeMessagePort* port = PortFromWrapper(info.GetIsolate(), wrapper); + if (port != nullptr) { + port->Start(); + } +} + +void StopCallback(const FunctionCallbackInfo& info) { + Local wrapper; + if (!PortArg(info, 0, &wrapper)) { + return; + } + NativeMessagePort* port = PortFromWrapper(info.GetIsolate(), wrapper); + if (port != nullptr) { + port->Stop(); + } +} + +void CloseCallback(const FunctionCallbackInfo& info) { + Local wrapper; + if (!PortArg(info, 0, &wrapper)) { + return; + } + NativeMessagePort* port = PortFromWrapper(info.GetIsolate(), wrapper); + if (port != nullptr) { + // The keepalive outlives the registry erase inside Close. + std::shared_ptr self = port->shared_from_this(); + self->Close(); + } +} + +void DrainOneCallback(const FunctionCallbackInfo& info) { + Isolate* isolate = info.GetIsolate(); + Local wrapper; + if (!PortArg(info, 0, &wrapper)) { + return; + } + // Null, not a sentinel: the box is what says a message was there at all, so + // a message whose value is undefined stays distinguishable from none. + info.GetReturnValue().SetNull(); + NativeMessagePort* port = PortFromWrapper(isolate, wrapper); + if (port == nullptr || port->IsDetached()) { + return; + } + Local context = isolate->GetCurrentContext(); + std::shared_ptr self = port->shared_from_this(); + Local message; + bool received = false; + if (!self->ReceiveOne(context, &message).To(&received) || !received) { + return; + } + Local box = Object::New(isolate); + if (box->Set(context, tns::ToV8String(isolate, "message"), message) + .FromMaybe(false)) { + info.GetReturnValue().Set(box); + } +} + +void IsDetachedCallback(const FunctionCallbackInfo& info) { + Isolate* isolate = info.GetIsolate(); + Local wrapper; + if (!PortArg(info, 0, &wrapper)) { + return; + } + NativeMessagePort* port = PortFromWrapper(isolate, wrapper); + info.GetReturnValue().Set(port == nullptr || port->IsDetached()); +} + +void SetEmitMessageCallback(const FunctionCallbackInfo& info) { + Isolate* isolate = info.GetIsolate(); + MessagingState* state = State(isolate); + if (state == nullptr || info.Length() < 1 || !info[0]->IsFunction()) { + return; + } + state->emitMessage.Reset(isolate, info[0].As()); +} + +void SetEnvironmentDataCallback(const FunctionCallbackInfo& info) { + Isolate* isolate = info.GetIsolate(); + if (info.Length() < 1) { + return; + } + Local context = isolate->GetCurrentContext(); + std::string key = tns::ToString(isolate, info[0]); + if (info.Length() < 2 || info[1]->IsUndefined()) { + std::lock_guard lock(g_environmentDataMutex); + g_environmentData.erase(key); + return; + } + // Cloned on the way in, so a later mutation of the value the caller kept is + // not visible to the threads that read it. + auto stored = std::make_shared(); + if (stored + ->Serialize(isolate, context, info[1], v8::Undefined(isolate), + serialization::HostObjectPolicy::kReject) + .IsNothing()) { + return; + } + std::lock_guard lock(g_environmentDataMutex); + g_environmentData[key] = std::move(stored); +} + +void GetEnvironmentDataCallback(const FunctionCallbackInfo& info) { + Isolate* isolate = info.GetIsolate(); + if (info.Length() < 1) { + return; + } + std::string key = tns::ToString(isolate, info[0]); + std::shared_ptr stored; + { + std::lock_guard lock(g_environmentDataMutex); + auto entry = g_environmentData.find(key); + if (entry == g_environmentData.end()) { + return; + } + stored = entry->second; + } + // Read back outside the lock: the read runs JS, and a value stored without a + // transfer list can be read any number of times, on any isolate. + Local value; + if (stored->Deserialize(isolate, isolate->GetCurrentContext()) + .ToLocal(&value)) { + info.GetReturnValue().Set(value); + } +} + +void MarkAsUntransferableCallback(const FunctionCallbackInfo& info) { + StampBrand(info, UntransferableBrand); +} + +void MarkAsUncloneableCallback(const FunctionCallbackInfo& info) { + StampBrand(info, UncloneableBrand); +} + +void IsMarkedAsUntransferableCallback(const FunctionCallbackInfo& info) { + Isolate* isolate = info.GetIsolate(); + if (info.Length() < 1 || !info[0]->IsObject()) { + info.GetReturnValue().Set(false); + return; + } + bool marked = false; + if (IsMarkedUntransferable(isolate, info[0].As()).To(&marked)) { + info.GetReturnValue().Set(marked); + } +} + +} // namespace + +MaybeLocal CreateBinding(Local context) { + Isolate* isolate = v8::Isolate::GetCurrent(); + if (State(isolate) == nullptr) { + return MaybeLocal(); + } + Local binding = Object::New(isolate); + + // Constant for the lifetime of the isolate, so they are values rather than + // calls. Node numbers the main thread 0; this runtime numbers its workers + // from 1 and leaves the main runtime's own id unset. + Runtime* runtime = Runtime::GetRuntime(isolate); + bool isWorker = runtime != nullptr && runtime->IsRuntimeWorker(); + if (!binding + ->Set(context, tns::ToV8String(isolate, "isMainThread"), + v8::Boolean::New(isolate, !isWorker)) + .FromMaybe(false) || + !binding + ->Set(context, tns::ToV8String(isolate, "threadId"), + v8::Integer::New(isolate, isWorker ? runtime->WorkerId() : 0)) + .FromMaybe(false)) { + return MaybeLocal(); + } + + tns::SetMethod(context, binding, "createChannel", CreateChannelCallback); + tns::SetMethod(context, binding, "createBroadcastPort", + CreateBroadcastPortCallback); + tns::SetMethod(context, binding, "postMessage", PostMessageCallback); + tns::SetMethod(context, binding, "start", StartCallback); + tns::SetMethod(context, binding, "stop", StopCallback); + tns::SetMethod(context, binding, "close", CloseCallback); + tns::SetMethod(context, binding, "drainOne", DrainOneCallback); + tns::SetMethodNoSideEffect(context, binding, "isDetached", + IsDetachedCallback); + tns::SetMethod(context, binding, "setEmitMessage", SetEmitMessageCallback); + tns::SetMethod(context, binding, "setEnvironmentData", + SetEnvironmentDataCallback); + tns::SetMethod(context, binding, "getEnvironmentData", + GetEnvironmentDataCallback); + tns::SetMethod(context, binding, "markAsUntransferable", + MarkAsUntransferableCallback); + tns::SetMethodNoSideEffect(context, binding, "isMarkedAsUntransferable", + IsMarkedAsUntransferableCallback); + tns::SetMethod(context, binding, "markAsUncloneable", + MarkAsUncloneableCallback); + return binding; +} + +MaybeLocal GetMessageChannelExports(Local context) { + return BuiltinLoader::GetExports(context, BuiltinId::kMessageChannel, + CreateBinding); +} + +MaybeLocal GetBroadcastChannelExports(Local context) { + return BuiltinLoader::GetExports(context, BuiltinId::kBroadcastChannel, + CreateBinding); +} + +} // namespace messaging +} // namespace tns diff --git a/NativeScript/runtime/Messaging.h b/NativeScript/runtime/Messaging.h new file mode 100644 index 000000000..60137f003 --- /dev/null +++ b/NativeScript/runtime/Messaging.h @@ -0,0 +1,224 @@ +#ifndef Messaging_h +#define Messaging_h + +#include +#include +#include +#include +#include + +#include "Common.h" +#include "IsolateWrapper.h" + +namespace tns { + +class EventLoop; + +namespace serialization { +class SerializedValue; +} + +namespace messaging { + +class NativeMessagePort; +class SiblingGroup; + +// What a port's group did with a message handed to it. +enum class DispatchResult { + // Queued on at least one destination. + kDelivered, + // The group has no other member; the message is dropped. + kNoDestination, + // Nothing was queued and the caller must not treat the send as done: the + // port is not entangled, or the message carries transferables and the group + // has more than one destination. The out-parameter says which. + kFailed, +}; + +// Everything about a port that is not tied to an isolate, so it can be moved +// into a message and adopted on the receiving side. +// +// `mutex_` is the only lock a producer on a foreign thread ever takes. Lock +// order across the whole subsystem is SiblingGroup's lock FIRST, a port's +// mutex_ second; never the reverse. Every path that needs both — dispatch, +// entangle, disentangle — is entered through the group. +class PortData { + public: + explicit PortData(NativeMessagePort* owner); + ~PortData(); + + PortData(const PortData&) = delete; + PortData& operator=(const PortData&) = delete; + + // The one cross-thread entry point. Appends `message` and wakes the owning + // port while STILL holding the mutex, so a port detaching concurrently + // either takes the mutex first and is never woken, or waits and observes the + // queued message. + void AddToIncomingQueue( + std::shared_ptr message); + + // Hands `message` to every other member of this port's group. + DispatchResult Dispatch( + std::shared_ptr message, + std::string* error); + + // Connects the two ends of a fresh channel. Neither end may already belong + // to a group. + static void Entangle(PortData* a, PortData* b); + + // Leaves the group, queueing a close sentinel on this port and — for an + // anonymous pair — on the sibling left behind. Once this returns, no other + // thread can reach this object through the group. Owner thread only. + void Disentangle(); + + private: + friend class NativeMessagePort; + friend class SiblingGroup; + + std::mutex mutex_; + std::deque> incoming_; + NativeMessagePort* owner_ = nullptr; + std::shared_ptr group_; +}; + +// The isolate-bound half of a port: the JS wrapper, the delivery callout and +// the drain that runs on the owning runtime's event loop. Home-thread only, +// TriggerAsync excepted. +class NativeMessagePort + : public std::enable_shared_from_this { + public: + ~NativeMessagePort(); + + NativeMessagePort(const NativeMessagePort&) = delete; + NativeMessagePort& operator=(const NativeMessagePort&) = delete; + + // Creates a port and its JS wrapper. With `data` the port adopts an + // in-flight port — the group travels with the data — and schedules a drain + // of whatever queued up while it was in transit; with `group` it joins that + // named group; with neither it is one unentangled end of a new channel. + // Null with an exception pending when the wrapper or the JS tier could not + // be built. + static std::shared_ptr New( + v8::Local context, std::unique_ptr data = nullptr, + std::shared_ptr group = nullptr); + + // Schedules a drain. Any thread; the caller must hold this port's data + // mutex, which is what keeps the port from detaching underneath the post. + void TriggerAsync(); + + // HTML's port message queue enable/disable. Starting a port with a backlog + // schedules a drain for it. + void Start(); + void Stop(); + + // Detaches the data, sentinels the sibling, drops the JS wrapper and fires + // the tier's close event on it. Safe to call on an already-closed port, and + // safe to call from inside that event. + void Close(); + + // Drops the data out of the port and out of its group, so nothing can reach + // it any more. What the teardown sweep does to a port app code never closed. + void OrphanData(); + + // Pops one message regardless of whether the port was started + // (receiveMessageOnPort). Just(false) when the queue holds nothing + // deliverable, Just(true) with `out` set otherwise, Nothing when the value + // could not be read. + v8::Maybe ReceiveOne(v8::Local context, + v8::Local* out); + + // The [[Detached]] internal slot. + bool IsDetached() const { return this->data_ == nullptr; } + + // Moves the data into a message. The handle side closes, but the data keeps + // its group membership and its queue: senders keep queueing into it while it + // is in flight, and with no owner nothing is woken. + std::unique_ptr TransferForMessaging(); + + // Empty once the port has been closed. + v8::Local Wrapper(v8::Isolate* isolate) const; + + PortData* Data() const { return this->data_.get(); } + + private: + NativeMessagePort(v8::Isolate* isolate, v8::Local wrapper); + + std::unique_ptr Detach(); + void CloseHandle(); + void EmitClose(v8::Local wrapper); + void Drain(); + std::shared_ptr TakeMessage(bool force); + bool Emit(v8::Local context, v8::Local receiver, + v8::Local emitMessage, v8::Local data, + v8::Local ports, const char* type); + + std::unique_ptr data_; + bool receiving_ = false; + // Set while a drain is queued, so a burst of messages costs one post. + // Atomic because producers flip it from their own threads. + std::atomic scheduled_{false}; + // Strong on purpose: a port and its JS wrapper stay alive until the port is + // closed, which is the lifetime model HTML and Node specify — reachability + // plays no part in it. + v8::Global wrapper_; + IsolateWrapper isolateWrapper_; + // Held by shared_ptr so a drain posted from a foreign thread can never race + // the loop's own teardown. + std::shared_ptr loop_; +}; + +// The port behind a JS wrapper, or null when `object` is not a port wrapper or +// its port has been closed. +NativeMessagePort* PortFromWrapper(v8::Isolate* isolate, + v8::Local object); + +// Whether `object` is a MessagePort wrapper at all, closed or not. The +// serializer needs the distinction: a closed port in a transfer list is a +// different error from a value that was never transferable. +bool IsPortWrapper(v8::Isolate* isolate, v8::Local object); + +// Adopts an in-flight port on this isolate and returns its fresh wrapper. +v8::MaybeLocal AdoptPort(v8::Local context, + std::unique_ptr data); + +// Whether this isolate has ever created a port or stamped a transfer brand. +// Gates the serializer's host-object claim: until one of those happens, no +// value in this isolate can need the messaging hooks. +bool AnyPortsOrBrands(v8::Isolate* isolate); + +// The markAsUntransferable / markAsUncloneable brands. Both answer Just(false) +// without creating anything when this isolate has never stamped one. +v8::Maybe IsMarkedUntransferable(v8::Isolate* isolate, + v8::Local object); +v8::Maybe IsMarkedUncloneable(v8::Isolate* isolate, + v8::Local object); + +// The markAsUncloneable brand itself, empty when this isolate has never +// stamped one. For the serializer, which is asked about every object in a +// claimed graph and hoists the lookup out of that loop. +v8::Local UncloneableBrandIfAny(v8::Isolate* isolate); + +// The natives behind the message-channel builtin: channel and port +// primitives, the two registration hooks the JS tier calls once per isolate, +// and the transfer brands. +v8::MaybeLocal CreateBinding(v8::Local context); + +// The two builtins' exports with that binding attached. GetExports consults +// the factory only on the run that populates the cache, so every call site for +// these builtins must go through here — a site passing a different factory +// would win or lose by init order. +v8::MaybeLocal GetMessageChannelExports( + v8::Local context); +v8::MaybeLocal GetBroadcastChannelExports( + v8::Local context); + +// Force-closes every port this isolate still owns: the data is orphaned and +// disentangled, so siblings on other isolates get their close sentinels and +// nothing can reach this isolate's ports afterwards. Must run after the event +// loop has stopped and while the isolate is still locked. +void CloseAllPorts(v8::Isolate* isolate); + +} // namespace messaging +} // namespace tns + +#endif /* Messaging_h */ diff --git a/NativeScript/runtime/NsBuiltinModules.cpp b/NativeScript/runtime/NsBuiltinModules.cpp index cbfc747e9..356315553 100644 --- a/NativeScript/runtime/NsBuiltinModules.cpp +++ b/NativeScript/runtime/NsBuiltinModules.cpp @@ -6,6 +6,7 @@ #include "Caches.h" #include "Console.h" #include "Helpers.h" +#include "Messaging.h" #include "ModuleInternalCallbacks.h" #include "Runtime.h" #include "StructuredSerialization.h" @@ -48,9 +49,16 @@ constexpr Registration kRegistry[] = { {"node:module", BuiltinId::kNodeModule, nullptr}, {"node:url", BuiltinId::kNodeUrl, nullptr}, {"node:util", BuiltinId::kNodeUtil, nullptr}, + {"node:worker_threads", BuiltinId::kNodeWorkerThreads, + messaging::CreateBinding}, + {"internal/broadcast-channel", BuiltinId::kBroadcastChannel, + messaging::CreateBinding, true}, {"internal/dom-exception", BuiltinId::kDomException, serialization::DomExceptionBinding, true}, {"internal/events", BuiltinId::kEvents, nullptr, true}, + {"internal/message-channel", BuiltinId::kMessageChannel, + messaging::CreateBinding, true}, + {"internal/message-event", BuiltinId::kMessageEvent, nullptr, true}, }; // ns:runtime config keys. Each key defines its value domain and scope here; diff --git a/NativeScript/runtime/Runtime.mm b/NativeScript/runtime/Runtime.mm index 369d6b898..d4325a9ac 100644 --- a/NativeScript/runtime/Runtime.mm +++ b/NativeScript/runtime/Runtime.mm @@ -13,6 +13,7 @@ #include "Interop.h" #include "IsolateTracked.h" #include "LazyGlobals.h" +#include "Messaging.h" #include "NativeScriptException.h" #include "NativeScriptPlatform.h" #include "ObjectManager.h" @@ -284,6 +285,12 @@ void DisposeIsolateWhenPossible(Isolate* isolate) { ObjectManager::DisposeAllRegistered(isolate_); IsolateTracked::SweepAll(isolate_); + // After the loop stopped: a port force-closed here can no longer be woken, + // and the disentangle both delivers the close sentinels this isolate's + // siblings are owed and puts each port's queue beyond the reach of the + // threads that were filling it. + messaging::CloseAllPorts(isolate_); + if (IsRuntimeWorker()) { std::shared_ptr workerState = Caches::Workers->Get(this->workerId_); WorkerWrapper* currentWorker = @@ -447,6 +454,7 @@ void DisposeIsolateWhenPossible(Isolate* isolate) { DefineCollectFunction(context); PromiseProxy::Init(context); Events::Init(context); + Worker::InitEvents(context); ErrorEvents::Init(context); StructuredClone::Init(context); Performance::Init(context); diff --git a/NativeScript/runtime/StructuredSerialization.cpp b/NativeScript/runtime/StructuredSerialization.cpp index 84f624be0..fe869c433 100644 --- a/NativeScript/runtime/StructuredSerialization.cpp +++ b/NativeScript/runtime/StructuredSerialization.cpp @@ -112,25 +112,31 @@ void ThrowDataCloneError(Isolate* isolate, const std::string& message) { namespace { // Every host object's payload starts with one of these, so the reader can -// dispatch. kHostObjectDegraded carries nothing further; -// kHostObjectDomException carries a uint32 index into the SerializedValue's -// out-of-band payload list. The bytes never outlive the process -// (structuredClone round-trips in one isolate, worker messages cross isolates -// in the same binary), so the format can evolve freely with this file. +// dispatch. kHostObjectDegraded carries nothing further; the other two carry a +// uint32 index into one of the SerializedValue's out-of-band lists. The bytes +// never outlive the process (structuredClone round-trips in one isolate, +// worker messages cross isolates in the same binary), so the format can evolve +// freely with this file. constexpr uint32_t kHostObjectDegraded = 0; constexpr uint32_t kHostObjectDomException = 1; +constexpr uint32_t kHostObjectMessagePort = 2; + +using PortList = std::vector>; class SerializerDelegate : public ValueSerializer::Delegate { public: SerializerDelegate( Isolate* isolate, HostObjectPolicy hostObjectPolicy, std::vector>* sharedBuffers, - std::vector* domExceptions) + std::vector* domExceptions, + const PortList* transferPorts) : isolate_(isolate), hostObjectPolicy_(hostObjectPolicy), sharedBuffers_(sharedBuffers), domExceptions_(domExceptions), - domExceptionBrand_(DomExceptionBrand(isolate)) {} + transferPorts_(transferPorts), + domExceptionBrand_(DomExceptionBrand(isolate)), + uncloneableBrand_(messaging::UncloneableBrandIfAny(isolate)) {} void SetSerializer(ValueSerializer* serializer) { serializer_ = serializer; } @@ -155,6 +161,16 @@ class SerializerDelegate : public ValueSerializer::Delegate { if (object->InternalFieldCount() > 0) { return Just(true); } + if (!uncloneableBrand_.IsEmpty()) { + bool uncloneable = false; + if (!object->HasPrivate(isolate->GetCurrentContext(), uncloneableBrand_) + .To(&uncloneable)) { + return Nothing(); + } + if (uncloneable) { + return Just(true); + } + } if (domExceptionBrand_.IsEmpty()) { return Just(false); } @@ -162,6 +178,21 @@ class SerializerDelegate : public ValueSerializer::Delegate { } Maybe WriteHostObject(Isolate* isolate, Local object) override { + // Ports are claimed ahead of every policy: transferring one is explicit + // intent, so a port in the graph is either in the transfer list or an + // error — degrading it under kDegrade would strand its sibling forever. + if (messaging::IsPortWrapper(isolate, object)) { + return WritePort(isolate, object); + } + bool uncloneable = false; + if (!messaging::IsMarkedUncloneable(isolate, object).To(&uncloneable)) { + return Nothing(); + } + if (uncloneable) { + serialization::ThrowDataCloneError( + isolate, "Cannot clone object of unsupported type."); + return Nothing(); + } // DOMException serializes under both policies: it is [Serializable] in // the IDL, and it is a plain JS object with no native half to lose. bool isDomException = false; @@ -210,6 +241,31 @@ class SerializerDelegate : public ValueSerializer::Delegate { } private: + // A port is written as its position in the transfer list; the port itself + // travels out of band. Nothing is detached here — the whole graph has to + // write successfully before anything changes hands. + Maybe WritePort(Isolate* isolate, Local object) { + messaging::NativeMessagePort* port = + messaging::PortFromWrapper(isolate, object); + if (port == nullptr || port->IsDetached()) { + serialization::ThrowDataCloneError( + isolate, "Cannot clone object of unsupported type."); + return Nothing(); + } + for (size_t i = 0; i < transferPorts_->size(); i++) { + if ((*transferPorts_)[i].get() == port) { + serializer_->WriteUint32(kHostObjectMessagePort); + serializer_->WriteUint32(static_cast(i)); + return Just(true); + } + } + serialization::ThrowDataCloneError( + isolate, + "Object that needs transfer was found in message but not listed in " + "transferList"); + return Nothing(); + } + // Web IDL's DOMException serialization steps (name and message), plus the // stack, matching Node. The payload travels out-of-band and only an index // enters the stream: the receiving side must construct instances before @@ -244,10 +300,12 @@ class SerializerDelegate : public ValueSerializer::Delegate { HostObjectPolicy hostObjectPolicy_; std::vector>* sharedBuffers_; std::vector* domExceptions_; + const PortList* transferPorts_; // Resolved once per serializer: V8 asks about every object in the graph, // and each lookup would otherwise re-resolve the state slot and push a // fresh handle into the caller's scope. Local domExceptionBrand_; + Local uncloneableBrand_; ValueSerializer* serializer_ = nullptr; }; @@ -255,16 +313,19 @@ class DeserializerDelegate : public ValueDeserializer::Delegate { public: DeserializerDelegate( const std::vector>* sharedBuffers, - const std::vector>* domExceptions) - : sharedBuffers_(sharedBuffers), domExceptions_(domExceptions) {} + const std::vector>* domExceptions, + const std::vector>* ports) + : sharedBuffers_(sharedBuffers), + domExceptions_(domExceptions), + ports_(ports) {} void SetDeserializer(ValueDeserializer* deserializer) { deserializer_ = deserializer; } // No JS may run in here (V8 forbids it during a read); DOMException - // instances were constructed by Deserialize before ReadValue started, and - // this only hands them out. + // instances and port wrappers were built by Deserialize before ReadValue + // started, and this only hands them out. MaybeLocal ReadHostObject(Isolate* isolate) override { uint32_t tag; if (!deserializer_->ReadUint32(&tag)) { @@ -283,6 +344,13 @@ class DeserializerDelegate : public ValueDeserializer::Delegate { } return (*domExceptions_)[index]; } + case kHostObjectMessagePort: { + uint32_t index; + if (!deserializer_->ReadUint32(&index) || index >= ports_->size()) { + return MaybeLocal(); + } + return (*ports_)[index]; + } default: return MaybeLocal(); } @@ -299,24 +367,28 @@ class DeserializerDelegate : public ValueDeserializer::Delegate { private: const std::vector>* sharedBuffers_; const std::vector>* domExceptions_; + const std::vector>* ports_; ValueDeserializer* deserializer_ = nullptr; }; -// Validates the transfer list and collects it in registration order. The -// detached and detachable checks are load-bearing rather than defensive: +// Validates the transfer list and splits it, each half in registration order, +// because the two are handed over by different mechanisms: buffers by id in +// the stream, ports by index into an out-of-band list. 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) { + Local transferList, Local sourcePort, + std::vector>& transfers, + PortList& ports) { 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"))); + isolate->ThrowException(Exception::TypeError( + tns::ToV8String(isolate, "The transfer list must be an array"))); return false; } @@ -327,28 +399,77 @@ bool CollectTransferList(Isolate* isolate, Local context, if (!list->Get(context, i).ToLocal(&item)) { return false; } - if (!item->IsArrayBuffer()) { + if (!item->IsObject()) { + ThrowDataCloneError(isolate, "Found invalid value in transferList."); + return false; + } + Local entry = item.As(); + + bool untransferable = false; + if (!messaging::IsMarkedUntransferable(isolate, entry) + .To(&untransferable)) { + return false; + } + if (untransferable) { ThrowDataCloneError(isolate, - "A value in the transfer list is not transferable"); + "Cannot transfer object of unsupported type."); return false; } - Local buffer = item.As(); - for (const Local& existing : transfers) { - if (existing == buffer) { - ThrowDataCloneError( - isolate, "The transfer list contains the same ArrayBuffer twice"); + if (entry->IsArrayBuffer()) { + Local buffer = entry.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); + continue; } - if (buffer->WasDetached() || !buffer->IsDetachable()) { - ThrowDataCloneError(isolate, - "An ArrayBuffer in the transfer list is detached and " - "cannot be transferred"); - return false; + + if (messaging::IsPortWrapper(isolate, entry)) { + // Ports transfer under every policy: the receiving-side plumbing lives + // in Deserialize itself, so kDegrade callers (Worker.postMessage) carry + // ports just as structuredClone does. + // A port cannot travel on itself: the message would arrive on a channel + // its own delivery destroyed. + if (!sourcePort.IsEmpty() && entry == sourcePort) { + ThrowDataCloneError(isolate, "Transfer list contains source port"); + return false; + } + messaging::NativeMessagePort* port = + messaging::PortFromWrapper(isolate, entry); + if (port == nullptr || port->IsDetached()) { + ThrowDataCloneError(isolate, + "MessagePort in transfer list is already detached"); + return false; + } + for (const std::shared_ptr& existing : + ports) { + if (existing.get() == port) { + ThrowDataCloneError( + isolate, "Transfer list contains duplicate " + + tns::ToString(isolate, entry->GetConstructorName())); + return false; + } + } + // Held strongly for the duration of the write: writing the graph runs + // user getters, and one of them closing a listed port would otherwise + // leave the delegate with a dangling pointer. + ports.push_back(port->shared_from_this()); + continue; } - transfers.push_back(buffer); + ThrowDataCloneError(isolate, "Found invalid value in transferList."); + return false; } return true; } @@ -358,18 +479,21 @@ bool CollectTransferList(Isolate* isolate, Local context, Maybe SerializedValue::Serialize(Isolate* isolate, Local context, Local input, Local transferList, - HostObjectPolicy hostObjectPolicy) { + HostObjectPolicy hostObjectPolicy, + Local sourcePort) { HandleScope handleScope(isolate); Context::Scope contextScope(context); tns::Assert(buffer_ == nullptr, isolate); std::vector> transfers; - if (!CollectTransferList(isolate, context, transferList, transfers)) { + PortList ports; + if (!CollectTransferList(isolate, context, transferList, sourcePort, + transfers, ports)) { return Nothing(); } SerializerDelegate delegate(isolate, hostObjectPolicy, &sharedBuffers_, - &domExceptions_); + &domExceptions_, &ports); ValueSerializer serializer(isolate, &delegate); delegate.SetSerializer(&serializer); for (size_t i = 0; i < transfers.size(); i++) { @@ -387,6 +511,18 @@ Maybe SerializedValue::Serialize(Isolate* isolate, Local context, return Nothing(); } + // Revalidated after the write, not before it: writing the graph runs user + // getters, and one of them may have closed a listed port. Checked while + // nothing has changed hands yet, so a message that cannot be completed + // leaves every buffer and every port exactly as it found them. + for (const std::shared_ptr& port : ports) { + if (port->IsDetached()) { + ThrowDataCloneError(isolate, + "MessagePort in transfer list is already detached"); + 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. @@ -405,15 +541,43 @@ Maybe SerializedValue::Serialize(Isolate* isolate, Local context, transferredBuffers_.push_back(std::move(backingStore)); } + // Each port's handle side closes here and its data joins the message, + // keeping its group and its queue: senders on the far end go on queueing + // into it while it is in flight, and the receiving port adopts the backlog. + for (const std::shared_ptr& port : ports) { + transferredPorts_.push_back(port->TransferForMessaging()); + } + buffer_ = std::move(owned); bufferSize_ = data.second; return Just(true); } +bool SerializedValue::TransfersPort(const messaging::PortData* data) const { + for (const std::unique_ptr& port : transferredPorts_) { + if (port.get() == data) { + return true; + } + } + return false; +} + MaybeLocal SerializedValue::Deserialize(Isolate* isolate, - Local context) { + Local context, + Local* portList) { Context::Scope contextScope(context); - EscapableHandleScope handleScope(isolate); + // No handle scope of its own: `portList` hands a second handle back to the + // caller, and only one can escape an EscapableHandleScope. Every caller + // opens a scope per message already. + + // A BroadcastChannel hands one message to every listener, which is only + // sound because a fan-out message carries nothing that can be handed over. + // Such a message may be read here from several isolates at once, so the + // consumed flag is written only on the single-receiver path. + tns::Assert(!consumed_, isolate); + if (HasTransferables()) { + consumed_ = true; + } std::vector> sharedBuffers; for (const std::shared_ptr& backingStore : sharedBuffers_) { @@ -463,7 +627,30 @@ MaybeLocal SerializedValue::Deserialize(Isolate* isolate, } } - DeserializerDelegate delegate(&sharedBuffers, &domExceptions); + // Ports are adopted before the read starts, for the same reason the + // exceptions above are: adopting one runs the JS tier's per-wrapper setup, + // and ReadHostObject may not run JS. The array doubles as what a message + // event hands out as its `ports`. + std::vector> ports; + if (!transferredPorts_.empty()) { + Local list = + v8::Array::New(isolate, static_cast(transferredPorts_.size())); + for (size_t i = 0; i < transferredPorts_.size(); i++) { + Local wrapper; + if (!messaging::AdoptPort(context, std::move(transferredPorts_[i])) + .ToLocal(&wrapper) || + !list->Set(context, static_cast(i), wrapper) + .FromMaybe(false)) { + return MaybeLocal(); + } + ports.push_back(wrapper); + } + if (portList != nullptr) { + *portList = list; + } + } + + DeserializerDelegate delegate(&sharedBuffers, &domExceptions, &ports); ValueDeserializer deserializer(isolate, buffer_.get(), bufferSize_, &delegate); delegate.SetDeserializer(&deserializer); @@ -481,7 +668,7 @@ MaybeLocal SerializedValue::Deserialize(Isolate* isolate, if (!deserializer.ReadValue(context).ToLocal(&result)) { return MaybeLocal(); } - return handleScope.Escape(result); + return result; } } // namespace serialization diff --git a/NativeScript/runtime/StructuredSerialization.h b/NativeScript/runtime/StructuredSerialization.h index ac176fdf4..4d955af25 100644 --- a/NativeScript/runtime/StructuredSerialization.h +++ b/NativeScript/runtime/StructuredSerialization.h @@ -7,6 +7,7 @@ #include #include "Common.h" +#include "Messaging.h" namespace tns { namespace serialization { @@ -57,20 +58,41 @@ class SerializedValue { 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 + // Serializes `input`, moving out of this isolate every ArrayBuffer and every + // MessagePort named by `transferList` (an Array, or undefined/null for + // none). `sourcePort` is the port a message is being posted on, which the + // spec forbids transferring with its own message. 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); + v8::Maybe Serialize( + v8::Isolate* isolate, v8::Local context, + v8::Local input, v8::Local transferList, + HostObjectPolicy hostObjectPolicy, + v8::Local sourcePort = v8::Local()); - // 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); + // Reads the value back into `context`, filling `portList` (when given) with + // the wrappers of the ports the message transferred. A value carrying + // anything transferred can be read exactly once — the memory and the ports + // change hands; one carrying only clones may be read any number of times, + // which is what lets a BroadcastChannel fan one message out. + v8::MaybeLocal Deserialize( + v8::Isolate* isolate, v8::Local context, + v8::Local* portList = nullptr); + + // The close sentinel a sibling group queues when a channel goes away: a + // message with no payload at all. + bool IsCloseMessage() const { return this->buffer_ == nullptr; } + + // Whether anything in here can only be handed over once, which is what makes + // a message undeliverable to more than one destination. + bool HasTransferables() const { + return !this->transferredBuffers_.empty() || + !this->transferredPorts_.empty(); + } + + // Whether `data` is one of the ports this message carries — a message + // transferring its own destination destroys the channel it travels on. + bool TransfersPort(const messaging::PortData* data) const; // Web IDL's DOMException serialization steps (name, message) plus the // stack, matching Node. Kept out-of-band because V8 forbids JS while a @@ -98,6 +120,13 @@ class SerializedValue { std::vector> transferredBuffers_; // Backing stores shared with — not moved from — the sending isolate. std::vector> sharedBuffers_; + // Ports moved out of the sending isolate, in transfer-list order: the wire + // carries the index, the port itself travels here. Each keeps its group and + // its queue, so senders can go on queueing into it while it is in flight. + std::vector> transferredPorts_; + // Set by the first read of a message that had something to hand over, so a + // second read is caught rather than handing out emptied slots. + bool consumed_ = false; // One entry per distinct DOMException in the graph, in write order (a // repeated reference is an object id in the stream, not a second entry). std::vector domExceptions_; diff --git a/NativeScript/runtime/Worker.h b/NativeScript/runtime/Worker.h index e8b6d12d5..8c9e9458d 100644 --- a/NativeScript/runtime/Worker.h +++ b/NativeScript/runtime/Worker.h @@ -13,6 +13,12 @@ class Worker { bool isWorkerThread); static void Init(v8::Isolate* isolate, v8::Local globalTemplate); + + // Turns Worker and the worker global scope into EventTargets and caches the + // builtin's delivery callout for this isolate. Runs during Runtime::Init, + // after Events::Init has installed the event primitives it builds on. + static void InitEvents(v8::Local context); + static std::vector GlobalFunctions; private: @@ -22,8 +28,12 @@ class Worker { const v8::FunctionCallbackInfo& info); static void TerminateCallback( const v8::FunctionCallbackInfo& info); + // Builds a MessageEvent out of `message` and dispatches it on `receiver` — + // the Worker object for worker-to-parent traffic, the global scope's + // EventTarget for parent-to-worker. A message that cannot be read arrives as + // a `messageerror` event instead. No-op before InitEvents has run. static void OnMessageCallback(v8::Isolate* isolate, - v8::Local receiver, + v8::Local receiver, std::shared_ptr message); static void PostMessageToMainCallback( const v8::FunctionCallbackInfo& info); diff --git a/NativeScript/runtime/Worker.mm b/NativeScript/runtime/Worker.mm index 48330ef85..001c7fb86 100644 --- a/NativeScript/runtime/Worker.mm +++ b/NativeScript/runtime/Worker.mm @@ -4,6 +4,7 @@ #include #include #include +#include "BuiltinLoader.h" #include "Caches.h" #include "Constants.h" #include "Helpers.h" @@ -18,6 +19,16 @@ namespace tns { +namespace { + +// The worker-events builtin's delivery callout for this isolate. Both +// directions share it; only the receiver differs. +struct WorkerEventsState { + Global emitMessage; +}; + +} // namespace + std::vector Worker::GlobalFunctions = {"postMessage", "close"}; namespace { @@ -263,6 +274,24 @@ bool ParseResourceLimits(Isolate* isolate, Local context, Local globalTemplate->Set(workerFuncName, workerFuncTemplate); } +void Worker::InitEvents(Local context) { + Isolate* isolate = v8::Isolate::GetCurrent(); + + Local exports; + bool success = + BuiltinLoader::GetExports(context, BuiltinId::kWorkerEvents, nullptr).ToLocal(&exports); + tns::Assert(success, isolate); + + Local emitMessage; + success = exports->Get(context, tns::ToV8String(isolate, "emitMessage")).ToLocal(&emitMessage) && + emitMessage->IsFunction(); + tns::Assert(success, isolate); + + WorkerEventsState* state = Caches::StateFor(isolate); + tns::Assert(state != nullptr, isolate); + state->emitMessage.Reset(isolate, emitMessage.As()); +} + void Worker::ConstructorCallback(const FunctionCallbackInfo& info) { Isolate* isolate = info.GetIsolate(); Local context = isolate->GetCurrentContext(); @@ -595,17 +624,9 @@ throw NativeScriptException( auto context = Caches::Get(isolate)->GetContext(); auto message = std::make_shared(); - 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"), 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, transferList, + ->Serialize(isolate, context, info[0], transferList, serialization::HostObjectPolicy::kDegrade) .IsNothing()) { // The transfer list was rejected or the value could not be cloned; the @@ -619,8 +640,12 @@ throw NativeScriptException( Isolate::Scope isolate_scope(isolate); HandleScope handle_scope(isolate); Local workerInstance = state->GetWorker()->Get(isolate); - tns::Assert(!workerInstance.IsEmpty() && workerInstance->IsObject(), isolate); - Worker::OnMessageCallback(isolate, workerInstance, message); + if (workerInstance.IsEmpty() || !workerInstance->IsObject()) { + // The parent dropped its reference to the worker object before the + // message landed; there is nothing left to dispatch on. + return; + } + Worker::OnMessageCallback(isolate, workerInstance.As(), message); }); } catch (NativeScriptException& ex) { ex.ReThrowToV8(isolate); @@ -651,17 +676,9 @@ throw NativeScriptException( auto context = Caches::Get(isolate)->GetContext(); auto message = std::make_shared(); - 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"), 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, transferList, + ->Serialize(isolate, context, info[0], transferList, serialization::HostObjectPolicy::kDegrade) .IsNothing()) { // The transfer list was rejected or the value could not be cloned; the @@ -675,38 +692,39 @@ throw NativeScriptException( } } -void Worker::OnMessageCallback(Isolate* isolate, Local receiver, +void Worker::OnMessageCallback(Isolate* isolate, Local receiver, std::shared_ptr message) { - Local context = Caches::Get(isolate)->GetContext(); - Local onMessageValue; - bool success = receiver.As() - ->Get(context, tns::ToV8String(isolate, "onmessage")) - .ToLocal(&onMessageValue); - tns::Assert(success, isolate); - - if (!onMessageValue->IsFunction()) { + WorkerEventsState* state = Caches::StateFor(isolate); + if (state == nullptr || state->emitMessage.IsEmpty()) { return; } + Local context = Caches::Get(isolate)->GetContext(); - Local onMessageFunc = onMessageValue.As(); - Local result; - - Local arg; + Local data; + Local ports; + const char* type = "message"; { - // Reading runs JS (a DOMException is rebuilt through its constructor), so - // a failure here must not stay pending on the isolate past this callout. TryCatch tc(isolate); - if (!message->Deserialize(isolate, context).ToLocal(&arg)) { - if (!tc.HasTerminated() && tc.HasCaught()) { - Log(@"Worker message could not be read: %s", - tns::ToString(isolate, tc.Exception()).c_str()); + if (!message->Deserialize(isolate, context, &ports).ToLocal(&data)) { + if (tc.HasTerminated()) { + return; } - return; + // HTML: a message that cannot be read still reaches its target, as a + // `messageerror` event carrying nothing. + tc.Reset(); + data = v8::Undefined(isolate); + ports = Local(); + type = "messageerror"; } } - Local args[1]{arg}; - success = onMessageFunc->Call(context, receiver, 1, args).ToLocal(&result); + Local args[3]{data, ports.IsEmpty() ? v8::Undefined(isolate).As() : ports, + tns::ToV8String(isolate, type)}; + Local result; + // A throw here is left pending on purpose: on the worker side the drain's + // TryCatch turns it into the scope's error event, and on the parent side + // V8's uncaught-message listener reports it. + (void)state->emitMessage.Get(isolate)->Call(context, receiver, 3, args).ToLocal(&result); } void Worker::CloseWorkerCallback(const FunctionCallbackInfo& info) { diff --git a/NativeScript/runtime/WorkerWrapper.mm b/NativeScript/runtime/WorkerWrapper.mm index 2535a8bf8..acc4490bd 100644 --- a/NativeScript/runtime/WorkerWrapper.mm +++ b/NativeScript/runtime/WorkerWrapper.mm @@ -106,8 +106,6 @@ static void PostToRuntimeLoop(Runtime* runtime, std::function fn, bool a v8::Locker locker(this->workerIsolate_); Isolate::Scope isolate_scope(this->workerIsolate_); HandleScope handle_scope(this->workerIsolate_); - Local context = Caches::Get(this->workerIsolate_)->GetContext(); - Local global = context->Global(); // WHATWG parity: the implicit port's message queue starts disabled and is // enabled by Worker.mm once the entry script has finished evaluating @@ -119,6 +117,18 @@ static void PostToRuntimeLoop(Runtime* runtime, std::function fn, bool a return; } + // Messages dispatch on the EventTarget backing the global scope's listener + // methods rather than on globalThis, so app code replacing + // globalThis.dispatchEvent cannot intercept delivery. + auto cache = Caches::Get(this->workerIsolate_); + if (cache->GlobalEventTarget == nullptr) { + return; + } + Local globalTarget = cache->GlobalEventTarget->Get(this->workerIsolate_); + if (globalTarget.IsEmpty()) { + return; + } + std::vector> messages = this->queue_.PopAll(); for (std::shared_ptr message : messages) { @@ -126,7 +136,7 @@ static void PostToRuntimeLoop(Runtime* runtime, std::function fn, bool a break; } TryCatch tc(this->workerIsolate_); - this->onMessage_(this->workerIsolate_, global, message); + this->onMessage_(this->workerIsolate_, globalTarget, message); if (tc.HasCaught()) { this->CallOnErrorHandlers(tc); diff --git a/NativeScript/runtime/js/README.md b/NativeScript/runtime/js/README.md index 9795dfdb1..c1bdcf732 100644 --- a/NativeScript/runtime/js/README.md +++ b/NativeScript/runtime/js/README.md @@ -34,7 +34,13 @@ module.exports = somethingTheCallSiteNeeds; cross-builtin capabilities that must never leak to app code (the `kListenerChanged` hook key abort-signal.js takes from events.js, the `setListenerErrorReporter` setter error-events.js calls): the producer puts - the capability in its `module.exports`, the consumer requires it. + the capability in its `module.exports`, the consumer requires it. The + `internal/events` bag publishes `globalEventTarget`, `CustomEvent`, + `kListenerChanged`, `setListenerErrorReporter`, `Event`, `EventTarget`, + `defineEventHandler` and `dispatchEventRethrowing` — the base classes and the + handler-attribute helper are there because a lazy builtin may not read live + globals (see the rule two sections down), so this is the sanctioned door to + them. `require("internal/…")` at first use runs the file through the shared exports cache — for a consumer of an eager producer that is a cache hit, and a miss runs the producer on demand. A consumer can therefore never @@ -74,9 +80,18 @@ property. That cache is the same one the `ns:`/`node:` module registry uses, so a module re-exporting a lazy builtin's interfaces (`ns:util`'s `TextEncoder`) hands out the objects the globals hold, in either access order. Until then nothing of it exists — no compile, no run, no allocation. `text-encoding.js` -(`TextEncoder`/`TextDecoder`), `base64.js` (`atob`/`btoa`) and -`dom-exception.js` (`DOMException`) are the current ones; new globals join by -adding a row to `kLazyGlobals`. +(`TextEncoder`/`TextDecoder`), `base64.js` (`atob`/`btoa`), +`dom-exception.js` (`DOMException`), `message-event.js` (`MessageEvent`), +`message-channel.js` (`MessagePort`/`MessageChannel`) and +`broadcast-channel.js` (`BroadcastChannel`) are the current ones; new globals +join by adding a row to `kLazyGlobals`. + +Two neighbours of that set are deliberately not in it. `worker-events.js` is +**eager**: it defines the handler attributes on `Worker.prototype` and the +worker global scope, which have to exist before app code assigns one. +`node-worker-threads.js` is a **public builtin module** (`node:worker_threads`) +rather than a lazy global — it is reached by specifier, so nothing places a +name for it. An **eager** file can also feed the tier: `events.js` (eager, `Events::Init`) exports `CustomEvent`, and the `CustomEvent` row reads it through the same @@ -100,6 +115,12 @@ The two extra rules a lazy builtin lives by: (`URLSearchParams`, …) capture it into a file-level `const`. A lazy builtin gets the same pristine `primordials`, but the live globals it would capture are whatever user code left behind, so it should not reach for them at all. +- The per-instance wrappers `defineEventHandler` creates live on the target's + **own listener bag**, under a private symbol — never in a WeakMap keyed by + the target. An ObjectManager-registered object (a `Worker`) can be + resurrected by its finalizer while its thread is alive, and a resurrected + object's weak-collection entries are already gone, so a WeakMap would hand + the revived object a fresh, empty handler map. - No `import`/`export` — these are classic function bodies, not modules. - ESLint (`eslint.config.mjs` at the repo root, run by lint-staged) declares `exports`, `require`, `module`, `binding`, `primordials` and the reachable diff --git a/NativeScript/runtime/js/broadcast-channel.js b/NativeScript/runtime/js/broadcast-channel.js new file mode 100644 index 000000000..3f4aa9980 --- /dev/null +++ b/NativeScript/runtime/js/broadcast-channel.js @@ -0,0 +1,119 @@ +"use strict"; +// BroadcastChannel (HTML Standard §9.5): every channel constructed with the +// same name joins one process-wide group, workers included — "same user agent" +// is the app process here. +// +// A channel owns a hidden MessagePort in that named group. The port is started +// and strongly held from construction (native holds the wrapper, the wrapper +// holds the relay listener, the relay holds the channel), so an unclosed +// channel stays deliverable whether or not app code keeps a reference — and +// close() is what ends that. +const { createBroadcastPort, postMessage: postMessageToPort, close: closePort } = + binding; + +const { + FunctionPrototypeCall, + ObjectDefineProperty, + SymbolToStringTag, + TypeError, +} = primordials; + +const { EventTarget, defineEventHandler } = require("internal/events"); +const { adoptPort } = require("internal/message-channel"); + +const addEventListener = EventTarget.prototype.addEventListener; +const dispatchEvent = EventTarget.prototype.dispatchEvent; + +let MessageEvent; +function getMessageEvent() { + if (MessageEvent === undefined) { + ({ MessageEvent } = require("internal/message-event")); + } + return MessageEvent; +} + +let DOMException; +function getDOMException() { + if (DOMException === undefined) { + ({ DOMException } = require("internal/dom-exception")); + } + return DOMException; +} + +class BroadcastChannel extends EventTarget { + #name; + #port; + + constructor(name) { + if (arguments.length < 1) { + throw new TypeError("BroadcastChannel: 1 argument required, but only 0 present"); + } + super(); + ObjectDefineProperty(this, "_listeners", { + __proto__: null, + value: this._listeners, + writable: true, + enumerable: false, + configurable: true, + }); + this.#name = `${name}`; + const port = adoptPort(createBroadcastPort(this.#name)); + this.#port = port; + const channel = this; + const relay = function (event) { + FunctionPrototypeCall( + dispatchEvent, + channel, + new (getMessageEvent())(event.type, { data: event.data }) + ); + }; + FunctionPrototypeCall(addEventListener, port, "message", relay); + FunctionPrototypeCall(addEventListener, port, "messageerror", relay); + } + + get name() { + return this.#name; + } + + postMessage(message) { + if (arguments.length < 1) { + throw new TypeError("postMessage: 1 argument required, but only 0 present"); + } + if (this.#port === undefined) { + throw new (getDOMException())( + "BroadcastChannel is closed.", + "InvalidStateError" + ); + } + // No transfer list: the spec's postMessage takes the message alone, and a + // fan-out message could not hand one object to every destination anyway. + postMessageToPort(this.#port, message, undefined); + } + + close() { + if (this.#port === undefined) { + return; + } + const port = this.#port; + this.#port = undefined; + closePort(port); + } +} + +defineEventHandler(BroadcastChannel.prototype, "message"); +defineEventHandler(BroadcastChannel.prototype, "messageerror"); + +for (const key of ["name", "postMessage", "close"]) { + ObjectDefineProperty(BroadcastChannel.prototype, key, { + __proto__: null, + enumerable: true, + }); +} + +ObjectDefineProperty(BroadcastChannel.prototype, SymbolToStringTag, { + __proto__: null, + value: "BroadcastChannel", + configurable: true, +}); + +module.exports = { BroadcastChannel }; diff --git a/NativeScript/runtime/js/events.js b/NativeScript/runtime/js/events.js index 7cb43312d..032227325 100644 --- a/NativeScript/runtime/js/events.js +++ b/NativeScript/runtime/js/events.js @@ -42,19 +42,59 @@ function setListenerErrorReporter(fn) { reportListenerError = fn; } -// Internal listener-mutation hook. A target (in practice: AbortSignal, on -// its prototype) may carry a function under this symbol; it is called with -// (target, type, newCount) from every path that changes a listener list — -// add, remove, and the once-splice inside dispatch. The key travels only -// through require("internal/events"), so the accounting cannot be bypassed -// the way an overridable addEventListener could. +// Event name -> handler-attribute wrapper (see defineEventHandler), stored on +// the target's own listener bag under a symbol so it cannot collide with an +// event type. Deliberately NOT a WeakMap keyed by the target: a Worker is an +// ObjectManager-registered object whose finalizer resurrects it while its +// thread is alive, and a resurrected object's weak-collection entries are +// already gone. Each wrapper carries a `delta` that the listener count is +// corrected by: the wrapper occupies one slot in the listener list from its +// first assignment onwards, but a cleared handler is not a listener. +var kHandlers = Symbol("handlers"); + +function handlersOf(target) { + var bag = target._listeners; + return bag === undefined ? undefined : bag[kHandlers]; +} + +// Internal listener-mutation hook. A target (in practice: AbortSignal and +// MessagePort, on their prototypes) may carry a function under this symbol; +// it is called with (target, type, newCount) from every path that changes a +// listener list — add, remove, the once-splice inside dispatch, and a handler +// attribute going active or inert. The key travels only through +// require("internal/events"), so the accounting cannot be bypassed the way an +// overridable addEventListener could. var kListenerChanged = Symbol("listenerChanged"); function notifyListenerChanged(target, type, count) { var hook = target[kListenerChanged]; - if (hook !== undefined) { hook(target, type, count); } + if (hook === undefined) { return; } + var wrappers = handlersOf(target); + if (wrappers !== undefined) { + var wrapper = wrappers[type]; + if (wrapper !== undefined) { count += wrapper.delta; } + } + hook(target, type, count); } function EventTargetImpl() { this._listeners = ObjectCreate(null); } + +// A target whose prototype was grafted onto EventTarget.prototype rather than +// built by the constructor — Worker, MessagePort — has no bag until it needs +// one. Non-enumerable, because those are platform objects. +function listenersOf(target) { + var bag = target._listeners; + if (bag === undefined) { + bag = ObjectCreate(null); + ObjectDefineProperty(target, "_listeners", { + value: bag, + writable: true, + enumerable: false, + configurable: true, + }); + } + return bag; +} + EventTargetImpl.prototype.addEventListener = function (type, callback, options) { if (callback === null || callback === undefined) { return; } type = String(type); @@ -65,8 +105,9 @@ EventTargetImpl.prototype.addEventListener = function (type, callback, options) capture = !!options.capture; once = !!options.once; } - var list = this._listeners[type]; - if (!list) { list = this._listeners[type] = []; } + var bag = listenersOf(this); + var list = bag[type]; + if (!list) { list = bag[type] = []; } for (var i = 0; i < list.length; i++) { if (list[i].callback === callback && list[i].capture === capture) { return; } } @@ -81,7 +122,8 @@ EventTargetImpl.prototype.removeEventListener = function (type, callback, option } else if (options && typeof options === "object") { capture = !!options.capture; } - var list = this._listeners[type]; + var bag = this._listeners; + var list = bag === undefined ? undefined : bag[type]; if (!list) { return; } for (var i = 0; i < list.length; i++) { if (list[i].callback === callback && list[i].capture === capture) { @@ -91,10 +133,13 @@ EventTargetImpl.prototype.removeEventListener = function (type, callback, option } } }; -EventTargetImpl.prototype.dispatchEvent = function (event) { - event.target = this; - event.currentTarget = this; - var list = this._listeners[event.type]; +function dispatch(target, event, rethrow) { + event.target = target; + event.currentTarget = target; + var thrown; + var hasThrown = false; + var bag = target._listeners; + var list = bag === undefined ? undefined : bag[event.type]; if (list) { // Snapshot so listeners added during dispatch are not invoked and // registration order is preserved. @@ -105,25 +150,43 @@ EventTargetImpl.prototype.dispatchEvent = function (event) { if (idx === -1) { continue; } // removed since snapshot if (entry.once) { ArrayPrototypeSplice(list, idx, 1); - notifyListenerChanged(this, event.type, list.length); + notifyListenerChanged(target, event.type, list.length); } var cb = entry.callback; try { if (typeof cb === "function") { - FunctionPrototypeCall(cb, this, event); + FunctionPrototypeCall(cb, target, event); } else if (cb && typeof cb.handleEvent === "function") { cb.handleEvent(event); } } catch (e) { - reportListenerError(e); + if (rethrow && !hasThrown) { + thrown = e; + hasThrown = true; + } else { + reportListenerError(e); + } } if (event._stopImmediate) { break; } } } event.currentTarget = null; + if (hasThrown) { throw thrown; } return !event.defaultPrevented; +} + +EventTargetImpl.prototype.dispatchEvent = function (event) { + return dispatch(this, event, false); }; +// Dispatch whose first listener exception reaches the caller instead of the +// uncaught-error reporter. Worker message delivery needs it: the native frame +// that called in owns the worker's error chain (the scope's `onerror`, then +// the parent's), and throwing back into it is the only way there. +function dispatchEventRethrowing(target, event) { + return dispatch(target, event, true); +} + // Internal EventTarget instance backing the global. globalThis's prototype // is intentionally NOT made an EventTarget; only the three methods are // bound onto it. @@ -146,6 +209,74 @@ EventTarget.prototype.dispatchEvent = EventTargetImpl.prototype.dispatchEvent; g.Event = Event; g.EventTarget = EventTarget; +// Event handler IDL attributes (HTML §8.1.7.2), Node's defineEventHandler. +// The handler is never registered directly: a wrapper listener takes its slot +// on the first assignment and stays there, so `onfoo` fires at the position it +// was FIRST set at even after being replaced or cleared, interleaved correctly +// with addEventListener registrations. A cleared handler leaves the wrapper in +// place but inert, which is why the wrapper carries the count correction the +// listener-changed hook applies. +var addListener = EventTargetImpl.prototype.addEventListener; + +function makeEventHandler(handler) { + function eventHandler(event) { + if (typeof eventHandler.handler !== "function") { return; } + return FunctionPrototypeCall(eventHandler.handler, this, event); + } + eventHandler.handler = handler; + eventHandler.delta = 0; + return eventHandler; +} + +function defineEventHandler(target, name, event) { + if (event === undefined) { event = name; } + var propName = "on" + name; + + function get() { + var wrappers = handlersOf(this); + if (wrappers === undefined) { return null; } + var wrapper = wrappers[event]; + return wrapper === undefined ? null : wrapper.handler; + } + + function set(value) { + // [LegacyTreatNonObjectAsNull]: anything neither callable nor an object + // clears the handler. + if (typeof value !== "function" && (typeof value !== "object" || value === null)) { + value = null; + } + var bag = listenersOf(this); + var wrappers = bag[kHandlers]; + if (wrappers === undefined) { + wrappers = bag[kHandlers] = ObjectCreate(null); + } + var wrapper = wrappers[event]; + if (wrapper === undefined) { + // First assignment ever, `null` included: the slot is claimed now, and + // the listener count rises with it (HTML port enabling depends on it). + wrapper = wrappers[event] = makeEventHandler(value); + FunctionPrototypeCall(addListener, this, event, wrapper); + return; + } + var wasActive = typeof wrapper.handler === "function"; + var isActive = typeof value === "function"; + wrapper.handler = value; + if (wasActive === isActive) { return; } + wrapper.delta += isActive ? 1 : -1; + var list = bag[event]; + notifyListenerChanged(this, event, list ? list.length : 0); + } + + ObjectDefineProperty(get, "name", { value: "get " + propName, configurable: true }); + ObjectDefineProperty(set, "name", { value: "set " + propName, configurable: true }); + ObjectDefineProperty(target, propName, { + get: get, + set: set, + enumerable: true, + configurable: true, + }); +} + // CustomEvent (DOM Standard §2.4): Event carrying an app-supplied `detail`. // Defined here so it extends the same Event the globals hold, but NOT // installed eagerly — the lazy-global tier (LazyGlobals) places it from this @@ -181,4 +312,10 @@ module.exports = { CustomEvent: CustomEvent, kListenerChanged: kListenerChanged, setListenerErrorReporter: setListenerErrorReporter, + // The base classes and the handler-attribute helper, for the lazy builtins + // that may not read them off the globals user code can replace. + Event: Event, + EventTarget: EventTarget, + defineEventHandler: defineEventHandler, + dispatchEventRethrowing: dispatchEventRethrowing, }; diff --git a/NativeScript/runtime/js/message-channel.js b/NativeScript/runtime/js/message-channel.js new file mode 100644 index 000000000..bcf3406ee --- /dev/null +++ b/NativeScript/runtime/js/message-channel.js @@ -0,0 +1,246 @@ +"use strict"; +// MessagePort / MessageChannel (HTML Standard §9.4) over the native messaging +// core (Messaging.cpp). +// +// The wrappers native code hands out — from createChannel, and from the +// deserializer for every port that arrives in a message — are bare objects +// carrying an internal field. `adoptPort` is what turns one into a +// MessagePort, and it is the only way an instance comes into being, which is +// why the constructor throws. Native must run it over every port wrapper it +// materializes that does not reach JS through emitMessage. +// +// Port enabling is HTML's: a port starts delivering when it gets its first +// 'message' listener — addEventListener or the onmessage attribute, including +// an `onmessage = null` first write — and stops when the last one goes. The +// events builtin's kListenerChanged hook is what reports those transitions. +// 'close' is delivered even to a port that was never started, so a port whose +// sibling died always learns about it. +const { + createChannel, + postMessage: postMessageToPort, + start: startPort, + stop: stopPort, + close: closePort, + drainOne, + setEmitMessage, +} = binding; + +const { + ArrayIsArray, + ArrayPrototypePush, + FunctionPrototypeCall, + ObjectCreate, + ObjectDefineProperty, + ObjectPrototypeHasOwnProperty, + ObjectSetPrototypeOf, + SymbolIterator, + SymbolToStringTag, + TypeError, + WeakSet, + WeakSetPrototypeAdd, + WeakSetPrototypeDelete, + WeakSetPrototypeHas, +} = primordials; + +const { + Event, + EventTarget, + defineEventHandler, + kListenerChanged, +} = require("internal/events"); + +const addEventListener = EventTarget.prototype.addEventListener; +const dispatchEvent = EventTarget.prototype.dispatchEvent; + +let MessageEvent; +function getMessageEvent() { + if (MessageEvent === undefined) { + ({ MessageEvent } = require("internal/message-event")); + } + return MessageEvent; +} + +// WebIDL sequence. Entries are handed to the native transfer-list +// collector unexamined: it owns the transferability rules and the +// DataCloneError messages that go with them. +function toTransferList(value) { + if (value === undefined || value === null) { + return undefined; + } + if (ArrayIsArray(value)) { + return value; + } + if (typeof value !== "object" && typeof value !== "function") { + throw new TypeError("postMessage: transfer is not iterable"); + } + // The HTML overload: a second argument that is not itself iterable is the + // StructuredSerializeOptions dictionary carrying the sequence. + const source = + typeof value[SymbolIterator] === "function" ? value : value.transfer; + if (source === undefined || source === null) { + return undefined; + } + if (ArrayIsArray(source)) { + return source; + } + if (typeof source !== "object" && typeof source !== "function") { + throw new TypeError("postMessage: transfer is not iterable"); + } + const method = source[SymbolIterator]; + if (typeof method !== "function") { + throw new TypeError("postMessage: transfer is not iterable"); + } + return drainIterable(method, source); +} + +function drainIterable(method, value) { + const iterator = FunctionPrototypeCall(method, value); + if (iterator === null || typeof iterator !== "object") { + throw new TypeError("postMessage: transfer is not iterable"); + } + const next = iterator.next; + if (typeof next !== "function") { + throw new TypeError("postMessage: transfer is not iterable"); + } + const list = []; + for (;;) { + const step = FunctionPrototypeCall(next, iterator); + if (step === null || typeof step !== "object") { + throw new TypeError("postMessage: transfer iterator returned a non-object"); + } + if (step.done) { + break; + } + ArrayPrototypePush(list, step.value); + } + return list; +} + +// Ports the native side is currently delivering to. The set is the idempotence +// guard for start/stop: the hook below sees every count transition, an explicit +// start() sees none. +const startedPorts = new WeakSet(); + +function listenerChanged(port, type, count) { + if (type !== "message") { + return; + } + if (count > 0) { + if (!WeakSetPrototypeHas(startedPorts, port)) { + WeakSetPrototypeAdd(startedPorts, port); + startPort(port); + } + } else if (WeakSetPrototypeHas(startedPorts, port)) { + WeakSetPrototypeDelete(startedPorts, port); + stopPort(port); + } +} + +class MessagePort extends EventTarget { + constructor() { + throw new TypeError("Illegal constructor"); + } + + postMessage(value, transfer) { + postMessageToPort(this, value, toTransferList(transfer)); + } + + start() { + if (!WeakSetPrototypeHas(startedPorts, this)) { + WeakSetPrototypeAdd(startedPorts, this); + startPort(this); + } + } + + close(callback) { + if (typeof callback === "function") { + FunctionPrototypeCall(addEventListener, this, "close", callback, { once: true }); + } + closePort(this); + } +} + +defineEventHandler(MessagePort.prototype, "message"); +defineEventHandler(MessagePort.prototype, "messageerror"); + +ObjectDefineProperty(MessagePort.prototype, kListenerChanged, { + __proto__: null, + value: listenerChanged, + writable: false, + enumerable: false, + configurable: false, +}); + +ObjectDefineProperty(MessagePort.prototype, SymbolToStringTag, { + __proto__: null, + value: "MessagePort", + configurable: true, +}); + +for (const key of ["postMessage", "start", "close"]) { + ObjectDefineProperty(MessagePort.prototype, key, { + __proto__: null, + enumerable: true, + }); +} + +function adoptPort(port) { + if (ObjectPrototypeHasOwnProperty(port, "_listeners")) { + return port; + } + ObjectSetPrototypeOf(port, MessagePort.prototype); + // The EventTarget base would install this as an own enumerable field; a port + // is a platform object, so keep it out of Object.keys(port). + ObjectDefineProperty(port, "_listeners", { + __proto__: null, + value: ObjectCreate(null), + writable: true, + enumerable: false, + configurable: true, + }); + return port; +} + +class MessageChannel { + constructor() { + const pair = createChannel(); + this.port1 = adoptPort(pair[0]); + this.port2 = adoptPort(pair[1]); + } +} + +ObjectDefineProperty(MessageChannel.prototype, SymbolToStringTag, { + __proto__: null, + value: "MessageChannel", + configurable: true, +}); + +function receiveMessageOnPort(port) { + const result = drainOne(port); + return result === null ? undefined : result; +} + +// The per-isolate delivery callout. Native invokes it with the receiving port +// wrapper as the receiver; `type` is "message", "messageerror" or "close". +function emitMessage(data, ports, type) { + if (type === "close") { + FunctionPrototypeCall(dispatchEvent, this, new Event("close")); + return; + } + const list = []; + if (ports !== undefined && ports !== null) { + for (let i = 0; i < ports.length; i++) { + ArrayPrototypePush(list, adoptPort(ports[i])); + } + } + const MessageEventCtor = getMessageEvent(); + FunctionPrototypeCall( + dispatchEvent, + this, + new MessageEventCtor(type, { data, ports: list }) + ); +} + +setEmitMessage(emitMessage); + +module.exports = { MessagePort, MessageChannel, receiveMessageOnPort, adoptPort }; diff --git a/NativeScript/runtime/js/message-event.js b/NativeScript/runtime/js/message-event.js new file mode 100644 index 000000000..387714896 --- /dev/null +++ b/NativeScript/runtime/js/message-event.js @@ -0,0 +1,151 @@ +"use strict"; +// MessageEvent (HTML Standard §9.2.5), the event every messaging surface in +// the runtime delivers: MessagePort, BroadcastChannel, Worker and the worker +// global scope. +// +// Lazy builtin: LazyGlobals places the global and the messaging builtins +// require this file at first delivery, so an app that never receives a message +// never runs it. Event/EventTarget come from require("internal/events") rather +// than the globals, which by then are whatever user code left behind. +const { + ArrayPrototypePush, + ArrayPrototypeSlice, + FunctionPrototypeCall, + ObjectDefineProperty, + ObjectFreeze, + SymbolIterator, + SymbolToStringTag, + TypeError, +} = primordials; + +const { Event } = require("internal/events"); + +// WebIDL sequence. Entry types are not checked here: the ports an +// event carries come from the native deserializer, and a hand-built event's +// `ports` is inert data. +function toPortSequence(value) { + if (value === null || (typeof value !== "object" && typeof value !== "function")) { + throw new TypeError("MessageEvent: ports is not iterable"); + } + const method = value[SymbolIterator]; + if (typeof method !== "function") { + throw new TypeError("MessageEvent: ports is not iterable"); + } + const iterator = FunctionPrototypeCall(method, value); + if (iterator === null || typeof iterator !== "object") { + throw new TypeError("MessageEvent: ports is not iterable"); + } + const next = iterator.next; + if (typeof next !== "function") { + throw new TypeError("MessageEvent: ports is not iterable"); + } + const list = []; + for (;;) { + const step = FunctionPrototypeCall(next, iterator); + if (step === null || typeof step !== "object") { + throw new TypeError("MessageEvent: ports iterator returned a non-object"); + } + if (step.done) { + break; + } + ArrayPrototypePush(list, step.value); + } + return list; +} + +class MessageEvent extends Event { + #data; + #origin; + #lastEventId; + #source; + #ports; + + constructor(type, init = undefined) { + if (arguments.length < 1) { + throw new TypeError("MessageEvent: 1 argument required, but only 0 present"); + } + if (init !== undefined && init !== null && + typeof init !== "object" && typeof init !== "function") { + throw new TypeError("MessageEvent: eventInitDict is not an object"); + } + super(type, init); + const options = init === undefined || init === null ? {} : init; + this.#data = options.data !== undefined ? options.data : null; + this.#origin = options.origin !== undefined ? `${options.origin}` : ""; + this.#lastEventId = + options.lastEventId !== undefined ? `${options.lastEventId}` : ""; + this.#source = options.source !== undefined ? options.source : null; + this.#ports = + options.ports !== undefined && options.ports !== null + ? toPortSequence(options.ports) + : []; + } + + get data() { + return this.#data; + } + + get origin() { + return this.#origin; + } + + get lastEventId() { + return this.#lastEventId; + } + + get source() { + return this.#source; + } + + get ports() { + // A frozen copy per read: freezing the backing array in place would let a + // caller's reference alias the event's own state. + return ObjectFreeze(ArrayPrototypeSlice(this.#ports)); + } + + initMessageEvent( + type, + bubbles = false, + cancelable = false, + data = null, + origin = "", + lastEventId = "", + source = null, + ports = [] + ) { + if (arguments.length < 1) { + throw new TypeError("initMessageEvent: 1 argument required, but only 0 present"); + } + // Event's initialize steps are a no-op while the event is being + // dispatched; currentTarget is what marks that window. + if (this.currentTarget !== null) { + return; + } + this.type = `${type}`; + this.bubbles = !!bubbles; + this.cancelable = !!cancelable; + this.defaultPrevented = false; + this.target = null; + this.#data = data; + this.#origin = `${origin}`; + this.#lastEventId = `${lastEventId}`; + this.#source = source; + this.#ports = ports === null ? [] : toPortSequence(ports); + } +} + +// Class members are non-enumerable; the IDL attributes and operations are not. +for (const key of ["data", "origin", "lastEventId", "source", "ports", "initMessageEvent"]) { + ObjectDefineProperty(MessageEvent.prototype, key, { + __proto__: null, + enumerable: true, + }); +} + +ObjectDefineProperty(MessageEvent.prototype, SymbolToStringTag, { + __proto__: null, + value: "MessageEvent", + configurable: true, +}); + +module.exports = { MessageEvent }; diff --git a/NativeScript/runtime/js/node-worker-threads.js b/NativeScript/runtime/js/node-worker-threads.js new file mode 100644 index 000000000..3f1e49caf --- /dev/null +++ b/NativeScript/runtime/js/node-worker-threads.js @@ -0,0 +1,272 @@ +"use strict"; + +// The `node:worker_threads` compatibility shim. The channel half — +// MessagePort, MessageChannel, BroadcastChannel, receiveMessageOnPort — is the +// real thing, shared with the globals of the same name. The thread half is a +// bridge over the runtime's own Worker: this runtime has no thread pool, no +// stdio plumbing and no per-thread environment, so what cannot be honoured +// throws with the option or function named rather than degrading silently. +// See docs/worker-threads.md for the real-vs-shim table. + +const { + isMainThread, + threadId, + markAsUntransferable, + isMarkedAsUntransferable, + markAsUncloneable, + setEnvironmentData, + getEnvironmentData, +} = binding; + +const { + ArrayPrototypeIndexOf, + ArrayPrototypePush, + ArrayPrototypeSlice, + ArrayPrototypeSplice, + Error, + FunctionPrototypeCall, + ObjectCreate, + ObjectDefineProperty, + ObjectFreeze, + PromisePrototypeThen, + PromiseResolve, + SymbolFor, + SymbolToStringTag, + TypeError, +} = primordials; + +const { + MessagePort, + MessageChannel, + receiveMessageOnPort, +} = require("internal/message-channel"); +const { BroadcastChannel } = require("internal/broadcast-channel"); +const { + EventTarget, + defineEventHandler, + globalEventTarget, +} = require("internal/events"); + +let MessageEvent; +function getMessageEvent() { + if (MessageEvent === undefined) { + ({ MessageEvent } = require("internal/message-event")); + } + return MessageEvent; +} + +const g = globalThis; +// The platform constructor this shim wraps, and the worker scope's channel +// back to its parent. +const NativeWorker = g.Worker; +const globalPostMessage = g.postMessage; + +const addEventListener = EventTarget.prototype.addEventListener; +const dispatchEvent = EventTarget.prototype.dispatchEvent; + +// Runs `fn` after the caller returns. Node reports 'online' and 'exit' from +// the thread's own lifecycle; the runtime's Worker has no equivalent signal, +// so both are reported off a microtask instead. +function soon(fn) { + PromisePrototypeThen(PromiseResolve(), fn); +} + +function notSupported(name) { + throw new Error(`${name} is not supported in this runtime`); +} + +// Worker options that carry meaning this runtime cannot honour. The three +// stdio ones default to false, so only an explicit request is an error. +const rejectedOptions = ["workerData", "env", "eval", "transferList"]; +const rejectedStdio = ["stdin", "stdout", "stderr"]; + +class WorkerEmitter { + #listeners = ObjectCreate(null); + + on(type, listener) { + if (typeof listener !== "function") { + throw new TypeError('The "listener" argument must be of type function'); + } + const key = `${type}`; + const list = this.#listeners[key] || (this.#listeners[key] = []); + ArrayPrototypePush(list, { listener, once: false }); + return this; + } + + once(type, listener) { + if (typeof listener !== "function") { + throw new TypeError('The "listener" argument must be of type function'); + } + const key = `${type}`; + const list = this.#listeners[key] || (this.#listeners[key] = []); + ArrayPrototypePush(list, { listener, once: true }); + return this; + } + + removeListener(type, listener) { + const list = this.#listeners[`${type}`]; + if (list === undefined) { + return this; + } + for (let i = 0; i < list.length; i++) { + if (list[i].listener === listener) { + ArrayPrototypeSplice(list, i, 1); + return this; + } + } + return this; + } + + off(type, listener) { + return this.removeListener(type, listener); + } + + emit(type, arg) { + const list = this.#listeners[type]; + if (list === undefined) { + return; + } + const snapshot = ArrayPrototypeSlice(list); + for (let i = 0; i < snapshot.length; i++) { + const entry = snapshot[i]; + if (entry.once) { + const index = ArrayPrototypeIndexOf(list, entry); + if (index !== -1) { + ArrayPrototypeSplice(list, index, 1); + } + } + FunctionPrototypeCall(entry.listener, this, arg); + } + } +} + +class Worker extends WorkerEmitter { + #worker; + #exited = false; + + constructor(filename, options) { + super(); + if (options !== undefined && options !== null) { + for (let i = 0; i < rejectedOptions.length; i++) { + if (options[rejectedOptions[i]] !== undefined) { + throw new TypeError( + `Worker option '${rejectedOptions[i]}' is not supported in this runtime` + ); + } + } + for (let i = 0; i < rejectedStdio.length; i++) { + if (options[rejectedStdio[i]]) { + throw new TypeError( + `Worker option '${rejectedStdio[i]}' is not supported in this runtime` + ); + } + } + } + + const worker = new NativeWorker(`${filename}`); + this.#worker = worker; + const self = this; + worker.onmessage = function (event) { + self.emit("message", event.data); + }; + worker.onmessageerror = function (event) { + self.emit("messageerror", event.data); + }; + worker.onerror = function (error) { + self.emit("error", error); + }; + soon(function () { + self.emit("online", undefined); + }); + } + + postMessage(value, transfer) { + this.#worker.postMessage(value, transfer); + } + + terminate() { + this.#worker.terminate(); + const self = this; + return PromisePrototypeThen(PromiseResolve(), function () { + if (!self.#exited) { + self.#exited = true; + self.emit("exit", 0); + } + return 0; + }); + } +} + +ObjectDefineProperty(Worker.prototype, SymbolToStringTag, { + __proto__: null, + value: "Worker", + configurable: true, +}); + +// The worker scope's end of the parent channel. Not a MessagePort: it is not +// transferable and it has no queue of its own, it forwards to the worker +// globals the runtime already provides. close() is a no-op — a worker ends +// through its own close()/terminate(). +class ParentPort extends EventTarget { + postMessage(value, transfer) { + FunctionPrototypeCall(globalPostMessage, g, value, transfer); + } + + start() {} + + close() {} +} + +defineEventHandler(ParentPort.prototype, "message"); +defineEventHandler(ParentPort.prototype, "messageerror"); + +ObjectDefineProperty(ParentPort.prototype, SymbolToStringTag, { + __proto__: null, + value: "MessagePort", + configurable: true, +}); + +let parentPort = null; +if (!isMainThread) { + parentPort = new ParentPort(); + const relay = function (event) { + FunctionPrototypeCall( + dispatchEvent, + parentPort, + new (getMessageEvent())(event.type, { data: event.data }) + ); + }; + FunctionPrototypeCall(addEventListener, globalEventTarget, "message", relay); + FunctionPrototypeCall(addEventListener, globalEventTarget, "messageerror", relay); +} + +module.exports = ObjectFreeze({ + BroadcastChannel, + MessageChannel, + MessagePort, + // Exported so an `options.env === SHARE_ENV` spelling still resolves; this + // runtime has one environment and never copies it. + SHARE_ENV: SymbolFor("nodejs.worker_threads.SHARE_ENV"), + Worker, + getEnvironmentData, + isInternalThread: false, + isMainThread, + isMarkedAsUntransferable, + markAsUncloneable, + markAsUntransferable, + moveMessagePortToContext() { + notSupported("moveMessagePortToContext"); + }, + parentPort, + postMessageToThread() { + notSupported("postMessageToThread"); + }, + receiveMessageOnPort, + resourceLimits: {}, + setEnvironmentData, + threadId, + threadName: undefined, + // No workerData: the Worker constructor rejects the option that would carry + // it, so there is never anything to hand a worker. + workerData: null, +}); diff --git a/NativeScript/runtime/js/primordials.js b/NativeScript/runtime/js/primordials.js index c9710670b..cec7a2e90 100644 --- a/NativeScript/runtime/js/primordials.js +++ b/NativeScript/runtime/js/primordials.js @@ -25,6 +25,7 @@ const intrinsics = { FinalizationRegistry, Map, Number, + Promise, Proxy, RangeError, Set, @@ -34,6 +35,8 @@ const intrinsics = { Uint32Array, URL, WeakRef, + WeakSet, + SymbolFor: Symbol.for, SymbolHasInstance: Symbol.hasInstance, SymbolIterator: Symbol.iterator, SymbolToStringTag: Symbol.toStringTag, @@ -63,6 +66,8 @@ const intrinsics = { ObjectIs: Object.is, ObjectKeys: Object.keys, ObjectSetPrototypeOf: Object.setPrototypeOf, + // Promise.resolve reads its receiver to pick the species to construct. + PromiseResolve: Promise.resolve.bind(Promise), ReflectConstruct: Reflect.construct, // Instance methods, uncurried. @@ -85,8 +90,10 @@ const intrinsics = { MapPrototypeGet: uncurryThis(Map.prototype.get), MapPrototypeSet: uncurryThis(Map.prototype.set), ObjectPrototypeHasOwnProperty: uncurryThis(Object.prototype.hasOwnProperty), + ObjectPrototypeIsPrototypeOf: uncurryThis(Object.prototype.isPrototypeOf), ObjectPrototypePropertyIsEnumerable: uncurryThis(Object.prototype.propertyIsEnumerable), ObjectPrototypeToString: uncurryThis(Object.prototype.toString), + PromisePrototypeThen: uncurryThis(Promise.prototype.then), RegExpPrototypeTest: uncurryThis(RegExp.prototype.test), RegExpPrototypeToString: uncurryThis(RegExp.prototype.toString), SetPrototypeAdd: uncurryThis(Set.prototype.add), @@ -102,6 +109,9 @@ const intrinsics = { StringPrototypeToLowerCase: uncurryThis(String.prototype.toLowerCase), SymbolPrototypeToString: uncurryThis(Symbol.prototype.toString), WeakRefPrototypeDeref: uncurryThis(WeakRef.prototype.deref), + WeakSetPrototypeAdd: uncurryThis(WeakSet.prototype.add), + WeakSetPrototypeDelete: uncurryThis(WeakSet.prototype.delete), + WeakSetPrototypeHas: uncurryThis(WeakSet.prototype.has), // Iterator-protocol escape hatches: the captured `next` of the live map/set // iterator prototypes, so entries can be walked with early exit even after diff --git a/NativeScript/runtime/js/structured-clone.js b/NativeScript/runtime/js/structured-clone.js index ff25ec285..511762d15 100644 --- a/NativeScript/runtime/js/structured-clone.js +++ b/NativeScript/runtime/js/structured-clone.js @@ -4,10 +4,10 @@ // WebIDL sequence handling for `transfer`; the clone itself is native // (v8::ValueSerializer round-tripped in this isolate). // -// Deviation from the HTML spec, forced by the platform: 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. Clone failures are "DataCloneError" +// Deviation from the HTML spec, forced by the platform: ArrayBuffers and +// MessagePorts are transferable, nothing else is. ImageBitmap and the +// native/interop wrapper objects have no serialization form in this runtime, +// so they are rejected rather than half-supported. Clone failures are "DataCloneError" // DOMExceptions, from here and from the native serializer alike // (StructuredSerialization.cpp builds the same class). @@ -16,6 +16,7 @@ const { ArrayBufferPrototypeGetByteLength, ArrayPrototypePush, FunctionPrototypeCall, + ObjectPrototypeIsPrototypeOf, SymbolIterator, TypeError, } = primordials; @@ -46,6 +47,19 @@ function isArrayBuffer(value) { } } +// The port check runs only for entries the ArrayBuffer test already rejected, +// so a transfer list of buffers never runs the messaging builtin. +let MessagePort; +function isMessagePort(value) { + if (value === null || typeof value !== "object") { + return false; + } + if (MessagePort === undefined) { + ({ MessagePort } = require("internal/message-channel")); + } + return ObjectPrototypeIsPrototypeOf(MessagePort.prototype, value); +} + // 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. @@ -80,7 +94,7 @@ function toTransferList(value) { break; } var item = step.value; - if (!isArrayBuffer(item)) { + if (!isArrayBuffer(item) && !isMessagePort(item)) { throw dataCloneError("structuredClone: value in transfer list is not transferable"); } ArrayPrototypePush(list, item); diff --git a/NativeScript/runtime/js/worker-events.js b/NativeScript/runtime/js/worker-events.js new file mode 100644 index 000000000..583024fc2 --- /dev/null +++ b/NativeScript/runtime/js/worker-events.js @@ -0,0 +1,71 @@ +"use strict"; +// Worker (HTML Standard §10.2.6) and the worker global scope (§10.2.1) as +// EventTargets: both deliver MessageEvents instead of the runtime's historical +// direct call of an `onmessage` property. +// +// Eager, because the handler attributes have to exist before app code assigns +// one. MessageEvent itself is pulled in on the first delivery, so a worker +// nobody talks to never runs that builtin. +const { ObjectDefineProperty, ObjectSetPrototypeOf } = primordials; + +const { + EventTarget, + defineEventHandler, + dispatchEventRethrowing, + globalEventTarget, +} = require("internal/events"); + +const g = globalThis; + +let MessageEvent; +function getMessageEvent() { + if (MessageEvent === undefined) { + ({ MessageEvent } = require("internal/message-event")); + } + return MessageEvent; +} + +// The delivery callout, invoked by native with the receiving target as `this`: +// the Worker object on the parent isolate, the global scope's EventTarget +// inside a worker. `ports` is the array of MessagePorts the message +// transferred, or undefined when it carried none. +// +// A handler that throws propagates back into the calling native frame: that +// is what feeds the worker's onerror chain — the worker scope's handler +// first, then the parent's — which the cross-runtime worker suite asserts. +function emitMessage(data, ports, type) { + const MessageEventCtor = getMessageEvent(); + dispatchEventRethrowing(this, new MessageEventCtor(type, { data, ports })); +} + +ObjectSetPrototypeOf(g.Worker.prototype, EventTarget.prototype); +defineEventHandler(g.Worker.prototype, "message"); +defineEventHandler(g.Worker.prototype, "messageerror"); +// `error` is a handler attribute and nothing more: the worker error path still +// reads `onerror` off the worker object and calls it with a plain error +// record, so no event is ever dispatched for it and addEventListener("error") +// on a Worker stays inert. +defineEventHandler(g.Worker.prototype, "error"); + +// The global scope's handler attributes are defined against the EventTarget +// backing the global listener methods, which is what native dispatches on and +// what globalThis.addEventListener registers with — so a handler and an +// addEventListener registration interleave in assignment order. globalThis +// only forwards. +defineEventHandler(globalEventTarget, "message"); +defineEventHandler(globalEventTarget, "messageerror"); +for (const name of ["onmessage", "onmessageerror"]) { + ObjectDefineProperty(g, name, { + __proto__: null, + get() { + return globalEventTarget[name]; + }, + set(value) { + globalEventTarget[name] = value; + }, + enumerable: true, + configurable: true, + }); +} + +module.exports = { emitMessage }; diff --git a/TestRunner/app/shared b/TestRunner/app/shared index 67da5fc0f..932e1fb0f 160000 --- a/TestRunner/app/shared +++ b/TestRunner/app/shared @@ -1 +1 @@ -Subproject commit 67da5fc0f692aafc6342c6e87b05ced3ee770c09 +Subproject commit 932e1fb0f0183d1006d36e57eac32fbbec476039 diff --git a/TestRunner/app/tests/RuntimeImplementedAPIs.js b/TestRunner/app/tests/RuntimeImplementedAPIs.js index e4b78cc10..6df75d6c8 100644 --- a/TestRunner/app/tests/RuntimeImplementedAPIs.js +++ b/TestRunner/app/tests/RuntimeImplementedAPIs.js @@ -130,3 +130,27 @@ describe("CustomEvent canary", () => { expect(new CustomEvent("x") instanceof Event).toBe(true); }); }); + +// Same contract again for the shared messaging suites (MessageChannel, +// BroadcastChannel, MessageEvent, WorkerEvents, NodeWorkerThreads): they +// self-gate, these unguarded specs turn absence into a failure. +describe("messaging canary", () => { + it("implements the messaging interfaces as globals", () => { + expect(typeof MessagePort).toBe("function"); + expect(typeof MessageChannel).toBe("function"); + expect(typeof BroadcastChannel).toBe("function"); + expect(typeof MessageEvent).toBe("function"); + }); + + it("is not reachable as a module from app code", () => { + expect(() => require("internal/message-channel")).toThrow(); + expect(() => require("internal/message-event")).toThrow(); + expect(() => require("internal/broadcast-channel")).toThrow(); + }); + + it("resolves node:worker_threads on the main thread", () => { + const workerThreads = require("node:worker_threads"); + expect(workerThreads.isMainThread).toBe(true); + expect(workerThreads.MessageChannel).toBe(MessageChannel); + }); +}); diff --git a/docs/README.md b/docs/README.md index e0cbc0bc8..eb0e6448f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,6 +20,8 @@ - [structuredClone](structured-clone.md) — the WHATWG `structuredClone(value, { transfer })` global: what clones, how graph identity and cycles are preserved, `ArrayBuffer` transfer, and the `DataCloneError` `DOMException` on failure. +- [Messaging and `node:worker_threads`](worker-threads.md) — `MessagePort`, `MessageChannel`, `BroadcastChannel` and `MessageEvent`, the `node:worker_threads` real-vs-shim table and its documented deviations, the strong-until-closed port lifetime, HTML port enabling, and the transfer support matrix with its `DataCloneError` messages. + - [Node-API](node-api.md) — writing a Node-API addon for this runtime: registering a module and loading it with `require()`, getting the `napi_env` from native code, the threading contract, finalizer timing, which Node-API version applies, and the divergences from Node's `node_api.h`. ## Knowledge diff --git a/docs/ns-builtin-modules.md b/docs/ns-builtin-modules.md index bf298c8b3..99da558c6 100644 --- a/docs/ns-builtin-modules.md +++ b/docs/ns-builtin-modules.md @@ -406,14 +406,17 @@ npm packages that require Node builtins by their prefixed names can run unmodified where a shim exists: - A shim implements a documented **subset** of the corresponding Node module's - API, backed by `ns:` modules. Unimplemented members are simply absent + API, backed by the runtime's own modules. Unimplemented members are simply absent (so `typeof util.promisify === "function"` feature-checks behave - correctly); they are never present-but-throwing. + correctly); they are never present-but-throwing. The one exception is a + member whose silent absence would read as a delivery bug rather than as a + missing feature — it may be present and throw, and the table below names + every such member. - **One source file per specifier.** A shim is its own module that consumes - the `ns:` module it adapts through the internal require, and it owns *all* - the adaptation — argument shapes, option names, aliases, anything that has - to track Node. A standard `ns:` module never contains compatibility code - and never knows a shim exists. + the module it adapts through the internal require, and it owns *all* the + adaptation — argument shapes, option names, aliases, anything that has to + track Node. A standard `ns:` module never contains compatibility code and + never knows a shim exists. - Shims are **lazy**: a shim's source is only evaluated when its specifier is first resolved, so an app that never touches the `node:` scheme never pays for one. @@ -437,6 +440,7 @@ unmodified where a shim exists: | `node:util` | `inspect`, `format`, `TextEncoder`, `TextDecoder` | Re-exports `ns:util`'s members unchanged (`nodeUtil.inspect === nsUtil.inspect`) from a **distinct, separately frozen module object**. `TextEncoder`/`TextDecoder` are the globals of those names, as they are in Node. Documented as partial. | | `node:url` | `fileURLToPath`, `pathToFileURL` | Node-strict converters between `file:` URLs and paths. Documented as partial — no `URL`/`URLSearchParams` re-exports (both are globals), no legacy `url.parse`/`format`/`resolve`. | | `node:module` | `createRequire` | Re-exports `ns:module`'s `createRequire` unchanged from a **distinct, separately frozen module object**. `createPumpingRequire` is deliberately absent: it has no Node counterpart, so code written against this shim keeps running on Node. `require.resolve`/`.cache`/`.main` are not implemented, and neither is any other `node:module` member (`Module`, `builtinModules`, `isBuiltin`, `register`, `syncBuiltinESMExports`). Documented as partial. | +| `node:worker_threads` | the messaging and thread surface — see [worker-threads.md](worker-threads.md) | The channel half (`MessagePort`, `MessageChannel`, `BroadcastChannel`, `receiveMessageOnPort`) is the real implementation, the same objects the globals of those names hold; the thread half is a bridge over the runtime's own `Worker`. It has no `ns:` counterpart — the surface tracks Node's, so there is nothing for a standard module to own. The one place it breaks the absent-not-throwing rule below is deliberate: `postMessageToThread` and `moveMessagePortToContext` are present and throw an `Error` naming themselves, because silently missing thread-addressed messaging reads as a delivery bug rather than as an unsupported call. Documented as partial. | `node:url`'s parsing goes through the URL intrinsic, so `file://localhost/x` is accepted (the URL spec folds a `localhost` authority to none) while any other @@ -745,21 +749,28 @@ shims are built on, so it is normative: both runtimes provide it. Android-only) note in between. - Internal runtime machinery must never be reachable through the scheme. -That last rule holds because public modules and internal builtins are **two -separate loading paths**, not one registry with a per-entry flag: - -- The **public registry** is a table mapping specifier → builtin, and it is the - only thing the `ns:`/`node:` resolver consults. A specifier absent from it - does not resolve, full stop. Today it holds six entries: `ns:module`, - `ns:runtime`, `ns:util`, `node:module`, `node:url`, `node:util`. -- **Internal builtins** (the intrinsics snapshot, the require factory, the - console formatter, and so on) are invoked directly from their own native call - sites. They are never named in the public registry, so there is no specifier - that could reach them and nothing to mark private. - -Adding an internal builtin therefore cannot accidentally expose it; exposing -one is an explicit registry entry, which is also the change this document has -to describe. +That last rule holds because every registry row carries its tier, and the two +resolvers read the same table differently: + +- The **`ns:`/`node:` resolver** — the app-facing one, behind `require()`, + `import` and `import()` — serves only rows *not* marked internal-only. An + internal-only specifier fails exactly as a name absent from the table does. + Seven rows are public today: `ns:module`, `ns:runtime`, `ns:util`, + `node:module`, `node:url`, `node:util`, `node:worker_threads`. +- The **internal require** builtins receive (previous section) is the only + thing that can name an internal-only row. Five rows are marked that way: + `internal/broadcast-channel`, `internal/dom-exception`, `internal/events`, + `internal/message-channel`, `internal/message-event`. Their exports carry + capabilities app code must not hold — listener-accounting hook keys, the + error-reporter setter, base classes that must be the runtime's own rather + than whatever a global currently names. +- Builtins with **no row at all** (the intrinsics snapshot, the require + factory, the console formatter) are invoked straight from their native call + sites. There is no specifier that could reach them and nothing to mark. + +So a builtin is unreachable from app code unless a registry row says +otherwise, and exposing one means editing that row's tier — which is also the +change this document has to describe. ## Source-text modules: deliberately not supported diff --git a/docs/structured-clone.md b/docs/structured-clone.md index cd20129fd..887984fc5 100644 --- a/docs/structured-clone.md +++ b/docs/structured-clone.md @@ -1,6 +1,6 @@ # 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`. +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 the `ArrayBuffer`s and `MessagePort`s named in `options.transfer`. ```js const clone = structuredClone({ when: new Date(), tags: new Set(["a"]) }); @@ -12,7 +12,7 @@ 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`. +`structuredClone(value)` returns a clone of `value`. `structuredClone(value, { transfer })` additionally transfers every `ArrayBuffer` and `MessagePort` 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. @@ -24,14 +24,16 @@ The clone preserves the shape of the graph, not just the values: an object refer `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. +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. A `MessagePort` is transferable but never cloneable, so one found in the graph has to be in the transfer list. ## 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. +The list is validated before anything is serialized: each entry must be an `ArrayBuffer` or a `MessagePort`, must not already be detached (an `ArrayBuffer` must additionally be detachable), and must appear at most once. A violation throws before the sources are touched, and nothing is detached or handed over until the whole graph has serialized successfully — so a rejected call never leaves a half-transferred graph behind, and every listed buffer and port is still usable afterwards. 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. +A transferred `MessagePort` is closed as a handle on this side while its queue and its channel membership move to the clone. Unlike a buffer, a port that *is* reachable in `value` must also be listed — an unlisted one is a `DataCloneError`, since a copied port would be a port to nowhere. [worker-threads.md](worker-threads.md) has the full transfer matrix and the exact `DataCloneError` messages. + ## 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: @@ -44,12 +46,12 @@ worker.postMessage({ pixels: buffer }, [buffer]); // buffer is detached here, 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 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. +- **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. `MessagePort` is outside the leniency: a port is rejected or transferred, never degraded, because an empty object in the receiver would strand its sibling. ## Deviations from the specification - **`DataCloneError` is a `DOMException`.** Failures throw a `DOMException` named `"DataCloneError"`, from the JS argument checks and the native serializer alike, so both `e.name === "DataCloneError"` and `instanceof DOMException` detect them. (The serializer falls back to a `DataCloneError`-named `Error` only when the builtin can no longer run, e.g. during isolate teardown.) -- **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`. +- **Only `ArrayBuffer` and `MessagePort` are transferable.** The spec's other transferable types — `ImageBitmap`, `ReadableStream` and friends — do not exist here, and neither do the runtime's own native/interop wrapper objects, which have no serialized form. Anything else in the transfer list is a `DataCloneError`. Port transfer has rules of its own (a port may not travel on itself, a port in the graph must be listed); [worker-threads.md](worker-threads.md) has the full matrix and the exact messages. - **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/docs/worker-threads.md b/docs/worker-threads.md new file mode 100644 index 000000000..ee331806a --- /dev/null +++ b/docs/worker-threads.md @@ -0,0 +1,247 @@ +# Messaging and `node:worker_threads` + +The runtime implements HTML's messaging primitives — `MessagePort`, +`MessageChannel`, `BroadcastChannel` and `MessageEvent` — and exposes them both +as globals and through a `node:worker_threads` module. + +```js +const channel = new MessageChannel(); +channel.port1.onmessage = (event) => console.log(event.data); +channel.port2.postMessage({ hello: "world" }); + +const worker = new Worker("./worker.js"); +worker.postMessage({ port: channel.port2 }, [channel.port2]); +``` + +## Surface + +`MessagePort`, `MessageChannel`, `BroadcastChannel` and `MessageEvent` are +**lazy globals**: the name is placed on the first read of it, so an app that +never mentions one never pays for it. They are ordinary globals once read — +`instanceof`, subclassing and property access all behave normally. + +`require("node:worker_threads")` (or `import` of the same specifier) returns a +frozen module. Its channel half is not a re-implementation: the classes it +exports are the very objects the globals of those names hold, so +`require("node:worker_threads").MessagePort === globalThis.MessagePort`. + +`MessagePort` has no constructor — `new MessagePort()` throws a `TypeError`. +Ports come from a `MessageChannel` or arrive on a message. + +## `node:worker_threads` exports + +"Real" means genuine behaviour, and for a class the same object the global of +that name holds. "Shim" means a bridge over the runtime's own `Worker`, which +has no thread pool, no stdio plumbing and no per-thread environment. "Throws" +means deliberately unsupported. + +| export | status | notes | +|---|---|---| +| `MessagePort` | real | The global `MessagePort`. | +| `MessageChannel` | real | The global `MessageChannel`. | +| `BroadcastChannel` | real | The global `BroadcastChannel`. The process-wide registry described below. | +| `receiveMessageOnPort(port)` | real | Synchronously pops one queued message, `{ message }` or `undefined`. Works on a port that was never started; a close sentinel at the head closes the port and reports `undefined`. | +| `isMainThread` | real | `false` inside a runtime worker. | +| `threadId` | real | `0` on the main isolate, the worker's id (from 1) inside one. | +| `isInternalThread` | real | Always `false`; this runtime has no internal threads. | +| `markAsUntransferable(obj)` | real | Brands `obj` so listing it in a transfer list is a `DataCloneError`. | +| `isMarkedAsUntransferable(obj)` | real | Reads that brand. | +| `markAsUncloneable(obj)` | real | Brands `obj` so serializing it at all is a `DataCloneError`, in `structuredClone` and every `postMessage` alike. | +| `setEnvironmentData(key, value)` | real, deviates | Clones and stores process-wide. No per-thread snapshot — see below. Passing `undefined` (or omitting the value) deletes the key. | +| `getEnvironmentData(key)` | real, deviates | Deserializes a fresh copy per read, on any isolate. | +| `resourceLimits` | shim | Always `{}`; the runtime imposes no per-worker limits and reports none. | +| `SHARE_ENV` | shim | Exported so the spelling resolves, but inert — see below. | +| `threadName` | shim | Always `undefined`. | +| `workerData` | shim | Always `null` — see below. | +| `parentPort` | shim | `null` on the main isolate. Inside a worker, a `MessagePort`-shaped `EventTarget` over the worker's existing parent channel: `postMessage` forwards to the global `postMessage`, `message`/`messageerror` are re-dispatched from the worker global scope, `start()` and `close()` are no-ops. It is **not** a real port: not transferable, no queue of its own. | +| `Worker` | shim | A class over the runtime's global `Worker` with a small Node-style emitter (`on`/`once`/`off`/`removeListener`) for `message`, `messageerror`, `error`, `online` and `exit`. `postMessage(value, transfer)` and `terminate()` forward. `online` is emitted off a microtask after construction, not from the thread. Unsupported options throw a `TypeError` naming the option: `workerData`, `env`, `eval`, `transferList`, and `stdin`/`stdout`/`stderr` when explicitly truthy. | +| `postMessageToThread` | throws | `Error: postMessageToThread is not supported in this runtime`. | +| `moveMessagePortToContext` | throws | `Error: moveMessagePortToContext is not supported in this runtime`. | +| `locks` | absent | Web Locks are not implemented; the property does not exist. | + +## Documented deviations + +### `setEnvironmentData` has no per-thread snapshot + +Node copies the environment-data store into a worker when it is spawned, so a +later write on the parent is invisible to it. Here the store is one +process-global map, and a worker reads it live: a `setEnvironmentData` call +made *after* a worker started is visible to that worker. + +Values are cloned on the way in and deserialized fresh on each read, so +mutating the object you passed does not reach a reader, and two readers never +share one object. + +### `exit` comes only from `terminate()` + +The runtime has no thread-exit signal — nothing reports that a worker's isolate +finished. `terminate()` therefore resolves with `0` and emits `exit` with code +`0` on the way, and that is the only path that emits it. A worker that ends by +its own `close()` produces no `exit`. + +### `worker.addEventListener("error", …)` is inert + +`Worker` is an `EventTarget` and `message`/`messageerror` are dispatched as +real `MessageEvent`s, so a handler attribute and an `addEventListener` +registration interleave in the order they were installed. `error` is the +exception: the worker error path reads the `onerror` property straight off the +worker object and calls it with a plain error record (`message`, `stackTrace`, +`filename`, `lineno`), never dispatching an event. **`worker.onerror` works; +`addEventListener("error", …)` on a `Worker` never fires.** + +### Inside a worker, `event.target` is not `globalThis` + +`globalThis` is not itself an `EventTarget` here. It forwards +`addEventListener`, `removeEventListener` and `dispatchEvent` to an internal +`EventTarget` that backs the worker global scope, and native delivery +dispatches on that internal target — which is what keeps app code from +intercepting message delivery by replacing `globalThis.dispatchEvent`. The +consequence is visible on the event: `event.target` inside a worker's message +handler is that internal target, not `globalThis`. + +### `SHARE_ENV` is a no-op + +It is exported so that an `options.env === SHARE_ENV` spelling resolves rather +than being a `ReferenceError`. There is one process environment and it is never +copied, so nothing distinguishes sharing it from not. (`env` is a rejected +`Worker` option regardless.) + +### No `workerData` + +There is no channel that would carry it: the `Worker` constructor rejects the +`workerData` option outright, so the export is permanently `null`. Send an +opening `postMessage` instead. + +### `BroadcastChannel`'s registry is process-global + +"Same user agent", in the spec's terms, is the app process. Every +`BroadcastChannel` built with the same name joins one group regardless of which +isolate constructed it, so a worker and the main isolate reach each other by +name alone. A channel is receiving from the moment it is constructed and stays +strongly held until `close()`. + +## `MessagePort` lifetime + +The GC model is Node's, not the browser's: **a port is held strongly by the +runtime from creation until it is closed.** An unreferenced-but-unclosed port +does not go away, and neither does its channel, its queue, or anything the +queue's messages hold. Close the ports you are done with. + +```js +const { port1, port2 } = new MessageChannel(); +port1.onmessage = handle; +// ... later +port1.close(); +``` + +Closing behaves as one channel-wide event: + +- `close()` sends a `close` event — a plain `Event`, not a `MessageEvent` — to + the port being closed **and** to its sibling. A channel with one end left is + no channel, so both ends learn about it. (A named `BroadcastChannel` group is + different: members join and leave it freely, so only the leaving member gets + the event.) +- The `close` event reaches a port that was never started. Enabling is about + *messages*; a port whose sibling died always learns about it. +- `close` orders behind whatever is already queued, on both ends — messages + already sent are still delivered first. +- `postMessage` on a closed port is a **silent no-op**. It still serializes: + the transfer list's side effects and its errors do not depend on delivery, so + a bad transfer list throws and a good one detaches its buffers, and only then + is the message dropped. +- `port.close(callback)` registers `callback` as a one-shot `close` listener + before closing. + +## Port enabling + +Delivery follows HTML's port-enable rules rather than starting automatically: + +- A port starts delivering on its **first `message` listener** — either + `addEventListener("message", …)` or an `onmessage` attribute assignment. The + first `onmessage` write counts even when it is `onmessage = null`: it is the + assignment, not the handler, that claims the listener slot. +- It stops when the last `message` listener goes away, and messages queue again + until one returns. +- `port.start()` forces delivery on regardless, for code that only uses + `addEventListener` and wants control over when the queue drains. +- `receiveMessageOnPort(port)` bypasses all of it and pops one message + synchronously. + +`BroadcastChannel` has no enable step; it receives from construction. + +## Transfer support matrix + +A transfer list moves ownership instead of copying. It is the second argument +to `port.postMessage` / `worker.postMessage`, and `options.transfer` for +`structuredClone`. + +| value | in a transfer list | in the message graph | +|---|---|---| +| `ArrayBuffer` | transferable — the receiver gets the original backing store, the sender's buffer is detached (`byteLength` 0, every view over it zero-length) | cloned | +| `MessagePort` | transferable — the sender's port is closed as a handle while its queue and channel membership travel to the receiver, so a sender on the far end keeps queueing into it while it is in flight | `DataCloneError` unless it is also listed | +| `SharedArrayBuffer` | **not** transferable — `DataCloneError` | *shared*: the receiver builds a second `SharedArrayBuffer` over the same memory, and writes through either are visible through the other | +| everything else | `DataCloneError` | per the [structured clone rules](structured-clone.md) | + +### Rejections + +Every one of these is a `DOMException` named `DataCloneError`, so both +`e.name === "DataCloneError"` and `instanceof DOMException` detect them. + +| condition | message | +|---|---| +| the port doing the posting is in its own transfer list | `Transfer list contains source port` | +| a listed port is already detached (closed, or transferred away) | `MessagePort in transfer list is already detached` | +| the same port listed twice | `Transfer list contains duplicate MessagePort` | +| the same `ArrayBuffer` listed twice | `The transfer list contains the same ArrayBuffer twice` | +| a listed `ArrayBuffer` is detached or not detachable | `An ArrayBuffer in the transfer list is detached and cannot be transferred` | +| a listed value branded by `markAsUntransferable` | `Cannot transfer object of unsupported type.` | +| anything else in the list (a non-object included) | `Found invalid value in transferList.` | +| a port reachable in the message but not listed | `Object that needs transfer was found in message but not listed in transferList` | +| a value branded by `markAsUncloneable`, anywhere in the graph | `Cannot clone object of unsupported type.` | + +The duplicate-port message ends in the constructor name of the listed object, +so a subclass of `MessagePort` names itself there. + +Those are the checks the native collector runs. What reaches it depends on the +entry point, and a list argument of the wrong *shape* is a `TypeError` rather +than a `DataCloneError`: + +- `port.postMessage(value, transfer)` does the WebIDL sequence conversion in + JavaScript, so an array, any iterable, or a `{ transfer }` dictionary all + work. Anything else is `TypeError: postMessage: transfer is not iterable`. +- `worker.postMessage(value, transfer)` is native all the way down and takes an + actual array; omitting it or passing `undefined`/`null` means "transfer + nothing", and any other value is + `TypeError: The transfer list must be an array`. +- `structuredClone(value, { transfer })` accepts any iterable and screens each + entry in its own wrapper first, so an untransferable entry there is still a + `DataCloneError` but carries that wrapper's message, + `structuredClone: value in transfer list is not transferable`, rather than + `Found invalid value in transferList.` + +### Nothing changes hands until the whole graph is written + +Validation and serialization run to completion before a single buffer is +detached or a single port is handed over. A `DataCloneError` from the middle of +a graph therefore leaves **every port and every buffer in the list exactly as +it found them** — still open, still holding their memory — so a failed +`postMessage` can be corrected and retried. + +The listed ports are re-checked after the write as well, because writing the +graph runs user getters and one of them may have closed a listed port; that +late failure is the same `MessagePort in transfer list is already detached`, +and it too leaves everything intact. + +## Worker messages + +The runtime's own `Worker` and the worker global scope are `EventTarget`s that +deliver real `MessageEvent`s, so `worker.onmessage`, `worker.addEventListener`, +and the same pair on `globalThis` inside a worker, all work and interleave in +installation order. Handlers keep receiving the payload as `event.data`. + +`worker.postMessage` differs from `port.postMessage` in one respect: an +interop/native object anywhere in the graph is delivered as an empty object +rather than raising a `DataCloneError`, which is long-standing behaviour app +code relies on. Transfer is not part of that leniency — a port in a worker +transfer list is validated exactly as it is everywhere else, since degrading a +transfer would strand the port's sibling. diff --git a/eslint.config.mjs b/eslint.config.mjs index e99fc31fc..7ea50f6d7 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -43,7 +43,7 @@ const capturedStatics = [ // fills in after init). Array.from has no primordial: copying `arguments` goes // through an index loop instead, because Array.from depends on the tamperable // array iterator protocol. -const restrictedGlobals = ['Error', 'FinalizationRegistry', 'Map', 'Number', 'Proxy', 'RangeError', 'Set', 'String', 'TypeError', 'WeakRef'].map((name) => ({ +const restrictedGlobals = ['Error', 'FinalizationRegistry', 'Map', 'Number', 'Promise', 'Proxy', 'RangeError', 'Set', 'String', 'TypeError', 'WeakMap', 'WeakRef', 'WeakSet'].map((name) => ({ name, message: `Destructure ${name} from primordials — builtins must not read intrinsics off globals user code can replace.`, })); diff --git a/tools/js2c-inputs.xcfilelist b/tools/js2c-inputs.xcfilelist index f39345643..e5929531b 100644 --- a/tools/js2c-inputs.xcfilelist +++ b/tools/js2c-inputs.xcfilelist @@ -2,6 +2,7 @@ $(SRCROOT)/tools/js2c.mjs $(SRCROOT)/NativeScript/runtime/js/abort-signal.js $(SRCROOT)/NativeScript/runtime/js/base64.js $(SRCROOT)/NativeScript/runtime/js/blob-url.js +$(SRCROOT)/NativeScript/runtime/js/broadcast-channel.js $(SRCROOT)/NativeScript/runtime/js/class-extends.js $(SRCROOT)/NativeScript/runtime/js/dom-exception.js $(SRCROOT)/NativeScript/runtime/js/error-events.js @@ -9,9 +10,12 @@ $(SRCROOT)/NativeScript/runtime/js/events.js $(SRCROOT)/NativeScript/runtime/js/inline-functions.js $(SRCROOT)/NativeScript/runtime/js/primordials.js $(SRCROOT)/NativeScript/runtime/js/inspect.js +$(SRCROOT)/NativeScript/runtime/js/message-channel.js +$(SRCROOT)/NativeScript/runtime/js/message-event.js $(SRCROOT)/NativeScript/runtime/js/node-module.js $(SRCROOT)/NativeScript/runtime/js/node-url.js $(SRCROOT)/NativeScript/runtime/js/node-util.js +$(SRCROOT)/NativeScript/runtime/js/node-worker-threads.js $(SRCROOT)/NativeScript/runtime/js/ns-module.js $(SRCROOT)/NativeScript/runtime/js/ns-runtime.js $(SRCROOT)/NativeScript/runtime/js/ns-util.js @@ -22,3 +26,4 @@ $(SRCROOT)/NativeScript/runtime/js/structured-clone.js $(SRCROOT)/NativeScript/runtime/js/text-encoding.js $(SRCROOT)/NativeScript/runtime/js/ts-helpers.js $(SRCROOT)/NativeScript/runtime/js/weak-ref.js +$(SRCROOT)/NativeScript/runtime/js/worker-events.js diff --git a/v8ios.xcodeproj/project.pbxproj b/v8ios.xcodeproj/project.pbxproj index f4674418c..7c53cc0fb 100644 --- a/v8ios.xcodeproj/project.pbxproj +++ b/v8ios.xcodeproj/project.pbxproj @@ -25,6 +25,8 @@ 3CAE10112F900001002ACC81 /* TextEncoding.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 3CAE10012F900001002ACC81 /* TextEncoding.cpp */; }; 3CAE10122F900001002ACC81 /* TextEncoding.h in Headers */ = {isa = PBXBuildFile; fileRef = 3CAE10022F900001002ACC81 /* TextEncoding.h */; }; 3CAE10132F900001002ACC81 /* Base64.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 3CAE10032F900001002ACC81 /* Base64.cpp */; }; + 3CAE20112F900002002ACC81 /* Messaging.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 3CAE20012F900002002ACC81 /* Messaging.cpp */; }; + 3CAE20122F900002002ACC81 /* Messaging.h in Headers */ = {isa = PBXBuildFile; fileRef = 3CAE20022F900002002ACC81 /* Messaging.h */; }; 3CAE10142F900001002ACC81 /* Base64.h in Headers */ = {isa = PBXBuildFile; fileRef = 3CAE10042F900001002ACC81 /* Base64.h */; }; 3CAE10152F900001002ACC81 /* LazyGlobals.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 3CAE10052F900001002ACC81 /* LazyGlobals.cpp */; }; 3CAE10162F900001002ACC81 /* LazyGlobals.h in Headers */ = {isa = PBXBuildFile; fileRef = 3CAE10062F900001002ACC81 /* LazyGlobals.h */; }; @@ -476,6 +478,8 @@ 3CAE10012F900001002ACC81 /* TextEncoding.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = TextEncoding.cpp; sourceTree = ""; }; 3CAE10022F900001002ACC81 /* TextEncoding.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = TextEncoding.h; sourceTree = ""; }; 3CAE10032F900001002ACC81 /* Base64.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = Base64.cpp; sourceTree = ""; }; + 3CAE20012F900002002ACC81 /* Messaging.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = Messaging.cpp; sourceTree = ""; }; + 3CAE20022F900002002ACC81 /* Messaging.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = Messaging.h; sourceTree = ""; }; 3CAE10042F900001002ACC81 /* Base64.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = Base64.h; sourceTree = ""; }; 3CAE10052F900001002ACC81 /* LazyGlobals.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = LazyGlobals.cpp; sourceTree = ""; }; 3CAE10062F900001002ACC81 /* LazyGlobals.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = LazyGlobals.h; sourceTree = ""; }; @@ -1556,6 +1560,8 @@ 3CAE10012F900001002ACC81 /* TextEncoding.cpp */, 3CAE10022F900001002ACC81 /* TextEncoding.h */, 3CAE10032F900001002ACC81 /* Base64.cpp */, + 3CAE20012F900002002ACC81 /* Messaging.cpp */, + 3CAE20022F900002002ACC81 /* Messaging.h */, 3CAE10042F900001002ACC81 /* Base64.h */, 3CAE10052F900001002ACC81 /* LazyGlobals.cpp */, 3CAE10062F900001002ACC81 /* LazyGlobals.h */, @@ -1658,6 +1664,7 @@ 3C1850552A6DCB2D002ACC81 /* Timers.hpp in Headers */, 3CAE10122F900001002ACC81 /* TextEncoding.h in Headers */, 3CAE10142F900001002ACC81 /* Base64.h in Headers */, + 3CAE20122F900002002ACC81 /* Messaging.h in Headers */, 3CAE10162F900001002ACC81 /* LazyGlobals.h in Headers */, 3CFCA0042E5A0001002ACC81 /* AnimationFrame.hpp in Headers */, C2C8EE7222CE323C001F8CEC /* ConcurrentMap.h in Headers */, @@ -2305,6 +2312,7 @@ 3C1850542A6DCB2D002ACC81 /* Timers.cpp in Sources */, 3CAE10112F900001002ACC81 /* TextEncoding.cpp in Sources */, 3CAE10132F900001002ACC81 /* Base64.cpp in Sources */, + 3CAE20112F900002002ACC81 /* Messaging.cpp in Sources */, 3CAE10152F900001002ACC81 /* LazyGlobals.cpp in Sources */, 3CFCA0032E5A0001002ACC81 /* AnimationFrame.mm in Sources */, C298C027233C9AEA000DDF54 /* TSHelpers.cpp in Sources */, From daf32abd718073a8e06fe919dd18fe712d2ee46b Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 26 Aug 2026 16:03:48 -0300 Subject: [PATCH 02/18] refactor(runtime): AbortSignal#onabort rides the shared defineEventHandler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deletes the bespoke private-field handler mechanism; the shared attribute keeps every observable behavior — assignment-order interleaving, LegacyTreatNonObjectAsNull, and the kListenerChanged transitions the GC-transparency accounting depends on — while the slot now claims at the first assignment, null included, matching HTML. The getter is unbranded on foreign receivers, as in Node. --- NativeScript/runtime/js/abort-signal.js | 56 ++++--------------------- 1 file changed, 7 insertions(+), 49 deletions(-) diff --git a/NativeScript/runtime/js/abort-signal.js b/NativeScript/runtime/js/abort-signal.js index e04b3d47e..d62ddcc09 100644 --- a/NativeScript/runtime/js/abort-signal.js +++ b/NativeScript/runtime/js/abort-signal.js @@ -49,12 +49,12 @@ const Event = g.Event; const setTimeout = g.setTimeout; const clearTimeout = g.clearTimeout; const dispatchEvent = EventTarget.prototype.dispatchEvent; -const addEventListener = EventTarget.prototype.addEventListener; -const removeEventListener = EventTarget.prototype.removeEventListener; // Published by events.js: the symbol under which EventTargetImpl looks up -// the listener-mutation hook. events.js already ran (Events::Init), so this -// require is a cache hit; a miss would run it on demand rather than fail. -const { kListenerChanged } = require("internal/events"); +// the listener-mutation hook, and the shared event-handler-attribute helper +// (whose wrapper registration routes through the same hook). events.js +// already ran (Events::Init), so this require is a cache hit; a miss would +// run it on demand rather than fail. +const { defineEventHandler, kListenerChanged } = require("internal/events"); // Construction token: AbortSignal instances come only from the factories in // this module (the controller, and the abort/timeout/any statics). @@ -102,11 +102,6 @@ let sourcePruneRegistry; class AbortSignal extends EventTarget { #aborted = false; #reason = undefined; - // Event handler attribute state (HTML semantics: registered as a plain - // listener on the first non-null assignment, so its slot in the listener - // order is where it was first set; cleared assignments free the slot). - #onabort = null; - #onabortWrapper = null; #isTimeout = false; // any() linkage, all WeakRefs. #sources: the plain sources a live // composite follows (null on plain signals and once aborted — composites @@ -147,45 +142,6 @@ class AbortSignal extends EventTarget { } } - get onabort() { - return this.#onabort; - } - - set onabort(handler) { - // TreatNonObjectAsNull: objects and functions are stored, any other value - // clears the handler; only a function is invoked at dispatch time. - const value = - typeof handler === "function" || - (handler !== null && typeof handler === "object") - ? handler - : null; - if (value !== null && this.#onabort === null) { - if (this.#onabortWrapper === null) { - const self = this; - this.#onabortWrapper = function (event) { - const cb = self.#onabort; - if (typeof cb === "function") { - FunctionPrototypeCall(cb, self, event); - } - }; - } - FunctionPrototypeCall( - addEventListener, - this, - "abort", - this.#onabortWrapper - ); - } else if (value === null && this.#onabort !== null) { - FunctionPrototypeCall( - removeEventListener, - this, - "abort", - this.#onabortWrapper - ); - } - this.#onabort = value; - } - static abort(reason) { return createAbortSignal( true, @@ -445,6 +401,8 @@ ObjectDefineProperty(AbortSignal.prototype, kListenerChanged, { configurable: false, }); +defineEventHandler(AbortSignal.prototype, "abort"); + class AbortController { #signal = createAbortSignal(false, undefined); From 4e3dee30720d784ecdae8f2c446f2aba7fd8aa83 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 26 Aug 2026 16:04:09 -0300 Subject: [PATCH 03/18] fix(runtime): worker errors reach the parent as cancelable ErrorEvents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An unhandled worker error now always propagates: a scope with no onerror falls through to the parent instead of dropping silently, and a scope handler that throws forwards its own error once. The parent-side delivery is a real ErrorEvent dispatched through the Worker EventTarget — addEventListener('error') fires in registration order, and handled means preventDefault() or a truthy onerror return (HTML's special error handling). Only primitives cross the isolate boundary, so the event carries message/filename/lineno plus stackTrace, this runtime's documented extension, and error stays null. Review round: an event handler attribute's count correction is now absolute rather than cumulative, so clearing a handler after a first-null claim reports zero again — a port stops and queues instead of discarding forever, and a GC-persisted AbortSignal is released. initMessageEvent resets the propagation flags per DOM's initialize steps. The transfer docs no longer promise rollback of user getter side effects, and the drain-budget comment states the Node floor semantics it implements. Suite: 1668/0. The shared Workers suite no longer pins the double-forward, so the android runtime must land the matching error-path fix before bumping its shared-tests submodule. --- NativeScript/runtime/DataWrapper.h | 3 - NativeScript/runtime/Messaging.cpp | 6 +- NativeScript/runtime/Worker.h | 11 +++ NativeScript/runtime/Worker.mm | 29 ++++++- NativeScript/runtime/WorkerWrapper.mm | 103 +++++++---------------- NativeScript/runtime/js/events.js | 21 +++-- NativeScript/runtime/js/message-event.js | 2 + NativeScript/runtime/js/worker-events.js | 45 ++++++++-- TestRunner/app/shared | 2 +- docs/structured-clone.md | 2 +- docs/worker-threads.md | 30 ++++--- 11 files changed, 148 insertions(+), 106 deletions(-) diff --git a/NativeScript/runtime/DataWrapper.h b/NativeScript/runtime/DataWrapper.h index 18069a555..9a4e00292 100644 --- a/NativeScript/runtime/DataWrapper.h +++ b/NativeScript/runtime/DataWrapper.h @@ -652,9 +652,6 @@ class WorkerWrapper : public BaseDataWrapper { const std::string& source, const std::string& stackTrace, int lineNumber, bool async); - static v8::Local ConstructErrorObject( - v8::Local context, std::string message, std::string source, - std::string stackTrace, int lineNumber); }; } // namespace tns diff --git a/NativeScript/runtime/Messaging.cpp b/NativeScript/runtime/Messaging.cpp index 08b154e7f..d9867e97e 100644 --- a/NativeScript/runtime/Messaging.cpp +++ b/NativeScript/runtime/Messaging.cpp @@ -659,8 +659,10 @@ void NativeMessagePort::Drain() { // or close this very port, so it is re-checked every iteration. while (this->data_ != nullptr) { if (budget-- == 0) { - // Hand the runloop back rather than starve it; the repost carries - // whatever is left. + // Only messages that arrived after this drain began are deferred: the + // budget is a floor, not a cap, so the backlog present at the trigger + // always drains in one turn (Node's processing_limit semantics). The + // repost carries the late arrivals. reschedule = true; break; } diff --git a/NativeScript/runtime/Worker.h b/NativeScript/runtime/Worker.h index 8c9e9458d..e4ab1b6eb 100644 --- a/NativeScript/runtime/Worker.h +++ b/NativeScript/runtime/Worker.h @@ -19,6 +19,17 @@ class Worker { // after Events::Init has installed the event primitives it builds on. static void InitEvents(v8::Local context); + // Dispatches an `error` ErrorEvent on `receiver` (the Worker object, on the + // parent isolate) and returns whether a handler took ownership of it — + // either by returning truthy from the `onerror` attribute or by calling + // preventDefault(). Only primitives cross the isolate boundary, so the event + // carries no error object. A listener that throws leaves the exception + // pending for the caller's TryCatch and reports as unhandled. False before + // InitEvents has run. + static bool EmitError(v8::Isolate* isolate, v8::Local receiver, + const std::string& message, const std::string& source, + const std::string& stackTrace, int lineNumber); + static std::vector GlobalFunctions; private: diff --git a/NativeScript/runtime/Worker.mm b/NativeScript/runtime/Worker.mm index 001c7fb86..fa84367b2 100644 --- a/NativeScript/runtime/Worker.mm +++ b/NativeScript/runtime/Worker.mm @@ -21,10 +21,12 @@ namespace { -// The worker-events builtin's delivery callout for this isolate. Both -// directions share it; only the receiver differs. +// The worker-events builtin's delivery callouts for this isolate. Both message +// directions share emitMessage; only the receiver differs. emitError is +// parent-side only. struct WorkerEventsState { Global emitMessage; + Global emitError; }; } // namespace @@ -287,9 +289,15 @@ bool ParseResourceLimits(Isolate* isolate, Local context, Local emitMessage->IsFunction(); tns::Assert(success, isolate); + Local emitError; + success = exports->Get(context, tns::ToV8String(isolate, "emitError")).ToLocal(&emitError) && + emitError->IsFunction(); + tns::Assert(success, isolate); + WorkerEventsState* state = Caches::StateFor(isolate); tns::Assert(state != nullptr, isolate); state->emitMessage.Reset(isolate, emitMessage.As()); + state->emitError.Reset(isolate, emitError.As()); } void Worker::ConstructorCallback(const FunctionCallbackInfo& info) { @@ -727,6 +735,23 @@ throw NativeScriptException( (void)state->emitMessage.Get(isolate)->Call(context, receiver, 3, args).ToLocal(&result); } +bool Worker::EmitError(Isolate* isolate, Local receiver, const std::string& message, + const std::string& source, const std::string& stackTrace, int lineNumber) { + WorkerEventsState* state = Caches::StateFor(isolate); + if (state == nullptr || state->emitError.IsEmpty()) { + return false; + } + Local context = Caches::Get(isolate)->GetContext(); + + Local args[4]{tns::ToV8String(isolate, message), tns::ToV8String(isolate, source), + Number::New(isolate, lineNumber), tns::ToV8String(isolate, stackTrace)}; + Local result; + if (!state->emitError.Get(isolate)->Call(context, receiver, 4, args).ToLocal(&result)) { + return false; + } + return result->BooleanValue(isolate); +} + void Worker::CloseWorkerCallback(const FunctionCallbackInfo& info) { Isolate* isolate = info.GetIsolate(); int workerId = Worker::GetWorkerId(isolate, info.This()); diff --git a/NativeScript/runtime/WorkerWrapper.mm b/NativeScript/runtime/WorkerWrapper.mm index acc4490bd..e32a72c51 100644 --- a/NativeScript/runtime/WorkerWrapper.mm +++ b/NativeScript/runtime/WorkerWrapper.mm @@ -5,6 +5,7 @@ #include "Helpers.h" #include "Runtime.h" #include "RuntimeConfig.h" +#include "Worker.h" #include "inspector/JsV8InspectorClient.h" #include "inspector/WorkerInspectorClient.h" @@ -339,36 +340,33 @@ static void PostToRuntimeLoop(Runtime* runtime, std::function fn, bool a if (this->isTerminating_) { return; } - Local context = Caches::Get(this->workerIsolate_)->GetContext(); + Isolate* isolate = this->workerIsolate_; + Local context = Caches::Get(isolate)->GetContext(); Local global = context->Global(); Local onErrorVal; - bool success = - global->Get(context, tns::ToV8String(this->workerIsolate_, "onerror")).ToLocal(&onErrorVal); - Isolate* isolate = v8::Isolate::GetCurrent(); - tns::Assert(success, isolate); - - if (!onErrorVal.IsEmpty() && onErrorVal->IsFunction()) { - Local onErrorFunc = onErrorVal.As(); - Local error = tc.Exception(); - Local args[1] = {error}; + if (global->Get(context, tns::ToV8String(isolate, "onerror")).ToLocal(&onErrorVal) && + !onErrorVal.IsEmpty() && onErrorVal->IsFunction()) { + Local args[1] = {tc.Exception()}; Local result; - TryCatch innerTc(this->workerIsolate_); - success = - onErrorFunc->Call(context, v8::Undefined(this->workerIsolate_), 1, args).ToLocal(&result); - - if (success && !result.IsEmpty() && result->BooleanValue(this->workerIsolate_)) { - // Do nothing, exception is handled and does not need to be raised to the main thread's - // onerror handler + TryCatch innerTc(isolate); + bool called = onErrorVal.As() + ->Call(context, v8::Undefined(isolate), 1, args) + .ToLocal(&result); + if (called && !result.IsEmpty() && result->BooleanValue(isolate)) { + // Truthy return means handled, which is where the web stops propagation. return; } - - if (!success && innerTc.HasCaught()) { + if (!called && innerTc.HasCaught()) { + // The handler itself threw; that error is what the parent should see. this->PassUncaughtExceptionFromWorkerToMain(context, innerTc); + return; } - - this->PassUncaughtExceptionFromWorkerToMain(context, tc); } + + // Unhandled at the worker scope — including when there is no scope handler + // at all — so it becomes the parent's error event. + this->PassUncaughtExceptionFromWorkerToMain(context, tc); } void WorkerWrapper::ReportEntryEvaluationRejection(Local context, Local reason) { @@ -499,66 +497,23 @@ static void PostToRuntimeLoop(Runtime* runtime, std::function fn, bool a v8::Locker locker(mainIsolate); Isolate::Scope isolate_scope(mainIsolate); HandleScope handle_scope(mainIsolate); - Local context = Caches::Get(mainIsolate)->GetContext(); - Local workerValue = poWorker->Get(mainIsolate); - if (workerValue.IsEmpty() || !workerValue->IsObject()) { + Local worker = poWorker->Get(mainIsolate); + if (worker.IsEmpty() || !worker->IsObject()) { return; } - Local worker = workerValue.As(); - - Local onErrorVal; - bool success = - worker->Get(context, tns::ToV8String(mainIsolate, "onerror")).ToLocal(&onErrorVal); - tns::Assert(success, mainIsolate); - - if (!onErrorVal.IsEmpty() && onErrorVal->IsFunction()) { - Local onErrorFunc = onErrorVal.As(); - Local arg = - ConstructErrorObject(context, message, source, stackTrace, lineNumber); - Local args[1] = {arg}; - Local result; - TryCatch tc(mainIsolate); - bool success = - onErrorFunc->Call(context, v8::Undefined(mainIsolate), 1, args).ToLocal(&result); - if (!success && tc.HasCaught()) { - Local error = tc.Exception(); - Log(@"%s", tns::ToString(mainIsolate, error).c_str()); - mainIsolate->ThrowException(error); - } + + TryCatch tc(mainIsolate); + Worker::EmitError(mainIsolate, worker.As(), message, source, stackTrace, + lineNumber); + if (tc.HasCaught()) { + Local error = tc.Exception(); + Log(@"%s", tns::ToString(mainIsolate, error).c_str()); + mainIsolate->ThrowException(error); } }, async); } -Local WorkerWrapper::ConstructErrorObject(Local context, std::string message, - std::string source, std::string stackTrace, - int lineNumber) { - Isolate* isolate = v8::Isolate::GetCurrent(); - Local objTemplate = ObjectTemplate::New(isolate); - Local obj; - bool success = objTemplate->NewInstance(context).ToLocal(&obj); - tns::Assert(success, isolate); - - tns::Assert( - obj->Set(context, tns::ToV8String(isolate, "message"), tns::ToV8String(isolate, message)) - .FromMaybe(false), - isolate); - tns::Assert( - obj->Set(context, tns::ToV8String(isolate, "filename"), tns::ToV8String(isolate, source)) - .FromMaybe(false), - isolate); - tns::Assert(obj->Set(context, tns::ToV8String(isolate, "stackTrace"), - tns::ToV8String(isolate, stackTrace)) - .FromMaybe(false), - isolate); - tns::Assert( - obj->Set(context, tns::ToV8String(isolate, "lineno"), Number::New(isolate, lineNumber)) - .FromMaybe(false), - isolate); - - return obj; -} - std::atomic WorkerWrapper::nextId_(0); } // namespace tns diff --git a/NativeScript/runtime/js/events.js b/NativeScript/runtime/js/events.js index 032227325..2bf478199 100644 --- a/NativeScript/runtime/js/events.js +++ b/NativeScript/runtime/js/events.js @@ -218,17 +218,23 @@ g.EventTarget = EventTarget; // listener-changed hook applies. var addListener = EventTargetImpl.prototype.addEventListener; -function makeEventHandler(handler) { +function makeEventHandler(handler, cancelOnTruthy) { function eventHandler(event) { if (typeof eventHandler.handler !== "function") { return; } - return FunctionPrototypeCall(eventHandler.handler, this, event); + var result = FunctionPrototypeCall(eventHandler.handler, this, event); + // Special error event handling (HTML §8.1.7.3): only for `onerror`, a + // truthy return cancels the event. It is the one way a handler + // attribute's return value is observable, so it is also how "the worker + // error was handled" leaves dispatch. + if (cancelOnTruthy && result) { event.preventDefault(); } + return result; } eventHandler.handler = handler; eventHandler.delta = 0; return eventHandler; } -function defineEventHandler(target, name, event) { +function defineEventHandler(target, name, event, cancelOnTruthy) { if (event === undefined) { event = name; } var propName = "on" + name; @@ -254,7 +260,7 @@ function defineEventHandler(target, name, event) { if (wrapper === undefined) { // First assignment ever, `null` included: the slot is claimed now, and // the listener count rises with it (HTML port enabling depends on it). - wrapper = wrappers[event] = makeEventHandler(value); + wrapper = wrappers[event] = makeEventHandler(value, cancelOnTruthy); FunctionPrototypeCall(addListener, this, event, wrapper); return; } @@ -262,7 +268,12 @@ function defineEventHandler(target, name, event) { var isActive = typeof value === "function"; wrapper.handler = value; if (wasActive === isActive) { return; } - wrapper.delta += isActive ? 1 : -1; + // Absolute, never cumulative: the wrapper holds its one slot for good, so + // the correction is all-or-nothing — a cleared handler cancels its slot + // out, an active one needs no correction. Accumulating instead drifts a + // count that never returns to zero, and the port/signal accounting built + // on it then never sees "no listeners left". + wrapper.delta = isActive ? 0 : -1; var list = bag[event]; notifyListenerChanged(this, event, list ? list.length : 0); } diff --git a/NativeScript/runtime/js/message-event.js b/NativeScript/runtime/js/message-event.js index 387714896..0ea1e99b1 100644 --- a/NativeScript/runtime/js/message-event.js +++ b/NativeScript/runtime/js/message-event.js @@ -126,6 +126,8 @@ class MessageEvent extends Event { this.cancelable = !!cancelable; this.defaultPrevented = false; this.target = null; + this._stopPropagation = false; + this._stopImmediate = false; this.#data = data; this.#origin = `${origin}`; this.#lastEventId = `${lastEventId}`; diff --git a/NativeScript/runtime/js/worker-events.js b/NativeScript/runtime/js/worker-events.js index 583024fc2..ecc3f0892 100644 --- a/NativeScript/runtime/js/worker-events.js +++ b/NativeScript/runtime/js/worker-events.js @@ -1,7 +1,10 @@ "use strict"; // Worker (HTML Standard §10.2.6) and the worker global scope (§10.2.1) as // EventTargets: both deliver MessageEvents instead of the runtime's historical -// direct call of an `onmessage` property. +// direct call of an `onmessage` property, and the Worker object receives the +// worker's unhandled errors as ErrorEvents. The worker global scope's own +// `onerror` stays a direct call with the error — a documented NativeScript +// contract, not the web's event. // // Eager, because the handler attributes have to exist before app code assigns // one. MessageEvent itself is pulled in on the first delivery, so a worker @@ -25,6 +28,17 @@ function getMessageEvent() { return MessageEvent; } +// ErrorEvent is installed by the error-events builtin, which Runtime::Init +// runs AFTER this one — so the constructor can only be taken on the first +// error delivery, not at init. +let ErrorEvent; +function getErrorEvent() { + if (ErrorEvent === undefined) { + ErrorEvent = g.ErrorEvent; + } + return ErrorEvent; +} + // The delivery callout, invoked by native with the receiving target as `this`: // the Worker object on the parent isolate, the global scope's EventTarget // inside a worker. `ports` is the array of MessagePorts the message @@ -38,14 +52,31 @@ function emitMessage(data, ports, type) { dispatchEventRethrowing(this, new MessageEventCtor(type, { data, ports })); } +// The parent-side error delivery callout, invoked by native with the Worker +// object as `this` once the worker scope has left the error unhandled. Only +// primitives cross the isolate boundary, so the event carries no `error` +// object; `stackTrace` is this runtime's addition to the ErrorEvent fields. +// +// Returns whether the error was handled: a truthy return from the `onerror` +// attribute cancels the event (HTML §8.1.7.3), as does preventDefault() from +// any listener. +function emitError(message, filename, lineno, stackTrace) { + const ErrorEventCtor = getErrorEvent(); + const event = new ErrorEventCtor("error", { + message, + filename, + lineno, + cancelable: true, + }); + event.stackTrace = stackTrace; + dispatchEventRethrowing(this, event); + return event.defaultPrevented; +} + ObjectSetPrototypeOf(g.Worker.prototype, EventTarget.prototype); defineEventHandler(g.Worker.prototype, "message"); defineEventHandler(g.Worker.prototype, "messageerror"); -// `error` is a handler attribute and nothing more: the worker error path still -// reads `onerror` off the worker object and calls it with a plain error -// record, so no event is ever dispatched for it and addEventListener("error") -// on a Worker stays inert. -defineEventHandler(g.Worker.prototype, "error"); +defineEventHandler(g.Worker.prototype, "error", "error", true); // The global scope's handler attributes are defined against the EventTarget // backing the global listener methods, which is what native dispatches on and @@ -68,4 +99,4 @@ for (const name of ["onmessage", "onmessageerror"]) { }); } -module.exports = { emitMessage }; +module.exports = { emitMessage, emitError }; diff --git a/TestRunner/app/shared b/TestRunner/app/shared index 932e1fb0f..a2ccd8c7b 160000 --- a/TestRunner/app/shared +++ b/TestRunner/app/shared @@ -1 +1 @@ -Subproject commit 932e1fb0f0183d1006d36e57eac32fbbec476039 +Subproject commit a2ccd8c7b211f1e2a614bab84918e27f0181b5de diff --git a/docs/structured-clone.md b/docs/structured-clone.md index 887984fc5..bef3f4f3e 100644 --- a/docs/structured-clone.md +++ b/docs/structured-clone.md @@ -28,7 +28,7 @@ Not cloneable — each throws (see the deviations below): functions, symbols, `W ## Transfer semantics -The list is validated before anything is serialized: each entry must be an `ArrayBuffer` or a `MessagePort`, must not already be detached (an `ArrayBuffer` must additionally be detachable), and must appear at most once. A violation throws before the sources are touched, and nothing is detached or handed over until the whole graph has serialized successfully — so a rejected call never leaves a half-transferred graph behind, and every listed buffer and port is still usable afterwards. +The list is validated before anything is serialized: each entry must be an `ArrayBuffer` or a `MessagePort`, must not already be detached (an `ArrayBuffer` must additionally be detachable), and must appear at most once. A violation throws before the sources are touched, and nothing is detached or handed over until the whole graph has serialized successfully — a rejected call never leaves a half-transferred graph behind. The guarantee covers transfer state only: serializing the graph runs user getters, and a getter's own side effects (closing a listed port, say) are not rolled back — a port closed that way makes the call fail, already closed. 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. diff --git a/docs/worker-threads.md b/docs/worker-threads.md index ee331806a..0dbb04e94 100644 --- a/docs/worker-threads.md +++ b/docs/worker-threads.md @@ -79,15 +79,22 @@ finished. `terminate()` therefore resolves with `0` and emits `exit` with code `0` on the way, and that is the only path that emits it. A worker that ends by its own `close()` produces no `exit`. -### `worker.addEventListener("error", …)` is inert - -`Worker` is an `EventTarget` and `message`/`messageerror` are dispatched as -real `MessageEvent`s, so a handler attribute and an `addEventListener` -registration interleave in the order they were installed. `error` is the -exception: the worker error path reads the `onerror` property straight off the -worker object and calls it with a plain error record (`message`, `stackTrace`, -`filename`, `lineno`), never dispatching an event. **`worker.onerror` works; -`addEventListener("error", …)` on a `Worker` never fires.** +### A worker error carries no `error` object, and the worker scope's `onerror` is not an event + +An error the worker scope leaves unhandled reaches the parent as a real +`ErrorEvent` dispatched on the `Worker`, so `worker.onerror` and +`addEventListener("error", …)` both fire, interleaved in the order they were +installed. Two things differ from a browser: + +- Only primitives cross the isolate boundary, so `event.error` is always + `null`; the worker's stack comes through as `event.stackTrace`, a string + alongside the standard `message`, `filename` and `lineno`. +- Inside the worker, `onerror` is still a direct call taking the thrown value — + not an `ErrorEvent`, and not reachable through `addEventListener`. Returning + truthy from it handles the error and stops it from reaching the parent, which + is the same "handled" contract `worker.onerror` has on the parent side (a + truthy return there cancels the event, as does `preventDefault()` from any + listener). ### Inside a worker, `event.target` is not `globalThis` @@ -229,8 +236,9 @@ it found them** — still open, still holding their memory — so a failed The listed ports are re-checked after the write as well, because writing the graph runs user getters and one of them may have closed a listed port; that -late failure is the same `MessagePort in transfer list is already detached`, -and it too leaves everything intact. +late failure is the same `MessagePort in transfer list is already detached`. +What it undoes is the transfer — nothing is detached, nothing changes hands — +not what the getters did on the way there: a port a getter closed stays closed. ## Worker messages From dc48fa3e1466c00d2d79a5a8f418ab6e6301f286 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Fri, 11 Sep 2026 10:57:01 -0300 Subject: [PATCH 04/18] fix(runtime): destroy dropped worker messages outside the loop and queue locks A message dropped by EventLoop::Shutdown or ConcurrentQueue::Terminate owns transferred buffers and ports, and destroying a port sentinels its sibling, which posts to the sibling's loop. Shutdown destroyed the dropped lanes while holding the loop mutex, so a sibling owned by the isolate shutting down re-entered that mutex and hung the thread. Terminate never emptied the message queue at all, so ports and buffers transferred to a worker that was terminated before its entry settled stayed pinned for the wrapper's lifetime and the sibling never learned the channel was gone. Both now move the queued entries out under the lock and let them die after it is released, and a push racing Terminate is dropped under the same mutex instead of landing in a queue nothing pops again. --- NativeScript/runtime/ConcurrentQueue.cpp | 26 +++++++++++++++++++----- NativeScript/runtime/ConcurrentQueue.h | 11 ++++++---- NativeScript/runtime/EventLoop.h | 5 +++++ NativeScript/runtime/EventLoop.mm | 16 +++++++++++---- 4 files changed, 45 insertions(+), 13 deletions(-) diff --git a/NativeScript/runtime/ConcurrentQueue.cpp b/NativeScript/runtime/ConcurrentQueue.cpp index 272c162c5..6e34073f1 100644 --- a/NativeScript/runtime/ConcurrentQueue.cpp +++ b/NativeScript/runtime/ConcurrentQueue.cpp @@ -20,7 +20,13 @@ void ConcurrentQueue::Push(std::shared_ptr message) { } { - std::unique_lock mlock(this->mutex_); + // Checked under the queue mutex, where Terminate() also flips it while + // emptying the queue: a push that loses the race is dropped rather than + // landing in a queue nothing will ever pop again. + std::unique_lock mlock(this->mutex_); + if (this->terminated) { + return; + } this->messagesQueue_.push(message); } @@ -67,6 +73,11 @@ void ConcurrentQueue::SignalAndWakeUp() { } void ConcurrentQueue::Terminate() { + // Whatever is still queued is destroyed after both locks are released: a + // message owns transferred buffers and ports, and destroying a port takes + // its sibling group's lock and posts to the sibling's loop. + std::queue> dropped; + { std::unique_lock lock(initializationMutex_); terminated = true; CFRunLoopRef runLoop = this->runLoop_; @@ -75,14 +86,19 @@ void ConcurrentQueue::Terminate() { this->runLoop_ = nullptr; if (runLoop) { - CFRunLoopStop(runLoop); + CFRunLoopStop(runLoop); } if (source) { - CFRunLoopRemoveSource(runLoop, source, kCFRunLoopCommonModes); - CFRunLoopSourceInvalidate(source); - CFRelease(source); + CFRunLoopRemoveSource(runLoop, source, kCFRunLoopCommonModes); + CFRunLoopSourceInvalidate(source); + CFRelease(source); } + } + { + std::unique_lock mlock(this->mutex_); + dropped.swap(this->messagesQueue_); + } } } diff --git a/NativeScript/runtime/ConcurrentQueue.h b/NativeScript/runtime/ConcurrentQueue.h index e7243f75d..b8fe1694f 100644 --- a/NativeScript/runtime/ConcurrentQueue.h +++ b/NativeScript/runtime/ConcurrentQueue.h @@ -2,10 +2,13 @@ #define ConcurrentQueue_h #include -#include -#include -#include + +#include #include +#include +#include +#include + #include "Message.hpp" namespace tns { @@ -26,7 +29,7 @@ struct ConcurrentQueue { std::queue> messagesQueue_; CFRunLoopSourceRef runLoopTasksSource_ = nullptr; CFRunLoopRef runLoop_ = nullptr; - bool terminated = false; + std::atomic terminated{false}; std::mutex mutex_; std::mutex initializationMutex_; void SignalAndWakeUp(); diff --git a/NativeScript/runtime/EventLoop.h b/NativeScript/runtime/EventLoop.h index 8448dacc9..3dd7598f9 100644 --- a/NativeScript/runtime/EventLoop.h +++ b/NativeScript/runtime/EventLoop.h @@ -69,6 +69,11 @@ class OrderedTaskSource { * "message to a terminated runtime" semantics of the mechanisms this * replaces. Producers only post; entries run exclusively on the home thread, * which is the only place the isolate's Locker is taken. + * + * No entry is ever destroyed while mutex_ is held: an entry's destructor may + * post (a dropped message carrying a transferred port sentinels the port's + * sibling, possibly on this loop), so Shutdown moves the lanes out and lets + * them die after the unlock. */ class EventLoop { public: diff --git a/NativeScript/runtime/EventLoop.mm b/NativeScript/runtime/EventLoop.mm index 57aa131b5..9ca381ca3 100644 --- a/NativeScript/runtime/EventLoop.mm +++ b/NativeScript/runtime/EventLoop.mm @@ -91,15 +91,23 @@ static CFAbsoluteTime FireDateFor(double dueMs, double nowMs) { } void EventLoop::Shutdown() { + // The dropped entries are moved out and destroyed only after the lock is + // released: an entry's destructor may post back into this very loop (a + // dropped message carrying a transferred port sentinels the port's sibling, + // and that sibling may live here), and mutex_ is not recursive. + std::deque droppedInternal; + std::multimap droppedInternalDelayed; + std::deque droppedOrdered; + std::multimap droppedOrderedDelayed; std::lock_guard lock(mutex_); if (stopped_) { return; } stopped_ = true; - internal_.immediate.clear(); - internal_.delayed.clear(); - ordered_.immediate.clear(); - ordered_.delayed.clear(); + droppedInternal.swap(internal_.immediate); + droppedInternalDelayed.swap(internal_.delayed); + droppedOrdered.swap(ordered_.immediate); + droppedOrderedDelayed.swap(ordered_.delayed); pendingTokens_.clear(); bufferedTokens_.clear(); if (internalSource_ != nullptr) { From e105124bdab2e5e7f267f5c55df2bb1c32a73dc4 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Fri, 11 Sep 2026 10:57:02 -0300 Subject: [PATCH 05/18] fix(runtime): revalidate transferred buffers after the write and close adopted ports nothing reaches A getter run while the graph was written could detach a listed ArrayBuffer; V8 still wrote it as a transfer and detaching it again succeeded on zero bytes, so the receiver silently got an empty buffer. The post-write check that already covered ports now covers buffers. A listed port the value never named was adopted into the receiving isolate's registry with no JS handle, pinned until isolate teardown with its sibling queueing into it. Deserialize now records which adopted ports the stream referenced and, for callers that surface no port list (structuredClone, receiveMessageOnPort), closes the rest; a read that fails after adoption closes every port it adopted. --- .../runtime/StructuredSerialization.cpp | 60 +++++++++++++++---- docs/worker-threads.md | 18 ++++-- 2 files changed, 63 insertions(+), 15 deletions(-) diff --git a/NativeScript/runtime/StructuredSerialization.cpp b/NativeScript/runtime/StructuredSerialization.cpp index fe869c433..578e8e48d 100644 --- a/NativeScript/runtime/StructuredSerialization.cpp +++ b/NativeScript/runtime/StructuredSerialization.cpp @@ -314,10 +314,11 @@ class DeserializerDelegate : public ValueDeserializer::Delegate { DeserializerDelegate( const std::vector>* sharedBuffers, const std::vector>* domExceptions, - const std::vector>* ports) + const std::vector>* ports, std::vector* portsRead) : sharedBuffers_(sharedBuffers), domExceptions_(domExceptions), - ports_(ports) {} + ports_(ports), + portsRead_(portsRead) {} void SetDeserializer(ValueDeserializer* deserializer) { deserializer_ = deserializer; @@ -349,6 +350,7 @@ class DeserializerDelegate : public ValueDeserializer::Delegate { if (!deserializer_->ReadUint32(&index) || index >= ports_->size()) { return MaybeLocal(); } + (*portsRead_)[index] = true; return (*ports_)[index]; } default: @@ -368,9 +370,28 @@ class DeserializerDelegate : public ValueDeserializer::Delegate { const std::vector>* sharedBuffers_; const std::vector>* domExceptions_; const std::vector>* ports_; + std::vector* portsRead_; ValueDeserializer* deserializer_ = nullptr; }; +// Closes adopted ports that nothing will ever reach: a port lives in the +// isolate's registry until it is closed, so one without a JS handle would be +// pinned for the isolate's lifetime with its sibling queueing into it. +void CloseUnreachablePorts(Isolate* isolate, + const std::vector>& ports, + const std::vector* reachable) { + for (size_t i = 0; i < ports.size(); i++) { + if (reachable != nullptr && (*reachable)[i]) { + continue; + } + messaging::NativeMessagePort* port = + messaging::PortFromWrapper(isolate, ports[i]); + if (port != nullptr) { + port->Close(); + } + } +} + // Validates the transfer list and splits it, each half in registration order, // because the two are handed over by different mechanisms: buffers by id in // the stream, ports by index into an out-of-band list. The detached and @@ -512,9 +533,11 @@ Maybe SerializedValue::Serialize(Isolate* isolate, Local context, } // Revalidated after the write, not before it: writing the graph runs user - // getters, and one of them may have closed a listed port. Checked while - // nothing has changed hands yet, so a message that cannot be completed - // leaves every buffer and every port exactly as it found them. + // getters, and one of them may have closed a listed port or detached a + // listed buffer (V8 still writes such a buffer as a transfer, and detaching + // it again below would succeed on zero bytes). Checked while nothing has + // changed hands yet, so a message that cannot be completed leaves every + // buffer and every port exactly as it found them. for (const std::shared_ptr& port : ports) { if (port->IsDetached()) { ThrowDataCloneError(isolate, @@ -522,6 +545,14 @@ Maybe SerializedValue::Serialize(Isolate* isolate, Local context, return Nothing(); } } + for (const Local& buffer : transfers) { + if (buffer->WasDetached() || !buffer->IsDetachable()) { + ThrowDataCloneError(isolate, + "An ArrayBuffer in the transfer list is detached " + "and cannot be transferred"); + 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 @@ -641,6 +672,7 @@ MaybeLocal SerializedValue::Deserialize(Isolate* isolate, .ToLocal(&wrapper) || !list->Set(context, static_cast(i), wrapper) .FromMaybe(false)) { + CloseUnreachablePorts(isolate, ports, nullptr); return MaybeLocal(); } ports.push_back(wrapper); @@ -650,7 +682,9 @@ MaybeLocal SerializedValue::Deserialize(Isolate* isolate, } } - DeserializerDelegate delegate(&sharedBuffers, &domExceptions, &ports); + std::vector portsRead(ports.size(), false); + DeserializerDelegate delegate(&sharedBuffers, &domExceptions, &ports, + &portsRead); ValueDeserializer deserializer(isolate, buffer_.get(), bufferSize_, &delegate); delegate.SetDeserializer(&deserializer); @@ -661,13 +695,19 @@ MaybeLocal SerializedValue::Deserialize(Isolate* isolate, ArrayBuffer::New(isolate, std::move(transferredBuffers_[i]))); } - if (deserializer.ReadHeader(context).IsNothing()) { - return MaybeLocal(); - } Local result; - if (!deserializer.ReadValue(context).ToLocal(&result)) { + if (deserializer.ReadHeader(context).IsNothing() || + !deserializer.ReadValue(context).ToLocal(&result)) { + CloseUnreachablePorts(isolate, ports, nullptr); return MaybeLocal(); } + // A caller that takes no port list (structuredClone, receiveMessageOnPort) + // surfaces a transferred port only through the value itself; a listed port + // the graph never named has no other way out and is closed here, the way an + // unreferenced transferred port is collected on the web. + if (portList == nullptr) { + CloseUnreachablePorts(isolate, ports, &portsRead); + } return result; } diff --git a/docs/worker-threads.md b/docs/worker-threads.md index 0dbb04e94..950f07580 100644 --- a/docs/worker-threads.md +++ b/docs/worker-threads.md @@ -234,11 +234,19 @@ a graph therefore leaves **every port and every buffer in the list exactly as it found them** — still open, still holding their memory — so a failed `postMessage` can be corrected and retried. -The listed ports are re-checked after the write as well, because writing the -graph runs user getters and one of them may have closed a listed port; that -late failure is the same `MessagePort in transfer list is already detached`. -What it undoes is the transfer — nothing is detached, nothing changes hands — -not what the getters did on the way there: a port a getter closed stays closed. +The listed ports and buffers are re-checked after the write as well, because +writing the graph runs user getters and one of them may have closed a listed +port or detached a listed buffer; those late failures are the same +`MessagePort in transfer list is already detached` and `An ArrayBuffer in the +transfer list is detached and cannot be transferred`. What they undo is the +transfer — nothing is detached, nothing changes hands — not what the getters +did on the way there: a port a getter closed stays closed. + +A listed port that the value itself never names still travels, but on arrival +it has no way out: a `message` event hands it over in `event.ports`, while +`structuredClone` and `receiveMessageOnPort` return only the value. Those two +close such a port as soon as it arrives, so its sibling learns the channel is +gone instead of queueing into a port nothing can ever read. ## Worker messages From 4b03f4a69db4efb0141c17234f412f759c6fba8c Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Fri, 11 Sep 2026 10:57:02 -0300 Subject: [PATCH 06/18] fix(runtime): enable a port on the first onmessage write without pinning AbortSignals The handler-attribute helper expressed HTML's "setting onmessage enables the port" as a listener-count increase on the first assignment, null included. The same count feeds AbortSignal's persistence, so a signal whose onabort was only ever set to null was held strongly until it aborted. The wrapper now cancels its own slot while its handler is inactive, and MessagePort enables itself through a separate first-assignment hook. --- NativeScript/runtime/js/events.js | 23 ++++++++++++++-------- NativeScript/runtime/js/message-channel.js | 19 ++++++++++++++++++ 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/NativeScript/runtime/js/events.js b/NativeScript/runtime/js/events.js index 2bf478199..22ba21cc4 100644 --- a/NativeScript/runtime/js/events.js +++ b/NativeScript/runtime/js/events.js @@ -65,6 +65,10 @@ function handlersOf(target) { // require("internal/events"), so the accounting cannot be bypassed the way an // overridable addEventListener could. var kListenerChanged = Symbol("listenerChanged"); +// Called on the first handler-attribute assignment, whatever the value: HTML +// enables a MessagePort the first time onmessage is set, even to null, which +// the listener count cannot express. Later assignments only move the count. +var kHandlerAssigned = Symbol("handlerAssigned"); function notifyListenerChanged(target, type, count) { var hook = target[kListenerChanged]; if (hook === undefined) { return; } @@ -230,7 +234,9 @@ function makeEventHandler(handler, cancelOnTruthy) { return result; } eventHandler.handler = handler; - eventHandler.delta = 0; + // A wrapper holds one listener slot for good; an inactive handler cancels + // its own slot out of the count the listener-changed hook receives. + eventHandler.delta = typeof handler === "function" ? 0 : -1; return eventHandler; } @@ -258,21 +264,21 @@ function defineEventHandler(target, name, event, cancelOnTruthy) { } var wrapper = wrappers[event]; if (wrapper === undefined) { - // First assignment ever, `null` included: the slot is claimed now, and - // the listener count rises with it (HTML port enabling depends on it). + // First assignment ever, `null` included: the slot is claimed now and + // kept, interleaved with addEventListener registrations at this point. wrapper = wrappers[event] = makeEventHandler(value, cancelOnTruthy); FunctionPrototypeCall(addListener, this, event, wrapper); + var assigned = this[kHandlerAssigned]; + if (assigned !== undefined) { assigned(this, event); } return; } var wasActive = typeof wrapper.handler === "function"; var isActive = typeof value === "function"; wrapper.handler = value; if (wasActive === isActive) { return; } - // Absolute, never cumulative: the wrapper holds its one slot for good, so - // the correction is all-or-nothing — a cleared handler cancels its slot - // out, an active one needs no correction. Accumulating instead drifts a - // count that never returns to zero, and the port/signal accounting built - // on it then never sees "no listeners left". + // Absolute, never cumulative: the correction is the whole slot or nothing, + // so the count the hook sees returns to zero when the last active listener + // goes. wrapper.delta = isActive ? 0 : -1; var list = bag[event]; notifyListenerChanged(this, event, list ? list.length : 0); @@ -322,6 +328,7 @@ module.exports = { globalEventTarget: globalTarget, CustomEvent: CustomEvent, kListenerChanged: kListenerChanged, + kHandlerAssigned: kHandlerAssigned, setListenerErrorReporter: setListenerErrorReporter, // The base classes and the handler-attribute helper, for the lazy builtins // that may not read them off the globals user code can replace. diff --git a/NativeScript/runtime/js/message-channel.js b/NativeScript/runtime/js/message-channel.js index bcf3406ee..bf2aba3b5 100644 --- a/NativeScript/runtime/js/message-channel.js +++ b/NativeScript/runtime/js/message-channel.js @@ -46,6 +46,7 @@ const { Event, EventTarget, defineEventHandler, + kHandlerAssigned, kListenerChanged, } = require("internal/events"); @@ -136,6 +137,16 @@ function listenerChanged(port, type, count) { } } +// HTML: the first time onmessage is set the port is enabled, as if start() had +// been called, even when the value assigned is null and adds no listener. +function handlerAssigned(port, type) { + if (type !== "message" || WeakSetPrototypeHas(startedPorts, port)) { + return; + } + WeakSetPrototypeAdd(startedPorts, port); + startPort(port); +} + class MessagePort extends EventTarget { constructor() { throw new TypeError("Illegal constructor"); @@ -163,6 +174,14 @@ class MessagePort extends EventTarget { defineEventHandler(MessagePort.prototype, "message"); defineEventHandler(MessagePort.prototype, "messageerror"); +ObjectDefineProperty(MessagePort.prototype, kHandlerAssigned, { + __proto__: null, + value: handlerAssigned, + writable: false, + enumerable: false, + configurable: false, +}); + ObjectDefineProperty(MessagePort.prototype, kListenerChanged, { __proto__: null, value: listenerChanged, From 9a30394b572a44f08cb310133c9eb88a4ddf985d Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Fri, 11 Sep 2026 10:57:03 -0300 Subject: [PATCH 07/18] test(runtime): pin messaging teardown, transfer and handler-attribute edges --- TestRunner/app/tests/MessagingTests.js | 150 ++++++++++++++++++ TestRunner/app/tests/index.js | 1 + .../app/tests/messaging/parkedWorker.mjs | 3 + 3 files changed, 154 insertions(+) create mode 100644 TestRunner/app/tests/MessagingTests.js create mode 100644 TestRunner/app/tests/messaging/parkedWorker.mjs diff --git a/TestRunner/app/tests/MessagingTests.js b/TestRunner/app/tests/MessagingTests.js new file mode 100644 index 000000000..ab8b594d0 --- /dev/null +++ b/TestRunner/app/tests/MessagingTests.js @@ -0,0 +1,150 @@ +// iOS-side regression specs for the messaging tier. The shared suites under +// app/shared cover the specified behavior; these pin runtime edges that need +// native wrappers, a worker that never settles, or a collection. +describe("Messaging runtime edges", function () { + var parkedEntry = "./messaging/parkedWorker.mjs"; + // Delivery goes through the event loop; SETTLE is long enough that an + // event which was going to arrive would have. + var SETTLE = 400; + + describe("transfer lists", function () { + it("rejects a buffer a getter detached while the graph was being written", function () { + var buffer = new ArrayBuffer(16); + var error = null; + try { + structuredClone({ get x() { buffer.transfer(); return 1; } }, { transfer: [buffer] }); + } catch (e) { + error = e; + } + expect(error).not.toBeNull(); + expect(error.name).toBe("DataCloneError"); + }); + + it("hands a listed port over through the cloned value", function (done) { + var channel = new MessageChannel(); + var clone = structuredClone(channel.port2, { transfer: [channel.port2] }); + expect(clone instanceof MessagePort).toBe(true); + expect(clone).not.toBe(channel.port2); + clone.addEventListener("message", function (event) { + expect(event.data).toBe("through"); + clone.close(); + channel.port1.close(); + done(); + }); + channel.port1.postMessage("through"); + }); + + it("closes a listed port the cloned value never names", function (done) { + var channel = new MessageChannel(); + var closed = false; + channel.port1.addEventListener("close", function () { closed = true; }); + structuredClone({}, { transfer: [channel.port2] }); + setTimeout(function () { + expect(closed).toBe(true); + channel.port1.close(); + done(); + }, SETTLE); + }); + + it("closes a port transferred to a worker that is terminated before its entry settles", function (done) { + var worker = new Worker(parkedEntry); + var channel = new MessageChannel(); + var closed = false; + channel.port1.addEventListener("close", function () { closed = true; }); + worker.postMessage(channel.port2, [channel.port2]); + worker.terminate(); + setTimeout(function () { + expect(closed).toBe(true); + channel.port1.close(); + done(); + }, SETTLE); + }); + }); + + describe("handler attributes", function () { + it("enables a port when onmessage is first set to null", function (done) { + var channel = new MessageChannel(); + channel.port2.postMessage("consumed"); + channel.port1.onmessage = null; + setTimeout(function () { + var received = 0; + channel.port1.addEventListener("message", function () { received++; }); + setTimeout(function () { + expect(received).toBe(0); + channel.port1.close(); + channel.port2.close(); + done(); + }, SETTLE); + }, SETTLE); + }); + + it("keeps a port disabled until a handler or listener arrives", function (done) { + var channel = new MessageChannel(); + channel.port2.postMessage("kept"); + setTimeout(function () { + var received = 0; + channel.port1.addEventListener("message", function () { received++; }); + setTimeout(function () { + expect(received).toBe(1); + channel.port1.close(); + channel.port2.close(); + done(); + }, SETTLE); + }, SETTLE); + }); + }); + + describe("AbortSignal handler attribute accounting", function () { + function pollGC(predicate, cb) { + var turns = 0; + (function poll() { + __collect(); + if (predicate() || turns >= 100) { + cb(); + return; + } + turns++; + setTimeout(poll, 20); + })(); + } + + it("a timeout signal whose onabort was only ever set to null is collectable", function (done) { + var wr = (function () { + var signal = AbortSignal.timeout(60000); + signal.onabort = null; + return new WeakRef(signal); + })(); + pollGC(function () { return wr.deref() === undefined; }, function () { + expect(wr.deref()).toBeUndefined(); + done(); + }); + }); + + it("a timeout signal whose onabort was cleared again is collectable", function (done) { + var wr = (function () { + var signal = AbortSignal.timeout(60000); + signal.onabort = function () {}; + signal.onabort = null; + return new WeakRef(signal); + })(); + pollGC(function () { return wr.deref() === undefined; }, function () { + expect(wr.deref()).toBeUndefined(); + done(); + }); + }); + + it("a timeout signal with an onabort handler survives GC and still aborts", function (done) { + var reasonName = null; + (function () { + AbortSignal.timeout(300).onabort = function (event) { + reasonName = event.target.reason.name; + }; + })(); + __collect(); + pollGC(function () { return reasonName !== null; }, function () { + expect(reasonName).toBe("TimeoutError"); + done(); + }); + }); + }); +}); diff --git a/TestRunner/app/tests/index.js b/TestRunner/app/tests/index.js index 2f73c060a..7409edb2c 100644 --- a/TestRunner/app/tests/index.js +++ b/TestRunner/app/tests/index.js @@ -133,6 +133,7 @@ require("./GCFinalizerTests"); require("./WorkerConcurrentStartupTests"); require("./WorkerOptionsTests"); require("./WorkerResourceLimitsTests"); +require("./MessagingTests"); require("./DeclarationConflicts"); // require("./Promises"); diff --git a/TestRunner/app/tests/messaging/parkedWorker.mjs b/TestRunner/app/tests/messaging/parkedWorker.mjs new file mode 100644 index 000000000..0e7d2da36 --- /dev/null +++ b/TestRunner/app/tests/messaging/parkedWorker.mjs @@ -0,0 +1,3 @@ +// Never finishes evaluating, so messages posted to this worker stay queued on +// the wrapper: the queue is only enabled once the entry has settled. +await new Promise(() => {}); From 76394871b836eef001e4e9cd9f4071b7f3fc39c2 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Fri, 11 Sep 2026 11:31:49 -0300 Subject: [PATCH 08/18] feat(runtime): parentPort emitter methods, worker option forwarding, MessagePort.onclose parentPort gains Node's on/once/off/removeListener/addListener, delivering the payload rather than the event as Node does. The node:worker_threads Worker passes its option bag to the runtime's Worker so ios and resourceLimits reach it. MessagePort exposes the onclose handler attribute the HTML IDL defines for the close event this runtime already dispatches. --- NativeScript/runtime/js/message-channel.js | 1 + .../runtime/js/node-worker-threads.js | 67 ++++++++++++++++++- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/NativeScript/runtime/js/message-channel.js b/NativeScript/runtime/js/message-channel.js index bf2aba3b5..7d49cac85 100644 --- a/NativeScript/runtime/js/message-channel.js +++ b/NativeScript/runtime/js/message-channel.js @@ -173,6 +173,7 @@ class MessagePort extends EventTarget { defineEventHandler(MessagePort.prototype, "message"); defineEventHandler(MessagePort.prototype, "messageerror"); +defineEventHandler(MessagePort.prototype, "close"); ObjectDefineProperty(MessagePort.prototype, kHandlerAssigned, { __proto__: null, diff --git a/NativeScript/runtime/js/node-worker-threads.js b/NativeScript/runtime/js/node-worker-threads.js index 3f1e49caf..36306894a 100644 --- a/NativeScript/runtime/js/node-worker-threads.js +++ b/NativeScript/runtime/js/node-worker-threads.js @@ -62,6 +62,7 @@ const NativeWorker = g.Worker; const globalPostMessage = g.postMessage; const addEventListener = EventTarget.prototype.addEventListener; +const removeEventListener = EventTarget.prototype.removeEventListener; const dispatchEvent = EventTarget.prototype.dispatchEvent; // Runs `fn` after the caller returns. Node reports 'online' and 'exit' from @@ -163,7 +164,12 @@ class Worker extends WorkerEmitter { } } - const worker = new NativeWorker(`${filename}`); + // The runtime's own options (ios, resourceLimits) ride along; the native + // constructor ignores keys it does not know. + const worker = + options === undefined || options === null + ? new NativeWorker(`${filename}`) + : new NativeWorker(`${filename}`, options); this.#worker = worker; const self = this; worker.onmessage = function (event) { @@ -208,6 +214,11 @@ ObjectDefineProperty(Worker.prototype, SymbolToStringTag, { // globals the runtime already provides. close() is a no-op — a worker ends // through its own close()/terminate(). class ParentPort extends EventTarget { + // Node's parentPort is an EventEmitter as well: on("message") receives the + // payload, not the event. Each listener is registered through its own + // wrapper so removal by the original function still works. + #wrappers = ObjectCreate(null); + postMessage(value, transfer) { FunctionPrototypeCall(globalPostMessage, g, value, transfer); } @@ -215,6 +226,60 @@ class ParentPort extends EventTarget { start() {} close() {} + + on(type, listener) { + return this.#add(`${type}`, listener, false); + } + + addListener(type, listener) { + return this.#add(`${type}`, listener, false); + } + + once(type, listener) { + return this.#add(`${type}`, listener, true); + } + + off(type, listener) { + this.#remove(`${type}`, listener); + return this; + } + + removeListener(type, listener) { + this.#remove(`${type}`, listener); + return this; + } + + #add(type, listener, once) { + if (typeof listener !== "function") { + throw new TypeError('The "listener" argument must be of type function'); + } + const self = this; + const wrapper = function (event) { + if (once) { + self.#remove(type, listener); + } + const arg = type === "message" || type === "messageerror" ? event.data : event; + FunctionPrototypeCall(listener, self, arg); + }; + const list = this.#wrappers[type] || (this.#wrappers[type] = []); + ArrayPrototypePush(list, { listener, wrapper }); + FunctionPrototypeCall(addEventListener, this, type, wrapper); + return this; + } + + #remove(type, listener) { + const list = this.#wrappers[type]; + if (list === undefined) { + return; + } + for (let i = 0; i < list.length; i++) { + if (list[i].listener === listener) { + FunctionPrototypeCall(removeEventListener, this, type, list[i].wrapper); + ArrayPrototypeSplice(list, i, 1); + return; + } + } + } } defineEventHandler(ParentPort.prototype, "message"); From bb6f0a3caa7741b9bd6a0cec17fa22c461c9ae75 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Fri, 11 Sep 2026 11:31:50 -0300 Subject: [PATCH 09/18] fix(runtime): a BroadcastChannel named "" is a named group, not an anonymous pair --- NativeScript/runtime/Messaging.cpp | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/NativeScript/runtime/Messaging.cpp b/NativeScript/runtime/Messaging.cpp index d9867e97e..785b14a91 100644 --- a/NativeScript/runtime/Messaging.cpp +++ b/NativeScript/runtime/Messaging.cpp @@ -199,7 +199,8 @@ class SiblingGroup final : public std::enable_shared_from_this { static std::shared_ptr Get(const std::string& name); SiblingGroup() = default; - explicit SiblingGroup(std::string name) : name_(std::move(name)) {} + explicit SiblingGroup(std::string name) + : name_(std::move(name)), named_(true) {} ~SiblingGroup(); SiblingGroup(const SiblingGroup&) = delete; @@ -213,6 +214,9 @@ class SiblingGroup final : public std::enable_shared_from_this { private: const std::string name_; + // A BroadcastChannel group, whatever its name ("" included); an anonymous + // group is one channel's two ends. + const bool named_ = false; std::shared_mutex mutex_; std::set ports_; }; @@ -239,7 +243,7 @@ std::shared_ptr SiblingGroup::Get(const std::string& name) { } SiblingGroup::~SiblingGroup() { - if (this->name_.empty()) { + if (!this->named_) { return; } std::lock_guard lock(g_groupsMutex); @@ -320,7 +324,7 @@ void SiblingGroup::Disentangle(PortData* data) { // Queued rather than delivered: a close orders behind everything already // sent, on both ends. data->AddToIncomingQueue(std::make_shared()); - if (this->ports_.size() == 1 && this->name_.empty()) { + if (this->ports_.size() == 1 && !this->named_) { // A channel with one end left is a channel no more; a named group outlives // any number of members joining and leaving. (*this->ports_.begin())->AddToIncomingQueue(std::make_shared()); From 516cd356f029d7568d94eaa621724bba20fa9485 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Fri, 11 Sep 2026 11:31:51 -0300 Subject: [PATCH 10/18] fix(runtime): a second read of a transferred message and an unreadable host object throw DataCloneError The single-read guard on a message carrying transferables was an assert that Release builds compile out, leaving a second read to hand out emptied slots. It now throws, and the moved-from transfer lists are cleared so the message stops reporting transferables it no longer holds. A host object the stream names but this side cannot hand out surfaces as a DataCloneError as well, raised after the read since no JS may run inside it, instead of V8's generic deserialization error. --- .../runtime/StructuredSerialization.cpp | 51 +++++++++++++++---- 1 file changed, 42 insertions(+), 9 deletions(-) diff --git a/NativeScript/runtime/StructuredSerialization.cpp b/NativeScript/runtime/StructuredSerialization.cpp index 578e8e48d..d38b677f6 100644 --- a/NativeScript/runtime/StructuredSerialization.cpp +++ b/NativeScript/runtime/StructuredSerialization.cpp @@ -320,6 +320,11 @@ class DeserializerDelegate : public ValueDeserializer::Delegate { ports_(ports), portsRead_(portsRead) {} + // Set when the stream named a host object this side cannot hand out (an + // unknown tag or an index past the out-of-band lists). No JS may run inside + // the read, so the DataCloneError for it is raised by the caller afterwards. + bool HostObjectReadFailed() const { return hostObjectReadFailed_; } + void SetDeserializer(ValueDeserializer* deserializer) { deserializer_ = deserializer; } @@ -330,7 +335,7 @@ class DeserializerDelegate : public ValueDeserializer::Delegate { MaybeLocal ReadHostObject(Isolate* isolate) override { uint32_t tag; if (!deserializer_->ReadUint32(&tag)) { - return MaybeLocal(); + return Failed(); } switch (tag) { case kHostObjectDegraded: @@ -341,20 +346,20 @@ class DeserializerDelegate : public ValueDeserializer::Delegate { uint32_t index; if (!deserializer_->ReadUint32(&index) || index >= domExceptions_->size()) { - return MaybeLocal(); + return Failed(); } return (*domExceptions_)[index]; } case kHostObjectMessagePort: { uint32_t index; if (!deserializer_->ReadUint32(&index) || index >= ports_->size()) { - return MaybeLocal(); + return Failed(); } (*portsRead_)[index] = true; return (*ports_)[index]; } default: - return MaybeLocal(); + return Failed(); } } @@ -369,9 +374,15 @@ class DeserializerDelegate : public ValueDeserializer::Delegate { private: const std::vector>* sharedBuffers_; const std::vector>* domExceptions_; + MaybeLocal Failed() { + hostObjectReadFailed_ = true; + return MaybeLocal(); + } + const std::vector>* ports_; std::vector* portsRead_; ValueDeserializer* deserializer_ = nullptr; + bool hostObjectReadFailed_ = false; }; // Closes adopted ports that nothing will ever reach: a port lives in the @@ -605,7 +616,12 @@ MaybeLocal SerializedValue::Deserialize(Isolate* isolate, // sound because a fan-out message carries nothing that can be handed over. // Such a message may be read here from several isolates at once, so the // consumed flag is written only on the single-receiver path. - tns::Assert(!consumed_, isolate); + if (consumed_) { + ThrowDataCloneError( + isolate, + "A message carrying transferred objects can only be read once."); + return MaybeLocal(); + } if (HasTransferables()) { consumed_ = true; } @@ -694,12 +710,29 @@ MaybeLocal SerializedValue::Deserialize(Isolate* isolate, static_cast(i), ArrayBuffer::New(isolate, std::move(transferredBuffers_[i]))); } + // Handed over above; the vectors would otherwise keep reporting + // transferables that are no longer here. + transferredBuffers_.clear(); + transferredPorts_.clear(); Local result; - if (deserializer.ReadHeader(context).IsNothing() || - !deserializer.ReadValue(context).ToLocal(&result)) { - CloseUnreachablePorts(isolate, ports, nullptr); - return MaybeLocal(); + { + TryCatch tc(isolate); + if (deserializer.ReadHeader(context).IsNothing() || + !deserializer.ReadValue(context).ToLocal(&result)) { + CloseUnreachablePorts(isolate, ports, nullptr); + if (delegate.HostObjectReadFailed() && !tc.HasTerminated()) { + // V8 reports a failed read with its own generic error; a host object + // this side could not hand out is a clone failure like every other. + tc.Reset(); + ThrowDataCloneError(isolate, + "A transferred object in the message could not be " + "read on this side."); + } else { + tc.ReThrow(); + } + return MaybeLocal(); + } } // A caller that takes no port list (structuredClone, receiveMessageOnPort) // surfaces a transferred port only through the value itself; a listed port From f154945f7e3d97d1efd79595951ae43e3286c114 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Fri, 11 Sep 2026 11:31:51 -0300 Subject: [PATCH 11/18] fix(runtime): report a worker error the Worker object left unhandled to the parent scope EmitError returned whether a listener handled the error and nothing read the result, so a worker error with no parent-side listener vanished. Per HTML the error is now dispatched as an ErrorEvent on the parent's global scope, and logged when nothing there handles it either. --- NativeScript/runtime/WorkerWrapper.mm | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/NativeScript/runtime/WorkerWrapper.mm b/NativeScript/runtime/WorkerWrapper.mm index e32a72c51..61011d656 100644 --- a/NativeScript/runtime/WorkerWrapper.mm +++ b/NativeScript/runtime/WorkerWrapper.mm @@ -2,6 +2,7 @@ #include "Caches.h" #include "Constants.h" #include "DataWrapper.h" +#include "ErrorEvents.h" #include "Helpers.h" #include "Runtime.h" #include "RuntimeConfig.h" @@ -503,12 +504,29 @@ static void PostToRuntimeLoop(Runtime* runtime, std::function fn, bool a } TryCatch tc(mainIsolate); - Worker::EmitError(mainIsolate, worker.As(), message, source, stackTrace, - lineNumber); + bool handled = Worker::EmitError(mainIsolate, worker.As(), message, source, + stackTrace, lineNumber); if (tc.HasCaught()) { Local error = tc.Exception(); Log(@"%s", tns::ToString(mainIsolate, error).c_str()); mainIsolate->ThrowException(error); + return; + } + if (handled) { + return; + } + // HTML: an error the Worker object leaves unhandled is reported to the + // parent's global scope. Only primitives crossed the isolate boundary, + // so the error object is rebuilt from them here. + Local context = Caches::Get(mainIsolate)->GetContext(); + Local error = v8::Exception::Error(tns::ToV8String(mainIsolate, message)); + if (error->IsObject() && !stackTrace.empty()) { + (void)error.As()->Set(context, tns::ToV8String(mainIsolate, "stack"), + tns::ToV8String(mainIsolate, stackTrace)); + } + if (!ErrorEvents::DispatchError(mainIsolate, error, message, stackTrace)) { + Log(@"Unhandled error in worker %s:%d: %s\n%s", source.c_str(), lineNumber, + message.c_str(), stackTrace.c_str()); } }, async); From 02b9c936fac3bdb8b1eb5e7352f53a20309ff835 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Fri, 11 Sep 2026 11:31:52 -0300 Subject: [PATCH 12/18] fix(runtime): a throwing scope onerror on an unhandled rejection forwards its own error once The other two worker error paths forward the handler's exception when the scope onerror throws; the rejection path dropped it and forwarded the original reason. It now forwards the thrown error, with its stack, once. --- NativeScript/runtime/NativeScriptException.mm | 38 +++++++++++++++---- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/NativeScript/runtime/NativeScriptException.mm b/NativeScript/runtime/NativeScriptException.mm index 3ebf07146..14ae3f287 100644 --- a/NativeScript/runtime/NativeScriptException.mm +++ b/NativeScript/runtime/NativeScriptException.mm @@ -541,9 +541,12 @@ static void ScheduleDeferredThrow(Isolate* isolate, NSException* e) { // Gives a worker's global `onerror` a chance to handle a rejected reason, // mirroring WorkerWrapper::CallOnErrorHandlers. Returns true when the handler -// signalled it consumed the error (truthy return). -static bool GiveWorkerOnErrorAChance(Isolate* isolate, Local context, - Local reason) { +// signalled it consumed the error (truthy return). A handler that throws +// replaces the reason: `thrown` receives its exception, and the caller +// forwards that instead of the original, the way the other worker error +// paths do. +static bool GiveWorkerOnErrorAChance(Isolate* isolate, Local context, Local reason, + Local* thrown) { Local global = context->Global(); Local onErrorVal; if (!global->Get(context, tns::ToV8String(isolate, "onerror")).ToLocal(&onErrorVal)) { @@ -558,7 +561,13 @@ static bool GiveWorkerOnErrorAChance(Isolate* isolate, Local context, Local result; TryCatch tc(isolate); bool success = onErrorFunc->Call(context, v8::Undefined(isolate), 1, args).ToLocal(&result); - return success && !result.IsEmpty() && result->BooleanValue(isolate); + if (!success) { + if (tc.HasCaught() && !tc.HasTerminated()) { + *thrown = tc.Exception(); + } + return false; + } + return !result.IsEmpty() && result->BooleanValue(isolate); } void PromiseRejectionTracker::Drain(Local context) { @@ -638,7 +647,8 @@ static bool GiveWorkerOnErrorAChance(Isolate* isolate, Local context, // through to the existing worker channel (worker-global onerror → // forward to the main isolate's worker.onerror). if (!ErrorEvents::DispatchUnhandledRejection(isolate_, promise, reason)) { - if (!GiveWorkerOnErrorAChance(isolate_, context, reason)) { + Local thrown; + if (!GiveWorkerOnErrorAChance(isolate_, context, reason, &thrown)) { Runtime* runtime = Runtime::GetRuntime(isolate_); if (runtime != nullptr) { int workerId = runtime->WorkerId(); @@ -647,8 +657,22 @@ static bool GiveWorkerOnErrorAChance(Isolate* isolate, Local context, if (found && state != nullptr) { auto* worker = static_cast(state->UserData()); if (worker != nullptr) { - std::string reasonMessage = tns::ToString(isolate_, reason); - worker->PassUncaughtRejectionToMain(reasonMessage, "Worker script", stack, 1); + Local forwarded = thrown.IsEmpty() ? reason : thrown; + std::string forwardedStack = stack; + if (!thrown.IsEmpty()) { + forwardedStack = ""; + Local thrownStack; + if (thrown->IsObject() && + thrown.As() + ->Get(context, tns::ToV8String(isolate_, "stack")) + .ToLocal(&thrownStack) && + thrownStack->IsString()) { + forwardedStack = tns::ToString(isolate_, thrownStack); + } + } + std::string reasonMessage = tns::ToString(isolate_, forwarded); + worker->PassUncaughtRejectionToMain(reasonMessage, "Worker script", + forwardedStack, 1); } } } From 4f3942d29c0fd4f0508c5f076f719973ef479dbf Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Fri, 11 Sep 2026 11:31:53 -0300 Subject: [PATCH 13/18] docs(runtime): close-event timing per end, and start() does not pin a port on --- docs/worker-threads.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/worker-threads.md b/docs/worker-threads.md index 950f07580..1d409c2da 100644 --- a/docs/worker-threads.md +++ b/docs/worker-threads.md @@ -150,8 +150,9 @@ Closing behaves as one channel-wide event: the event.) - The `close` event reaches a port that was never started. Enabling is about *messages*; a port whose sibling died always learns about it. -- `close` orders behind whatever is already queued, on both ends — messages - already sent are still delivered first. +- On the port being closed the event fires synchronously, inside `close()`. + On the sibling it orders behind whatever was already queued to it, so + messages already sent are still delivered first. - `postMessage` on a closed port is a **silent no-op**. It still serializes: the transfer list's side effects and its errors do not depend on delivery, so a bad transfer list throws and a good one detaches its buffers, and only then @@ -169,8 +170,10 @@ Delivery follows HTML's port-enable rules rather than starting automatically: assignment, not the handler, that claims the listener slot. - It stops when the last `message` listener goes away, and messages queue again until one returns. -- `port.start()` forces delivery on regardless, for code that only uses - `addEventListener` and wants control over when the queue drains. +- `port.start()` enables delivery for code that only uses `addEventListener` + and wants control over when the queue drains. As in Node, it does not pin + the port on: removing the last `message` listener stops delivery again until + a listener returns or `start()` is called once more. - `receiveMessageOnPort(port)` bypasses all of it and pops one message synchronously. From c091498efa5a74a97ff11dedae59eb7049455305 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Fri, 11 Sep 2026 11:31:53 -0300 Subject: [PATCH 14/18] test(runtime): pin onclose, empty BroadcastChannel names, parentPort emitter, option forwarding, and error reporting --- TestRunner/app/tests/MessagingTests.js | 100 ++++++++++++++++++ .../app/tests/messaging/parentPortWorker.js | 7 ++ .../app/tests/messaging/rejectingWorker.js | 4 + .../app/tests/messaging/throwingWorker.js | 1 + 4 files changed, 112 insertions(+) create mode 100644 TestRunner/app/tests/messaging/parentPortWorker.js create mode 100644 TestRunner/app/tests/messaging/rejectingWorker.js create mode 100644 TestRunner/app/tests/messaging/throwingWorker.js diff --git a/TestRunner/app/tests/MessagingTests.js b/TestRunner/app/tests/MessagingTests.js index ab8b594d0..390e7aced 100644 --- a/TestRunner/app/tests/MessagingTests.js +++ b/TestRunner/app/tests/MessagingTests.js @@ -94,6 +94,106 @@ describe("Messaging runtime edges", function () { }); }); + describe("MessagePort surface", function () { + it("runs an onclose handler when the port is closed", function () { + var channel = new MessageChannel(); + var seen = null; + channel.port1.onclose = function (event) { seen = event.type; }; + channel.port1.close(); + expect(seen).toBe("close"); + channel.port2.close(); + }); + }); + + describe("BroadcastChannel", function () { + it("treats the empty name as a channel like any other", function (done) { + var a = new BroadcastChannel(""); + var b = new BroadcastChannel(""); + var c = new BroadcastChannel(""); + var got = []; + a.onmessage = function (event) { got.push(event.data); }; + b.close(); + setTimeout(function () { + c.postMessage("still open"); + setTimeout(function () { + expect(got).toEqual(["still open"]); + a.close(); + c.close(); + done(); + }, SETTLE); + }, SETTLE); + }); + }); + + describe("node:worker_threads", function () { + var wt = require("node:worker_threads"); + + it("exposes the emitter surface on parentPort", function (done) { + // The shim resolves the entry from the app root, not from the + // requiring test file, hence the ~/ form. + var worker = new wt.Worker("~/tests/messaging/parentPortWorker.js"); + var got = []; + worker.on("message", function (value) { + got.push(value); + if (got.length === 3) { + expect(got).toEqual([{ once: 1 }, { on: 1 }, { on: 2 }]); + worker.terminate(); + done(); + } + }); + worker.on("error", function (error) { + fail("worker error: " + error.message); + worker.terminate(); + done(); + }); + worker.postMessage(1); + worker.postMessage(2); + }); + + it("forwards the option bag to the runtime's Worker", function () { + expect(function () { + new wt.Worker("~/tests/messaging/parentPortWorker.js", { + resourceLimits: { maxOldGenerationSizeMb: "not a number" }, + }); + }).toThrowError(TypeError); + }); + }); + + describe("worker error reporting", function () { + it("reports an error the Worker object left unhandled to the parent scope", function (done) { + var seen = null; + var listener = function (event) { + seen = event; + event.preventDefault(); + }; + addEventListener("error", listener); + var worker = new Worker("./messaging/throwingWorker.js"); + setTimeout(function () { + removeEventListener("error", listener); + expect(seen).not.toBeNull(); + expect(seen.message).toContain("boom from worker"); + expect(seen.error instanceof Error).toBe(true); + worker.terminate(); + done(); + }, SETTLE); + }); + + it("forwards the error a throwing scope onerror raised for a rejection, once", function (done) { + var worker = new Worker("./messaging/rejectingWorker.js"); + var messages = []; + worker.onerror = function (event) { + messages.push(event.message); + event.preventDefault(); + }; + setTimeout(function () { + expect(messages.length).toBe(1); + expect(messages[0]).toContain("thrown by scope onerror"); + worker.terminate(); + done(); + }, SETTLE * 2); + }); + }); + describe("AbortSignal handler attribute accounting", function () { function pollGC(predicate, cb) { var turns = 0; diff --git a/TestRunner/app/tests/messaging/parentPortWorker.js b/TestRunner/app/tests/messaging/parentPortWorker.js new file mode 100644 index 000000000..c6c14bda4 --- /dev/null +++ b/TestRunner/app/tests/messaging/parentPortWorker.js @@ -0,0 +1,7 @@ +var parentPort = require("node:worker_threads").parentPort; +parentPort.once("message", function (value) { + parentPort.postMessage({ once: value }); +}); +parentPort.on("message", function (value) { + parentPort.postMessage({ on: value }); +}); diff --git a/TestRunner/app/tests/messaging/rejectingWorker.js b/TestRunner/app/tests/messaging/rejectingWorker.js new file mode 100644 index 000000000..1ff2eed06 --- /dev/null +++ b/TestRunner/app/tests/messaging/rejectingWorker.js @@ -0,0 +1,4 @@ +onerror = function () { + throw new Error("thrown by scope onerror"); +}; +Promise.reject(new Error("original rejection")); diff --git a/TestRunner/app/tests/messaging/throwingWorker.js b/TestRunner/app/tests/messaging/throwingWorker.js new file mode 100644 index 000000000..22d3ee93a --- /dev/null +++ b/TestRunner/app/tests/messaging/throwingWorker.js @@ -0,0 +1 @@ +throw new Error("boom from worker"); From a14af0ac2d27a45e0b0fbb877e71f45ee058e804 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Fri, 11 Sep 2026 12:03:02 -0300 Subject: [PATCH 15/18] fix(runtime): parentPort once() detaches its own registration, relays ports, and a throwing stack accessor only drops the stack A once() wrapper removed the first registration holding the same listener, which with on() and once() sharing a function could detach the wrong one and leave the once() wrapper firing forever; each registration is now its own entry and removeListener() takes the most recent, as Node does. The relay onto parentPort forwards event.ports. Reading `stack` off the error a scope onerror threw runs under a TryCatch so a throwing accessor cannot leave an exception pending past the rejection drain. --- NativeScript/runtime/NativeScriptException.mm | 3 ++ .../runtime/js/node-worker-threads.js | 33 +++++++++++---- TestRunner/app/tests/MessagingTests.js | 42 +++++++++++++++++++ .../tests/messaging/parentPortOnceWorker.js | 8 ++++ .../tests/messaging/parentPortPortsWorker.js | 4 ++ 5 files changed, 82 insertions(+), 8 deletions(-) create mode 100644 TestRunner/app/tests/messaging/parentPortOnceWorker.js create mode 100644 TestRunner/app/tests/messaging/parentPortPortsWorker.js diff --git a/NativeScript/runtime/NativeScriptException.mm b/NativeScript/runtime/NativeScriptException.mm index 14ae3f287..e3f8ebb98 100644 --- a/NativeScript/runtime/NativeScriptException.mm +++ b/NativeScript/runtime/NativeScriptException.mm @@ -660,7 +660,10 @@ static bool GiveWorkerOnErrorAChance(Isolate* isolate, Local context, L Local forwarded = thrown.IsEmpty() ? reason : thrown; std::string forwardedStack = stack; if (!thrown.IsEmpty()) { + // `stack` may be an accessor that throws; that only costs + // the stack, never the forward. forwardedStack = ""; + TryCatch stackTc(isolate_); Local thrownStack; if (thrown->IsObject() && thrown.As() diff --git a/NativeScript/runtime/js/node-worker-threads.js b/NativeScript/runtime/js/node-worker-threads.js index 36306894a..3fc99daf6 100644 --- a/NativeScript/runtime/js/node-worker-threads.js +++ b/NativeScript/runtime/js/node-worker-threads.js @@ -254,32 +254,49 @@ class ParentPort extends EventTarget { throw new TypeError('The "listener" argument must be of type function'); } const self = this; - const wrapper = function (event) { + const entry = { listener, wrapper: undefined }; + // A once registration detaches its own entry, not whichever entry happens + // to hold the same listener: the same function may be on() and once() at + // the same time. + entry.wrapper = function (event) { if (once) { - self.#remove(type, listener); + self.#removeEntry(type, entry); } const arg = type === "message" || type === "messageerror" ? event.data : event; FunctionPrototypeCall(listener, self, arg); }; const list = this.#wrappers[type] || (this.#wrappers[type] = []); - ArrayPrototypePush(list, { listener, wrapper }); - FunctionPrototypeCall(addEventListener, this, type, wrapper); + ArrayPrototypePush(list, entry); + FunctionPrototypeCall(addEventListener, this, type, entry.wrapper); return this; } + // Node removes the most recently added registration of a listener. #remove(type, listener) { const list = this.#wrappers[type]; if (list === undefined) { return; } - for (let i = 0; i < list.length; i++) { + for (let i = list.length - 1; i >= 0; i--) { if (list[i].listener === listener) { - FunctionPrototypeCall(removeEventListener, this, type, list[i].wrapper); - ArrayPrototypeSplice(list, i, 1); + this.#removeEntry(type, list[i]); return; } } } + + #removeEntry(type, entry) { + const list = this.#wrappers[type]; + if (list === undefined) { + return; + } + const index = ArrayPrototypeIndexOf(list, entry); + if (index === -1) { + return; + } + ArrayPrototypeSplice(list, index, 1); + FunctionPrototypeCall(removeEventListener, this, type, entry.wrapper); + } } defineEventHandler(ParentPort.prototype, "message"); @@ -298,7 +315,7 @@ if (!isMainThread) { FunctionPrototypeCall( dispatchEvent, parentPort, - new (getMessageEvent())(event.type, { data: event.data }) + new (getMessageEvent())(event.type, { data: event.data, ports: event.ports }) ); }; FunctionPrototypeCall(addEventListener, globalEventTarget, "message", relay); diff --git a/TestRunner/app/tests/MessagingTests.js b/TestRunner/app/tests/MessagingTests.js index 390e7aced..888840a44 100644 --- a/TestRunner/app/tests/MessagingTests.js +++ b/TestRunner/app/tests/MessagingTests.js @@ -150,6 +150,48 @@ describe("Messaging runtime edges", function () { worker.postMessage(2); }); + it("lets the same listener be on() and once() at the same time", function (done) { + var worker = new wt.Worker("~/tests/messaging/parentPortOnceWorker.js"); + var got = []; + worker.on("message", function (value) { + got.push(value); + if (got.length === 3) { + setTimeout(function () { + // Three messages: the once() registration fires only + // for the first, the on() one for all three. + expect(got).toEqual([1, 2, 3, 4]); + worker.terminate(); + done(); + }, SETTLE); + } + }); + worker.on("error", function (error) { + fail("worker error: " + error.message); + worker.terminate(); + done(); + }); + worker.postMessage("a"); + worker.postMessage("b"); + worker.postMessage("c"); + }); + + it("relays transferred ports to parentPort message events", function (done) { + var worker = new wt.Worker("~/tests/messaging/parentPortPortsWorker.js"); + var channel = new MessageChannel(); + worker.on("message", function (value) { + expect(value).toBe(1); + channel.port1.close(); + worker.terminate(); + done(); + }); + worker.on("error", function (error) { + fail("worker error: " + error.message); + worker.terminate(); + done(); + }); + worker.postMessage(channel.port2, [channel.port2]); + }); + it("forwards the option bag to the runtime's Worker", function () { expect(function () { new wt.Worker("~/tests/messaging/parentPortWorker.js", { diff --git a/TestRunner/app/tests/messaging/parentPortOnceWorker.js b/TestRunner/app/tests/messaging/parentPortOnceWorker.js new file mode 100644 index 000000000..a3a255fe5 --- /dev/null +++ b/TestRunner/app/tests/messaging/parentPortOnceWorker.js @@ -0,0 +1,8 @@ +var parentPort = require("node:worker_threads").parentPort; +var count = 0; +function listener() { + count++; + parentPort.postMessage(count); +} +parentPort.on("message", listener); +parentPort.once("message", listener); diff --git a/TestRunner/app/tests/messaging/parentPortPortsWorker.js b/TestRunner/app/tests/messaging/parentPortPortsWorker.js new file mode 100644 index 000000000..6873b1301 --- /dev/null +++ b/TestRunner/app/tests/messaging/parentPortPortsWorker.js @@ -0,0 +1,4 @@ +var parentPort = require("node:worker_threads").parentPort; +parentPort.addEventListener("message", function (event) { + parentPort.postMessage(event.ports.length); +}); From 25fc7818873207b890d5cf3517514e65b6cb83ff Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Fri, 11 Sep 2026 12:25:55 -0300 Subject: [PATCH 16/18] test(runtime): wait for the worker error instead of a fixed delay in the error-reporting specs --- TestRunner/app/tests/MessagingTests.js | 48 ++++++++++++++++++-------- 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/TestRunner/app/tests/MessagingTests.js b/TestRunner/app/tests/MessagingTests.js index 888840a44..59441b261 100644 --- a/TestRunner/app/tests/MessagingTests.js +++ b/TestRunner/app/tests/MessagingTests.js @@ -202,22 +202,38 @@ describe("Messaging runtime edges", function () { }); describe("worker error reporting", function () { + // A worker boots on its own thread, so the first error arrives whenever + // the runner gets to it; specs wait for it and only then settle for + // duplicates. + var originalTimeout; + beforeEach(function () { + originalTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL; + jasmine.DEFAULT_TIMEOUT_INTERVAL = 60000; + }); + afterEach(function () { + jasmine.DEFAULT_TIMEOUT_INTERVAL = originalTimeout; + }); + it("reports an error the Worker object left unhandled to the parent scope", function (done) { - var seen = null; + var seen = []; + var worker = null; var listener = function (event) { - seen = event; + seen.push(event); event.preventDefault(); + if (seen.length === 1) { + setTimeout(finish, SETTLE); + } }; - addEventListener("error", listener); - var worker = new Worker("./messaging/throwingWorker.js"); - setTimeout(function () { + var finish = function () { removeEventListener("error", listener); - expect(seen).not.toBeNull(); - expect(seen.message).toContain("boom from worker"); - expect(seen.error instanceof Error).toBe(true); + expect(seen.length).toBe(1); + expect(seen[0].message).toContain("boom from worker"); + expect(seen[0].error instanceof Error).toBe(true); worker.terminate(); done(); - }, SETTLE); + }; + addEventListener("error", listener); + worker = new Worker("./messaging/throwingWorker.js"); }); it("forwards the error a throwing scope onerror raised for a rejection, once", function (done) { @@ -226,13 +242,15 @@ describe("Messaging runtime edges", function () { worker.onerror = function (event) { messages.push(event.message); event.preventDefault(); + if (messages.length === 1) { + setTimeout(function () { + expect(messages.length).toBe(1); + expect(messages[0]).toContain("thrown by scope onerror"); + worker.terminate(); + done(); + }, SETTLE); + } }; - setTimeout(function () { - expect(messages.length).toBe(1); - expect(messages[0]).toContain("thrown by scope onerror"); - worker.terminate(); - done(); - }, SETTLE * 2); }); }); From d13571d8b135804417afaaff5a35e273dfeb3d82 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Fri, 11 Sep 2026 13:36:54 -0300 Subject: [PATCH 17/18] refactor(runtime): drop the host-object claim gate's messaging half The serializer claims custom host objects unconditionally now, so the "any ports or transfer brands exist" flag that extended the former gate has no reader. --- NativeScript/runtime/Messaging.cpp | 15 ++------------- NativeScript/runtime/Messaging.h | 5 ----- 2 files changed, 2 insertions(+), 18 deletions(-) diff --git a/NativeScript/runtime/Messaging.cpp b/NativeScript/runtime/Messaging.cpp index 785b14a91..e823ac355 100644 --- a/NativeScript/runtime/Messaging.cpp +++ b/NativeScript/runtime/Messaging.cpp @@ -44,7 +44,6 @@ struct MessagingState { // plain object in every graph. An isolate that has neither created a port // nor stamped a brand cannot be holding either, so it keeps serializing on // the cheap path. - bool claimHostObjects = false; }; // The isolate's Caches is invalidated long before ~Runtime reaches the point @@ -181,12 +180,8 @@ void StampBrand(const FunctionCallbackInfo& info, if (brand.IsEmpty()) { return; } - if (info[0] - .As() - ->SetPrivate(isolate->GetCurrentContext(), brand, v8::True(isolate)) - .FromMaybe(false)) { - State(isolate)->claimHostObjects = true; - } + (void)info[0].As()->SetPrivate(isolate->GetCurrentContext(), brand, + v8::True(isolate)); } } // namespace @@ -411,7 +406,6 @@ std::shared_ptr NativeMessagePort::New( wrapper->SetAlignedPointerInInternalField(0, port.get(), v8::kEmbedderDataTypeTagDefault); state->livePorts.insert(port); - state->claimHostObjects = true; if (data != nullptr) { port->data_ = std::move(data); @@ -746,11 +740,6 @@ MaybeLocal AdoptPort(Local context, return port->Wrapper(v8::Isolate::GetCurrent()); } -bool AnyPortsOrBrands(Isolate* isolate) { - MessagingState* state = State(isolate); - return state != nullptr && state->claimHostObjects; -} - Maybe IsMarkedUntransferable(Isolate* isolate, Local object) { Local brand = UntransferableBrand(isolate, false); if (brand.IsEmpty()) { diff --git a/NativeScript/runtime/Messaging.h b/NativeScript/runtime/Messaging.h index 60137f003..b296736ef 100644 --- a/NativeScript/runtime/Messaging.h +++ b/NativeScript/runtime/Messaging.h @@ -181,11 +181,6 @@ bool IsPortWrapper(v8::Isolate* isolate, v8::Local object); v8::MaybeLocal AdoptPort(v8::Local context, std::unique_ptr data); -// Whether this isolate has ever created a port or stamped a transfer brand. -// Gates the serializer's host-object claim: until one of those happens, no -// value in this isolate can need the messaging hooks. -bool AnyPortsOrBrands(v8::Isolate* isolate); - // The markAsUntransferable / markAsUncloneable brands. Both answer Just(false) // without creating anything when this isolate has never stamped one. v8::Maybe IsMarkedUntransferable(v8::Isolate* isolate, From 3641a56a22815fc3b726e12f43c0c3e63c3fde75 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Fri, 11 Sep 2026 14:00:45 -0300 Subject: [PATCH 18/18] fix(runtime): shim removeListener drops the newest registration; an adopted port is recorded before the list write can fail WorkerEmitter.removeListener scanned forwards while ParentPort and Node remove the most recently added registration. In Deserialize a port adopted successfully but not yet recorded was skipped by the failure cleanup when the ports array write failed, leaving it registered with no handle. --- NativeScript/runtime/StructuredSerialization.cpp | 11 ++++++++--- NativeScript/runtime/js/node-worker-threads.js | 3 ++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/NativeScript/runtime/StructuredSerialization.cpp b/NativeScript/runtime/StructuredSerialization.cpp index d38b677f6..789d94300 100644 --- a/NativeScript/runtime/StructuredSerialization.cpp +++ b/NativeScript/runtime/StructuredSerialization.cpp @@ -685,13 +685,18 @@ MaybeLocal SerializedValue::Deserialize(Isolate* isolate, for (size_t i = 0; i < transferredPorts_.size(); i++) { Local wrapper; if (!messaging::AdoptPort(context, std::move(transferredPorts_[i])) - .ToLocal(&wrapper) || - !list->Set(context, static_cast(i), wrapper) - .FromMaybe(false)) { + .ToLocal(&wrapper)) { CloseUnreachablePorts(isolate, ports, nullptr); return MaybeLocal(); } + // Recorded before anything else can fail: an adopted port that is not + // in this list would never be closed. ports.push_back(wrapper); + if (!list->Set(context, static_cast(i), wrapper) + .FromMaybe(false)) { + CloseUnreachablePorts(isolate, ports, nullptr); + return MaybeLocal(); + } } if (portList != nullptr) { *portList = list; diff --git a/NativeScript/runtime/js/node-worker-threads.js b/NativeScript/runtime/js/node-worker-threads.js index 3fc99daf6..7f26e5736 100644 --- a/NativeScript/runtime/js/node-worker-threads.js +++ b/NativeScript/runtime/js/node-worker-threads.js @@ -104,12 +104,13 @@ class WorkerEmitter { return this; } + // Node removes the most recently added registration of a listener. removeListener(type, listener) { const list = this.#listeners[`${type}`]; if (list === undefined) { return this; } - for (let i = 0; i < list.length; i++) { + for (let i = list.length - 1; i >= 0; i--) { if (list[i].listener === listener) { ArrayPrototypeSplice(list, i, 1); return this;