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/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..d9867e97 --- /dev/null +++ b/NativeScript/runtime/Messaging.cpp @@ -0,0 +1,1092 @@ +#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) { + // 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; + } + 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..e4ab1b6e 100644 --- a/NativeScript/runtime/Worker.h +++ b/NativeScript/runtime/Worker.h @@ -13,6 +13,23 @@ 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); + + // 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: @@ -22,8 +39,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..97db23f7 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,18 @@ namespace tns { +namespace { + +// 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 + std::vector Worker::GlobalFunctions = {"postMessage", "close"}; void Worker::Init(Isolate* isolate, Local globalTemplate) { @@ -49,6 +62,30 @@ 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); + + 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) { Isolate* isolate = info.GetIsolate(); Local context = isolate->GetCurrentContext(); @@ -355,17 +392,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 +408,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 +444,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 +460,56 @@ throw NativeScriptException( } } -void Worker::OnMessageCallback(Isolate* isolate, Local receiver, +void Worker::OnMessageCallback(Isolate* isolate, Local receiver, std::shared_ptr message) { + WorkerEventsState* state = Caches::StateFor(isolate); + if (state == nullptr || state->emitMessage.IsEmpty()) { + return; + } 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()) { - 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 onMessageFunc = onMessageValue.As(); + 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); +} - Local arg; - // TryCatch tc(isolate); - if (!message->Deserialize(isolate, context).ToLocal(&arg)) { - // tc.ReThrow(); - return; +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[1]{arg}; - success = onMessageFunc->Call(context, receiver, 1, args).ToLocal(&result); + 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) { diff --git a/NativeScript/runtime/WorkerWrapper.mm b/NativeScript/runtime/WorkerWrapper.mm index ff5a44e3..228f3321 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" @@ -104,8 +105,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 +116,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 +135,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); @@ -272,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) { @@ -391,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, @@ -452,68 +426,42 @@ 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 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); - } + [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(mainIsolate); + Worker::EmitError(mainIsolate, worker.As(), message, source, stackTrace, + lineNumber); + if (tc.HasCaught()) { + Local error = tc.Exception(); + Log(@"%s", tns::ToString(mainIsolate, error).c_str()); + mainIsolate->ThrowException(error); } }, async); } -Local WorkerWrapper::ConstructErrorObject(Local context, std::string message, - std::string source, std::string stackTrace, - int lineNumber) { - Isolate* isolate = v8::Isolate::GetCurrent(); - Local objTemplate = ObjectTemplate::New(isolate); - Local obj; - bool success = objTemplate->NewInstance(context).ToLocal(&obj); - tns::Assert(success, isolate); - - tns::Assert( - obj->Set(context, tns::ToV8String(isolate, "message"), tns::ToV8String(isolate, message)) - .FromMaybe(false), - isolate); - tns::Assert( - obj->Set(context, tns::ToV8String(isolate, "filename"), tns::ToV8String(isolate, source)) - .FromMaybe(false), - isolate); - tns::Assert(obj->Set(context, tns::ToV8String(isolate, "stackTrace"), - tns::ToV8String(isolate, stackTrace)) - .FromMaybe(false), - isolate); - tns::Assert( - obj->Set(context, tns::ToV8String(isolate, "lineno"), Number::New(isolate, lineNumber)) - .FromMaybe(false), - isolate); - - return obj; -} - std::atomic WorkerWrapper::nextId_(0); } // namespace tns diff --git a/NativeScript/runtime/js/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/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); 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..2bf47819 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,85 @@ 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, cancelOnTruthy) { + function eventHandler(event) { + if (typeof eventHandler.handler !== "function") { return; } + 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, cancelOnTruthy) { + 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, cancelOnTruthy); + FunctionPrototypeCall(addListener, this, event, wrapper); + return; + } + var wasActive = typeof wrapper.handler === "function"; + var isActive = typeof value === "function"; + wrapper.handler = value; + if (wasActive === isActive) { return; } + // Absolute, never cumulative: the wrapper holds its one slot for good, so + // the correction is all-or-nothing — a cleared handler cancels its slot + // out, an active one needs no correction. Accumulating instead drifts a + // count that never returns to zero, and the port/signal accounting built + // on it then never sees "no listeners left". + wrapper.delta = isActive ? 0 : -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 +323,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..0ea1e99b --- /dev/null +++ b/NativeScript/runtime/js/message-event.js @@ -0,0 +1,153 @@ +"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._stopPropagation = false; + this._stopImmediate = false; + 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..ecc3f089 --- /dev/null +++ b/NativeScript/runtime/js/worker-events.js @@ -0,0 +1,102 @@ +"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, 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 +// 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; +} + +// 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 +// 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 })); +} + +// 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"); +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 +// 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, emitError }; diff --git a/TestRunner/app/shared b/TestRunner/app/shared index 67da5fc0..a2ccd8c7 160000 --- a/TestRunner/app/shared +++ b/TestRunner/app/shared @@ -1 +1 @@ -Subproject commit 67da5fc0f692aafc6342c6e87b05ced3ee770c09 +Subproject commit a2ccd8c7b211f1e2a614bab84918e27f0181b5de 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..bef3f4f3 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 — 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. +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..0dbb04e9 --- /dev/null +++ b/docs/worker-threads.md @@ -0,0 +1,255 @@ +# 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`. + +### 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` + +`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`. +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 + +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 */,