From 14cd6b9c5c2b7ce3223e4072d18214bbe4c205dd Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 26 Aug 2026 15:09:16 -0300 Subject: [PATCH 1/4] 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 | 286 ++++- .../runtime/StructuredSerialization.h | 53 +- NativeScript/runtime/Worker.h | 12 +- NativeScript/runtime/Worker.mm | 103 +- 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, 3100 insertions(+), 156 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 148e7ec2..81ac7dd9 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 00000000..08b154e7 --- /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 00000000..60137f00 --- /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 cbfc747e..35631555 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 a5d7a58f..b31ffb06 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" @@ -286,6 +287,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()) { auto currentWorker = static_cast(Caches::Workers->Get(this->workerId_)->UserData()); @@ -435,6 +442,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 c016a10d..683ddb2e 100644 --- a/NativeScript/runtime/StructuredSerialization.cpp +++ b/NativeScript/runtime/StructuredSerialization.cpp @@ -123,24 +123,30 @@ 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) {} + domExceptions_(domExceptions), + transferPorts_(transferPorts), + uncloneableBrand_(messaging::UncloneableBrandIfAny(isolate)) {} void SetSerializer(ValueSerializer* serializer) { serializer_ = serializer; } @@ -153,19 +159,36 @@ class SerializerDelegate : public ValueSerializer::Delegate { // object in the graph — the cost of claiming a plain-JS class as a host // object is one private-symbol lookup per object (Node pays the same for // its JSTransferable protocol). Claimed only once this isolate has actually - // constructed a DOMException; until then serialization runs the pre-claim - // path untouched. V8 samples this once per ValueSerializer. Accepted edge: - // a getter invoked during this very clone could construct the isolate's - // FIRST DOMException and return it into the graph after a false sample — - // that one instance degrades to a plain object (the pre-feature behavior) - // instead of cloning; every later serialization sees the flag. + // constructed a DOMException, created a port or stamped a transfer brand; + // until then serialization runs the pre-claim path untouched. V8 samples + // this once per ValueSerializer. Accepted edge: a getter invoked during this + // very clone could construct the isolate's FIRST DOMException and return it + // into the graph after a false sample — that one instance degrades to a + // plain object (the pre-feature behavior) instead of cloning; every later + // serialization sees the flag. bool HasCustomHostObject(Isolate* isolate) override { - return AnyDomExceptionInstances(isolate); + return AnyDomExceptionInstances(isolate) || + messaging::AnyPortsOrBrands(isolate); } Maybe IsHostObject(Isolate* isolate, Local object) override { - // Only branded DOMException instances are claimed; native-backed wrappers - // keep reaching WriteHostObject through V8's embedder-field detection. + // Claiming custom host objects REPLACES V8's own embedder-field detection + // rather than adding to it, so anything with a native half has to be + // claimed here too — otherwise an ObjC wrapper would be written out as a + // plain object, silently losing the half that mattered. + 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); + } + } Local brand = DomExceptionBrand(isolate); if (brand.IsEmpty()) { return Just(false); @@ -174,6 +197,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. Local brand = DomExceptionBrand(isolate); @@ -223,6 +261,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 @@ -257,6 +320,8 @@ class SerializerDelegate : public ValueSerializer::Delegate { HostObjectPolicy hostObjectPolicy_; std::vector>* sharedBuffers_; std::vector* domExceptions_; + const PortList* transferPorts_; + Local uncloneableBrand_; ValueSerializer* serializer_ = nullptr; }; @@ -264,16 +329,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)) { @@ -292,6 +360,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(); } @@ -308,24 +383,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; } @@ -336,28 +415,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; } @@ -367,18 +495,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++) { @@ -396,6 +527,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. @@ -414,15 +557,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_) { @@ -466,7 +637,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); @@ -484,7 +678,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 ac176fdf..4d955af2 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 e8b6d12d..8c9e9458 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 4f2e14d5..058a3e5b 100644 --- a/NativeScript/runtime/Worker.mm +++ b/NativeScript/runtime/Worker.mm @@ -1,5 +1,6 @@ #include "Worker.h" #include +#include "BuiltinLoader.h" #include "Caches.h" #include "Constants.h" #include "Helpers.h" @@ -14,6 +15,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"}; void Worker::Init(Isolate* isolate, Local globalTemplate) { @@ -49,6 +60,24 @@ 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(); @@ -355,17 +384,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 @@ -379,8 +400,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); @@ -411,17 +436,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 @@ -435,31 +452,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; - // TryCatch tc(isolate); - if (!message->Deserialize(isolate, context).ToLocal(&arg)) { - // tc.ReThrow(); - return; + Local data; + Local ports; + const char* type = "message"; + { + TryCatch tc(isolate); + if (!message->Deserialize(isolate, context, &ports).ToLocal(&data)) { + if (tc.HasTerminated()) { + 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 ff5a44e3..74b83e0d 100644 --- a/NativeScript/runtime/WorkerWrapper.mm +++ b/NativeScript/runtime/WorkerWrapper.mm @@ -104,8 +104,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 @@ -117,6 +115,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) { @@ -124,7 +134,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 9795dfdb..c1bdcf73 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 00000000..3f4aa998 --- /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 7cb43312..03222732 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 00000000..bcf3406e --- /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 00000000..38771489 --- /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 00000000..3f1e49ca --- /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 c9710670..cec7a2e9 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 ff25ec28..511762d1 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 00000000..583024fc --- /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 67da5fc0..932e1fb0 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 946f799d..8ba1b195 100644 --- a/TestRunner/app/tests/RuntimeImplementedAPIs.js +++ b/TestRunner/app/tests/RuntimeImplementedAPIs.js @@ -95,3 +95,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 e0cbc0bc..eb0e6448 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 bf298c8b..99da558c 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 cd20129f..887984fc 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 00000000..ee331806 --- /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 e99fc31f..7ea50f6d 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 f3934564..e5929531 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 1afa9b63..adef64a9 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 */; }; @@ -478,6 +480,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 = ""; }; @@ -1562,6 +1566,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 */, @@ -1664,6 +1670,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 */, @@ -2312,6 +2319,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 486630275853eea1b9bc67d2c3197d7698a195fd Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 26 Aug 2026 16:03:48 -0300 Subject: [PATCH 2/4] 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 e04b3d47..d62ddcc0 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 65e8404c9faf4ad7781c7312e582328144109391 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 26 Aug 2026 16:04:09 -0300 Subject: [PATCH 3/4] 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 | 5 - NativeScript/runtime/Messaging.cpp | 6 +- NativeScript/runtime/Worker.h | 11 ++ NativeScript/runtime/Worker.mm | 29 ++++- NativeScript/runtime/WorkerWrapper.mm | 141 ++++++----------------- 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, 153 insertions(+), 141 deletions(-) diff --git a/NativeScript/runtime/DataWrapper.h b/NativeScript/runtime/DataWrapper.h index a72d37db..5ee3c1dd 100644 --- a/NativeScript/runtime/DataWrapper.h +++ b/NativeScript/runtime/DataWrapper.h @@ -616,11 +616,6 @@ class WorkerWrapper : public BaseDataWrapper { const std::string& source, const std::string& stackTrace, int lineNumber, bool async); - 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 08b154e7..d9867e97 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 8c9e9458..e4ab1b6e 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 058a3e5b..97db23f7 100644 --- a/NativeScript/runtime/Worker.mm +++ b/NativeScript/runtime/Worker.mm @@ -17,10 +17,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 @@ -73,9 +75,15 @@ 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) { @@ -487,6 +495,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 74b83e0d..3ea6b2ba 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" @@ -282,36 +283,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) { @@ -401,41 +399,7 @@ static void PostToRuntimeLoop(Runtime* runtime, std::function fn, bool a } } - auto runtime = static_cast(mainIsolate_->GetData(Constants::RUNTIME_SLOT)); - if (runtime == nullptr) { - return; - } - PostToRuntimeLoop( - runtime, - [this, message, src, stackTrace, lineNumber]() { - v8::Locker locker(this->mainIsolate_); - Isolate::Scope isolate_scope(this->mainIsolate_); - HandleScope handle_scope(this->mainIsolate_); - Local worker = this->poWorker_->Get(this->mainIsolate_).As(); - Local context = Caches::Get(this->mainIsolate_)->GetContext(); - - Local onErrorVal; - bool success = worker->Get(context, tns::ToV8String(this->mainIsolate_, "onerror")) - .ToLocal(&onErrorVal); - tns::Assert(success, this->mainIsolate_); - - if (!onErrorVal.IsEmpty() && onErrorVal->IsFunction()) { - Local onErrorFunc = onErrorVal.As(); - Local arg = - this->ConstructErrorObject(context, message, src, stackTrace, lineNumber); - Local args[1] = {arg}; - Local result; - TryCatch tc(this->mainIsolate_); - bool success = onErrorFunc->Call(context, v8::Undefined(this->mainIsolate_), 1, args) - .ToLocal(&result); - if (!success && tc.HasCaught()) { - Local error = tc.Exception(); - Log(@"%s", tns::ToString(this->mainIsolate_, error).c_str()); - this->mainIsolate_->ThrowException(error); - } - } - }, - async); + this->ForwardErrorPayloadToMain(message, src, stackTrace, lineNumber, async); } void WorkerWrapper::PassUncaughtExceptionFromWorkerToMain(const std::string& message, @@ -468,62 +432,25 @@ static void PostToRuntimeLoop(Runtime* runtime, std::function fn, bool a v8::Locker locker(this->mainIsolate_); Isolate::Scope isolate_scope(this->mainIsolate_); HandleScope handle_scope(this->mainIsolate_); - Local context = Caches::Get(this->mainIsolate_)->GetContext(); - Local worker = this->poWorker_->Get(this->mainIsolate_).As(); - - Local onErrorVal; - bool success = worker->Get(context, tns::ToV8String(this->mainIsolate_, "onerror")) - .ToLocal(&onErrorVal); - tns::Assert(success, this->mainIsolate_); - - if (!onErrorVal.IsEmpty() && onErrorVal->IsFunction()) { - Local onErrorFunc = onErrorVal.As(); - Local arg = - this->ConstructErrorObject(context, message, source, stackTrace, lineNumber); - Local args[1] = {arg}; - Local result; - TryCatch tc(this->mainIsolate_); - bool success = onErrorFunc->Call(context, v8::Undefined(this->mainIsolate_), 1, args) - .ToLocal(&result); - if (!success && tc.HasCaught()) { - Local error = tc.Exception(); - Log(@"%s", tns::ToString(this->mainIsolate_, error).c_str()); - this->mainIsolate_->ThrowException(error); - } + Local worker = this->poWorker_->Get(this->mainIsolate_); + if (worker.IsEmpty() || !worker->IsObject()) { + // The parent dropped its reference to the worker object; there is + // nothing left to dispatch on. + return; + } + + TryCatch tc(this->mainIsolate_); + Worker::EmitError(this->mainIsolate_, worker.As(), message, source, stackTrace, + lineNumber); + if (tc.HasCaught()) { + Local error = tc.Exception(); + Log(@"%s", tns::ToString(this->mainIsolate_, error).c_str()); + this->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 03222732..2bf47819 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 38771489..0ea1e99b 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 583024fc..ecc3f089 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 932e1fb0..a2ccd8c7 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 887984fc..bef3f4f3 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 ee331806..0dbb04e9 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 80c918feaa904923bd88e684d981f6e9db991bed Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 26 Aug 2026 22:18:09 -0300 Subject: [PATCH 4/4] fix(runtime): error-forwarding entry must not capture the worker wrapper The posted lambda ran on the parent loop after the worker thread's teardown may have deleted the wrapper it captured. The isolate pointer and the persistent travel by value instead; the shared_ptr keeps the Persistent alive and a teardown-reset handle takes the existing empty-worker bail. --- NativeScript/runtime/WorkerWrapper.mm | 29 ++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/NativeScript/runtime/WorkerWrapper.mm b/NativeScript/runtime/WorkerWrapper.mm index 3ea6b2ba..228f3321 100644 --- a/NativeScript/runtime/WorkerWrapper.mm +++ b/NativeScript/runtime/WorkerWrapper.mm @@ -426,26 +426,37 @@ static void PostToRuntimeLoop(Runtime* runtime, std::function fn, bool a if (runtime == nullptr) { return; } + // Captured by value, never `this`: the wrapper dies with the worker thread's + // teardown while this entry may still be queued on the parent's loop. The + // shared_ptr keeps the Persistent object alive; a teardown-reset handle + // surfaces as the empty-worker bail below. The isolate pointer stays valid + // for as long as the loop runs entries — the loop shuts down before the + // isolate is disposed, and posts after that are dropped. + Isolate* mainIsolate = this->mainIsolate_; + std::shared_ptr> poWorker = this->poWorker_; + if (poWorker == nullptr) { + return; + } PostToRuntimeLoop( runtime, - [this, message, source, stackTrace, lineNumber]() { - v8::Locker locker(this->mainIsolate_); - Isolate::Scope isolate_scope(this->mainIsolate_); - HandleScope handle_scope(this->mainIsolate_); - Local worker = this->poWorker_->Get(this->mainIsolate_); + [mainIsolate, poWorker, message, source, stackTrace, lineNumber]() { + v8::Locker locker(mainIsolate); + Isolate::Scope isolate_scope(mainIsolate); + HandleScope handle_scope(mainIsolate); + Local worker = poWorker->Get(mainIsolate); if (worker.IsEmpty() || !worker->IsObject()) { // The parent dropped its reference to the worker object; there is // nothing left to dispatch on. return; } - TryCatch tc(this->mainIsolate_); - Worker::EmitError(this->mainIsolate_, worker.As(), message, source, stackTrace, + TryCatch tc(mainIsolate); + Worker::EmitError(mainIsolate, worker.As(), message, source, stackTrace, lineNumber); if (tc.HasCaught()) { Local error = tc.Exception(); - Log(@"%s", tns::ToString(this->mainIsolate_, error).c_str()); - this->mainIsolate_->ThrowException(error); + Log(@"%s", tns::ToString(mainIsolate, error).c_str()); + mainIsolate->ThrowException(error); } }, async);