From b11b360fbb7430d5efd577e2498ab4c17ec5b1f4 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 12 Aug 2026 13:05:54 -0300 Subject: [PATCH 01/10] feat: per-runtime event loop pumping v8 platform foreground tasks Wrap the default platform in NativeScriptPlatform and serve GetForegroundTaskRunner from a per-isolate EventLoop bound to the runtime's CFRunLoop, so v8 foreground tasks (Atomics.waitAsync wakeups, GC tasks, streaming-compilation merge-backs) actually run instead of sitting in never-pumped libplatform queues. Every non-bare entry ends with a microtask checkpoint, fixing the kAuto stall for promises resolved from pure native. Two lanes by ordering contract: the ordered lane rides CFRunLoopPerformBlock tokens (FIFO with foreign performed blocks) and backs the new __ns__queueMacrotask seam; the internal lane rides a version-0 CFRunLoopSource that runs one due entry per pass and re-signals while more are due, hosting v8 tasks, worker->parent messages and the deferred-throw sites. The registry survives isolate-pointer reuse across worker churn via matched erase plus refresh-on-create, and the v8 runner resolves its loop per post. Migrated 1:1 onto the loop: Worker/WorkerWrapper ExecuteOnRunLoop call sites (internal lane), ClassBuilder's cross-thread GcProtect/GcUnprotect and NativeScriptException's deferred @throw blocks (bare entries, own ceremony, outside the loop's exception guard), and both inspector pause loops (PumpMessageLoop -> RunNestableV8Tasks, nestable-only, bounded). --- NativeScript/inspector/JsV8InspectorClient.mm | 7 +- .../inspector/WorkerInspectorClient.mm | 6 +- NativeScript/runtime/Caches.h | 3 + NativeScript/runtime/ClassBuilder.mm | 12 +- NativeScript/runtime/EventLoop.h | 216 ++++++++ NativeScript/runtime/EventLoop.mm | 482 ++++++++++++++++++ NativeScript/runtime/NativeScriptException.mm | 16 +- NativeScript/runtime/NativeScriptPlatform.h | 112 ++++ NativeScript/runtime/NativeScriptPlatform.mm | 184 +++++++ NativeScript/runtime/Runtime.h | 10 + NativeScript/runtime/Runtime.mm | 60 ++- NativeScript/runtime/Worker.mm | 2 +- NativeScript/runtime/WorkerWrapper.mm | 30 +- v8ios.xcodeproj/project.pbxproj | 12 + 14 files changed, 1129 insertions(+), 23 deletions(-) create mode 100644 NativeScript/runtime/EventLoop.h create mode 100644 NativeScript/runtime/EventLoop.mm create mode 100644 NativeScript/runtime/NativeScriptPlatform.h create mode 100644 NativeScript/runtime/NativeScriptPlatform.mm diff --git a/NativeScript/inspector/JsV8InspectorClient.mm b/NativeScript/inspector/JsV8InspectorClient.mm index 0737d7e2..5648715c 100644 --- a/NativeScript/inspector/JsV8InspectorClient.mm +++ b/NativeScript/inspector/JsV8InspectorClient.mm @@ -13,6 +13,7 @@ #include "Helpers.h" #include "InspectorServer.h" #include "JsV8InspectorClient.h" +#include "NativeScriptPlatform.h" #include "RuntimeConfig.h" #include "WorkerInspectorClient.h" #include "include/libplatform/libplatform.h" @@ -436,9 +437,9 @@ bool ShouldRewriteSourceMapURLs() { shouldWait = true; } - std::shared_ptr platform = tns::Runtime::GetPlatform(); - Isolate* isolate = isolate_; - platform::PumpMessageLoop(platform.get(), isolate, platform::MessageLoopBehavior::kDoNotWait); + // JS frames are on the stack, so only nestable v8 foreground tasks may + // run; everything else fires from its own wakeup after resume + tns::NativeScriptPlatform::Instance()->GetEventLoop(isolate_)->RunNestableV8Tasks(); if (shouldWait && !terminated_) { dispatch_semaphore_wait(messageArrived_, dispatch_time(DISPATCH_TIME_NOW, 1 * NSEC_PER_MSEC)); // 1ms diff --git a/NativeScript/inspector/WorkerInspectorClient.mm b/NativeScript/inspector/WorkerInspectorClient.mm index 4c7c5aea..5772d807 100644 --- a/NativeScript/inspector/WorkerInspectorClient.mm +++ b/NativeScript/inspector/WorkerInspectorClient.mm @@ -8,6 +8,7 @@ #include "Caches.h" #include "Helpers.h" #include "JsV8InspectorClient.h" +#include "NativeScriptPlatform.h" #include "include/libplatform/libplatform.h" #include "utils.h" @@ -190,8 +191,9 @@ StringView Make8BitStringView(const std::string& value) { this->DispatchOne(message); } - std::shared_ptr platform = tns::Runtime::GetPlatform(); - platform::PumpMessageLoop(platform.get(), isolate_, platform::MessageLoopBehavior::kDoNotWait); + // JS frames are on the stack, so only nestable v8 foreground tasks may + // run; everything else fires from its own wakeup after resume + tns::NativeScriptPlatform::Instance()->GetEventLoop(isolate_)->RunNestableV8Tasks(); if (shouldWait && !pauseTerminated_ && !dying_) { dispatch_semaphore_wait(messageArrived_, dispatch_time(DISPATCH_TIME_NOW, 1 * NSEC_PER_MSEC)); // 1ms diff --git a/NativeScript/runtime/Caches.h b/NativeScript/runtime/Caches.h index 68c7f1b7..57bc2c5c 100644 --- a/NativeScript/runtime/Caches.h +++ b/NativeScript/runtime/Caches.h @@ -81,6 +81,9 @@ class Caches { void SetContext(v8::Local context); v8::Local GetContext(); + // GetContext crashes before SetContext; work can run in that window (v8 + // posts foreground tasks during Isolate::New) + inline bool HasContext() { return context_ != nullptr; } // Per-isolate unhandled promise rejection tracking. Fed by // NativeScriptException::OnPromiseRejected and drained once per runloop turn. diff --git a/NativeScript/runtime/ClassBuilder.mm b/NativeScript/runtime/ClassBuilder.mm index 61a24d11..012f1fdc 100644 --- a/NativeScript/runtime/ClassBuilder.mm +++ b/NativeScript/runtime/ClassBuilder.mm @@ -321,7 +321,7 @@ void ScopeClassNameToIsolate(std::string& name, int isolateId) { auto runtime = Runtime::GetRuntime(isolate); auto runtimeLoop = runtime->RuntimeLoop(); void* weakSelf = (__bridge void*)self; - auto gcProtect = ^() { + auto gcProtect = [isolateWrapper, weakSelf, isolate]() { auto innerCache = isolateWrapper.GetCache(); auto it = innerCache->Instances.find((id)weakSelf); if (it != innerCache->Instances.end()) { @@ -337,7 +337,9 @@ void ScopeClassNameToIsolate(std::string& name, int isolateId) { } }; if (CFRunLoopGetCurrent() != runtimeLoop) { - tns::ExecuteOnRunLoop(runtimeLoop, gcProtect); + // bare entry: the closure does its own Locker ceremony, exactly + // like the performed block it replaces + runtime->GetEventLoop()->PostInternalBare(gcProtect); } else { gcProtect(); } @@ -357,7 +359,7 @@ void ScopeClassNameToIsolate(std::string& name, int isolateId) { if ([self retainCount] == 2) { void* weakSelf = (__bridge void*)self; - auto gcUnprotect = ^() { + auto gcUnprotect = [isolateWrapper, weakSelf, isolate]() { auto innerCache = isolateWrapper.GetCache(); auto it = innerCache->Instances.find((id)weakSelf); if (it != innerCache->Instances.end()) { @@ -377,7 +379,9 @@ void ScopeClassNameToIsolate(std::string& name, int isolateId) { auto runtime = Runtime::GetRuntime(isolate); auto runtimeLoop = runtime->RuntimeLoop(); if (CFRunLoopGetCurrent() != runtimeLoop) { - tns::ExecuteOnRunLoop(runtimeLoop, gcUnprotect); + // bare entry: the closure does its own Locker ceremony, exactly + // like the performed block it replaces + runtime->GetEventLoop()->PostInternalBare(gcUnprotect); } else { auto innerCache = isolateWrapper.GetCache(); auto it = innerCache->Instances.find(self); diff --git a/NativeScript/runtime/EventLoop.h b/NativeScript/runtime/EventLoop.h new file mode 100644 index 00000000..a3489fef --- /dev/null +++ b/NativeScript/runtime/EventLoop.h @@ -0,0 +1,216 @@ +#ifndef EventLoop_h +#define EventLoop_h + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "Common.h" +#include "v8-platform.h" + +namespace tns { + +/** + * A producer of ordered-lane work that keeps its own bookkeeping (Timers). + * The EventLoop's token drain consults it so timers and ordered entries form + * ONE due-ordered domain: each anonymous token runs the earliest due item + * across both. Home-thread only. + */ +class OrderedTaskSource { + public: + /** + * If the source's earliest item is due at `now` and is earlier-or-equal to + * `otherDue` (a negative otherDue means no competitor), consumes that slot - + * running the item, or nothing if the slot is a tombstone (cancelled item) - + * and returns true. Consumes exactly one slot per call so tokens and slots + * stay 1:1. Check and run form a single call so the source can do both under + * one acquisition of whatever guards its state (Timers' bookkeeping is + * guarded by the isolate Locker). + */ + virtual bool RunIfEarliest(double now, double otherDue) = 0; + + virtual ~OrderedTaskSource() = default; +}; + +/** + * Per-runtime scheduler for work that must run on the runtime's home thread. + * Two lanes, split by ordering contract (the iOS port of the Android + * runtime's EventLoop; both descend from ExecuteOnRunLoop): + * + * Ordered lane - work whose ordering is observable against other app-level + * runloop work (spec'd macrotasks, JS timers). Due-now posts ride + * CFRunLoopPerformBlock as anonymous "task due" tokens, so they are strictly + * FIFO with foreign performed blocks on the same runloop; future-due tokens + * (delayed posts and Timers' tokens) arrive through one CFRunLoopTimer armed + * at the earliest pending due time. Each token runs the earliest due item + * across the ordered entries and the OrderedTaskSource (Timers) - one + * due-ordered domain, and a leftover token is a cheap no-op. + * + * Internal lane - work in its own ordering domain: v8 platform foreground + * tasks (Atomics.waitAsync wakeups, GC tasks, streaming-compilation + * merge-backs), worker->parent messages and drains. Rides a version-0 + * CFRunLoopSource plus one CFRunLoopTimer for delayed work. The source's + * perform callback runs exactly one due entry and re-signals itself while + * more are due, so bursts interleave with other runloop work instead of + * draining in one go. + * + * Posts are accepted from any thread. The loop starts unbound and buffers + * (v8 requests its task runner during Isolate::New, before the home thread is + * committed); BindToCurrentThread attaches both lanes and flushes. Posts + * after Shutdown are dropped (Post* returns false), preserving the + * "message to a terminated runtime" semantics of the mechanisms this + * replaces. Producers only post; entries run exclusively on the home thread, + * which is the only place the isolate's Locker is taken. + */ +class EventLoop : public std::enable_shared_from_this { + public: + explicit EventLoop(v8::Isolate* isolate) : isolate_(isolate) {} + + ~EventLoop(); + + /** + * Attaches both lanes to the calling thread's CFRunLoop and flushes work + * buffered before the bind. Must run on the runtime's home thread, before + * its runloop starts dispatching. + */ + void BindToCurrentThread(); + + /** + * Drops all queued work and detaches both lanes; posts after this are + * dropped. Must run on the home thread (invalidating runloop sources + * concurrently with a callback dispatch is racy), before the isolate is + * disposed. + */ + void Shutdown(); + + // ordered lane: strictly FIFO with CFRunLoopPerformBlock work on the home + // runloop. Returns false if the post was dropped (loop already shut down). + bool PostOrdered(std::function fn); + bool PostOrderedDelayed(std::function fn, double delayMs); + + /** + * Posts a bare ordered token due at an absolute CLOCK_MONOTONIC time + * (NowMs() units) for an item the OrderedTaskSource keeps in its own + * bookkeeping (Timers). One token per item; the drain picks the earliest + * due item across the source and the ordered entries, so the token needn't + * name what it will run. + */ + void PostOrderedToken(double dueTimeMs); + + /** + * Registers the ordered lane's external source. Home thread only; pass + * nullptr to unregister (the source is being destroyed). + */ + void SetTimerSource(OrderedTaskSource* source); + + // internal lane: runs on the home thread as soon as the runloop polls. + // Returns false if the post was dropped (loop already shut down). + bool PostInternal(std::function fn); + bool PostInternalDelayed(std::function fn, double delayMs); + + /** + * Internal-lane post whose fn does its OWN isolate ceremony: RunEntry skips + * the loop's Locker/scopes/microtask checkpoint. Required when the fn locks + * a different isolate than this loop's, or must run with no V8 scopes on + * the stack at all (NativeScriptException's deferred @throw) - bare entries + * also run outside the loop's exception guard so an NSException unwinds + * into the runloop frame exactly like a CFRunLoopPerformBlock did. + */ + bool PostInternalBare(std::function fn); + + /** + * Posts a v8 foreground task into the internal lane. Called by the + * platform's per-isolate v8::TaskRunner adapter, from any thread. + */ + void PostV8Task(std::unique_ptr task, bool nestable, + double delaySeconds); + + /** + * True once Shutdown ran. A stopped loop found in the platform registry for + * a (reused) isolate pointer is stale and must be replaced. + */ + bool IsStopped(); + + /** + * Runs the internal-lane v8 tasks that are due and nestable, bounded to the + * entries present at call time. For nested message loops (inspector pause) + * where the runloop isn't polling: JS is on the stack, so non-nestable + * tasks and plain function posts stay queued and run from their own wakeups + * after the loop unwinds. + */ + void RunNestableV8Tasks(); + + /** + * Runs at most one due ordered-lane item (an entry, a timer, or a + * tombstone), then performs a microtask checkpoint for entries. Invoked + * once per token, on the home thread. + */ + void RunOrderedTask(); + + // CLOCK_MONOTONIC milliseconds - the clock every due time and token is on + static double NowMs(); + + private: + struct Entry { + // exactly one of task/fn is set; fn entries are never drained by + // RunNestableV8Tasks + std::unique_ptr task; + std::function fn; + bool nestable; + // bare entries run without the loop's Locker/scopes/checkpoint/guard + bool bare = false; + // enqueue time for immediate entries, due time for delayed ones, so one + // comparison orders both queues + double time = 0; + }; + struct Lane { + std::deque immediate; + std::multimap delayed; + }; + + // all *Locked members require mutex_ to be held + void PostInternalLocked(Entry entry, double delayMs); + void PostOrderedLocked(Entry entry, double delayMs); + void PostOrderedTokenLocked(double dueTimeMs, double now); + void PostTokenBlockLocked(); + static std::unique_ptr TakeDueLocked(Lane& lane, bool nestableOnly, + bool v8Only, double now); + // earliest due entry time in the lane, or a negative value if none is due + static double PeekDueLocked(Lane& lane, double now); + static bool HasDueLocked(Lane& lane, double now); + void SignalInternalLocked(); + void ArmInternalTimerLocked(double now); + void ArmOrderedTimerLocked(double now); + void RunEntry(Entry& entry); + void RunOneInternal(); + + static void InternalSourcePerform(void* info); + static void InternalTimerFired(CFRunLoopTimerRef timer, void* info); + static void OrderedTimerFired(CFRunLoopTimerRef timer, void* info); + + v8::Isolate* isolate_; + std::mutex mutex_; + Lane internal_; + Lane ordered_; + // ordered-lane source with its own bookkeeping (Timers); home-thread only + OrderedTaskSource* timerSource_ = nullptr; + // future-due ordered tokens awaiting orderedTimer_ + std::multiset pendingTokens_; + // tokens posted before the bind; flushed by BindToCurrentThread + std::vector bufferedTokens_; + CFRunLoopRef loop_ = nullptr; + CFRunLoopSourceRef internalSource_ = nullptr; + CFRunLoopTimerRef internalTimer_ = nullptr; + CFRunLoopTimerRef orderedTimer_ = nullptr; + bool stopped_ = false; +}; + +} // namespace tns + +#endif /* EventLoop_h */ diff --git a/NativeScript/runtime/EventLoop.mm b/NativeScript/runtime/EventLoop.mm new file mode 100644 index 00000000..af53a5d8 --- /dev/null +++ b/NativeScript/runtime/EventLoop.mm @@ -0,0 +1,482 @@ +#include "EventLoop.h" + +#include +#include +#include + +#include "Caches.h" +#include "Helpers.h" +#include "NativeScriptException.h" + +using namespace v8; + +namespace { + +// far enough that an "unarmed" repeating timer never fires; re-armed with +// CFRunLoopTimerSetNextFireDate when a real deadline exists +const CFTimeInterval kNeverFireInterval = 1.0e10; + +// runs one unit of non-bare work without letting a C++ exception escape into +// a CFRunLoop callback frame. Deliberately no catch(...): on Darwin it would +// also swallow NSExceptions, and bare entries (which may @throw on purpose) +// never come through here anyway. +template +void RunGuarded(F&& body) { + try { + body(); + } catch (tns::NativeScriptException& ex) { + Log(@"NativeScript: uncaught NativeScriptException in event loop task: %s", + ex.getMessage().c_str()); + } catch (std::exception& ex) { + Log(@"NativeScript: c++ exception in event loop task: %s", ex.what()); + } +} + +} // namespace + +namespace tns { + +double EventLoop::NowMs() { + struct timespec res; + clock_gettime(CLOCK_MONOTONIC, &res); + return 1000.0 * res.tv_sec + (double)res.tv_nsec / 1e6; +} + +// converts a CLOCK_MONOTONIC due time to a CFRunLoopTimer fire date. The two +// clocks can drift (CFAbsoluteTime is wall-based), so consumers must treat a +// fire as "check what is due now", never as proof a specific item is due. +static CFAbsoluteTime FireDateFor(double dueMs, double nowMs) { + return CFAbsoluteTimeGetCurrent() + std::max(0.0, dueMs - nowMs) / 1000.0; +} + +void EventLoop::BindToCurrentThread() { + std::lock_guard lock(mutex_); + if (loop_ != nullptr || stopped_) { + return; + } + + loop_ = CFRunLoopGetCurrent(); + + CFRunLoopSourceContext sourceContext = { + 0, this, nullptr, nullptr, nullptr, + nullptr, nullptr, nullptr, nullptr, &EventLoop::InternalSourcePerform}; + internalSource_ = CFRunLoopSourceCreate(kCFAllocatorDefault, 0, &sourceContext); + CFRunLoopAddSource(loop_, internalSource_, kCFRunLoopCommonModes); + + CFRunLoopTimerContext timerContext = {0, this, nullptr, nullptr, nullptr}; + internalTimer_ = + CFRunLoopTimerCreate(kCFAllocatorDefault, CFAbsoluteTimeGetCurrent() + kNeverFireInterval, + kNeverFireInterval, 0, 0, &EventLoop::InternalTimerFired, &timerContext); + CFRunLoopAddTimer(loop_, internalTimer_, kCFRunLoopCommonModes); + orderedTimer_ = + CFRunLoopTimerCreate(kCFAllocatorDefault, CFAbsoluteTimeGetCurrent() + kNeverFireInterval, + kNeverFireInterval, 0, 0, &EventLoop::OrderedTimerFired, &timerContext); + CFRunLoopAddTimer(loop_, orderedTimer_, kCFRunLoopCommonModes); + + // flush work buffered before the home thread was known + auto now = NowMs(); + if (HasDueLocked(internal_, now)) { + SignalInternalLocked(); + } + ArmInternalTimerLocked(now); + auto tokens = std::move(bufferedTokens_); + bufferedTokens_.clear(); + for (double due : tokens) { + PostOrderedTokenLocked(due, now); + } +} + +void EventLoop::Shutdown() { + std::lock_guard lock(mutex_); + if (stopped_) { + return; + } + stopped_ = true; + internal_.immediate.clear(); + internal_.delayed.clear(); + ordered_.immediate.clear(); + ordered_.delayed.clear(); + pendingTokens_.clear(); + bufferedTokens_.clear(); + if (internalSource_ != nullptr) { + CFRunLoopSourceInvalidate(internalSource_); + CFRelease(internalSource_); + internalSource_ = nullptr; + } + if (internalTimer_ != nullptr) { + CFRunLoopTimerInvalidate(internalTimer_); + CFRelease(internalTimer_); + internalTimer_ = nullptr; + } + if (orderedTimer_ != nullptr) { + CFRunLoopTimerInvalidate(orderedTimer_); + CFRelease(orderedTimer_); + orderedTimer_ = nullptr; + } + loop_ = nullptr; +} + +EventLoop::~EventLoop() { + // Normally a no-op: ~Runtime already shut the loop down on the home thread. + // A transient shared_ptr taken on a foreign posting thread can be the last + // reference only after that shutdown, when this is just member cleanup. + Shutdown(); +} + +void EventLoop::PostInternalLocked(Entry entry, double delayMs) { + auto now = NowMs(); + if (delayMs <= 0) { + entry.time = now; + internal_.immediate.push_back(std::move(entry)); + SignalInternalLocked(); + } else { + auto due = now + delayMs; + entry.time = due; + internal_.delayed.emplace(due, std::move(entry)); + ArmInternalTimerLocked(now); + } +} + +void EventLoop::PostOrderedLocked(Entry entry, double delayMs) { + auto now = NowMs(); + if (delayMs <= 0) { + entry.time = now; + ordered_.immediate.push_back(std::move(entry)); + PostOrderedTokenLocked(now, now); + } else { + auto due = now + delayMs; + entry.time = due; + ordered_.delayed.emplace(due, std::move(entry)); + PostOrderedTokenLocked(due, now); + } +} + +void EventLoop::PostTokenBlockLocked() { + // the block can outlive this object (a performed block cannot be + // cancelled), so it holds a weak reference and no-ops once the loop is gone + std::weak_ptr weakSelf = weak_from_this(); + CFRunLoopPerformBlock(loop_, kCFRunLoopCommonModes, ^{ + if (auto self = weakSelf.lock()) { + self->RunOrderedTask(); + } + }); + CFRunLoopWakeUp(loop_); +} + +void EventLoop::PostOrderedTokenLocked(double dueTimeMs, double now) { + if (loop_ == nullptr) { + bufferedTokens_.push_back(dueTimeMs); + return; + } + if (dueTimeMs <= now) { + PostTokenBlockLocked(); + } else { + pendingTokens_.insert(dueTimeMs); + ArmOrderedTimerLocked(now); + } +} + +bool EventLoop::PostInternal(std::function fn) { + std::lock_guard lock(mutex_); + if (stopped_) { + return false; + } + PostInternalLocked(Entry{nullptr, std::move(fn), true, false, 0}, 0); + return true; +} + +bool EventLoop::PostInternalDelayed(std::function fn, double delayMs) { + std::lock_guard lock(mutex_); + if (stopped_) { + return false; + } + PostInternalLocked(Entry{nullptr, std::move(fn), true, false, 0}, delayMs); + return true; +} + +bool EventLoop::PostInternalBare(std::function fn) { + std::lock_guard lock(mutex_); + if (stopped_) { + return false; + } + PostInternalLocked(Entry{nullptr, std::move(fn), true, true, 0}, 0); + return true; +} + +bool EventLoop::PostOrdered(std::function fn) { + std::lock_guard lock(mutex_); + if (stopped_) { + return false; + } + PostOrderedLocked(Entry{nullptr, std::move(fn), true, false, 0}, 0); + return true; +} + +bool EventLoop::PostOrderedDelayed(std::function fn, double delayMs) { + std::lock_guard lock(mutex_); + if (stopped_) { + return false; + } + PostOrderedLocked(Entry{nullptr, std::move(fn), true, false, 0}, delayMs); + return true; +} + +void EventLoop::PostOrderedToken(double dueTimeMs) { + std::lock_guard lock(mutex_); + if (stopped_) { + return; + } + PostOrderedTokenLocked(dueTimeMs, NowMs()); +} + +void EventLoop::SetTimerSource(OrderedTaskSource* source) { + // home thread only, like every consumer of timerSource_ + timerSource_ = source; +} + +void EventLoop::PostV8Task(std::unique_ptr task, bool nestable, double delaySeconds) { + std::lock_guard lock(mutex_); + if (stopped_) { + return; + } + PostInternalLocked(Entry{std::move(task), nullptr, nestable, false, 0}, delaySeconds * 1000.0); +} + +bool EventLoop::IsStopped() { + std::lock_guard lock(mutex_); + return stopped_; +} + +std::unique_ptr EventLoop::TakeDueLocked(Lane& lane, bool nestableOnly, + bool v8Only, double now) { + auto matches = [&](const Entry& e) { + return (!nestableOnly || e.nestable) && (!v8Only || e.task != nullptr); + }; + auto imIt = lane.immediate.begin(); + while (imIt != lane.immediate.end() && !matches(*imIt)) { + ++imIt; + } + auto delIt = lane.delayed.begin(); + while (delIt != lane.delayed.end() && !matches(delIt->second)) { + ++delIt; + } + bool hasImmediate = imIt != lane.immediate.end(); + bool hasDelayed = delIt != lane.delayed.end() && delIt->first <= now; + if (hasImmediate && (!hasDelayed || imIt->time <= delIt->first)) { + auto entry = std::make_unique(std::move(*imIt)); + lane.immediate.erase(imIt); + return entry; + } + if (hasDelayed) { + auto entry = std::make_unique(std::move(delIt->second)); + lane.delayed.erase(delIt); + return entry; + } + return nullptr; +} + +double EventLoop::PeekDueLocked(Lane& lane, double now) { + // immediate entries are enqueued with monotonically increasing times, so + // the front is the earliest + double due = lane.immediate.empty() ? -1 : lane.immediate.front().time; + if (!lane.delayed.empty() && lane.delayed.begin()->first <= now && + (due < 0 || lane.delayed.begin()->first < due)) { + due = lane.delayed.begin()->first; + } + return due; +} + +bool EventLoop::HasDueLocked(Lane& lane, double now) { + return !lane.immediate.empty() || (!lane.delayed.empty() && lane.delayed.begin()->first <= now); +} + +void EventLoop::SignalInternalLocked() { + if (internalSource_ != nullptr) { + CFRunLoopSourceSignal(internalSource_); + CFRunLoopWakeUp(loop_); + } +} + +void EventLoop::ArmInternalTimerLocked(double now) { + if (internalTimer_ == nullptr) { + return; + } + // earliest not-yet-due delayed entry; already-due ones are the signal's job + double due = -1; + for (auto& pair : internal_.delayed) { + if (pair.first > now) { + due = pair.first; + break; + } + } + CFRunLoopTimerSetNextFireDate( + internalTimer_, + due >= 0 ? FireDateFor(due, now) : CFAbsoluteTimeGetCurrent() + kNeverFireInterval); +} + +void EventLoop::ArmOrderedTimerLocked(double now) { + if (orderedTimer_ == nullptr) { + return; + } + CFRunLoopTimerSetNextFireDate( + orderedTimer_, !pendingTokens_.empty() ? FireDateFor(*pendingTokens_.begin(), now) + : CFAbsoluteTimeGetCurrent() + kNeverFireInterval); +} + +void EventLoop::RunEntry(Entry& entry) { + if (entry.bare) { + // the fn does its own ceremony - it may lock a different isolate, or + // deliberately @throw with no V8 scopes on the stack + entry.fn(); + return; + } + v8::Locker locker(isolate_); + v8::Isolate::Scope isolate_scope(isolate_); + v8::HandleScope handle_scope(isolate_); + auto run = [&]() { + if (entry.task != nullptr) { + entry.task->Run(); + } else { + entry.fn(); + } + // work may enqueue microtasks without entering JS (e.g. resolving the + // Atomics.waitAsync promise), which never reaches kAuto's depth-0 drain + isolate_->PerformMicrotaskCheckpoint(); + }; + auto cache = Caches::Get(isolate_); + if (cache != nullptr && cache->IsValid() && cache->HasContext()) { + Context::Scope context_scope(cache->GetContext()); + run(); + } else { + // v8 can post tasks before Runtime::Init creates the context + run(); + } +} + +void EventLoop::RunOneInternal() { + std::unique_ptr entry; + { + std::lock_guard lock(mutex_); + if (stopped_) { + return; + } + auto now = NowMs(); + entry = TakeDueLocked(internal_, false, false, now); + // re-signal BEFORE running: one entry per runloop pass keeps the lane + // fair with other runloop work, and a bare entry may @throw and never + // return control here + if (entry != nullptr && HasDueLocked(internal_, now)) { + SignalInternalLocked(); + } + } + if (entry == nullptr) { + // leftover signal: the work it announced ran early from a nested drain + return; + } + if (entry->bare) { + RunEntry(*entry); + return; + } + RunGuarded([&] { RunEntry(*entry); }); +} + +void EventLoop::RunNestableV8Tasks() { + // bounded to the entries present at call time so a task that reposts can't + // wedge the inspector pause loop that called us + size_t budget; + { + std::lock_guard lock(mutex_); + budget = internal_.immediate.size() + internal_.delayed.size(); + } + while (budget-- > 0) { + std::unique_ptr entry; + { + std::lock_guard lock(mutex_); + if (stopped_) { + return; + } + entry = TakeDueLocked(internal_, true, true, NowMs()); + } + if (entry == nullptr) { + return; + } + // the pause loops call this from inside v8 inspector frames - a C++ + // exception must not unwind through them + RunGuarded([&] { RunEntry(*entry); }); + } +} + +void EventLoop::RunOrderedTask() { + // one anonymous token = one due slot across the whole ordered domain: pick + // the earliest due item among the ordered entries and the timer source, + // whichever it is. Timers and entries only ever run on this thread, so the + // peeked winner can't be taken by anyone else before we re-lock (a + // concurrent post can only add later work). + auto now = NowMs(); + double entryDue; + { + std::lock_guard lock(mutex_); + if (stopped_) { + return; + } + entryDue = PeekDueLocked(ordered_, now); + } + if (timerSource_ != nullptr && timerSource_->RunIfEarliest(now, entryDue)) { + return; + } + if (entryDue < 0) { + // leftover token: nothing in the domain is due yet + return; + } + std::unique_ptr entry; + { + std::lock_guard lock(mutex_); + if (stopped_) { + return; + } + entry = TakeDueLocked(ordered_, false, false, NowMs()); + } + if (entry != nullptr) { + RunGuarded([&] { RunEntry(*entry); }); + } +} + +void EventLoop::InternalSourcePerform(void* info) { + static_cast(info)->RunOneInternal(); +} + +void EventLoop::InternalTimerFired(CFRunLoopTimerRef timer, void* info) { + auto self = static_cast(info); + std::lock_guard lock(self->mutex_); + if (self->stopped_) { + return; + } + auto now = NowMs(); + if (HasDueLocked(self->internal_, now)) { + self->SignalInternalLocked(); + } + self->ArmInternalTimerLocked(now); +} + +void EventLoop::OrderedTimerFired(CFRunLoopTimerRef timer, void* info) { + auto self = static_cast(info); + size_t due = 0; + { + std::lock_guard lock(self->mutex_); + if (self->stopped_) { + return; + } + auto now = NowMs(); + while (!self->pendingTokens_.empty() && *self->pendingTokens_.begin() <= now) { + self->pendingTokens_.erase(self->pendingTokens_.begin()); + due++; + } + self->ArmOrderedTimerLocked(now); + } + // tokens and slots stay 1:1: each matured token gets exactly one drain + while (due-- > 0) { + self->RunOrderedTask(); + } +} + +} // namespace tns diff --git a/NativeScript/runtime/NativeScriptException.mm b/NativeScript/runtime/NativeScriptException.mm index aeb83648..e429b76c 100644 --- a/NativeScript/runtime/NativeScriptException.mm +++ b/NativeScript/runtime/NativeScriptException.mm @@ -227,14 +227,13 @@ static void ScheduleDeferredThrow(Isolate* isolate, NSException* e) { if (rt == nullptr) { return; } - CFRunLoopRef loop = rt->RuntimeLoop(); + auto loop = rt->GetEventLoop(); if (loop == nullptr) { return; } - CFRunLoopPerformBlock(loop, kCFRunLoopCommonModes, ^{ - @throw e; - }); - CFRunLoopWakeUp(loop); + // bare entry: runs with no V8 scopes and outside the loop's exception + // guard, so the NSException unwinds into the runloop frame + loop->PostInternalBare([e]() { @throw e; }); } void NativeScriptException::DepositPendingPolicyThrow(Isolate* isolate, id exception) { @@ -441,15 +440,16 @@ static void ScheduleDeferredThrow(Isolate* isolate, NSException* e) { // scheduled for. DepositPendingPolicyThrow(isolate, fatal); Runtime* rt = Runtime::GetRuntime(isolate); - CFRunLoopRef loop = rt != nullptr ? rt->RuntimeLoop() : nullptr; + auto loop = rt != nullptr ? rt->GetEventLoop() : nullptr; if (loop != nullptr) { - CFRunLoopPerformBlock(loop, kCFRunLoopCommonModes, ^{ + // bare entry: clean, V8-scope-free frame, outside the loop's + // exception guard + loop->PostInternalBare([isolate, fatal]() { id e = ClaimPendingPolicyThrowIfEqual(isolate, fatal); if (e != nil) { @throw e; } }); - CFRunLoopWakeUp(loop); } } else if (policy != "report") { static std::once_flag warnedPolicy; diff --git a/NativeScript/runtime/NativeScriptPlatform.h b/NativeScript/runtime/NativeScriptPlatform.h new file mode 100644 index 00000000..058e8a5e --- /dev/null +++ b/NativeScript/runtime/NativeScriptPlatform.h @@ -0,0 +1,112 @@ +#ifndef NativeScriptPlatform_h +#define NativeScriptPlatform_h + +#include +#include +#include + +#include "Common.h" +#include "EventLoop.h" +#include "v8-platform.h" + +namespace tns { + +/** + * v8::Platform that delegates worker-thread scheduling, time and tracing to + * the default libplatform implementation but serves per-isolate foreground + * task runners backed by each runtime's EventLoop. This is what makes v8's + * own foreground tasks (Atomics.waitAsync wakeups, GC finalization, + * streaming-compilation merge-backs) actually run - nothing pumps the default + * platform's internal queues outside the debugger pause loops. + */ +class NativeScriptPlatform : public v8::Platform { + public: + explicit NativeScriptPlatform(std::unique_ptr defaultPlatform); + + static NativeScriptPlatform* Instance() { return instance_; } + + /** + * Returns the isolate's event loop, creating an unbound one if v8 asks + * before Runtime::CreateIsolate binds it to the isolate's home thread. + */ + std::shared_ptr GetEventLoop(v8::Isolate* isolate); + + /** + * GetEventLoop, but replaces a stopped loop with a fresh one first. Used by + * Runtime::CreateIsolate: a stopped loop under this key is a leftover from + * a disposed isolate that had the same address (worker churn reuses them). + * The registry's v8 runner resolves the loop per post, so replacement also + * redirects tasks posted through an already-handed-out runner. + */ + std::shared_ptr RefreshEventLoop(v8::Isolate* isolate); + + /** + * The loop for the isolate, or null - never creates. The post path uses + * this so a disposed isolate's late posts drop instead of minting a fresh + * registry entry under a dead (or recycled) pointer. + */ + std::shared_ptr LookupEventLoop(v8::Isolate* isolate); + + /** + * Drops the registry entry, but only while it still maps to `loop`: isolate + * pointers are reused, and an unconditional erase from a late destructor + * could evict the pointer's new tenant. ~Runtime calls this after handing + * the isolate to DisposeIsolateWhenPossible. + */ + void IsolateDisposed(v8::Isolate* isolate, + const std::shared_ptr& loop); + + // v8::Platform + v8::PageAllocator* GetPageAllocator() override; + v8::ThreadIsolatedAllocator* GetThreadIsolatedAllocator() override; + size_t GetZeroSegmentSize() override; + void OnCriticalMemoryPressure() override; + int NumberOfWorkerThreads() override; + std::shared_ptr GetForegroundTaskRunner( + v8::Isolate* isolate, v8::TaskPriority priority) override; + bool IdleTasksEnabled(v8::Isolate* isolate) override; + std::unique_ptr CreateBoostablePriorityScope() + override; + std::unique_ptr CreateBlockingScope( + v8::BlockingType blocking_type) override; + double MonotonicallyIncreasingTime() override; + int64_t CurrentClockTimeMilliseconds() override; + double CurrentClockTimeMillis() override; + double CurrentClockTimeMillisecondsHighResolution() override; + StackTracePrinter GetStackTracePrinter() override; + v8::TracingController* GetTracingController() override; + void DumpWithoutCrashing() override; + v8::HighAllocationThroughputObserver* GetHighAllocationThroughputObserver() + override; + + protected: + std::unique_ptr CreateJobImpl( + v8::TaskPriority priority, std::unique_ptr job_task, + const v8::SourceLocation& location) override; + void PostTaskOnWorkerThreadImpl(v8::TaskPriority priority, + std::unique_ptr task, + const v8::SourceLocation& location) override; + void PostDelayedTaskOnWorkerThreadImpl( + v8::TaskPriority priority, std::unique_ptr task, + double delay_in_seconds, const v8::SourceLocation& location) override; + + private: + struct IsolateEntry { + std::shared_ptr loop; + // handed to v8 once per isolate; resolves the loop through the registry + // on every post so RefreshEventLoop redirects it + std::shared_ptr runner; + }; + + IsolateEntry& GetEntryLocked(v8::Isolate* isolate); + + std::unique_ptr default_; + std::mutex loopsMutex_; + std::unordered_map loops_; + + static NativeScriptPlatform* instance_; +}; + +} // namespace tns + +#endif /* NativeScriptPlatform_h */ diff --git a/NativeScript/runtime/NativeScriptPlatform.mm b/NativeScript/runtime/NativeScriptPlatform.mm new file mode 100644 index 00000000..9259cdc6 --- /dev/null +++ b/NativeScript/runtime/NativeScriptPlatform.mm @@ -0,0 +1,184 @@ +#include "NativeScriptPlatform.h" + +using namespace v8; + +namespace tns { + +NativeScriptPlatform* NativeScriptPlatform::instance_ = nullptr; + +namespace { + +/** + * The v8::TaskRunner handed to v8 for one isolate. Stateless beyond the + * isolate pointer: every post resolves the current EventLoop through the + * platform registry, so a stale loop replaced by RefreshEventLoop is + * redirected transparently, and posts for a disposed isolate (registry entry + * gone) drop instead of reviving a dead pointer's entry. + */ +class V8TaskRunnerAdapter : public v8::TaskRunner { + public: + explicit V8TaskRunnerAdapter(Isolate* isolate) : isolate_(isolate) {} + + bool IdleTasksEnabled() override { return false; } + + bool NonNestableTasksEnabled() const override { return true; } + + bool NonNestableDelayedTasksEnabled() const override { return true; } + + protected: + void PostTaskImpl(std::unique_ptr task, const SourceLocation& location) override { + Post(std::move(task), true, 0); + } + + void PostNonNestableTaskImpl(std::unique_ptr task, + const SourceLocation& location) override { + Post(std::move(task), false, 0); + } + + void PostDelayedTaskImpl(std::unique_ptr task, double delay_in_seconds, + const SourceLocation& location) override { + Post(std::move(task), true, delay_in_seconds); + } + + void PostNonNestableDelayedTaskImpl(std::unique_ptr task, double delay_in_seconds, + const SourceLocation& location) override { + Post(std::move(task), false, delay_in_seconds); + } + + private: + void Post(std::unique_ptr task, bool nestable, double delaySeconds) { + auto loop = NativeScriptPlatform::Instance()->LookupEventLoop(isolate_); + if (loop != nullptr) { + loop->PostV8Task(std::move(task), nestable, delaySeconds); + } + } + + Isolate* isolate_; +}; + +} // namespace + +NativeScriptPlatform::NativeScriptPlatform(std::unique_ptr defaultPlatform) + : default_(std::move(defaultPlatform)) { + instance_ = this; +} + +NativeScriptPlatform::IsolateEntry& NativeScriptPlatform::GetEntryLocked(Isolate* isolate) { + auto it = loops_.find(isolate); + if (it != loops_.end()) { + return it->second; + } + auto emplaced = + loops_.emplace(isolate, IsolateEntry{std::make_shared(isolate), + std::make_shared(isolate)}); + return emplaced.first->second; +} + +std::shared_ptr NativeScriptPlatform::GetEventLoop(Isolate* isolate) { + std::lock_guard lock(loopsMutex_); + return GetEntryLocked(isolate).loop; +} + +std::shared_ptr NativeScriptPlatform::RefreshEventLoop(Isolate* isolate) { + std::lock_guard lock(loopsMutex_); + auto& entry = GetEntryLocked(isolate); + if (entry.loop->IsStopped()) { + entry.loop = std::make_shared(isolate); + } + return entry.loop; +} + +std::shared_ptr NativeScriptPlatform::LookupEventLoop(Isolate* isolate) { + std::lock_guard lock(loopsMutex_); + auto it = loops_.find(isolate); + return it != loops_.end() ? it->second.loop : nullptr; +} + +void NativeScriptPlatform::IsolateDisposed(Isolate* isolate, + const std::shared_ptr& loop) { + std::lock_guard lock(loopsMutex_); + auto it = loops_.find(isolate); + if (it != loops_.end() && it->second.loop == loop) { + loops_.erase(it); + } +} + +PageAllocator* NativeScriptPlatform::GetPageAllocator() { return default_->GetPageAllocator(); } + +ThreadIsolatedAllocator* NativeScriptPlatform::GetThreadIsolatedAllocator() { + return default_->GetThreadIsolatedAllocator(); +} + +size_t NativeScriptPlatform::GetZeroSegmentSize() { return default_->GetZeroSegmentSize(); } + +void NativeScriptPlatform::OnCriticalMemoryPressure() { default_->OnCriticalMemoryPressure(); } + +int NativeScriptPlatform::NumberOfWorkerThreads() { return default_->NumberOfWorkerThreads(); } + +std::shared_ptr NativeScriptPlatform::GetForegroundTaskRunner(Isolate* isolate, + TaskPriority priority) { + // one runner regardless of priority: the home runloop's FIFO order is the + // priority model of the runtime thread + std::lock_guard lock(loopsMutex_); + return GetEntryLocked(isolate).runner; +} + +bool NativeScriptPlatform::IdleTasksEnabled(Isolate* isolate) { return false; } + +std::unique_ptr NativeScriptPlatform::CreateBoostablePriorityScope() { + return default_->CreateBoostablePriorityScope(); +} + +std::unique_ptr NativeScriptPlatform::CreateBlockingScope( + BlockingType blocking_type) { + return default_->CreateBlockingScope(blocking_type); +} + +double NativeScriptPlatform::MonotonicallyIncreasingTime() { + return default_->MonotonicallyIncreasingTime(); +} + +int64_t NativeScriptPlatform::CurrentClockTimeMilliseconds() { + return default_->CurrentClockTimeMilliseconds(); +} + +double NativeScriptPlatform::CurrentClockTimeMillis() { return default_->CurrentClockTimeMillis(); } + +double NativeScriptPlatform::CurrentClockTimeMillisecondsHighResolution() { + return default_->CurrentClockTimeMillisecondsHighResolution(); +} + +Platform::StackTracePrinter NativeScriptPlatform::GetStackTracePrinter() { + return default_->GetStackTracePrinter(); +} + +TracingController* NativeScriptPlatform::GetTracingController() { + return default_->GetTracingController(); +} + +void NativeScriptPlatform::DumpWithoutCrashing() { default_->DumpWithoutCrashing(); } + +HighAllocationThroughputObserver* NativeScriptPlatform::GetHighAllocationThroughputObserver() { + return default_->GetHighAllocationThroughputObserver(); +} + +std::unique_ptr NativeScriptPlatform::CreateJobImpl(TaskPriority priority, + std::unique_ptr job_task, + const SourceLocation& location) { + return default_->CreateJob(priority, std::move(job_task), location); +} + +void NativeScriptPlatform::PostTaskOnWorkerThreadImpl(TaskPriority priority, + std::unique_ptr task, + const SourceLocation& location) { + default_->PostTaskOnWorkerThread(priority, std::move(task), location); +} + +void NativeScriptPlatform::PostDelayedTaskOnWorkerThreadImpl(TaskPriority priority, + std::unique_ptr task, + double delay_in_seconds, + const SourceLocation& location) { + default_->PostDelayedTaskOnWorkerThread(priority, std::move(task), delay_in_seconds, location); +} + +} // namespace tns diff --git a/NativeScript/runtime/Runtime.h b/NativeScript/runtime/Runtime.h index d6f3a5ac..2b18b85e 100644 --- a/NativeScript/runtime/Runtime.h +++ b/NativeScript/runtime/Runtime.h @@ -3,6 +3,7 @@ #include "Caches.h" #include "Common.h" +#include "EventLoop.h" #include "MetadataBuilder.h" #include "ModuleInternal.h" #include "SpinLock.h" @@ -26,6 +27,12 @@ class Runtime { inline CFRunLoopRef RuntimeLoop() { return runtimeLoop_; } + /* + * Scheduler bound to this runtime's runloop. Producers on any thread post; + * entries run only on the runtime's home thread. + */ + inline std::shared_ptr GetEventLoop() const { return eventLoop_; } + void RunModule(const std::string moduleName); void RunScript(const std::string script); @@ -89,6 +96,8 @@ class Runtime { v8::Local globalTemplate); void DefineDrainMicrotaskMethod(v8::Isolate* isolate, v8::Local globalTemplate); + void DefineQueueMacrotaskMethod(v8::Isolate* isolate, + v8::Local globalTemplate); void DefineDateTimeConfigurationChangeNotificationMethod( v8::Isolate* isolate, v8::Local globalTemplate); @@ -98,6 +107,7 @@ class Runtime { std::unique_ptr moduleInternal_; int workerId_; CFRunLoopRef runtimeLoop_; + std::shared_ptr eventLoop_; // Drains unhandled promise rejections once per runloop turn // (kCFRunLoopBeforeWaiting). Torn down before isolate disposal in ~Runtime. CFRunLoopObserverRef rejectionObserver_ = nullptr; diff --git a/NativeScript/runtime/Runtime.mm b/NativeScript/runtime/Runtime.mm index 6ff6c072..d20dd46e 100644 --- a/NativeScript/runtime/Runtime.mm +++ b/NativeScript/runtime/Runtime.mm @@ -12,6 +12,7 @@ #include "InlineFunctions.h" #include "Interop.h" #include "NativeScriptException.h" +#include "NativeScriptPlatform.h" #include "ObjectManager.h" #include "Performance.h" #include "PromiseProxy.h" @@ -223,6 +224,13 @@ void DisposeIsolateWhenPossible(Isolate* isolate) { { v8::Locker lock(isolate_); + // Stop the event loop before any handle disposal: queued entries touch + // caches and persistents that go away below, and posts from other threads + // must start dropping now. + if (eventLoop_ != nullptr) { + eventLoop_->Shutdown(); + } + // Clear module registry before disposing other handles // This prevents crashes during g_moduleRegistry cleanup extern std::unordered_map> g_moduleRegistry; @@ -249,6 +257,13 @@ void DisposeIsolateWhenPossible(Isolate* isolate) { DisposeIsolateWhenPossible(this->isolate_); + // Matched erase: only removes the registry entry while it still maps to + // this runtime's loop, so a worker isolate that reuses this pointer after + // the (possibly deferred) Dispose can't be evicted by us. + if (eventLoop_ != nullptr) { + NativeScriptPlatform::Instance()->IsolateDisposed(currentIsolate, eventLoop_); + } + currentRuntime_ = nullptr; } @@ -269,7 +284,9 @@ void DisposeIsolateWhenPossible(Isolate* isolate) { std::string flags = RuntimeConfig.IsDebug ? "--expose_gc" : "--expose_gc --no-lazy"; V8::SetFlagsFromString(flags.c_str(), flags.size()); - Runtime::platform_ = platform::NewDefaultPlatform(); + // wrap the default platform so foreground tasks ride each runtime + // thread's CFRunLoop instead of sitting in never-pumped libplatform queues + Runtime::platform_ = std::make_shared(platform::NewDefaultPlatform()); V8::InitializePlatform(Runtime::platform_.get()); V8::Initialize(); @@ -285,6 +302,12 @@ void DisposeIsolateWhenPossible(Isolate* isolate) { create_params.array_buffer_allocator = &allocator_; Isolate* isolate = Isolate::New(create_params); runtimeLoop_ = CFRunLoopGetCurrent(); + // v8 already asked for this isolate's task runner during Isolate::New, so + // the registry may hold an unbound loop - or a stopped one, when a worker + // isolate reuses a disposed isolate's address. Refresh, then attach to this + // thread; foreground tasks buffered so far start flowing from here on. + eventLoop_ = NativeScriptPlatform::Instance()->RefreshEventLoop(isolate); + eventLoop_->BindToCurrentThread(); isolate->SetData(Constants::RUNTIME_SLOT, this); { @@ -313,6 +336,7 @@ void DisposeIsolateWhenPossible(Isolate* isolate) { // Worker::Init(isolate, globalTemplate, isWorker); DefineTimeMethod(isolate, globalTemplate); DefineDrainMicrotaskMethod(isolate, globalTemplate); + DefineQueueMacrotaskMethod(isolate, globalTemplate); // queueMicrotask(callback) per spec { Local qmtTemplate = @@ -546,6 +570,40 @@ void DisposeIsolateWhenPossible(Isolate* isolate) { globalTemplate->Set(ToV8String(isolate, "__drainMicrotaskQueue"), drainMicrotaskTemplate); } +// TODO: remove the __ns__ prefix once the event loop's ordered lane backs +// public macrotask APIs (performance observers etc.) +void Runtime::DefineQueueMacrotaskMethod(v8::Isolate* isolate, + v8::Local globalTemplate) { + Local queueMacrotaskTemplate = + FunctionTemplate::New(isolate, [](const FunctionCallbackInfo& info) { + auto* isolate = info.GetIsolate(); + if (info.Length() < 1 || !info[0]->IsFunction()) { + isolate->ThrowException(Exception::TypeError( + tns::ToV8String(isolate, "__ns__queueMacrotask: callback must be a function"))); + return; + } + Runtime* runtime = Runtime::GetRuntime(isolate); + if (runtime == nullptr || runtime->GetEventLoop() == nullptr) { + return; + } + auto callback = + std::make_shared>(isolate, info[0].As()); + // the ordered lane rides the home runloop's performed-block order, so + // the callback runs as a macrotask in strict FIFO order with JS timers + runtime->GetEventLoop()->PostOrdered([isolate, callback]() { + auto cache = Caches::Get(isolate); + Local context = cache->GetContext(); + Context::Scope context_scope(context); + Local cb = callback->Get(isolate); + callback->Reset(); + // no TryCatch: like timer callbacks, uncaught errors surface + // through the isolate's message listener + (void)cb->Call(context, context->Global(), 0, nullptr); + }); + }); + globalTemplate->Set(ToV8String(isolate, "__ns__queueMacrotask"), queueMacrotaskTemplate); +} + void Runtime::DefineDateTimeConfigurationChangeNotificationMethod( v8::Isolate* isolate, v8::Local globalTemplate) { Local drainMicrotaskTemplate = diff --git a/NativeScript/runtime/Worker.mm b/NativeScript/runtime/Worker.mm index dbb02997..b601dddd 100644 --- a/NativeScript/runtime/Worker.mm +++ b/NativeScript/runtime/Worker.mm @@ -311,7 +311,7 @@ throw NativeScriptException( return; } - tns::ExecuteOnRunLoop(runtime->RuntimeLoop(), [state, message]() { + runtime->GetEventLoop()->PostInternal([state, message]() { Isolate* isolate = state->GetIsolate(); v8::Locker locker(isolate); Isolate::Scope isolate_scope(isolate); diff --git a/NativeScript/runtime/WorkerWrapper.mm b/NativeScript/runtime/WorkerWrapper.mm index c2af86f6..9a090ee7 100644 --- a/NativeScript/runtime/WorkerWrapper.mm +++ b/NativeScript/runtime/WorkerWrapper.mm @@ -19,6 +19,28 @@ workers_.maxConcurrentOperationCount = 100; } +// Posts to the target runtime's internal lane from the worker thread. When +// async is false, blocks until the entry ran; a post that raced the runtime's +// shutdown is dropped and not waited on. +static void PostToRuntimeLoop(Runtime* runtime, std::function fn, bool async) { + auto loop = runtime->GetEventLoop(); + if (loop == nullptr) { + return; + } + if (async) { + loop->PostInternal(std::move(fn)); + return; + } + dispatch_semaphore_t done = dispatch_semaphore_create(0); + bool posted = loop->PostInternal([fn = std::move(fn), done]() { + fn(); + dispatch_semaphore_signal(done); + }); + if (posted) { + dispatch_semaphore_wait(done, DISPATCH_TIME_FOREVER); + } +} + WorkerWrapper::WorkerWrapper( v8::Isolate* mainIsolate, std::function thiz, std::shared_ptr)> @@ -284,8 +306,8 @@ if (runtime == nullptr) { return; } - tns::ExecuteOnRunLoop( - runtime->RuntimeLoop(), + PostToRuntimeLoop( + runtime, [this, message, src, stackTrace, lineNumber]() { v8::Locker locker(this->mainIsolate_); Isolate::Scope isolate_scope(this->mainIsolate_); @@ -341,8 +363,8 @@ if (runtime == nullptr) { return; } - tns::ExecuteOnRunLoop( - runtime->RuntimeLoop(), + PostToRuntimeLoop( + runtime, [this, message, source, stackTrace, lineNumber]() { v8::Locker locker(this->mainIsolate_); Isolate::Scope isolate_scope(this->mainIsolate_); diff --git a/v8ios.xcodeproj/project.pbxproj b/v8ios.xcodeproj/project.pbxproj index 91c741cd..cdf9db6f 100644 --- a/v8ios.xcodeproj/project.pbxproj +++ b/v8ios.xcodeproj/project.pbxproj @@ -294,6 +294,8 @@ 4A5C201A2E2B000100000007 /* RuntimeBuiltins.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 4A5C201A2E2B000100000003 /* RuntimeBuiltins.cpp */; }; 4A5C201A2E2B000300000006 /* Performance.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 4A5C201A2E2B000300000001 /* Performance.cpp */; }; 4A5C201A2E2B000200000006 /* NsBuiltinModules.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 4A5C201A2E2B000200000001 /* NsBuiltinModules.cpp */; }; + 4AE7100A2E2B000400000003 /* EventLoop.mm in Sources */ = {isa = PBXBuildFile; fileRef = 4AE7100A2E2B000400000001 /* EventLoop.mm */; }; + 4AE7100A2E2B000400000006 /* NativeScriptPlatform.mm in Sources */ = {isa = PBXBuildFile; fileRef = 4AE7100A2E2B000400000004 /* NativeScriptPlatform.mm */; }; 4A5C201A2E2B000400000003 /* StructuredClone.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 4A5C201A2E2B000400000001 /* StructuredClone.cpp */; }; 4A5C201A2E2B000500000003 /* StructuredSerialization.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 4A5C201A2E2B000500000001 /* StructuredSerialization.cpp */; }; C2DDEB93229EAC8300345BFE /* ArgConverter.mm in Sources */ = {isa = PBXBuildFile; fileRef = C2DDEB6A229EAC8200345BFE /* ArgConverter.mm */; }; @@ -816,6 +818,10 @@ 4A5C201A2E2B000100000003 /* RuntimeBuiltins.cpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.cpp; name = RuntimeBuiltins.cpp; path = generated/RuntimeBuiltins.cpp; sourceTree = ""; }; 4A5C201A2E2B000100000004 /* RuntimeBuiltins.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = RuntimeBuiltins.h; path = generated/RuntimeBuiltins.h; sourceTree = ""; }; 4A5C201A2E2B000100000005 /* js */ = {isa = PBXFileReference; lastKnownFileType = folder; path = js; sourceTree = ""; }; + 4AE7100A2E2B000400000001 /* EventLoop.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = EventLoop.mm; sourceTree = ""; }; + 4AE7100A2E2B000400000002 /* EventLoop.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = EventLoop.h; sourceTree = ""; }; + 4AE7100A2E2B000400000004 /* NativeScriptPlatform.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = NativeScriptPlatform.mm; sourceTree = ""; }; + 4AE7100A2E2B000400000005 /* NativeScriptPlatform.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = NativeScriptPlatform.h; sourceTree = ""; }; 4A5C201A2E2B000400000001 /* StructuredClone.cpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.cpp; path = StructuredClone.cpp; sourceTree = ""; }; 4A5C201A2E2B000400000002 /* StructuredClone.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = StructuredClone.h; sourceTree = ""; }; 4A5C201A2E2B000500000001 /* StructuredSerialization.cpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.cpp; path = StructuredSerialization.cpp; sourceTree = ""; }; @@ -1525,6 +1531,10 @@ C2C8EE7922CF64E4001F8CEC /* SimpleAllocator.h */, C2C8EE7822CF64E4001F8CEC /* SimpleAllocator.cpp */, C2DDEB87229EAC8300345BFE /* StringHasher.h */, + 4AE7100A2E2B000400000002 /* EventLoop.h */, + 4AE7100A2E2B000400000001 /* EventLoop.mm */, + 4AE7100A2E2B000400000005 /* NativeScriptPlatform.h */, + 4AE7100A2E2B000400000004 /* NativeScriptPlatform.mm */, 4A5C201A2E2B000400000002 /* StructuredClone.h */, 4A5C201A2E2B000400000001 /* StructuredClone.cpp */, 4A5C201A2E2B000500000002 /* StructuredSerialization.h */, @@ -2300,6 +2310,8 @@ C2DDEB92229EAC8300345BFE /* WeakRef.cpp in Sources */, 4A5C201A2E2B000100000006 /* BuiltinLoader.cpp in Sources */, 4A5C201A2E2B000200000006 /* NsBuiltinModules.cpp in Sources */, + 4AE7100A2E2B000400000003 /* EventLoop.mm in Sources */, + 4AE7100A2E2B000400000006 /* NativeScriptPlatform.mm in Sources */, 4A5C201A2E2B000400000003 /* StructuredClone.cpp in Sources */, 4A5C201A2E2B000500000003 /* StructuredSerialization.cpp in Sources */, 4A5C201A2E2B000100000007 /* RuntimeBuiltins.cpp in Sources */, From b680e368f744e091162b4f4b87d757d0dd6c8fb2 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 12 Aug 2026 13:06:27 -0300 Subject: [PATCH 02/10] feat(timers): merge JS timers into the event loop's ordered lane Replace the per-timer CFRunLoopTimers (and the TimerContext retain/release machinery) with the Android runtime's token scheme: scheduled timers keep an exact sub-millisecond sorted list and post one anonymous due token through the EventLoop, whose drain runs the earliest due item across timers and ordered macrotasks - one due-ordered domain, FIFO with performed blocks on the same runloop. clearTimeout/clearInterval leave a tombstone whose own token consumes it as a no-op, so no token gains surplus capacity to run a later-scheduled item ahead of foreign runloop work queued between the two token positions. Intervals realign to startTime + k*frequency and re-arm before the callback runs, preserving the skipped-fire and throwing-callback behavior of the repeating CFRunLoopTimers this replaces. --- NativeScript/runtime/Timers.cpp | 298 ++++++++++++++++++-------------- NativeScript/runtime/Timers.hpp | 7 +- 2 files changed, 166 insertions(+), 139 deletions(-) diff --git a/NativeScript/runtime/Timers.cpp b/NativeScript/runtime/Timers.cpp index 897e6290..9c5dafc5 100644 --- a/NativeScript/runtime/Timers.cpp +++ b/NativeScript/runtime/Timers.cpp @@ -8,15 +8,32 @@ #include "Timers.hpp" -#include - +#include #include #include "Caches.h" +#include "EventLoop.h" #include "Helpers.h" #include "ModuleBinding.hpp" #include "Runtime.h" +/* + * Overall rules when modifying this file: + * Everything runs on the isolate's home thread under its v8::Locker. + * `sortedTimers_` must always be sorted by dueTime (stable for equal + * dueTimes) and in sync with `timerMap_` (except tombstones, which only live + * in `sortedTimers_`). + * + * Scheduling model: every scheduled timer posts one anonymous "due token" + * through the runtime EventLoop's ordered lane, at a due time >= the timer's. + * Timers therefore share one due-ordered domain with ordered macrotasks and + * stay FIFO with CFRunLoopPerformBlock work on the same runloop. The token + * does not name a timer: the EventLoop drain consumes the earliest due item + * across this list and its own ordered entries. Cancelled timers leave a + * tombstone so their token consumes a slot as a no-op instead of lending its + * position to a later item. + */ + using namespace v8; // Takes a value and transform into a positive number @@ -46,88 +63,171 @@ static double now_ms() { namespace tns { -class TimerState { +struct TimerReference { + int id; + double dueTime; + // clearTimeout/clearInterval tombstones the entry instead of erasing it: + // its already-posted token then consumes this slot as a no-op, so no token + // gains surplus capacity to run a LATER-scheduled item ahead of foreign + // runloop work queued between the two token positions + bool cancelled = false; +}; + +class TimerState : public OrderedTaskSource { public: - std::mutex timerMutex_; std::atomic currentTimerId = 0; robin_hood::unordered_map> timerMap_; - CFRunLoopRef runloop; + // scheduled timers (and tombstones) sorted by exact (sub-millisecond) + // dueTime, stable for equal dueTimes; touched only on the home thread + std::vector sortedTimers_; + v8::Isolate* isolate_ = nullptr; + std::shared_ptr eventLoop_; + bool stopped_ = false; + + ~TimerState() override { + stopped_ = true; + if (eventLoop_ != nullptr) { + // the loop is already shut down by ~Runtime at this point (every + // pending token dropped), but the source pointer must not outlive us + eventLoop_->SetTimerSource(nullptr); + eventLoop_.reset(); + } + for (auto& entry : timerMap_) { + entry.second->Unschedule(); + } + timerMap_.clear(); + sortedTimers_.clear(); + } - void removeTask(const std::shared_ptr& task) { - removeTask(task->id_); + void insertSorted(int id, double dueTime) { + auto it = + std::upper_bound(sortedTimers_.begin(), sortedTimers_.end(), dueTime, + [](double due, const TimerReference& ref) { + return due < ref.dueTime; + }); + sortedTimers_.insert(it, TimerReference{id, dueTime}); } - void removeTask(const int& taskId) { - auto it = timerMap_.find(taskId); - if (it != timerMap_.end()) { - // auto wasScheduled = it->second->queued_; - auto timer = it->second->timer; - it->second->Unschedule(); - timerMap_.erase(it); - CFRunLoopTimerInvalidate(timer); - // CFRunLoopTimerInvalidate triggers our TimerRelease callback, which - // deletes TimerContext, whose destructor calls CFRelease(task->timer) - } + void postToken(double dueTime, double now) { + // an overdue timer's token goes out at `now`: it must not jump items + // already queued ahead of it + eventLoop_->PostOrderedToken(dueTime <= now ? now : dueTime); } - // this all comes from the android runtime implementation - void addTask(std::shared_ptr task) { + void addTask(const std::shared_ptr& task) { if (task->queued_) { return; } - // auto now = now_ms(); - // task->nestingLevel_ = nesting + 1; task->queued_ = true; - // theoretically this should be >5 on the spec, but we're following chromium - // behavior here again - // if (task->nestingLevel_ >= 5 && task->frequency_ < 4) { - // task->frequency_ = 4; - // task->startTime_ = now; - // } timerMap_.emplace(task->id_, task); - // not needed on the iOS runtime for now - // auto newTime = task->NextTime(now); - // task->dueTime_ = newTime; + insertSorted(task->id_, task->dueTime_); } -}; -// this class is attached to the timer object itself -// we use a retain/release flow because we want to bind this to the Timer itself -// additionally it helps if we deal with timers on different threads -// The current implementation puts the timers on the runtime's runloop, so it -// shouldn't be necessary. -class TimerContext { - public: - std::atomic retainCount{0}; - std::shared_ptr task; - TimerState* state; - ~TimerContext() { - task->Unschedule(); - CFRelease(task->timer); + void removeTask(const int& taskId) { + auto it = timerMap_.find(taskId); + if (it == timerMap_.end()) { + return; + } + if (it->second->queued_) { + auto dueTime = it->second->dueTime_; + auto sit = + std::lower_bound(sortedTimers_.begin(), sortedTimers_.end(), dueTime, + [](const TimerReference& ref, double due) { + return ref.dueTime < due; + }); + while (sit != sortedTimers_.end() && sit->dueTime == dueTime) { + if (sit->id == taskId) { + sit->cancelled = true; + break; + } + ++sit; + } + } + it->second->Unschedule(); + timerMap_.erase(it); } - static const void* TimerRetain(const void* ret) { - auto v = (TimerContext*)(ret); - v->retainCount++; - return ret; - } + /** + * Invoked by the EventLoop's ordered-lane token drain on the isolate's + * thread: if the front slot is due and earlier-or-equal to the loop's own + * earliest entry, consume it - firing the earliest due timer (exact + * sub-millisecond order, not necessarily the timer that enqueued the token) + * or swallowing a tombstone left by clearTimeout/clearInterval. + */ + bool RunIfEarliest(double now, double otherDue) override { + auto isolate = isolate_; + if (stopped_ || isolate == nullptr || isolate->IsDead()) { + return false; + } + v8::Locker locker(isolate); + v8::Isolate::Scope isolate_scope(isolate); + v8::HandleScope handleScope(isolate); + if (sortedTimers_.empty()) { + return false; + } + auto ref = sortedTimers_.front(); + if (ref.dueTime > now_ms() || (otherDue >= 0 && ref.dueTime > otherDue)) { + // not due, or the loop's own entry is earlier - not this source's slot + return false; + } + sortedTimers_.erase(sortedTimers_.begin()); + if (ref.cancelled) { + // tombstone: this slot's token is spent doing nothing, keeping tokens + // and slots 1:1 + return true; + } + auto it = timerMap_.find(ref.id); + if (it == timerMap_.end()) { + return true; + } + auto task = it->second; + if (!task->queued_ || !task->wrapper.IsValid()) { + return true; + } + + // reschedule before invoking, so a throwing callback can't kill the + // interval - matching the repeating CFRunLoopTimer behavior this replaces + if (task->repeats_) { + task->dueTime_ = task->NextTime(now_ms()); + insertSorted(task->id_, task->dueTime_); + postToken(task->dueTime_, now_ms()); + } + + v8::Local cb = task->callback_.Get(isolate); + v8::Local context = + cb->GetCreationContextChecked(v8::Isolate::GetCurrent()); + Context::Scope context_scope(context); + int argc = task->args_ ? static_cast(task->args_->size()) : 0; + if (argc > 0) { + std::vector> argv(argc); + for (int i = 0; i < argc; ++i) { + argv[i] = task->args_->at(i)->Get(isolate); + } + (void)cb->Call(context, context->Global(), argc, argv.data()); + } else { + (void)cb->Call(context, context->Global(), 0, nullptr); + } - static void TimerRelease(const void* ret) { - auto v = (TimerContext*)(ret); - if (--v->retainCount <= 0) { - delete v; - }; + if (!task->repeats_) { + // re-resolve: the callback may have cleared this id itself + auto post = timerMap_.find(ref.id); + if (post != timerMap_.end() && post->second == task) { + post->second->Unschedule(); + timerMap_.erase(post); + } + } + return true; } }; void Timers::Init(Isolate* isolate, Local globalTemplate) { auto timerState = new TimerState(); - timerState->runloop = Runtime::GetRuntime(isolate)->RuntimeLoop(); + timerState->isolate_ = isolate; + // Runtime::CreateIsolate bound the loop to this thread's runloop before + // any builtin initialization runs + timerState->eventLoop_ = Runtime::GetRuntime(isolate)->GetEventLoop(); + timerState->eventLoop_->SetTimerSource(timerState); Caches::Get(isolate)->registerCacheBoundObject(timerState); - tns::NewFunctionTemplate( - isolate, Timers::SetTimeoutCallback, - v8::External::New(isolate, timerState, - v8::kExternalPointerTypeTagDefault)); tns::SetMethod(isolate, globalTemplate, "__ns__setTimeout", Timers::SetTimeoutCallback, v8::External::New(isolate, timerState, @@ -144,51 +244,6 @@ void Timers::Init(Isolate* isolate, Local globalTemplate) { Timers::ClearTimeoutCallback, v8::External::New(isolate, timerState, v8::kExternalPointerTypeTagDefault)); - Caches::Get(isolate)->registerCacheBoundObject(new TimerState()); -} - -void TimerCallback(CFRunLoopTimerRef timer, void* info) { - TimerContext* data = (TimerContext*)info; - auto task = data->task; - // we check for this first so we can be 100% sure that this task is still - // alive since we're always dealing with the runtime's runloop, it should - // always work if we even support firing the timers in a another runloop, then - // this is useful as it'll avoid use-after-free issues - if (!task->queued_ || !task->wrapper.IsValid()) { - return; - } - auto isolate = task->isolate_; - - v8::Locker locker(isolate); - v8::Isolate::Scope isolate_scope(isolate); - v8::HandleScope handleScope(isolate); - // ensure we're still queued after locking - if (!task->queued_) { - return; - } - - v8::Local cb = task->callback_.Get(isolate); - v8::Local context = - cb->GetCreationContextChecked(v8::Isolate::GetCurrent()); - Context::Scope context_scope(context); - int argc = task->args_ ? static_cast(task->args_->size()) : 0; - if (argc > 0) { - // allocate an array of the right size - std::vector> argv(argc); - - for (int i = 0; i < argc; ++i) { - argv[i] = task->args_->at(i)->Get(isolate); - } - - // pass pointer to the first element - (void)cb->Call(context, context->Global(), argc, argv.data()); - } else { - (void)cb->Call(context, context->Global(), 0, nullptr); - } - - if (!task->repeats_) { - data->state->removeTask(task); - } } void Timers::SetTimer(const v8::FunctionCallbackInfo& args, @@ -228,41 +283,16 @@ void Timers::SetTimer(const v8::FunctionCallbackInfo& args, } } + auto now = now_ms(); auto task = std::make_shared(isolate, handler, timeout, - repeatable, argArray, id, now_ms()); + repeatable, argArray, id, now); #ifdef DEBUG task->callback_.AnnotateStrongRetainer("timer"); #endif task->repeats_ = repeatable; - - CFRunLoopTimerContext timerContext = {0, NULL, NULL, NULL, NULL}; - auto timerData = new TimerContext(); - timerData->task = task; - timerData->state = state; - timerContext.info = timerData; - timerContext.retain = TimerContext::TimerRetain; - timerContext.release = TimerContext::TimerRelease; - - // we do this because the timer should take hold of exactly 1 retaincount - // after scheduling so if by our manual release the retain is 0 then we need - // to cleanup the TimerContext - TimerContext::TimerRetain(timerData); - - // timeout should be bigger than 0 if it's repeatable and 0 - auto timeoutInSeconds = - repeatable && timeout == 0 ? 0.0000001f : timeout / 1000.f; - auto timer = CFRunLoopTimerCreate( - kCFAllocatorDefault, CFAbsoluteTimeGetCurrent() + timeoutInSeconds, - repeatable ? timeoutInSeconds : 0, 0, 0, TimerCallback, &timerContext); + task->dueTime_ = now + (double)timeout; state->addTask(task); - // set the actual timer we created - task->timer = timer; - CFRunLoopAddTimer(state->runloop, timer, kCFRunLoopCommonModes); - TimerContext::TimerRelease(timerData); - // auto task = std::make_shared(isolate, handler, timeout, - // repeatable, - // argArray, id, now_ms()); - // thiz->addTask(task); + state->postToken(task->dueTime_, now); } args.GetReturnValue().Set(id); } diff --git a/NativeScript/runtime/Timers.hpp b/NativeScript/runtime/Timers.hpp index 9f3b611d..b6fc150c 100644 --- a/NativeScript/runtime/Timers.hpp +++ b/NativeScript/runtime/Timers.hpp @@ -43,9 +43,7 @@ class TimerTask { isolate_ = nullptr; queued_ = false; } - - // unused for now as we're using CFRunLoopTimers - // + int nestingLevel_ = 0; v8::Isolate *isolate_; v8::Persistent callback_; @@ -53,12 +51,11 @@ class TimerTask { double frequency_ = 0; bool repeats_ = false; bool queued_ = false; - + double dueTime_ = -1; double startTime_ = -1; int id_; IsolateWrapper wrapper; - CFRunLoopTimerRef timer = nullptr; }; class Timers { public: From 28276c0f4edbd19b4009a995df97bc8a0255f784 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 12 Aug 2026 13:06:28 -0300 Subject: [PATCH 03/10] test: event loop specs Atomics.waitAsync notify/timeout/mismatch/promise-chain (the async cases hang without the event loop), the kAuto-stall assertion (microtasks of a natively-resolved promise beat a later macrotask), __ns__queueMacrotask ordering and argument validation, clear-tombstone coverage, and worker specs: message ordering, waitAsync inside a worker's own loop, a reply racing an overdue waitAsync timeout, terminate-with-queued-work, and create/terminate churn exercising the isolate-pointer-reuse registry path. --- TestRunner/app/tests/EventLoopTests.js | 283 ++++++++++++++++++ .../app/tests/EventLoopWaitAsyncWorker.js | 7 + TestRunner/app/tests/eventLoopEchoWorker.js | 3 + TestRunner/app/tests/index.js | 1 + 4 files changed, 294 insertions(+) create mode 100644 TestRunner/app/tests/EventLoopTests.js create mode 100644 TestRunner/app/tests/EventLoopWaitAsyncWorker.js create mode 100644 TestRunner/app/tests/eventLoopEchoWorker.js diff --git a/TestRunner/app/tests/EventLoopTests.js b/TestRunner/app/tests/EventLoopTests.js new file mode 100644 index 00000000..276935e5 --- /dev/null +++ b/TestRunner/app/tests/EventLoopTests.js @@ -0,0 +1,283 @@ +// V8 delivers these resolutions as platform foreground tasks, so they only +// settle if the runtime pumps its foreground task runner (the EventLoop's +// internal lane). +describe("event loop foreground tasks", function () { + it("resolves Atomics.waitAsync when notified on the same thread", function (done) { + const sab = new SharedArrayBuffer(4); + const i32 = new Int32Array(sab); + + const result = Atomics.waitAsync(i32, 0, 0); + expect(result.async).toBe(true); + + result.value.then(value => { + expect(value).toBe("ok"); + done(); + }).catch(e => { + fail("Atomics.waitAsync promise rejected: " + e); + done(); + }); + + const woken = Atomics.notify(i32, 0); + expect(woken).toBe(1); + }); + + it("resolves Atomics.waitAsync with 'timed-out' after the timeout", function (done) { + const sab = new SharedArrayBuffer(4); + const i32 = new Int32Array(sab); + + const result = Atomics.waitAsync(i32, 0, 0, 50); + expect(result.async).toBe(true); + + result.value.then(value => { + expect(value).toBe("timed-out"); + done(); + }).catch(e => { + fail("Atomics.waitAsync promise rejected: " + e); + done(); + }); + }); + + it("resolves Atomics.waitAsync synchronously on value mismatch", function () { + const sab = new SharedArrayBuffer(4); + const i32 = new Int32Array(sab); + i32[0] = 42; + + const result = Atomics.waitAsync(i32, 0, 0); + expect(result.async).toBe(false); + expect(result.value).toBe("not-equal"); + }); + + it("keeps ordinary promise chains working alongside foreground tasks", function (done) { + const sab = new SharedArrayBuffer(4); + const i32 = new Int32Array(sab); + const order = []; + + Atomics.waitAsync(i32, 0, 0).value.then(() => { + order.push("waitAsync"); + return Promise.resolve(); + }).then(() => { + order.push("chained"); + expect(order).toEqual(["waitAsync", "chained"]); + done(); + }).catch(e => { + fail("promise chain failed: " + e); + done(); + }); + + Atomics.notify(i32, 0); + }); + + // The kAuto-stall fix: the wakeup task resolves the promise without + // entering JS, so its .then only runs if each loop entry ends with a + // microtask checkpoint. It must beat a timer scheduled well after it. + it("runs microtasks of a natively-resolved promise before a later macrotask", function (done) { + const i32 = new Int32Array(new SharedArrayBuffer(4)); + const order = []; + + Atomics.waitAsync(i32, 0, 0).value.then(() => { + order.push("wait"); + }); + Atomics.notify(i32, 0); + + __ns__setTimeout(() => { + order.push("timer"); + expect(order).toEqual(["wait", "timer"]); + done(); + }, 50); + }); +}); + +// The ordered lane rides the home runloop's performed-block order, so these +// callbacks must be strict macrotasks: after the current turn's microtasks, +// FIFO with native timers by due time. +describe("event loop ordered macrotasks", function () { + it("__ns__queueMacrotask runs the callback asynchronously", function (done) { + let ran = false; + __ns__queueMacrotask(() => { + ran = true; + done(); + }); + expect(ran).toBe(false); + }); + + it("runs after the current turn's microtasks", function (done) { + const order = []; + __ns__queueMacrotask(() => { + order.push("macrotask"); + expect(order).toEqual(["microtask", "macrotask"]); + done(); + }); + Promise.resolve().then(() => order.push("microtask")); + }); + + it("is FIFO among itself", function (done) { + const order = []; + __ns__queueMacrotask(() => order.push(1)); + __ns__queueMacrotask(() => order.push(2)); + __ns__queueMacrotask(() => { + order.push(3); + expect(order).toEqual([1, 2, 3]); + done(); + }); + }); + + // native timers (__ns__*): the app-level `setTimeout` global in this test + // app is an NSTimer-based polyfill, not the runtime timers + it("stays FIFO-ordered with native setTimeout(0)", function (done) { + const order = []; + __ns__queueMacrotask(() => order.push("macro1")); + __ns__setTimeout(() => order.push("timeout"), 0); + __ns__queueMacrotask(() => { + order.push("macro2"); + expect(order).toEqual(["macro1", "timeout", "macro2"]); + done(); + }); + }); + + it("rejects non-function arguments", function () { + expect(() => __ns__queueMacrotask("nope")).toThrowError(TypeError); + expect(() => __ns__queueMacrotask()).toThrowError(TypeError); + }); +}); + +// clearTimeout leaves a tombstone in the merged ordered domain, so the +// cleared timer's already-posted token consumes its own slot as a no-op +// instead of running a later-scheduled item ahead of foreign runloop work +// queued between the two tokens' positions. +describe("event loop ordered tombstones", function () { + it("clear + re-schedule does not reorder against queued macrotasks", function (done) { + const order = []; + const t1 = __ns__setTimeout(() => order.push("cleared"), 0); + __ns__clearTimeout(t1); + __ns__queueMacrotask(() => order.push("macro")); + __ns__setTimeout(() => { + order.push("t2"); + expect(order).toEqual(["macro", "t2"]); + done(); + }, 0); + }); + + it("clearing an overdue timer does not fire a later timer early", function (done) { + const order = []; + const t1 = __ns__setTimeout(() => order.push("cleared"), 0); + __ns__setTimeout(() => order.push("late"), 30); + __ns__clearTimeout(t1); + // busy-wait so both timers are overdue when their tokens drain in the + // same runloop burst + const start = Date.now(); + while (Date.now() - start < 50) { } + __ns__queueMacrotask(() => { + order.push("macro"); + expect(order).toEqual(["late", "macro"]); + done(); + }); + }); + + it("keeps interval callbacks running after an unrelated clear", function (done) { + const order = []; + const cleared = __ns__setInterval(() => order.push("cleared"), 1); + __ns__clearInterval(cleared); + let count = 0; + const interval = __ns__setInterval(() => { + if (++count === 3) { + __ns__clearInterval(interval); + expect(order).toEqual([]); + done(); + } + }, 5); + }); +}); + +describe("event loop workers", function () { + beforeEach(function () { + this.originalTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL; + jasmine.DEFAULT_TIMEOUT_INTERVAL = 15000; + }); + + afterEach(function () { + jasmine.DEFAULT_TIMEOUT_INTERVAL = this.originalTimeout; + }); + + it("keeps worker->parent messages ordered", function (done) { + const worker = new Worker("./eventLoopEchoWorker.js"); + const received = []; + worker.onmessage = function (msg) { + received.push(msg.data); + if (received.length === 3) { + expect(received).toEqual([1, 2, 3]); + worker.terminate(); + done(); + } + }; + worker.postMessage(1); + worker.postMessage(2); + worker.postMessage(3); + }); + + it("resolves Atomics.waitAsync inside a worker's own loop", function (done) { + const worker = new Worker("./EventLoopWaitAsyncWorker.js"); + worker.onmessage = function (msg) { + expect(msg.data).toBe("ok"); + worker.terminate(); + done(); + }; + worker.postMessage("go"); + }); + + // A worker reply racing an overdue waitAsync timeout on the parent: the + // reply's wakeup must not be starved by the timeout entry. + it("delivers worker messages whose wakeup raced an overdue waitAsync timeout", function (done) { + const worker = new Worker("./eventLoopEchoWorker.js"); + let warm = false; + worker.onmessage = function (msg) { + if (msg.data === "warmup") { + warm = true; + const i32 = new Int32Array(new SharedArrayBuffer(4)); + Atomics.waitAsync(i32, 0, 0, 50); + worker.postMessage("ping"); + // block the runloop until both the timeout and the reply are + // pending, so their wakeups are serviced from the same burst + const start = Date.now(); + while (Date.now() - start < 150) { } + } else { + expect(warm).toBe(true); + expect(msg.data).toBe("ping"); + worker.terminate(); + done(); + } + }; + worker.postMessage("warmup"); + }); + + it("survives terminating a worker with queued loop work", function (done) { + const worker = new Worker("./eventLoopEchoWorker.js"); + worker.onmessage = function () { + // several messages are still queued on the worker's loop when the + // terminate lands; none of them may crash or hang teardown + for (let i = 0; i < 20; i++) { + worker.postMessage("queued-" + i); + } + worker.terminate(); + __ns__setTimeout(done, 500); + }; + worker.postMessage("alive"); + }); + + // Rapid create/terminate cycles reuse isolate addresses; each new worker + // must get a live loop, not a dead predecessor's (registry refresh path). + it("keeps event loops healthy across worker churn", function (done) { + let remaining = 8; + (function cycle() { + const worker = new Worker("./eventLoopEchoWorker.js"); + worker.onmessage = function () { + worker.terminate(); + if (--remaining === 0) { + __ns__queueMacrotask(done); + } else { + cycle(); + } + }; + worker.postMessage("alive"); + })(); + }); +}); diff --git a/TestRunner/app/tests/EventLoopWaitAsyncWorker.js b/TestRunner/app/tests/EventLoopWaitAsyncWorker.js new file mode 100644 index 00000000..cd67dcb0 --- /dev/null +++ b/TestRunner/app/tests/EventLoopWaitAsyncWorker.js @@ -0,0 +1,7 @@ +onmessage = function () { + const i32 = new Int32Array(new SharedArrayBuffer(4)); + Atomics.waitAsync(i32, 0, 0).value.then(value => { + postMessage(value); + }); + Atomics.notify(i32, 0); +}; diff --git a/TestRunner/app/tests/eventLoopEchoWorker.js b/TestRunner/app/tests/eventLoopEchoWorker.js new file mode 100644 index 00000000..dd42a874 --- /dev/null +++ b/TestRunner/app/tests/eventLoopEchoWorker.js @@ -0,0 +1,3 @@ +onmessage = function (msg) { + postMessage(msg.data); +}; diff --git a/TestRunner/app/tests/index.js b/TestRunner/app/tests/index.js index 29099ece..7489eaef 100644 --- a/TestRunner/app/tests/index.js +++ b/TestRunner/app/tests/index.js @@ -139,6 +139,7 @@ require("./Modules"); require("./RuntimeImplementedAPIs"); require("./Timers"); +require("./EventLoopTests"); require("./URL"); require("./URLSearchParams"); From e13596b9e6c9f816a429711ea601c2c76cb05259 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 12 Aug 2026 14:25:50 -0300 Subject: [PATCH 04/10] feat(timers): recall a cleared timer's pending token clearTimeout on a future-due timer now removes its anonymous token from the loop's own bookkeeping and re-arms the wakeup timer, restoring the immediate-cancellation behavior of the CFRunLoopTimerInvalidate path this branch replaced - a cleared long timer no longer wakes the runloop at its original due time just to swallow a tombstone. Tokens are anonymous and counted, so removing one token plus one sorted entry keeps slots 1:1 regardless of which producer's token is physically removed. Tokens already sent out as performed blocks cannot be recalled; those slots keep the tombstone. Also run only ONE matured token per ordered-timer fire, re-arming with the already-past due time so the remainder fires on the next runloop pass: foreign timers and blocks due between two matured tokens now interleave by due time instead of waiting out a batch, matching the one-token-one-message fairness of the Android MessageQueue design. --- NativeScript/runtime/EventLoop.h | 10 ++++++++++ NativeScript/runtime/EventLoop.mm | 31 ++++++++++++++++++++++++++----- NativeScript/runtime/Timers.cpp | 10 +++++++++- 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/NativeScript/runtime/EventLoop.h b/NativeScript/runtime/EventLoop.h index a3489fef..25334a65 100644 --- a/NativeScript/runtime/EventLoop.h +++ b/NativeScript/runtime/EventLoop.h @@ -103,6 +103,16 @@ class EventLoop : public std::enable_shared_from_this { */ void PostOrderedToken(double dueTimeMs); + /** + * Removes one not-yet-matured ordered token at this due time, un-arming its + * wakeup. Returns false when no such token is pending - it already went out + * as a performed block (or matured), which cannot be recalled; the caller + * must leave a tombstone for it instead. Tokens are anonymous and counted, + * so cancelling one token plus one item keeps slots 1:1 no matter which + * producer's token is physically removed. + */ + bool TryCancelOrderedToken(double dueTimeMs); + /** * Registers the ordered lane's external source. Home thread only; pass * nullptr to unregister (the source is being destroyed). diff --git a/NativeScript/runtime/EventLoop.mm b/NativeScript/runtime/EventLoop.mm index af53a5d8..2bd23cc4 100644 --- a/NativeScript/runtime/EventLoop.mm +++ b/NativeScript/runtime/EventLoop.mm @@ -229,6 +229,25 @@ static CFAbsoluteTime FireDateFor(double dueMs, double nowMs) { PostOrderedTokenLocked(dueTimeMs, NowMs()); } +bool EventLoop::TryCancelOrderedToken(double dueTimeMs) { + std::lock_guard lock(mutex_); + if (stopped_) { + return false; + } + auto bufferedIt = std::find(bufferedTokens_.begin(), bufferedTokens_.end(), dueTimeMs); + if (bufferedIt != bufferedTokens_.end()) { + bufferedTokens_.erase(bufferedIt); + return true; + } + auto pendingIt = pendingTokens_.find(dueTimeMs); + if (pendingIt == pendingTokens_.end()) { + return false; + } + pendingTokens_.erase(pendingIt); + ArmOrderedTimerLocked(NowMs()); + return true; +} + void EventLoop::SetTimerSource(OrderedTaskSource* source) { // home thread only, like every consumer of timerSource_ timerSource_ = source; @@ -460,21 +479,23 @@ static CFAbsoluteTime FireDateFor(double dueMs, double nowMs) { void EventLoop::OrderedTimerFired(CFRunLoopTimerRef timer, void* info) { auto self = static_cast(info); - size_t due = 0; + bool due = false; { std::lock_guard lock(self->mutex_); if (self->stopped_) { return; } auto now = NowMs(); - while (!self->pendingTokens_.empty() && *self->pendingTokens_.begin() <= now) { + if (!self->pendingTokens_.empty() && *self->pendingTokens_.begin() <= now) { self->pendingTokens_.erase(self->pendingTokens_.begin()); - due++; + due = true; } + // one matured token per fire: re-arming with an already-past due time + // fires again on the next runloop pass, so foreign timers and blocks due + // between two matured tokens interleave instead of waiting out a batch self->ArmOrderedTimerLocked(now); } - // tokens and slots stay 1:1: each matured token gets exactly one drain - while (due-- > 0) { + if (due) { self->RunOrderedTask(); } } diff --git a/NativeScript/runtime/Timers.cpp b/NativeScript/runtime/Timers.cpp index 9c5dafc5..6d28b7a0 100644 --- a/NativeScript/runtime/Timers.cpp +++ b/NativeScript/runtime/Timers.cpp @@ -137,7 +137,15 @@ class TimerState : public OrderedTaskSource { }); while (sit != sortedTimers_.end() && sit->dueTime == dueTime) { if (sit->id == taskId) { - sit->cancelled = true; + // a future-due token is still in the loop's own bookkeeping and can + // be recalled outright, un-arming its wakeup (the pre-event-loop + // behavior of CFRunLoopTimerInvalidate). One already sent out as a + // performed block cannot - its slot gets the tombstone instead. + if (eventLoop_->TryCancelOrderedToken(dueTime)) { + sortedTimers_.erase(sit); + } else { + sit->cancelled = true; + } break; } ++sit; From 7a8ef79b4ed493666850eaaeea1d3ca88956ee9f Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 12 Aug 2026 14:25:51 -0300 Subject: [PATCH 05/10] test: foreign-work interleave and tombstone probes Two tombstone probes posting a real CFRunLoopPerformBlock between two timer tokens (the cleared timer's token must not run the later timer or a queued macrotask ahead of the native block), and an NSTimer interleave spec: a foreign timer due between two overdue native timers fires between them, not after the batch. --- TestRunner/app/tests/EventLoopTests.js | 45 ++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/TestRunner/app/tests/EventLoopTests.js b/TestRunner/app/tests/EventLoopTests.js index 276935e5..a8a99ab3 100644 --- a/TestRunner/app/tests/EventLoopTests.js +++ b/TestRunner/app/tests/EventLoopTests.js @@ -138,6 +138,24 @@ describe("event loop ordered macrotasks", function () { expect(() => __ns__queueMacrotask("nope")).toThrowError(TypeError); expect(() => __ns__queueMacrotask()).toThrowError(TypeError); }); + + // the global setTimeout is the harness's NSTimer polyfill - a foreign + // CFRunLoopTimer on the same runloop. A foreign timer due between two of + // our matured tokens must fire between them (one token drains per fire; + // the runloop orders due timers by fire date), not after the batch. + it("interleaves with foreign NSTimer timeouts by due time", function (done) { + const order = []; + __ns__setTimeout(() => order.push("ns20"), 20); + setTimeout(() => order.push("nstimer25"), 25); + __ns__setTimeout(() => order.push("ns30"), 30); + // make all three overdue so they drain from the same runloop burst + const start = Date.now(); + while (Date.now() - start < 45) { } + setTimeout(() => { + expect(order).toEqual(["ns20", "nstimer25", "ns30"]); + done(); + }, 30); + }); }); // clearTimeout leaves a tombstone in the merged ordered domain, so the @@ -173,6 +191,33 @@ describe("event loop ordered tombstones", function () { }); }); + // The exact hazard the tombstone exists for: a foreign runloop block + // queued between two timer tokens. Without the tombstone, the cleared + // timer's token would fire the later timer ahead of the native block. + it("does not run a later timer ahead of native blocks queued between the tokens", function (done) { + const order = []; + const first = __ns__setTimeout(() => order.push("cleared"), 0); + CFRunLoopPerformBlock(CFRunLoopGetCurrent(), kCFRunLoopCommonModes, () => order.push("native")); + __ns__setTimeout(() => { + order.push("t3"); + expect(order).toEqual(["native", "t3"]); + done(); + }, 0); + __ns__clearTimeout(first); + }); + + it("does not run a queued macrotask ahead of native blocks queued between the tokens", function (done) { + const order = []; + const first = __ns__setTimeout(() => order.push("cleared"), 0); + CFRunLoopPerformBlock(CFRunLoopGetCurrent(), kCFRunLoopCommonModes, () => order.push("native")); + __ns__queueMacrotask(() => { + order.push("macro"); + expect(order).toEqual(["native", "macro"]); + done(); + }); + __ns__clearTimeout(first); + }); + it("keeps interval callbacks running after an unrelated clear", function (done) { const order = []; const cleared = __ns__setInterval(() => order.push("cleared"), 1); From 6ea96ea294df37d4ad716bbe6f913e82999f2b78 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 12 Aug 2026 15:00:24 -0300 Subject: [PATCH 06/10] fix(timers): carry due-now ordered tokens on the timer phase Route every ordered token through the single token CFRunLoopTimer - a due-now token gets a past fire date and fires on the next runloop pass - instead of delivering due-now tokens as performed blocks. This keeps same-instant tokens in fire-date order with foreign NSTimers, exactly like the per-timer CFRunLoopTimers the event loop replaced, and each fire yields a full runloop pass, so a self-rescheduling setTimeout(0) chain cannot monopolize block servicing. The yield spec measures this through the runtime's own before-waiting observer (the rejection drain): a rejection injected mid-chain must be reported while the chain is still running, alongside a foreign NSTimer and a GCD main-queue block. CFRunLoopObserver handlers created from JS never fire, so the drain is the only trustworthy probe. --- NativeScript/runtime/EventLoop.h | 19 ++++++----- NativeScript/runtime/EventLoop.mm | 28 ++++++--------- TestRunner/app/tests/EventLoopTests.js | 47 ++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 27 deletions(-) diff --git a/NativeScript/runtime/EventLoop.h b/NativeScript/runtime/EventLoop.h index 25334a65..0891822e 100644 --- a/NativeScript/runtime/EventLoop.h +++ b/NativeScript/runtime/EventLoop.h @@ -44,13 +44,15 @@ class OrderedTaskSource { * runtime's EventLoop; both descend from ExecuteOnRunLoop): * * Ordered lane - work whose ordering is observable against other app-level - * runloop work (spec'd macrotasks, JS timers). Due-now posts ride - * CFRunLoopPerformBlock as anonymous "task due" tokens, so they are strictly - * FIFO with foreign performed blocks on the same runloop; future-due tokens - * (delayed posts and Timers' tokens) arrive through one CFRunLoopTimer armed - * at the earliest pending due time. Each token runs the earliest due item - * across the ordered entries and the OrderedTaskSource (Timers) - one - * due-ordered domain, and a leftover token is a cheap no-op. + * runloop work (spec'd macrotasks, JS timers). Every post is an anonymous + * "task due" token on ONE CFRunLoopTimer armed at the earliest pending due + * time (a due-now token gets a past fire date and fires on the next pass), so + * tokens stay in fire-date order with foreign NSTimers and each fire yields a + * full runloop pass - performed-block delivery would be sooner but a + * self-rescheduling chain of blocks starves the before-waiting phase (CA + * rendering, autorelease pool, the rejection drain). Each token runs the + * earliest due item across the ordered entries and the OrderedTaskSource + * (Timers) - one due-ordered domain, and a leftover token is a cheap no-op. * * Internal lane - work in its own ordering domain: v8 platform foreground * tasks (Atomics.waitAsync wakeups, GC tasks, streaming-compilation @@ -68,7 +70,7 @@ class OrderedTaskSource { * replaces. Producers only post; entries run exclusively on the home thread, * which is the only place the isolate's Locker is taken. */ -class EventLoop : public std::enable_shared_from_this { +class EventLoop { public: explicit EventLoop(v8::Isolate* isolate) : isolate_(isolate) {} @@ -188,7 +190,6 @@ class EventLoop : public std::enable_shared_from_this { void PostInternalLocked(Entry entry, double delayMs); void PostOrderedLocked(Entry entry, double delayMs); void PostOrderedTokenLocked(double dueTimeMs, double now); - void PostTokenBlockLocked(); static std::unique_ptr TakeDueLocked(Lane& lane, bool nestableOnly, bool v8Only, double now); // earliest due entry time in the lane, or a negative value if none is due diff --git a/NativeScript/runtime/EventLoop.mm b/NativeScript/runtime/EventLoop.mm index 2bd23cc4..b8bb0c00 100644 --- a/NativeScript/runtime/EventLoop.mm +++ b/NativeScript/runtime/EventLoop.mm @@ -151,29 +151,21 @@ static CFAbsoluteTime FireDateFor(double dueMs, double nowMs) { } } -void EventLoop::PostTokenBlockLocked() { - // the block can outlive this object (a performed block cannot be - // cancelled), so it holds a weak reference and no-ops once the loop is gone - std::weak_ptr weakSelf = weak_from_this(); - CFRunLoopPerformBlock(loop_, kCFRunLoopCommonModes, ^{ - if (auto self = weakSelf.lock()) { - self->RunOrderedTask(); - } - }); - CFRunLoopWakeUp(loop_); -} - void EventLoop::PostOrderedTokenLocked(double dueTimeMs, double now) { if (loop_ == nullptr) { bufferedTokens_.push_back(dueTimeMs); return; } - if (dueTimeMs <= now) { - PostTokenBlockLocked(); - } else { - pendingTokens_.insert(dueTimeMs); - ArmOrderedTimerLocked(now); - } + // every token rides the timer phase, even due-now ones (a past fire date + // fires on the next runloop pass). Performed blocks would deliver sooner, + // but a self-rescheduling chain of them never lets the loop reach its + // before-waiting phase - starving CA rendering commits, the autorelease + // pool and the rejection drain - while a due timer still yields a full + // pass between fires. This also keeps due-now tokens in fire-date order + // with foreign NSTimers, like the per-timer CFRunLoopTimers this replaces. + pendingTokens_.insert(std::max(dueTimeMs, now)); + ArmOrderedTimerLocked(now); + CFRunLoopWakeUp(loop_); } bool EventLoop::PostInternal(std::function fn) { diff --git a/TestRunner/app/tests/EventLoopTests.js b/TestRunner/app/tests/EventLoopTests.js index a8a99ab3..2d4c07b6 100644 --- a/TestRunner/app/tests/EventLoopTests.js +++ b/TestRunner/app/tests/EventLoopTests.js @@ -158,6 +158,53 @@ describe("event loop ordered macrotasks", function () { }); }); +// A self-rescheduling immediate timer must not starve the rest of the +// runloop: foreign timers, the GCD main queue and the before-waiting phase +// all have to keep running between steps. The before-waiting probe is the +// runtime's own rejection drain (a native kCFRunLoopBeforeWaiting observer): +// a rejection created mid-chain must be reported while the chain is still +// running, not after it ends. +describe("event loop immediate timer yielding", function () { + it("yields to native work between immediate timer chain steps", function (done) { + let step = 0; + let nstimerStep = -1; + let mainQueueStep = -1; + let drainStep = -1; + const onRejection = (e) => { + e.preventDefault(); + drainStep = step; + }; + global.addEventListener("unhandledrejection", onRejection); + setTimeout(() => { nstimerStep = step; }, 0); // foreign NSTimer + NSOperationQueue.mainQueue.addOperationWithBlock(() => { mainQueueStep = step; }); + + (function chain() { + step++; + if (step === 5) { + Promise.reject(new Error("event loop yield probe")); + } + if (step < 50) { + __ns__setTimeout(chain, 0); + return; + } + // NSTimer wait: guarantees the loop reaches before-waiting at + // least once after the chain, so a starved drain still fires + // before the assertions instead of leaking into later specs + setTimeout(() => { + global.removeEventListener("unhandledrejection", onRejection); + expect(nstimerStep).toBeGreaterThan(0); + expect(nstimerStep).toBeLessThan(25); + expect(mainQueueStep).toBeGreaterThan(0); + expect(mainQueueStep).toBeLessThan(25); + expect(drainStep).toBeGreaterThan(4); + expect(drainStep).toBeLessThan(45); + done(); + }, 100); + })(); + }); + +}); + // clearTimeout leaves a tombstone in the merged ordered domain, so the // cleared timer's already-posted token consumes its own slot as a no-op // instead of running a later-scheduled item ahead of foreign runloop work From 8dfa2d7a557eafd18cc6cf6fe1920d07c3149812 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 12 Aug 2026 15:24:50 -0300 Subject: [PATCH 07/10] chore: delete the dead SetTimeout module Never initialized (its Init call has been commented out since the timers rework) and hardcoded to the main queue; the event loop's ordered lane is the replacement seam. --- NativeScript/runtime/Runtime.mm | 2 - NativeScript/runtime/SetTimeout.cpp | 106 ---------------------------- NativeScript/runtime/SetTimeout.h | 34 --------- v8ios.xcodeproj/project.pbxproj | 8 --- 4 files changed, 150 deletions(-) delete mode 100644 NativeScript/runtime/SetTimeout.cpp delete mode 100644 NativeScript/runtime/SetTimeout.h diff --git a/NativeScript/runtime/Runtime.mm b/NativeScript/runtime/Runtime.mm index d20dd46e..629d39c1 100644 --- a/NativeScript/runtime/Runtime.mm +++ b/NativeScript/runtime/Runtime.mm @@ -23,7 +23,6 @@ #include "TSHelpers.h" #include "WeakRef.h" #include "Worker.h" -// #include "SetTimeout.h" #include "IsolateWrapper.h" @@ -353,7 +352,6 @@ void DisposeIsolateWhenPossible(Isolate* isolate) { globalTemplate->Set(tns::ToV8String(isolate, "queueMicrotask"), qmtTemplate); } ObjectManager::Init(isolate, globalTemplate); - // SetTimeout::Init(isolate, globalTemplate); MetadataBuilder::RegisterConstantsOnGlobalObject(isolate, globalTemplate, isWorker); isolate->SetCaptureStackTraceForUncaughtExceptions(true, 100, StackTrace::kOverview); diff --git a/NativeScript/runtime/SetTimeout.cpp b/NativeScript/runtime/SetTimeout.cpp deleted file mode 100644 index a8472618..00000000 --- a/NativeScript/runtime/SetTimeout.cpp +++ /dev/null @@ -1,106 +0,0 @@ -#include -#include "SetTimeout.h" -#include "Helpers.h" -#include "Caches.h" - -using namespace v8; - -namespace tns { - -void SetTimeout::Init(Isolate* isolate, Local globalTemplate) { - Local setTimeoutFuncTemplate = FunctionTemplate::New(isolate, SetTimeoutCallback); - globalTemplate->Set(ToV8String(isolate, "setTimeout"), setTimeoutFuncTemplate); - - Local clearTimeoutFuncTemplate = FunctionTemplate::New(isolate, ClearTimeoutCallback); - globalTemplate->Set(ToV8String(isolate, "clearTimeout"), clearTimeoutFuncTemplate); -} - -void SetTimeout::SetTimeoutCallback(const FunctionCallbackInfo& args) { - Isolate* isolate = args.GetIsolate(); - if (!args[0]->IsFunction()) { - tns::Assert(false, isolate); - } - - Local context = isolate->GetCurrentContext(); - - double timeout = 0.0; - if (args.Length() > 1 && args[1]->IsNumber()) { - if (!args[1]->NumberValue(context).To(&timeout)) { - tns::Assert(false, isolate); - } - } - - // TODO: implement better unique number generator - uint32_t key = ++count_; - Local callback = args[0].As(); - dispatch_block_t block = dispatch_block_create(DISPATCH_BLOCK_INHERIT_QOS_CLASS, ^{ Elapsed(key); }); - CacheEntry entry(isolate, new Persistent(isolate, callback)); - cache_.emplace(key, entry); - - dispatch_time_t time = dispatch_time(DISPATCH_TIME_NOW, timeout * NSEC_PER_MSEC); - dispatch_after(time, dispatch_get_main_queue(), block); - - args.GetReturnValue().Set(key); -} - -void SetTimeout::ClearTimeoutCallback(const FunctionCallbackInfo& args) { - Isolate* isolate = args.GetIsolate(); - if (!args[0]->IsNumber()) { - tns::Assert(false, isolate); - } - - Local context = isolate->GetCurrentContext(); - double value; - if (!args[0]->NumberValue(context).To(&value)) { - tns::Assert(false, isolate); - } - - uint32_t key = value; - auto it = cache_.find(key); - if (it == cache_.end()) { - return; - } - - RemoveKey(key); -} - -void SetTimeout::Elapsed(const uint32_t key) { - auto it = cache_.find(key); - if (it == cache_.end()) { - return; - } - - Isolate* isolate = it->second.isolate_; - Persistent* poCallback = it->second.callback_; - v8::Locker locker(isolate); - Isolate::Scope isolate_scope(isolate); - HandleScope handle_scope(isolate); - - Local cb = poCallback->Get(isolate); - std::shared_ptr cache = Caches::Get(isolate); - Local context = cache->GetContext(); - Local global = context->Global(); - Local result; - if (!cb->Call(context, global, 0, nullptr).ToLocal(&result)) { - tns::Assert(false, isolate); - } - - RemoveKey(key); -} - -void SetTimeout::RemoveKey(const uint32_t key) { - auto it = cache_.find(key); - if (it == cache_.end()) { - return; - } - - Persistent* poCallback = it->second.callback_; - poCallback->Reset(); - delete poCallback; - cache_.erase(it); -} - -robin_hood::unordered_map SetTimeout::cache_; -uint32_t SetTimeout::count_ = 0; - -} diff --git a/NativeScript/runtime/SetTimeout.h b/NativeScript/runtime/SetTimeout.h deleted file mode 100644 index bba1bf0d..00000000 --- a/NativeScript/runtime/SetTimeout.h +++ /dev/null @@ -1,34 +0,0 @@ -#ifndef SetTimeout_h -#define SetTimeout_h - -#include "Common.h" -#include "robin_hood.h" - -namespace tns { - -class SetTimeout { -public: - static void Init(v8::Isolate* isolate, v8::Local globalTemplate); -private: - static void SetTimeoutCallback(const v8::FunctionCallbackInfo& args); - static void ClearTimeoutCallback(const v8::FunctionCallbackInfo& args); - static void Elapsed(const uint32_t key); - static void RemoveKey(const uint32_t key); - static uint32_t count_; - - struct CacheEntry { - CacheEntry(v8::Isolate* isolate, v8::Persistent* callback) - : isolate_(isolate), - callback_(callback) { - } - - v8::Isolate* isolate_; - v8::Persistent* callback_; - }; - - static robin_hood::unordered_map cache_; -}; - -} - -#endif /* SetTimeout_h */ diff --git a/v8ios.xcodeproj/project.pbxproj b/v8ios.xcodeproj/project.pbxproj index cdf9db6f..2ba17e1b 100644 --- a/v8ios.xcodeproj/project.pbxproj +++ b/v8ios.xcodeproj/project.pbxproj @@ -300,7 +300,6 @@ 4A5C201A2E2B000500000003 /* StructuredSerialization.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 4A5C201A2E2B000500000001 /* StructuredSerialization.cpp */; }; C2DDEB93229EAC8300345BFE /* ArgConverter.mm in Sources */ = {isa = PBXBuildFile; fileRef = C2DDEB6A229EAC8200345BFE /* ArgConverter.mm */; }; C2DDEB94229EAC8300345BFE /* Console.cpp in Sources */ = {isa = PBXBuildFile; fileRef = C2DDEB6B229EAC8200345BFE /* Console.cpp */; }; - C2DDEB95229EAC8300345BFE /* SetTimeout.cpp in Sources */ = {isa = PBXBuildFile; fileRef = C2DDEB6C229EAC8200345BFE /* SetTimeout.cpp */; }; C2DDEB96229EAC8300345BFE /* Helpers.h in Headers */ = {isa = PBXBuildFile; fileRef = C2DDEB6D229EAC8200345BFE /* Helpers.h */; }; C2DDEB97229EAC8300345BFE /* Metadata.h in Headers */ = {isa = PBXBuildFile; fileRef = C2DDEB6E229EAC8200345BFE /* Metadata.h */; }; C2DDEB98229EAC8300345BFE /* ArrayAdapter.h in Headers */ = {isa = PBXBuildFile; fileRef = C2DDEB6F229EAC8200345BFE /* ArrayAdapter.h */; }; @@ -308,7 +307,6 @@ C2DDEB9A229EAC8300345BFE /* MetadataBuilder.mm in Sources */ = {isa = PBXBuildFile; fileRef = C2DDEB71229EAC8200345BFE /* MetadataBuilder.mm */; }; C2DDEB9B229EAC8300345BFE /* DataWrapper.h in Headers */ = {isa = PBXBuildFile; fileRef = C2DDEB72229EAC8200345BFE /* DataWrapper.h */; }; C2DDEB9C229EAC8300345BFE /* ClassBuilder.cpp in Sources */ = {isa = PBXBuildFile; fileRef = C2DDEB73229EAC8200345BFE /* ClassBuilder.cpp */; }; - C2DDEB9D229EAC8300345BFE /* SetTimeout.h in Headers */ = {isa = PBXBuildFile; fileRef = C2DDEB74229EAC8200345BFE /* SetTimeout.h */; }; C2DDEB9E229EAC8300345BFE /* SymbolLoader.mm in Sources */ = {isa = PBXBuildFile; fileRef = C2DDEB75229EAC8200345BFE /* SymbolLoader.mm */; }; C2DDEB9F229EAC8300345BFE /* Tasks.h in Headers */ = {isa = PBXBuildFile; fileRef = C2DDEB76229EAC8200345BFE /* Tasks.h */; }; C2DDEBA0229EAC8300345BFE /* Interop.mm in Sources */ = {isa = PBXBuildFile; fileRef = C2DDEB77229EAC8200345BFE /* Interop.mm */; }; @@ -830,7 +828,6 @@ 4A5C201A2E2B000300000002 /* Performance.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = Performance.h; sourceTree = ""; }; C2DDEB6A229EAC8200345BFE /* ArgConverter.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = ArgConverter.mm; sourceTree = ""; }; C2DDEB6B229EAC8200345BFE /* Console.cpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.cpp; path = Console.cpp; sourceTree = ""; }; - C2DDEB6C229EAC8200345BFE /* SetTimeout.cpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.cpp; path = SetTimeout.cpp; sourceTree = ""; }; C2DDEB6D229EAC8200345BFE /* Helpers.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = Helpers.h; sourceTree = ""; }; C2DDEB6E229EAC8200345BFE /* Metadata.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = Metadata.h; sourceTree = ""; }; C2DDEB6F229EAC8200345BFE /* ArrayAdapter.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ArrayAdapter.h; sourceTree = ""; }; @@ -838,7 +835,6 @@ C2DDEB71229EAC8200345BFE /* MetadataBuilder.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = MetadataBuilder.mm; sourceTree = ""; }; C2DDEB72229EAC8200345BFE /* DataWrapper.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = DataWrapper.h; sourceTree = ""; }; C2DDEB73229EAC8200345BFE /* ClassBuilder.cpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.cpp; path = ClassBuilder.cpp; sourceTree = ""; }; - C2DDEB74229EAC8200345BFE /* SetTimeout.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SetTimeout.h; sourceTree = ""; }; C2DDEB75229EAC8200345BFE /* SymbolLoader.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = SymbolLoader.mm; sourceTree = ""; }; C2DDEB76229EAC8200345BFE /* Tasks.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = Tasks.h; sourceTree = ""; }; C2DDEB77229EAC8200345BFE /* Interop.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = Interop.mm; sourceTree = ""; }; @@ -1526,8 +1522,6 @@ C2DDEB7C229EAC8200345BFE /* Runtime.mm */, C2F4D0C92334A6E70008A2EB /* RuntimeConfig.h */, C2F4D0CC2334B1BC0008A2EB /* RuntimeConfig.cpp */, - C2DDEB74229EAC8200345BFE /* SetTimeout.h */, - C2DDEB6C229EAC8200345BFE /* SetTimeout.cpp */, C2C8EE7922CF64E4001F8CEC /* SimpleAllocator.h */, C2C8EE7822CF64E4001F8CEC /* SimpleAllocator.cpp */, C2DDEB87229EAC8300345BFE /* StringHasher.h */, @@ -1673,7 +1667,6 @@ C2DDEBAD229EAC8300345BFE /* Caches.h in Headers */, 3C48F68D2F57905500C14231 /* json.hpp in Headers */, C247C17022F82842001D2CA2 /* libffi.h in Headers */, - C2DDEB9D229EAC8300345BFE /* SetTimeout.h in Headers */, C2DDEB90229EAC8300345BFE /* MetadataBuilder.h in Headers */, C266569F22B282BA00EE15CC /* FunctionReference.h in Headers */, C2FEA17022A3C75C00A5C0FC /* InlineFunctions.h in Headers */, @@ -2339,7 +2332,6 @@ C2FEA16F22A3C75C00A5C0FC /* InlineFunctions.cpp in Sources */, C2DDEB9A229EAC8300345BFE /* MetadataBuilder.mm in Sources */, C266569722AFFFB000EE15CC /* Reference.cpp in Sources */, - C2DDEB95229EAC8300345BFE /* SetTimeout.cpp in Sources */, 6573B9EE291FE5B700B0ED7C /* JSIRuntime.m in Sources */, F6191AB229C0FCE8003F588F /* InspectorServer.mm in Sources */, 6573B9D3291FE29F00B0ED7C /* HostProxy.cpp in Sources */, From dc2081a515fd0702ab22376e279bcb458a0fba8d Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 12 Aug 2026 15:24:51 -0300 Subject: [PATCH 08/10] feat(modules): pump nestable v8 tasks while top-level await settles The module evaluation spin only performed microtask checkpoints, so an await whose resolution arrives as a v8 foreground task could never settle within the wait. Drain the event loop's nestable tasks each iteration - JS frames are on the stack, so like the inspector pause loops non-nestable tasks (e.g. Atomics.waitAsync wakeups, which v8 posts as non-nestable) stay queued and run from their own wakeups after the turn; the specs pin both behaviors. --- NativeScript/runtime/ModuleInternal.mm | 12 ++++++- TestRunner/app/tests/EventLoopTests.js | 34 +++++++++++++++++++ .../tests/esm/tla-foreground-task-import.mjs | 9 +++++ .../app/tests/esm/tla-foreground-task.mjs | 6 ++++ 4 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 TestRunner/app/tests/esm/tla-foreground-task-import.mjs create mode 100644 TestRunner/app/tests/esm/tla-foreground-task.mjs diff --git a/NativeScript/runtime/ModuleInternal.mm b/NativeScript/runtime/ModuleInternal.mm index a864298a..5a6c8c75 100644 --- a/NativeScript/runtime/ModuleInternal.mm +++ b/NativeScript/runtime/ModuleInternal.mm @@ -12,7 +12,7 @@ #include "ModuleInternalCallbacks.h" // for ResolveModuleCallback #include "NativeScriptException.h" #include "NsBuiltinModules.h" -#include "Runtime.h" // for GetAppConfigValue +#include "Runtime.h" #include "RuntimeConfig.h" using namespace v8; @@ -851,7 +851,17 @@ ScriptOrigin origin(urlString, 0, 0, false, -1, Local(), false, false, int maxAttempts = 100; int attempts = 0; + // an await whose resolution arrives as a v8 foreground task (e.g. + // Atomics.waitAsync, streaming compilation) never settles from + // checkpoints alone; JS frames are on the stack, so like the inspector + // pause loops only nestable tasks may run here + Runtime* runtime = Runtime::GetRuntime(isolate); + std::shared_ptr eventLoop = runtime != nullptr ? runtime->GetEventLoop() : nullptr; + while (attempts < maxAttempts && !promiseTc.HasCaught()) { + if (eventLoop != nullptr) { + eventLoop->RunNestableV8Tasks(); + } isolate->PerformMicrotaskCheckpoint(); if (promiseTc.HasCaught()) { diff --git a/TestRunner/app/tests/EventLoopTests.js b/TestRunner/app/tests/EventLoopTests.js index 2d4c07b6..4531e58d 100644 --- a/TestRunner/app/tests/EventLoopTests.js +++ b/TestRunner/app/tests/EventLoopTests.js @@ -280,6 +280,40 @@ describe("event loop ordered tombstones", function () { }); }); +// Top-level await whose continuation arrives as a v8 foreground task. The +// waitAsync wakeup is a NON-nestable task (v8 futex-emulation), so the +// synchronous require spin must not run it while JS frames are on the stack: +// require returns a namespace still in its TDZ, and the module completes from +// the loop right after the turn - on main it stayed incomplete forever. +describe("event loop top-level await", function () { + it("require() of a TLA module blocked on a foreground task completes after the turn", function (done) { + const mod = require("./esm/tla-foreground-task.mjs"); + expect(() => mod.value).toThrowError(ReferenceError); + __ns__setTimeout(() => { + expect(mod.value).toBe("ok"); + done(); + }, 100); + }); + + it("dynamic import of a TLA module blocked on a foreground task settles", function (done) { + let importSettled = false; + import("~/tests/esm/tla-foreground-task-import.mjs").then(mod => { + importSettled = true; + expect(mod.value).toBe("ok"); + }).catch(e => { + importSettled = true; + fail("dynamic import rejected: " + e); + }); + __ns__setTimeout(() => { + // the module itself must have completed from the loop by now... + expect(globalThis.__tlaImportProbeDone).toBe("ok"); + // ...and the import promise must have observed that completion + expect(importSettled).toBe(true); + done(); + }, 300); + }); +}); + describe("event loop workers", function () { beforeEach(function () { this.originalTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL; diff --git a/TestRunner/app/tests/esm/tla-foreground-task-import.mjs b/TestRunner/app/tests/esm/tla-foreground-task-import.mjs new file mode 100644 index 00000000..01fe6e99 --- /dev/null +++ b/TestRunner/app/tests/esm/tla-foreground-task-import.mjs @@ -0,0 +1,9 @@ +// Same shape as tla-foreground-task.mjs, kept separate so the dynamic-import +// spec is not served the module registry entry the require spec created. +const i32 = new Int32Array(new SharedArrayBuffer(4)); +const wait = Atomics.waitAsync(i32, 0, 0); +Atomics.notify(i32, 0); +export const value = await wait.value; +// lets the spec tell "module never completed" apart from "import promise +// never observed the completion" +globalThis.__tlaImportProbeDone = value; diff --git a/TestRunner/app/tests/esm/tla-foreground-task.mjs b/TestRunner/app/tests/esm/tla-foreground-task.mjs new file mode 100644 index 00000000..df5e04f6 --- /dev/null +++ b/TestRunner/app/tests/esm/tla-foreground-task.mjs @@ -0,0 +1,6 @@ +// Settles only if v8 foreground tasks are pumped while the module evaluation +// promise is pending: the notify wakeup arrives as a platform task. +const i32 = new Int32Array(new SharedArrayBuffer(4)); +const wait = Atomics.waitAsync(i32, 0, 0); +Atomics.notify(i32, 0); +export const value = await wait.value; From 7c72b7f19fbad56b1041f33bf8c234ab4bfbd73c Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 12 Aug 2026 16:29:22 -0300 Subject: [PATCH 09/10] fix: harden event loop edges from review - Inspector pause loops resolve the loop via LookupEventLoop with a null guard: GetEventLoop creates on miss, so a stale pause iteration could mint a registry entry for an isolate whose runtime is gone. - PostOrderedToken returns the key it actually recorded (an overdue due time is clamped to the loop's now); Timers stores it per cycle as postedTokenTime_ and cancels by it, so clearing an overdue timer recalls the token instead of leaving a tombstone plus a wakeup. - The sync PostToRuntimeLoop wait is released even when Shutdown drops the entry unrun: a shared_ptr deleter signals the semaphore when the posted closure is destroyed, whichever way that happens. - Timers::Init and postToken guard a null runtime/event loop, degrading to inert timers instead of crashing. - Stale performed-block wording in the ordered-lane docs replaced with the timer-phase contract. - One-shot guard in the terminate-with-queued-work spec: late echoes must not re-drive the handler and double-invoke done(). --- NativeScript/inspector/JsV8InspectorClient.mm | 9 ++++-- .../inspector/WorkerInspectorClient.mm | 9 ++++-- NativeScript/runtime/EventLoop.h | 19 +++++++----- NativeScript/runtime/EventLoop.mm | 14 +++++---- NativeScript/runtime/Runtime.mm | 4 +-- NativeScript/runtime/Timers.cpp | 29 ++++++++++++------- NativeScript/runtime/Timers.hpp | 4 +++ NativeScript/runtime/WorkerWrapper.mm | 13 +++++---- TestRunner/app/tests/EventLoopTests.js | 7 +++++ 9 files changed, 72 insertions(+), 36 deletions(-) diff --git a/NativeScript/inspector/JsV8InspectorClient.mm b/NativeScript/inspector/JsV8InspectorClient.mm index 5648715c..0d24ed92 100644 --- a/NativeScript/inspector/JsV8InspectorClient.mm +++ b/NativeScript/inspector/JsV8InspectorClient.mm @@ -438,8 +438,13 @@ bool ShouldRewriteSourceMapURLs() { } // JS frames are on the stack, so only nestable v8 foreground tasks may - // run; everything else fires from its own wakeup after resume - tns::NativeScriptPlatform::Instance()->GetEventLoop(isolate_)->RunNestableV8Tasks(); + // run; everything else fires from its own wakeup after resume. Lookup, + // never create: a create here could mint a registry entry for an isolate + // whose runtime is already gone + auto eventLoop = tns::NativeScriptPlatform::Instance()->LookupEventLoop(isolate_); + if (eventLoop != nullptr) { + eventLoop->RunNestableV8Tasks(); + } if (shouldWait && !terminated_) { dispatch_semaphore_wait(messageArrived_, dispatch_time(DISPATCH_TIME_NOW, 1 * NSEC_PER_MSEC)); // 1ms diff --git a/NativeScript/inspector/WorkerInspectorClient.mm b/NativeScript/inspector/WorkerInspectorClient.mm index 5772d807..9ef94bc5 100644 --- a/NativeScript/inspector/WorkerInspectorClient.mm +++ b/NativeScript/inspector/WorkerInspectorClient.mm @@ -192,8 +192,13 @@ StringView Make8BitStringView(const std::string& value) { } // JS frames are on the stack, so only nestable v8 foreground tasks may - // run; everything else fires from its own wakeup after resume - tns::NativeScriptPlatform::Instance()->GetEventLoop(isolate_)->RunNestableV8Tasks(); + // run; everything else fires from its own wakeup after resume. Lookup, + // never create: a create here could mint a registry entry for an isolate + // whose runtime is already gone + auto eventLoop = tns::NativeScriptPlatform::Instance()->LookupEventLoop(isolate_); + if (eventLoop != nullptr) { + eventLoop->RunNestableV8Tasks(); + } if (shouldWait && !pauseTerminated_ && !dying_) { dispatch_semaphore_wait(messageArrived_, dispatch_time(DISPATCH_TIME_NOW, 1 * NSEC_PER_MSEC)); // 1ms diff --git a/NativeScript/runtime/EventLoop.h b/NativeScript/runtime/EventLoop.h index 0891822e..8448dacc 100644 --- a/NativeScript/runtime/EventLoop.h +++ b/NativeScript/runtime/EventLoop.h @@ -91,8 +91,9 @@ class EventLoop { */ void Shutdown(); - // ordered lane: strictly FIFO with CFRunLoopPerformBlock work on the home - // runloop. Returns false if the post was dropped (loop already shut down). + // ordered lane: one due-ordered domain with JS timers, in fire-date order + // with foreign runloop timers. Returns false if the post was dropped (loop + // already shut down). bool PostOrdered(std::function fn); bool PostOrderedDelayed(std::function fn, double delayMs); @@ -101,15 +102,17 @@ class EventLoop { * (NowMs() units) for an item the OrderedTaskSource keeps in its own * bookkeeping (Timers). One token per item; the drain picks the earliest * due item across the source and the ordered entries, so the token needn't - * name what it will run. + * name what it will run. Returns the key actually recorded (an overdue due + * time is clamped to now) - the value TryCancelOrderedToken must be given + * to recall this token. */ - void PostOrderedToken(double dueTimeMs); + double PostOrderedToken(double dueTimeMs); /** * Removes one not-yet-matured ordered token at this due time, un-arming its - * wakeup. Returns false when no such token is pending - it already went out - * as a performed block (or matured), which cannot be recalled; the caller - * must leave a tombstone for it instead. Tokens are anonymous and counted, + * wakeup. Returns false when no such token is pending - it already matured, + * so its slot cannot be recalled; the caller must leave a tombstone for it + * instead. Tokens are anonymous and counted, * so cancelling one token plus one item keeps slots 1:1 no matter which * producer's token is physically removed. */ @@ -189,7 +192,7 @@ class EventLoop { // all *Locked members require mutex_ to be held void PostInternalLocked(Entry entry, double delayMs); void PostOrderedLocked(Entry entry, double delayMs); - void PostOrderedTokenLocked(double dueTimeMs, double now); + double PostOrderedTokenLocked(double dueTimeMs, double now); static std::unique_ptr TakeDueLocked(Lane& lane, bool nestableOnly, bool v8Only, double now); // earliest due entry time in the lane, or a negative value if none is due diff --git a/NativeScript/runtime/EventLoop.mm b/NativeScript/runtime/EventLoop.mm index b8bb0c00..6265af8d 100644 --- a/NativeScript/runtime/EventLoop.mm +++ b/NativeScript/runtime/EventLoop.mm @@ -151,10 +151,10 @@ static CFAbsoluteTime FireDateFor(double dueMs, double nowMs) { } } -void EventLoop::PostOrderedTokenLocked(double dueTimeMs, double now) { +double EventLoop::PostOrderedTokenLocked(double dueTimeMs, double now) { if (loop_ == nullptr) { bufferedTokens_.push_back(dueTimeMs); - return; + return dueTimeMs; } // every token rides the timer phase, even due-now ones (a past fire date // fires on the next runloop pass). Performed blocks would deliver sooner, @@ -163,9 +163,11 @@ static CFAbsoluteTime FireDateFor(double dueMs, double nowMs) { // pool and the rejection drain - while a due timer still yields a full // pass between fires. This also keeps due-now tokens in fire-date order // with foreign NSTimers, like the per-timer CFRunLoopTimers this replaces. - pendingTokens_.insert(std::max(dueTimeMs, now)); + double key = std::max(dueTimeMs, now); + pendingTokens_.insert(key); ArmOrderedTimerLocked(now); CFRunLoopWakeUp(loop_); + return key; } bool EventLoop::PostInternal(std::function fn) { @@ -213,12 +215,12 @@ static CFAbsoluteTime FireDateFor(double dueMs, double nowMs) { return true; } -void EventLoop::PostOrderedToken(double dueTimeMs) { +double EventLoop::PostOrderedToken(double dueTimeMs) { std::lock_guard lock(mutex_); if (stopped_) { - return; + return dueTimeMs; } - PostOrderedTokenLocked(dueTimeMs, NowMs()); + return PostOrderedTokenLocked(dueTimeMs, NowMs()); } bool EventLoop::TryCancelOrderedToken(double dueTimeMs) { diff --git a/NativeScript/runtime/Runtime.mm b/NativeScript/runtime/Runtime.mm index 629d39c1..d0b2ce26 100644 --- a/NativeScript/runtime/Runtime.mm +++ b/NativeScript/runtime/Runtime.mm @@ -586,8 +586,8 @@ void DisposeIsolateWhenPossible(Isolate* isolate) { } auto callback = std::make_shared>(isolate, info[0].As()); - // the ordered lane rides the home runloop's performed-block order, so - // the callback runs as a macrotask in strict FIFO order with JS timers + // the ordered lane is one due-ordered domain with JS timers, so the + // callback runs as a macrotask in strict FIFO order with them runtime->GetEventLoop()->PostOrdered([isolate, callback]() { auto cache = Caches::Get(isolate); Local context = cache->GetContext(); diff --git a/NativeScript/runtime/Timers.cpp b/NativeScript/runtime/Timers.cpp index 6d28b7a0..c859e2fc 100644 --- a/NativeScript/runtime/Timers.cpp +++ b/NativeScript/runtime/Timers.cpp @@ -108,10 +108,13 @@ class TimerState : public OrderedTaskSource { sortedTimers_.insert(it, TimerReference{id, dueTime}); } - void postToken(double dueTime, double now) { - // an overdue timer's token goes out at `now`: it must not jump items - // already queued ahead of it - eventLoop_->PostOrderedToken(dueTime <= now ? now : dueTime); + void postToken(const std::shared_ptr& task) { + if (eventLoop_ == nullptr) { + return; + } + // the loop clamps an overdue due time to its own `now`; remember the key + // it actually recorded so cancellation can recall this exact token + task->postedTokenTime_ = eventLoop_->PostOrderedToken(task->dueTime_); } void addTask(const std::shared_ptr& task) { @@ -141,7 +144,8 @@ class TimerState : public OrderedTaskSource { // be recalled outright, un-arming its wakeup (the pre-event-loop // behavior of CFRunLoopTimerInvalidate). One already sent out as a // performed block cannot - its slot gets the tombstone instead. - if (eventLoop_->TryCancelOrderedToken(dueTime)) { + if (eventLoop_ != nullptr && + eventLoop_->TryCancelOrderedToken(it->second->postedTokenTime_)) { sortedTimers_.erase(sit); } else { sit->cancelled = true; @@ -198,7 +202,7 @@ class TimerState : public OrderedTaskSource { if (task->repeats_) { task->dueTime_ = task->NextTime(now_ms()); insertSorted(task->id_, task->dueTime_); - postToken(task->dueTime_, now_ms()); + postToken(task); } v8::Local cb = task->callback_.Get(isolate); @@ -232,9 +236,14 @@ void Timers::Init(Isolate* isolate, Local globalTemplate) { auto timerState = new TimerState(); timerState->isolate_ = isolate; // Runtime::CreateIsolate bound the loop to this thread's runloop before - // any builtin initialization runs - timerState->eventLoop_ = Runtime::GetRuntime(isolate)->GetEventLoop(); - timerState->eventLoop_->SetTimerSource(timerState); + // any builtin initialization runs; guard anyway so an embedding path with + // no runtime degrades to inert timers instead of crashing + Runtime* runtime = Runtime::GetRuntime(isolate); + timerState->eventLoop_ = + runtime != nullptr ? runtime->GetEventLoop() : nullptr; + if (timerState->eventLoop_ != nullptr) { + timerState->eventLoop_->SetTimerSource(timerState); + } Caches::Get(isolate)->registerCacheBoundObject(timerState); tns::SetMethod(isolate, globalTemplate, "__ns__setTimeout", Timers::SetTimeoutCallback, @@ -300,7 +309,7 @@ void Timers::SetTimer(const v8::FunctionCallbackInfo& args, task->repeats_ = repeatable; task->dueTime_ = now + (double)timeout; state->addTask(task); - state->postToken(task->dueTime_, now); + state->postToken(task); } args.GetReturnValue().Set(id); } diff --git a/NativeScript/runtime/Timers.hpp b/NativeScript/runtime/Timers.hpp index b6fc150c..cc9b5c2e 100644 --- a/NativeScript/runtime/Timers.hpp +++ b/NativeScript/runtime/Timers.hpp @@ -53,6 +53,10 @@ class TimerTask { bool queued_ = false; double dueTime_ = -1; + // the ordered-token key actually posted for the current cycle: dueTime_, + // or the (later) post time when the timer was already overdue - the value + // cancellation must use to recall the token + double postedTokenTime_ = -1; double startTime_ = -1; int id_; IsolateWrapper wrapper; diff --git a/NativeScript/runtime/WorkerWrapper.mm b/NativeScript/runtime/WorkerWrapper.mm index 9a090ee7..246799a3 100644 --- a/NativeScript/runtime/WorkerWrapper.mm +++ b/NativeScript/runtime/WorkerWrapper.mm @@ -20,8 +20,8 @@ } // Posts to the target runtime's internal lane from the worker thread. When -// async is false, blocks until the entry ran; a post that raced the runtime's -// shutdown is dropped and not waited on. +// async is false, blocks until the entry ran - or until it is destroyed +// unrun by a shutdown that raced the post, which must release the waiter too. static void PostToRuntimeLoop(Runtime* runtime, std::function fn, bool async) { auto loop = runtime->GetEventLoop(); if (loop == nullptr) { @@ -32,10 +32,11 @@ static void PostToRuntimeLoop(Runtime* runtime, std::function fn, bool a return; } dispatch_semaphore_t done = dispatch_semaphore_create(0); - bool posted = loop->PostInternal([fn = std::move(fn), done]() { - fn(); - dispatch_semaphore_signal(done); - }); + // signals when the LAST reference dies: after fn ran, or when Shutdown + // clears the queue and destroys the entry without running it + std::shared_ptr completion(nullptr, [done](void*) { dispatch_semaphore_signal(done); }); + bool posted = loop->PostInternal([fn = std::move(fn), completion]() { fn(); }); + completion.reset(); if (posted) { dispatch_semaphore_wait(done, DISPATCH_TIME_FOREVER); } diff --git a/TestRunner/app/tests/EventLoopTests.js b/TestRunner/app/tests/EventLoopTests.js index 4531e58d..a8e79e59 100644 --- a/TestRunner/app/tests/EventLoopTests.js +++ b/TestRunner/app/tests/EventLoopTests.js @@ -377,7 +377,14 @@ describe("event loop workers", function () { it("survives terminating a worker with queued loop work", function (done) { const worker = new Worker("./eventLoopEchoWorker.js"); + let handled = false; worker.onmessage = function () { + // echoes of the queued messages can arrive before the terminate + // lands; only the first delivery drives the spec + if (handled) { + return; + } + handled = true; // several messages are still queued on the worker's loop when the // terminate lands; none of them may crash or hang teardown for (let i = 0; i < 20; i++) { From 9f7a95cf686fd8f0273d0af5ea798de01eb85750 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 12 Aug 2026 16:46:59 -0300 Subject: [PATCH 10/10] fix: replay buffered ordered tokens under their original keys PostOrderedToken returns the recorded key as the producer's recall handle; the bind-time flush re-clamped buffered keys against the bind time, so a pre-bind poster whose due time had passed could never recall its token. Insert buffered keys verbatim - a past key simply fires on the next runloop pass. Also note the reused-isolate-address window (tasks v8 posts during Isolate::New land in the stale stopped loop until the refresh right after) and finish the stale performed-block wording in Timers. --- NativeScript/runtime/EventLoop.mm | 6 +++++- NativeScript/runtime/Runtime.mm | 4 ++++ NativeScript/runtime/Timers.cpp | 18 +++++++++--------- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/NativeScript/runtime/EventLoop.mm b/NativeScript/runtime/EventLoop.mm index 6265af8d..57aa131b 100644 --- a/NativeScript/runtime/EventLoop.mm +++ b/NativeScript/runtime/EventLoop.mm @@ -79,11 +79,15 @@ static CFAbsoluteTime FireDateFor(double dueMs, double nowMs) { SignalInternalLocked(); } ArmInternalTimerLocked(now); + // replay buffered tokens under their original keys - PostOrderedToken + // already returned those to producers as the recall handle, so the flush + // must not re-clamp them (a past key simply fires on the next pass) auto tokens = std::move(bufferedTokens_); bufferedTokens_.clear(); for (double due : tokens) { - PostOrderedTokenLocked(due, now); + pendingTokens_.insert(due); } + ArmOrderedTimerLocked(now); } void EventLoop::Shutdown() { diff --git a/NativeScript/runtime/Runtime.mm b/NativeScript/runtime/Runtime.mm index d0b2ce26..dea78030 100644 --- a/NativeScript/runtime/Runtime.mm +++ b/NativeScript/runtime/Runtime.mm @@ -305,6 +305,10 @@ void DisposeIsolateWhenPossible(Isolate* isolate) { // the registry may hold an unbound loop - or a stopped one, when a worker // isolate reuses a disposed isolate's address. Refresh, then attach to this // thread; foreground tasks buffered so far start flowing from here on. + // (In the reused-address case, tasks v8 posted DURING Isolate::New landed + // in the stale stopped loop and were dropped - refreshing any earlier is + // impossible (the address isn't known) and refreshing on runner lookup + // would defeat the matched-erase teardown protection.) eventLoop_ = NativeScriptPlatform::Instance()->RefreshEventLoop(isolate); eventLoop_->BindToCurrentThread(); isolate->SetData(Constants::RUNTIME_SLOT, this); diff --git a/NativeScript/runtime/Timers.cpp b/NativeScript/runtime/Timers.cpp index c859e2fc..f97e2b6b 100644 --- a/NativeScript/runtime/Timers.cpp +++ b/NativeScript/runtime/Timers.cpp @@ -27,11 +27,11 @@ * Scheduling model: every scheduled timer posts one anonymous "due token" * through the runtime EventLoop's ordered lane, at a due time >= the timer's. * Timers therefore share one due-ordered domain with ordered macrotasks and - * stay FIFO with CFRunLoopPerformBlock work on the same runloop. The token - * does not name a timer: the EventLoop drain consumes the earliest due item - * across this list and its own ordered entries. Cancelled timers leave a - * tombstone so their token consumes a slot as a no-op instead of lending its - * position to a later item. + * stay in fire-date order with foreign runloop timers. The token does not + * name a timer: the EventLoop drain consumes the earliest due item across + * this list and its own ordered entries. Cancelling recalls the pending + * token when possible and otherwise leaves a tombstone so the matured token + * consumes a slot as a no-op instead of lending its position to a later item. */ using namespace v8; @@ -140,10 +140,10 @@ class TimerState : public OrderedTaskSource { }); while (sit != sortedTimers_.end() && sit->dueTime == dueTime) { if (sit->id == taskId) { - // a future-due token is still in the loop's own bookkeeping and can - // be recalled outright, un-arming its wakeup (the pre-event-loop - // behavior of CFRunLoopTimerInvalidate). One already sent out as a - // performed block cannot - its slot gets the tombstone instead. + // a not-yet-matured token is still in the loop's own bookkeeping + // and can be recalled outright, un-arming its wakeup (the + // pre-event-loop behavior of CFRunLoopTimerInvalidate). A matured + // one cannot - its slot gets the tombstone instead. if (eventLoop_ != nullptr && eventLoop_->TryCancelOrderedToken(it->second->postedTokenTime_)) { sortedTimers_.erase(sit);