From cb526bc2843df03946842b1ff5201911f960f048 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Tue, 11 Aug 2026 16:17:41 -0300 Subject: [PATCH 1/5] feat: run v8 platform foreground tasks on the runtime looper V8 platform foreground tasks (async WASM compilation callbacks, Atomics.waitAsync wakeups, GC finalization tasks) sat in the default platform's internal queues, which nothing pumped outside the WASM-scoped MessageLoopTimer (an ALooper fd fed by a detached 100ms-polling thread) and the inspector pause loops. Atomics.waitAsync promises never resolved at all. Wrap the default platform in NativeScriptPlatform: worker-thread scheduling, jobs, time and tracing still delegate to libplatform, but GetForegroundTaskRunner serves a per-isolate ForegroundTaskRunner that delivers tasks through a dedicated com.tns.EventLoopHandler bound to the runtime thread's Looper - the same anonymous-token scheme Timers use, so platform tasks are strictly FIFO-ordered with Handler.post runnables and JS timers on the same looper: - each posted task enqueues into a native queue (immediate deque plus a due-time-sorted delayed map) and posts one "task due" token; a token runs the earliest due task, then performs a microtask checkpoint, since a task may resolve promises without entering JS (e.g. Atomics.waitAsync), which kAuto's depth-0 drain never sees - delayed tasks ride sendMessageAtTime at ceil(dueTime), so a token never arrives before its due time - v8 requests the runner during Isolate::New, before the home thread is known, so the runner starts unbound and buffers; PrepareV8Runtime binds it to the thread's Looper and flushes one token per buffered task; posts are accepted from any thread - inspector pause loops can't receive tokens (the Java looper isn't spinning), so they drain nestable tasks directly; non-nestable tasks keep their queued tokens until the pause unwinds, and leftover tokens no-op like cleared-timer tokens - the runner shuts down in DestroyRuntime and is unregistered after isolate disposal, so workers can churn without leaking map entries MessageLoopTimer, its polling thread and the WebAssembly method proxies in message-loop-timer.js are removed: async WASM promises now resolve promptly through the runner with no start/stop windows. The runner is also the seam for future macrotask dispatch (e.g. performance API observer callbacks). Microtask policy is deliberately untouched. Adds Atomics.waitAsync regression tests (notify, timeout, sync mismatch, promise-chain ordering); the async cases hang without this change. --- test-app/app/src/main/assets/app/mainpage.js | 1 + .../main/assets/app/tests/testEventLoop.js | 63 +++ test-app/runtime/CMakeLists.txt | 3 +- .../src/main/cpp/JsV8InspectorClient.cpp | 8 +- .../runtime/src/main/cpp/MessageLoopTimer.cpp | 98 ----- .../runtime/src/main/cpp/MessageLoopTimer.h | 23 -- .../src/main/cpp/NativeScriptPlatform.cpp | 379 ++++++++++++++++++ .../src/main/cpp/NativeScriptPlatform.h | 205 ++++++++++ test-app/runtime/src/main/cpp/Runtime.cpp | 21 +- test-app/runtime/src/main/cpp/Runtime.h | 3 - .../src/main/cpp/WorkerInspectorClient.cpp | 8 +- .../src/main/cpp/js/message-loop-timer.js | 43 -- .../main/java/com/tns/EventLoopHandler.java | 61 +++ 13 files changed, 738 insertions(+), 178 deletions(-) create mode 100644 test-app/app/src/main/assets/app/tests/testEventLoop.js delete mode 100644 test-app/runtime/src/main/cpp/MessageLoopTimer.cpp delete mode 100644 test-app/runtime/src/main/cpp/MessageLoopTimer.h create mode 100644 test-app/runtime/src/main/cpp/NativeScriptPlatform.cpp create mode 100644 test-app/runtime/src/main/cpp/NativeScriptPlatform.h delete mode 100644 test-app/runtime/src/main/cpp/js/message-loop-timer.js create mode 100644 test-app/runtime/src/main/java/com/tns/EventLoopHandler.java diff --git a/test-app/app/src/main/assets/app/mainpage.js b/test-app/app/src/main/assets/app/mainpage.js index ca13a904d..9277a93af 100644 --- a/test-app/app/src/main/assets/app/mainpage.js +++ b/test-app/app/src/main/assets/app/mainpage.js @@ -19,6 +19,7 @@ shared.runWeakRefTests(); shared.runRuntimeTests(); shared.runWorkerTests(); require("./tests/testWebAssembly"); +require("./tests/testEventLoop"); require("./tests/testMultithreadedJavascript"); require("./tests/testInterfaceDefaultMethods"); require("./tests/testInterfaceStaticMethods"); diff --git a/test-app/app/src/main/assets/app/tests/testEventLoop.js b/test-app/app/src/main/assets/app/tests/testEventLoop.js new file mode 100644 index 000000000..95765a793 --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/testEventLoop.js @@ -0,0 +1,63 @@ +// V8 delivers these resolutions as platform foreground tasks, so they only +// settle if the runtime pumps its foreground task runner (EventLoopHandler). +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 => { + done.fail("Atomics.waitAsync promise rejected: " + e); + }); + + 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 => { + done.fail("Atomics.waitAsync promise rejected: " + e); + }); + }); + + 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(); + }); + + Atomics.notify(i32, 0); + }); +}); diff --git a/test-app/runtime/CMakeLists.txt b/test-app/runtime/CMakeLists.txt index f61bbd353..01648d79e 100644 --- a/test-app/runtime/CMakeLists.txt +++ b/test-app/runtime/CMakeLists.txt @@ -69,7 +69,6 @@ set(RUNTIME_BUILTIN_JS ${RUNTIME_BUILTIN_JS_DIR}/events.js ${RUNTIME_BUILTIN_JS_DIR}/inspect.js ${RUNTIME_BUILTIN_JS_DIR}/json-helper.js - ${RUNTIME_BUILTIN_JS_DIR}/message-loop-timer.js ${RUNTIME_BUILTIN_JS_DIR}/node-util.js ${RUNTIME_BUILTIN_JS_DIR}/ns-util.js ${RUNTIME_BUILTIN_JS_DIR}/primordials.js @@ -165,7 +164,6 @@ add_library( src/main/cpp/Logger.cpp src/main/cpp/LooperTasks.cpp src/main/cpp/ManualInstrumentation.cpp - src/main/cpp/MessageLoopTimer.cpp src/main/cpp/MetadataMethodInfo.cpp src/main/cpp/MetadataNode.cpp src/main/cpp/MetadataReader.cpp @@ -176,6 +174,7 @@ add_library( src/main/cpp/ModuleInternal.cpp src/main/cpp/ModuleInternalCallbacks.cpp src/main/cpp/NativeScriptException.cpp + src/main/cpp/NativeScriptPlatform.cpp src/main/cpp/NsBuiltinModules.cpp src/main/cpp/NumericCasts.cpp src/main/cpp/ObjectManager.cpp diff --git a/test-app/runtime/src/main/cpp/JsV8InspectorClient.cpp b/test-app/runtime/src/main/cpp/JsV8InspectorClient.cpp index 4487b327d..fae47004d 100644 --- a/test-app/runtime/src/main/cpp/JsV8InspectorClient.cpp +++ b/test-app/runtime/src/main/cpp/JsV8InspectorClient.cpp @@ -13,6 +13,7 @@ #include "Runtime.h" #include "NativeScriptException.h" #include "NativeScriptAssert.h" +#include "NativeScriptPlatform.h" #include "ArgConverter.h" #include "Constants.h" @@ -383,8 +384,11 @@ void JsV8InspectorClient::runMessageLoopOnPause(int context_group_id) { doDispatchMessage(inspectorMessage); } - while (v8::platform::PumpMessageLoop(Runtime::platform, isolate_)) { - } + // JS frames are on the stack, so only nestable foreground tasks may + // run; non-nestable ones fire from their queue tokens after resume + tns::NativeScriptPlatform::Instance() + ->GetForegroundRunner(isolate_) + ->RunNestableTasks(); } isPausedNestedLoop_.store(false, std::memory_order_release); terminated_ = false; diff --git a/test-app/runtime/src/main/cpp/MessageLoopTimer.cpp b/test-app/runtime/src/main/cpp/MessageLoopTimer.cpp deleted file mode 100644 index 45bf50f29..000000000 --- a/test-app/runtime/src/main/cpp/MessageLoopTimer.cpp +++ /dev/null @@ -1,98 +0,0 @@ -#include "MessageLoopTimer.h" -#include -#include -#include -#include -#include "include/libplatform/libplatform.h" -#include "NativeScriptAssert.h" -#include "ArgConverter.h" -#include "BuiltinLoader.h" -#include "Runtime.h" - -using namespace tns; -using namespace v8; - -static const int SLEEP_INTERVAL_MS = 100; - -void MessageLoopTimer::Init(Local context) { - Isolate* isolate = v8::Isolate::GetCurrent(); - - Local ext = External::New(isolate, this, v8::kExternalPointerTypeTagDefault); - Local startFunc; - Local stopFunc; - bool success = Function::New(context, MessageLoopTimer::StartCallback, ext).ToLocal(&startFunc); - assert(success); - success = Function::New(context, MessageLoopTimer::StopCallback, ext).ToLocal(&stopFunc); - assert(success); - - Local binding = Object::New(isolate); - binding->Set(context, ArgConverter::ConvertToV8String(isolate, "messageLoopTimerStart"), startFunc); - binding->Set(context, ArgConverter::ConvertToV8String(isolate, "messageLoopTimerStop"), stopFunc); - - success = !BuiltinLoader::RunBuiltin(context, BuiltinId::kMessageLoopTimer, binding).IsEmpty(); - assert(success); -} - -void MessageLoopTimer::StartCallback(const v8::FunctionCallbackInfo& info) { - auto self = static_cast(info.Data().As()->Value(v8::kExternalPointerTypeTagDefault)); - if (self->m_isRunning) { - return; - } - - self->m_isRunning = true; - - auto looper = ALooper_forThread(); - if (looper == nullptr) { - __android_log_print(ANDROID_LOG_ERROR, "TNS.Native", "Unable to get looper for the current thread"); - return; - } - - int status = pipe(self->m_fd); - if (status != 0) { - __android_log_print(ANDROID_LOG_ERROR, "TNS.Native", "Unable to create a pipe: %s", strerror(errno)); - return; - } - - Isolate* isolate = info.GetIsolate(); - ALooper_addFd(looper, self->m_fd[0], 0, ALOOPER_EVENT_INPUT, MessageLoopTimer::PumpMessageLoopCallback, isolate); - - std::thread worker(MessageLoopTimer::WorkerThreadRun, self); - - worker.detach(); -} - -void MessageLoopTimer::StopCallback(const v8::FunctionCallbackInfo& info) { - auto self = static_cast(info.Data().As()->Value(v8::kExternalPointerTypeTagDefault)); - if (!self->m_isRunning) { - return; - } - - self->m_isRunning = false; -} - -int MessageLoopTimer::PumpMessageLoopCallback(int fd, int events, void* data) { - uint8_t msg; - read(fd, &msg, sizeof(uint8_t)); - - auto isolate = static_cast(data); - v8::Locker locker(isolate); - v8::Isolate::Scope isolate_scope(isolate); - v8::HandleScope handleScope(isolate); - - while (v8::platform::PumpMessageLoop(Runtime::platform, isolate)) { - isolate->PerformMicrotaskCheckpoint(); - } - - return msg; -} - -void MessageLoopTimer::WorkerThreadRun(MessageLoopTimer* timer) { - while (timer->m_isRunning) { - uint8_t msg = 1; - write(timer->m_fd[1], &msg, sizeof(uint8_t)); - std::this_thread::sleep_for(std::chrono::milliseconds(SLEEP_INTERVAL_MS)); - } - - uint8_t msg = 0; - write(timer->m_fd[1], &msg, sizeof(uint8_t)); -} diff --git a/test-app/runtime/src/main/cpp/MessageLoopTimer.h b/test-app/runtime/src/main/cpp/MessageLoopTimer.h deleted file mode 100644 index 8bdb929ca..000000000 --- a/test-app/runtime/src/main/cpp/MessageLoopTimer.h +++ /dev/null @@ -1,23 +0,0 @@ -#ifndef MESSAGELOOPTIMER_H -#define MESSAGELOOPTIMER_H - -#include "v8.h" - -namespace tns { - -class MessageLoopTimer { -public: - void Init(v8::Local context); -private: - bool m_isRunning; - int m_fd[2]; - - static void StartCallback(const v8::FunctionCallbackInfo& info); - static void StopCallback(const v8::FunctionCallbackInfo& info); - static int PumpMessageLoopCallback(int fd, int events, void* data); - static void WorkerThreadRun(MessageLoopTimer* timer); -}; - -} - -#endif //MESSAGELOOPTIMER_H diff --git a/test-app/runtime/src/main/cpp/NativeScriptPlatform.cpp b/test-app/runtime/src/main/cpp/NativeScriptPlatform.cpp new file mode 100644 index 000000000..9d2679e1b --- /dev/null +++ b/test-app/runtime/src/main/cpp/NativeScriptPlatform.cpp @@ -0,0 +1,379 @@ +#include "NativeScriptPlatform.h" + +#include +#include +#include +#include +#include +#include + +#include "JEnv.h" +#include "JniLocalRef.h" +#include "NativeScriptException.h" + +using namespace v8; + +namespace { + +// same clock as android.os.SystemClock.uptimeMillis() +double now_ms() { + struct timespec res; + clock_gettime(CLOCK_MONOTONIC, &res); + return 1000.0 * res.tv_sec + (double) res.tv_nsec / 1e6; +} + +} // namespace + +namespace tns { + +jclass ForegroundTaskRunner::EVENT_LOOP_HANDLER_CLASS = nullptr; +jmethodID ForegroundTaskRunner::EVENT_LOOP_HANDLER_CTOR = nullptr; +jmethodID ForegroundTaskRunner::EVENT_LOOP_HANDLER_POST = nullptr; +jmethodID ForegroundTaskRunner::EVENT_LOOP_HANDLER_RELEASE = nullptr; + +void ForegroundTaskRunner::BindToCurrentThread() { + JEnv env; + std::vector tokens; + jobject handler = nullptr; + { + std::lock_guard lock(mutex_); + if (handler_ != nullptr || stopped_) { + return; + } + if (EVENT_LOOP_HANDLER_CLASS == nullptr) { + // JEnv::FindClass caches a global ref to the class. The first bind + // happens on the main runtime's thread before any worker exists, + // so the one-time write is not racy. + EVENT_LOOP_HANDLER_CLASS = env.FindClass("com/tns/EventLoopHandler"); + assert(EVENT_LOOP_HANDLER_CLASS != nullptr); + EVENT_LOOP_HANDLER_CTOR = env.GetMethodID(EVENT_LOOP_HANDLER_CLASS, "", "(J)V"); + EVENT_LOOP_HANDLER_POST = env.GetMethodID(EVENT_LOOP_HANDLER_CLASS, "post", "(J)V"); + EVENT_LOOP_HANDLER_RELEASE = env.GetMethodID(EVENT_LOOP_HANDLER_CLASS, "release", "()V"); + } + JniLocalRef localHandler(env.NewObject(EVENT_LOOP_HANDLER_CLASS, EVENT_LOOP_HANDLER_CTOR, + reinterpret_cast(this))); + handler = env.NewGlobalRef(localHandler); + handler_ = handler; + // tasks posted before the home thread was known get their tokens now + for (auto& entry : immediate_) { + tokens.push_back((jlong) entry.time); + } + for (auto& pair : delayed_) { + tokens.push_back((jlong) std::ceil(pair.first)); + } + } + for (auto when : tokens) { + env.CallVoidMethod(handler, EVENT_LOOP_HANDLER_POST, when); + } +} + +void ForegroundTaskRunner::Shutdown() { + jobject handler; + { + std::lock_guard lock(mutex_); + if (stopped_) { + return; + } + stopped_ = true; + immediate_.clear(); + delayed_.clear(); + handler = handler_; + } + if (handler != nullptr) { + // the global ref stays alive until the runner is destroyed: an + // off-thread post may have read handler_ just before stopped_ was set + // and still be calling post() on it - released handlers ignore tokens + JEnv env; + env.CallVoidMethod(handler, EVENT_LOOP_HANDLER_RELEASE); + } +} + +ForegroundTaskRunner::~ForegroundTaskRunner() { + // normally a no-op: RuntimeDestroyed already shut the runner down + Shutdown(); + if (handler_ != nullptr) { + JEnv env; + env.DeleteGlobalRef(handler_); + handler_ = nullptr; + } +} + +void ForegroundTaskRunner::PostToken(jobject handler, jlong uptimeMillis) { + JEnv env; + env.CallVoidMethod(handler, EVENT_LOOP_HANDLER_POST, uptimeMillis); +} + +void ForegroundTaskRunner::PostImmediate(std::unique_ptr task, bool nestable) { + auto now = now_ms(); + jobject handler; + { + std::lock_guard lock(mutex_); + if (stopped_) { + return; + } + immediate_.push_back(Entry{std::move(task), nestable, now}); + handler = handler_; + } + if (handler != nullptr) { + PostToken(handler, (jlong) now); + } +} + +void ForegroundTaskRunner::PostDelayed(std::unique_ptr task, bool nestable, + double delay_in_seconds) { + auto now = now_ms(); + auto due = now + std::max(delay_in_seconds, 0.0) * 1000.0; + jobject handler; + { + std::lock_guard lock(mutex_); + if (stopped_) { + return; + } + delayed_.emplace(due, Entry{std::move(task), nestable, due}); + handler = handler_; + } + if (handler != nullptr) { + // ceil so the token never arrives before the due time + PostToken(handler, (jlong) std::ceil(due)); + } +} + +void ForegroundTaskRunner::PostTaskImpl(std::unique_ptr task, + const SourceLocation& location) { + PostImmediate(std::move(task), true); +} + +void ForegroundTaskRunner::PostNonNestableTaskImpl(std::unique_ptr task, + const SourceLocation& location) { + PostImmediate(std::move(task), false); +} + +void ForegroundTaskRunner::PostDelayedTaskImpl(std::unique_ptr task, + double delay_in_seconds, + const SourceLocation& location) { + PostDelayed(std::move(task), true, delay_in_seconds); +} + +void ForegroundTaskRunner::PostNonNestableDelayedTaskImpl(std::unique_ptr task, + double delay_in_seconds, + const SourceLocation& location) { + PostDelayed(std::move(task), false, delay_in_seconds); +} + +std::unique_ptr ForegroundTaskRunner::TakeDueTaskLocked(bool nestableOnly, double now) { + auto imIt = immediate_.begin(); + if (nestableOnly) { + while (imIt != immediate_.end() && !imIt->nestable) { + ++imIt; + } + } + auto delIt = delayed_.begin(); + if (nestableOnly) { + while (delIt != delayed_.end() && !delIt->second.nestable) { + ++delIt; + } + } + bool hasImmediate = imIt != immediate_.end(); + bool hasDelayed = delIt != delayed_.end() && delIt->first <= now; + if (hasImmediate && (!hasDelayed || imIt->time <= delIt->first)) { + auto task = std::move(imIt->task); + immediate_.erase(imIt); + return task; + } + if (hasDelayed) { + auto task = std::move(delIt->second.task); + delayed_.erase(delIt); + return task; + } + return nullptr; +} + +void ForegroundTaskRunner::RunTask() { + std::unique_ptr task; + { + std::lock_guard lock(mutex_); + if (stopped_) { + return; + } + task = TakeDueTaskLocked(false, now_ms()); + } + if (task == nullptr) { + // leftover token: the task ran early from a nested loop drain, or the + // earliest delayed task isn't due yet + return; + } + auto isolate = isolate_; + v8::Locker locker(isolate); + v8::Isolate::Scope isolate_scope(isolate); + v8::HandleScope handleScope(isolate); + task->Run(); + // a task may enqueue microtasks without entering JS (e.g. resolving the + // Atomics.waitAsync promise), which never reaches kAuto's depth-0 drain + isolate->PerformMicrotaskCheckpoint(); +} + +void ForegroundTaskRunner::RunNestableTasks() { + while (true) { + std::unique_ptr task; + { + std::lock_guard lock(mutex_); + if (stopped_) { + return; + } + task = TakeDueTaskLocked(true, now_ms()); + } + if (task == nullptr) { + return; + } + v8::Locker locker(isolate_); + v8::Isolate::Scope isolate_scope(isolate_); + v8::HandleScope handleScope(isolate_); + task->Run(); + } +} + +NativeScriptPlatform* NativeScriptPlatform::instance_ = nullptr; + +NativeScriptPlatform::NativeScriptPlatform(std::unique_ptr defaultPlatform) + : default_(std::move(defaultPlatform)) { + instance_ = this; +} + +std::shared_ptr NativeScriptPlatform::GetForegroundRunner(Isolate* isolate) { + std::lock_guard lock(runnersMutex_); + auto it = runners_.find(isolate); + if (it != runners_.end()) { + return it->second; + } + auto runner = std::make_shared(isolate); + runners_.emplace(isolate, runner); + return runner; +} + +void NativeScriptPlatform::RuntimeDestroyed(Isolate* isolate) { + std::shared_ptr runner; + { + std::lock_guard lock(runnersMutex_); + auto it = runners_.find(isolate); + if (it == runners_.end()) { + return; + } + runner = it->second; + } + runner->Shutdown(); +} + +void NativeScriptPlatform::IsolateDisposed(Isolate* isolate) { + std::lock_guard lock(runnersMutex_); + runners_.erase(isolate); +} + +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 Java MessageQueue's FIFO order is + // the priority model of the runtime thread + return GetForegroundRunner(isolate); +} + +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 + +extern "C" JNIEXPORT void JNICALL Java_com_tns_EventLoopHandler_nativeRunTask( + JNIEnv* env, jclass clazz, jlong nativeRunnerPtr) { + try { + reinterpret_cast(nativeRunnerPtr)->RunTask(); + } catch (tns::NativeScriptException& e) { + e.ReThrowToJava(); + } catch (std::exception& e) { + std::stringstream ss; + ss << "Error: c++ exception: " << e.what() << std::endl; + tns::NativeScriptException nsEx(ss.str()); + nsEx.ReThrowToJava(); + } catch (...) { + tns::NativeScriptException nsEx(std::string("Error: c++ exception!")); + nsEx.ReThrowToJava(); + } +} diff --git a/test-app/runtime/src/main/cpp/NativeScriptPlatform.h b/test-app/runtime/src/main/cpp/NativeScriptPlatform.h new file mode 100644 index 000000000..566333914 --- /dev/null +++ b/test-app/runtime/src/main/cpp/NativeScriptPlatform.h @@ -0,0 +1,205 @@ +#ifndef TEST_APP_NATIVESCRIPTPLATFORM_H +#define TEST_APP_NATIVESCRIPTPLATFORM_H + +#include +#include +#include +#include +#include +#include "v8.h" +#include "v8-platform.h" +#include "robin_hood.h" + +namespace tns { + +/** + * Foreground v8::TaskRunner for one isolate, delivering tasks on the + * isolate's home thread via a dedicated Java Handler (com.tns.EventLoopHandler) + * bound to that thread's Looper. + * + * Scheduling model (same as Timers): every posted task enqueues one anonymous + * "task due" token on the Java MessageQueue, so platform tasks are strictly + * FIFO-ordered with Handler.post runnables and JS timers on the same looper. + * The token doesn't name a task: each token runs the front of the native + * queue (or the earliest due delayed task); a token whose work was already + * drained (see RunNestableTasks) is a no-op. + * + * V8 may request this runner (and post to it) before the isolate's home + * thread is known - e.g. during Isolate::New - so the runner starts unbound + * and buffers tasks; BindToCurrentThread attaches the Java handler and flushes + * one token per buffered task. Posts are accepted from any thread. + */ +class ForegroundTaskRunner : public v8::TaskRunner { +public: + explicit ForegroundTaskRunner(v8::Isolate* isolate) : isolate_(isolate) {} + + ~ForegroundTaskRunner() override; + + /** + * Creates the Java handler bound to the calling thread's Looper and posts + * tokens for tasks buffered before the bind. Must be called on the + * isolate's home thread, before that thread's looper starts dispatching. + */ + void BindToCurrentThread(); + + /** + * Releases the Java handler (removing all pending tokens) and drops all + * queued tasks; posts after this are silently dropped. Must be called on + * the home thread, before the isolate is disposed. + */ + void Shutdown(); + + /** + * Runs at most one due task, then performs a microtask checkpoint. + * Invoked by Java EventLoopHandler.handleMessage once per token, on the + * home thread. + */ + void RunTask(); + + /** + * Runs all currently due nestable tasks without a microtask checkpoint. + * For nested message loops (inspector pause) where the Java looper isn't + * spinning: JS is on the stack, so non-nestable tasks stay queued and run + * from their tokens after the loop unwinds. + */ + void RunNestableTasks(); + + 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 v8::SourceLocation& location) override; + + void PostNonNestableTaskImpl(std::unique_ptr task, + const v8::SourceLocation& location) override; + + void PostDelayedTaskImpl(std::unique_ptr task, + double delay_in_seconds, + const v8::SourceLocation& location) override; + + void PostNonNestableDelayedTaskImpl(std::unique_ptr task, + double delay_in_seconds, + const v8::SourceLocation& location) override; + +private: + struct Entry { + std::unique_ptr task; + bool nestable; + // enqueue time for immediate tasks, due time for delayed ones (both + // CLOCK_MONOTONIC ms) so TakeDueTaskLocked has one comparable order + double time; + }; + + void PostImmediate(std::unique_ptr task, bool nestable); + void PostDelayed(std::unique_ptr task, bool nestable, + double delay_in_seconds); + // returns the earliest due task, or nullptr; caller must hold mutex_ + std::unique_ptr TakeDueTaskLocked(bool nestableOnly, double now); + static void PostToken(jobject handler, jlong uptimeMillis); + + v8::Isolate* isolate_; + std::mutex mutex_; + std::deque immediate_; + // delayed tasks keyed by absolute due time (CLOCK_MONOTONIC ms, the same + // clock as uptimeMillis); each posted its token at ceil(dueTime) + std::multimap delayed_; + // global ref to the com.tns.EventLoopHandler for the isolate's home thread + jobject handler_ = nullptr; + bool stopped_ = false; + + // process-wide JNI cache, written once under the first bind's lock + static jclass EVENT_LOOP_HANDLER_CLASS; + static jmethodID EVENT_LOOP_HANDLER_CTOR; + static jmethodID EVENT_LOOP_HANDLER_POST; + static jmethodID EVENT_LOOP_HANDLER_RELEASE; +}; + +/** + * v8::Platform that delegates worker-thread scheduling, time and tracing to + * the default libplatform implementation but serves per-isolate foreground + * task runners riding each runtime thread's Java Looper. This is what makes + * v8's own foreground tasks (WASM async compilation, Atomics.waitAsync + * wakeups, GC finalization) actually run - nothing pumps the default + * platform's internal queues. + */ +class NativeScriptPlatform : public v8::Platform { +public: + explicit NativeScriptPlatform(std::unique_ptr defaultPlatform); + + static NativeScriptPlatform* Instance() { + return instance_; + } + + /** + * Returns the isolate's runner, creating an unbound one if v8 asks before + * Runtime::PrepareV8Runtime binds it to the isolate's home thread. + */ + std::shared_ptr GetForegroundRunner(v8::Isolate* isolate); + + /** + * Shuts down the isolate's runner (home thread only). The map entry + * survives until IsolateDisposed so late GetForegroundTaskRunner calls + * during teardown see the stopped runner instead of a fresh one. + */ + void RuntimeDestroyed(v8::Isolate* isolate); + + /** + * Drops the runner map entry. Call after v8::Isolate::Dispose, when the + * isolate pointer may be reused for a future isolate. + */ + void IsolateDisposed(v8::Isolate* isolate); + + // 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: + std::unique_ptr default_; + std::mutex runnersMutex_; + robin_hood::unordered_map> runners_; + + static NativeScriptPlatform* instance_; +}; + +} // namespace tns + +#endif // TEST_APP_NATIVESCRIPTPLATFORM_H diff --git a/test-app/runtime/src/main/cpp/Runtime.cpp b/test-app/runtime/src/main/cpp/Runtime.cpp index b463cb728..4de85f95f 100644 --- a/test-app/runtime/src/main/cpp/Runtime.cpp +++ b/test-app/runtime/src/main/cpp/Runtime.cpp @@ -30,6 +30,7 @@ #include "ModuleInternalCallbacks.h" #include "NativeScriptAssert.h" #include "NativeScriptException.h" +#include "NativeScriptPlatform.h" #include "SimpleAllocator.h" #include "SimpleProfiler.h" #include "URLImpl.h" @@ -141,7 +142,6 @@ Runtime::Runtime(JNIEnv* env, jobject runtime, int id) m_runGC(false) { m_runtime = env->NewGlobalRef(runtime); m_objectManager = new ObjectManager(m_runtime); - m_loopTimer = new MessageLoopTimer(); { std::lock_guard lock(s_runtimeCacheMutex); s_id2RuntimeCache.emplace(id, this); @@ -296,7 +296,9 @@ void Runtime::Init(JNIEnv* env, jstring filesPath, jstring nativeLibDir, Runtime::~Runtime() { delete this->m_objectManager; - delete this->m_loopTimer; + // the isolate pointer may be reused by a future isolate once disposed, so + // the platform's runner entry has to go before this Runtime is forgotten + NativeScriptPlatform::Instance()->IsolateDisposed(m_isolate); CallbackHandlers::RemoveIsolateEntries(m_isolate); if (m_isMainThread) { if (m_mainLooper_fd[0] != -1) { @@ -591,7 +593,10 @@ static void InitializeV8() { // per isolate. Runtime::Init has already read them out of the Java config. V8::SetFlagsFromString(Constants::V8_STARTUP_FLAGS.c_str(), Constants::V8_STARTUP_FLAGS.size()); - Runtime::platform = v8::platform::NewDefaultPlatform().release(); + // wrap the default platform so foreground tasks ride each runtime thread's + // Java Looper instead of sitting in never-pumped libplatform queues + Runtime::platform = + new NativeScriptPlatform(v8::platform::NewDefaultPlatform()); V8::InitializePlatform(Runtime::platform); V8::Initialize(); } @@ -630,6 +635,11 @@ Isolate* Runtime::PrepareV8Runtime(const string& filesPath, std::lock_guard lock(s_runtimeCacheMutex); s_isolate2RuntimesCache[isolate] = this; } + // attach the isolate's foreground task runner to this thread's looper; + // tasks v8 buffered during Isolate::New start flowing from here on + NativeScriptPlatform::Instance() + ->GetForegroundRunner(isolate) + ->BindToCurrentThread(); v8::Locker locker(isolate); Isolate::Scope isolate_scope(isolate); HandleScope handleScope(isolate); @@ -919,8 +929,6 @@ Isolate* Runtime::PrepareV8Runtime(const string& filesPath, m_arrayBufferHelper.CreateConvertFunctions(context, global, m_objectManager); - m_loopTimer->Init(context); - this->m_context = new Persistent(isolate, context); s_mainThreadInitialized = true; @@ -971,6 +979,9 @@ void Runtime::DestroyRuntime() { // will have their posts dropped from now on m_looperTasks->Terminate(); } + // stop the foreground task runner before the isolate goes away; v8 posts + // made during teardown are dropped + NativeScriptPlatform::Instance()->RuntimeDestroyed(m_isolate); // The events state holds v8::Global handles (backing event target, dispatch // closures and tracked promise rejections) - reset them while the isolate // is still alive. diff --git a/test-app/runtime/src/main/cpp/Runtime.h b/test-app/runtime/src/main/cpp/Runtime.h index ec80975f9..64f06ffc7 100644 --- a/test-app/runtime/src/main/cpp/Runtime.h +++ b/test-app/runtime/src/main/cpp/Runtime.h @@ -9,7 +9,6 @@ #include "ArrayBufferHelper.h" #include "Profiler.h" #include "ModuleInternal.h" -#include "MessageLoopTimer.h" #include "File.h" #include "Timers.h" #include "LooperTasks.h" @@ -194,8 +193,6 @@ class Runtime { Profiler m_profiler; - MessageLoopTimer* m_loopTimer; - std::shared_ptr m_looperTasks; v8::Global m_globalEventTarget; diff --git a/test-app/runtime/src/main/cpp/WorkerInspectorClient.cpp b/test-app/runtime/src/main/cpp/WorkerInspectorClient.cpp index 4ff8ae745..214b8b21e 100644 --- a/test-app/runtime/src/main/cpp/WorkerInspectorClient.cpp +++ b/test-app/runtime/src/main/cpp/WorkerInspectorClient.cpp @@ -11,6 +11,7 @@ #include #include "JsV8InspectorClient.h" +#include "NativeScriptPlatform.h" #include "Runtime.h" using namespace v8; @@ -218,8 +219,11 @@ void WorkerInspectorClient::runMessageLoopOnPause(int contextGroupId) { this->DispatchOne(message); } - while (v8::platform::PumpMessageLoop(Runtime::platform, isolate_)) { - } + // JS frames are on the stack, so only nestable foreground tasks may + // run; non-nestable ones fire from their queue tokens after resume + tns::NativeScriptPlatform::Instance() + ->GetForegroundRunner(isolate_) + ->RunNestableTasks(); if (shouldWait && !pauseTerminated_ && !dying_) { std::unique_lock lock(messageArrivedMutex_); diff --git a/test-app/runtime/src/main/cpp/js/message-loop-timer.js b/test-app/runtime/src/main/cpp/js/message-loop-timer.js deleted file mode 100644 index a1ed2b330..000000000 --- a/test-app/runtime/src/main/cpp/js/message-loop-timer.js +++ /dev/null @@ -1,43 +0,0 @@ -const { messageLoopTimerStart, messageLoopTimerStop } = binding; -const { - ArrayPrototypeIndexOf, - FunctionPrototypeApply, - PromisePrototypeCatch, - PromisePrototypeThen, - Proxy, -} = primordials; - -// We proxy the WebAssembly's compile, compileStreaming, instantiate and -// instantiateStreaming methods so that they can start and stop a -// MessageLoopTimer inside the promise callbacks. This timer will call -// the v8::platform::PumpMessageLoop method at regular intervals. -// https://github.com/NativeScript/android-runtime/issues/1558 - -global.WebAssembly = new Proxy(WebAssembly, { - get: (target, name) => { - let origMethod = target[name]; - let proxyMethods = [ - "compile", - "compileStreaming", - "instantiate", - "instantiateStreaming" - ]; - - if (ArrayPrototypeIndexOf(proxyMethods, name) < 0) { - return origMethod; - } - - return function (...args) { - messageLoopTimerStart(); - let result = FunctionPrototypeApply(origMethod, this, args); - let settled = PromisePrototypeThen(result, x => { - messageLoopTimerStop(); - return x; - }); - return PromisePrototypeCatch(settled, e => { - messageLoopTimerStop(); - throw e; - }); - }; - } -}); diff --git a/test-app/runtime/src/main/java/com/tns/EventLoopHandler.java b/test-app/runtime/src/main/java/com/tns/EventLoopHandler.java new file mode 100644 index 000000000..c1a18022d --- /dev/null +++ b/test-app/runtime/src/main/java/com/tns/EventLoopHandler.java @@ -0,0 +1,61 @@ +package com.tns; + +import android.os.Handler; +import android.os.Looper; +import android.os.Message; + +/** + * Dedicated per-runtime Handler that delivers V8 platform foreground tasks + * (and, in the future, any runtime work that must run as a macrotask) on the + * runtime thread's Looper. One instance per isolate, created and used + * exclusively from native code (NativeScriptPlatform.cpp). + * + * Messages are anonymous "task due" tokens: native code owns the actual task + * queue and runs at most one due task per token, so a token never names a + * task and a leftover token is a cheap no-op. Riding the Java MessageQueue + * (rather than an ALooper fd) keeps tasks strictly FIFO-ordered with + * Handler.post runnables and JS timers on the same looper. + */ +final class EventLoopHandler extends Handler { + private static final int MSG_RUN_TASK = 1; + + private final long nativeRunnerPtr; + private boolean released; + + // constructed from native code (ForegroundTaskRunner::BindToCurrentThread) + EventLoopHandler(long nativeRunnerPtr) { + super(Looper.myLooper()); + this.nativeRunnerPtr = nativeRunnerPtr; + } + + /** + * Enqueues an anonymous "task due" token at an absolute uptimeMillis. + * Immediate tasks pass the current uptime; delayed tasks pass their due + * time. Callable from any thread. + */ + @RuntimeCallable + void post(long uptimeMillis) { + sendMessageAtTime(obtainMessage(MSG_RUN_TASK), uptimeMillis); + } + + /** + * Called from ForegroundTaskRunner::Shutdown on this handler's own + * thread. After this no token can fire into the (about to be freed) + * native runner. + */ + @RuntimeCallable + void release() { + released = true; + removeCallbacksAndMessages(null); // safe: this handler is tasks-only + } + + @Override + public void handleMessage(Message msg) { + if (released || msg.what != MSG_RUN_TASK) { + return; + } + nativeRunTask(nativeRunnerPtr); + } + + private static native void nativeRunTask(long nativeRunnerPtr); +} From 11f7b69633c2b06ed3122aee9b1e25fac20aeb68 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Tue, 11 Aug 2026 16:50:34 -0300 Subject: [PATCH 2/5] refactor: two-lane EventLoop scheduler (ordered Java lane, internal fd lane) Restructure the foreground task runner into a per-runtime EventLoop, the Android analogue of the iOS runtime's ExecuteOnRunLoop seam, routing work by ordering contract: - ordered lane: work whose ordering is observable against app-level Java messages rides the Java MessageQueue via EventLoopHandler tokens, strictly FIFO with Handler.post and JS timers. First producer: __ns__queueMacrotask(cb), the seam future spec'd macrotasks (performance observers etc.) will use. - internal lane: work in its own ordering domain - v8 platform foreground tasks, worker->parent messages, unhandled-rejection drains - rides an EFD_SEMAPHORE eventfd plus a timerfd for delayed tasks on the thread's ALooper. No JNI on the post path, so v8's non-JVM worker threads post without attaching to the JVM. One eventfd unit runs one entry per looper callback, keeping bursts fair with Java messages. LooperTasks is consolidated into the internal lane (worker messaging and exception-drain call sites ported 1:1, keeping the weak_ptr child semantics and drop-after-shutdown behavior). Timers stays separate: it is the ordered lane specialized with sub-millisecond ordering machinery. Also addresses review findings: ordered-lane token posts and destructor now synchronize on the loop mutex; the inspector pause drain is bounded to the entries present at call time so a self-reposting task cannot wedge the CDP read; ~Runtime guards the platform instance and isolate against early construction failure; EventLoopHandler fails loudly when constructed on a thread with no prepared Looper; the async waitAsync test chain got its missing rejection handler. Adds ordered-lane tests: async delivery, runs-after-microtasks, FIFO interleaving with setTimeout(0), TypeError on non-function. --- .../main/assets/app/tests/testEventLoop.js | 41 ++ test-app/runtime/CMakeLists.txt | 2 +- .../runtime/src/main/cpp/CallbackHandlers.cpp | 41 ++ .../runtime/src/main/cpp/CallbackHandlers.h | 2 + test-app/runtime/src/main/cpp/EventLoop.cpp | 490 ++++++++++++++++++ test-app/runtime/src/main/cpp/EventLoop.h | 157 ++++++ .../src/main/cpp/JsV8InspectorClient.cpp | 8 +- test-app/runtime/src/main/cpp/LooperTasks.cpp | 105 ---- test-app/runtime/src/main/cpp/LooperTasks.h | 41 -- .../src/main/cpp/NativeScriptException.cpp | 10 +- .../src/main/cpp/NativeScriptException.h | 6 +- .../src/main/cpp/NativeScriptPlatform.cpp | 282 +--------- .../src/main/cpp/NativeScriptPlatform.h | 146 +----- test-app/runtime/src/main/cpp/Runtime.cpp | 42 +- test-app/runtime/src/main/cpp/Runtime.h | 12 +- .../src/main/cpp/WorkerInspectorClient.cpp | 8 +- .../runtime/src/main/cpp/WorkerWrapper.cpp | 8 +- test-app/runtime/src/main/cpp/WorkerWrapper.h | 6 +- .../main/java/com/tns/EventLoopHandler.java | 30 +- 19 files changed, 823 insertions(+), 614 deletions(-) create mode 100644 test-app/runtime/src/main/cpp/EventLoop.cpp create mode 100644 test-app/runtime/src/main/cpp/EventLoop.h delete mode 100644 test-app/runtime/src/main/cpp/LooperTasks.cpp delete mode 100644 test-app/runtime/src/main/cpp/LooperTasks.h diff --git a/test-app/app/src/main/assets/app/tests/testEventLoop.js b/test-app/app/src/main/assets/app/tests/testEventLoop.js index 95765a793..a2d3d642b 100644 --- a/test-app/app/src/main/assets/app/tests/testEventLoop.js +++ b/test-app/app/src/main/assets/app/tests/testEventLoop.js @@ -56,8 +56,49 @@ describe("event loop foreground tasks", function () { order.push("chained"); expect(order).toEqual(["waitAsync", "chained"]); done(); + }).catch(e => { + done.fail("promise chain failed: " + e); }); Atomics.notify(i32, 0); }); }); + +// The ordered lane rides the Java MessageQueue, so these callbacks must be +// strict macrotasks: after the current turn's microtasks, FIFO with timers. +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("stays FIFO-ordered with setTimeout(0)", function (done) { + const order = []; + __ns__queueMacrotask(() => order.push("macro1")); + 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); + }); +}); diff --git a/test-app/runtime/CMakeLists.txt b/test-app/runtime/CMakeLists.txt index 01648d79e..2aabd9f46 100644 --- a/test-app/runtime/CMakeLists.txt +++ b/test-app/runtime/CMakeLists.txt @@ -149,6 +149,7 @@ add_library( src/main/cpp/Constants.cpp src/main/cpp/DirectBuffer.cpp src/main/cpp/ErrorEvents.cpp + src/main/cpp/EventLoop.cpp src/main/cpp/Events.cpp src/main/cpp/FieldAccessor.cpp src/main/cpp/File.cpp @@ -162,7 +163,6 @@ add_library( src/main/cpp/JsArgToArrayConverter.cpp src/main/cpp/JSONObjectHelper.cpp src/main/cpp/Logger.cpp - src/main/cpp/LooperTasks.cpp src/main/cpp/ManualInstrumentation.cpp src/main/cpp/MetadataMethodInfo.cpp src/main/cpp/MetadataNode.cpp diff --git a/test-app/runtime/src/main/cpp/CallbackHandlers.cpp b/test-app/runtime/src/main/cpp/CallbackHandlers.cpp index de1d0b345..0663ef414 100644 --- a/test-app/runtime/src/main/cpp/CallbackHandlers.cpp +++ b/test-app/runtime/src/main/cpp/CallbackHandlers.cpp @@ -766,6 +766,47 @@ void CallbackHandlers::DrainMicrotaskCallback(const v8::FunctionCallbackInfo &args) { + try { + auto isolate = args.GetIsolate(); + if (args.Length() < 1 || !args[0]->IsFunction()) { + isolate->ThrowException(v8::Exception::TypeError(ArgConverter::ConvertToV8String( + isolate, "__ns__queueMacrotask: callback must be a function"))); + return; + } + auto eventLoop = Runtime::GetRuntime(isolate)->GetEventLoop(); + if (eventLoop == nullptr) { + return; + } + auto callback = std::make_shared>(isolate, args[0].As()); + // the ordered lane rides the Java MessageQueue, so the callback runs + // as a macrotask in strict FIFO order with JS timers and Handler.post + eventLoop->PostOrdered([isolate, callback]() { + auto runtime = Runtime::GetRuntime(isolate); + auto context = runtime->GetContext(); + Context::Scope context_scope(context); + TryCatch tc(isolate); + auto cb = callback->Get(isolate); + cb->Call(context, context->Global(), 0, nullptr); + callback->Reset(); + if (tc.HasCaught() && + !NativeScriptException::ContainUncaughtCallbackException(isolate, tc)) { + NativeScriptException(tc).ReThrowToJava(); + } + }); + } catch (NativeScriptException &e) { + e.ReThrowToV8(); + } catch (std::exception e) { + stringstream ss; + ss << "Error: c++ exception: " << e.what() << endl; + NativeScriptException nsEx(ss.str()); + nsEx.ReThrowToV8(); + } catch (...) { + NativeScriptException nsEx(std::string("Error: c++ exception!")); + nsEx.ReThrowToV8(); + } +} + void CallbackHandlers::TimeCallback(const v8::FunctionCallbackInfo &args) { auto nano = std::chrono::time_point_cast( std::chrono::system_clock::now()); diff --git a/test-app/runtime/src/main/cpp/CallbackHandlers.h b/test-app/runtime/src/main/cpp/CallbackHandlers.h index f62eeef7d..23d271727 100644 --- a/test-app/runtime/src/main/cpp/CallbackHandlers.h +++ b/test-app/runtime/src/main/cpp/CallbackHandlers.h @@ -84,6 +84,8 @@ namespace tns { static void DrainMicrotaskCallback(const v8::FunctionCallbackInfo& args); + static void QueueMacrotaskCallback(const v8::FunctionCallbackInfo& args); + static void DumpReferenceTablesMethod(); static void ExitMethodCallback(const v8::FunctionCallbackInfo &args); diff --git a/test-app/runtime/src/main/cpp/EventLoop.cpp b/test-app/runtime/src/main/cpp/EventLoop.cpp new file mode 100644 index 000000000..0c16465d8 --- /dev/null +++ b/test-app/runtime/src/main/cpp/EventLoop.cpp @@ -0,0 +1,490 @@ +#include "EventLoop.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "JEnv.h" +#include "JniLocalRef.h" +#include "NativeScriptAssert.h" +#include "NativeScriptException.h" + +using namespace v8; + +namespace { + +// same clock as android.os.SystemClock.uptimeMillis() and the timerfd below +double now_ms() { + struct timespec res; + clock_gettime(CLOCK_MONOTONIC, &res); + return 1000.0 * res.tv_sec + (double) res.tv_nsec / 1e6; +} + +// runs one unit of work without letting a C++ exception escape into an +// ALooper callback frame +template +void RunGuarded(F&& body) { + try { + body(); + } catch (tns::NativeScriptException& ex) { + ex.ReThrowToJava(); + } catch (std::exception& ex) { + DEBUG_WRITE_FORCE("Error: c++ exception in event loop task: %s", ex.what()); + } catch (...) { + DEBUG_WRITE_FORCE("Error: unknown c++ exception in event loop task!"); + } +} + +} // namespace + +namespace tns { + +jclass EventLoop::EVENT_LOOP_HANDLER_CLASS = nullptr; +jmethodID EventLoop::EVENT_LOOP_HANDLER_CTOR = nullptr; +jmethodID EventLoop::EVENT_LOOP_HANDLER_POST = nullptr; +jmethodID EventLoop::EVENT_LOOP_HANDLER_RELEASE = nullptr; + +/** + * Adapter v8 holds (via shared_ptr) as the isolate's foreground task runner. + * Holds the EventLoop weakly: the loop is owned by the platform's registry and + * the Runtime; once it shuts down and is released, late posts from v8 lock() + * to nullptr and drop, matching the loop's own post-after-Shutdown behavior. + */ +class V8TaskRunnerAdapter : public v8::TaskRunner { +public: + explicit V8TaskRunnerAdapter(std::weak_ptr loop) : loop_(std::move(loop)) {} + + 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 { + if (auto loop = loop_.lock()) { + loop->PostV8Task(std::move(task), true, 0); + } + } + + void PostNonNestableTaskImpl(std::unique_ptr task, + const SourceLocation& location) override { + if (auto loop = loop_.lock()) { + loop->PostV8Task(std::move(task), false, 0); + } + } + + void PostDelayedTaskImpl(std::unique_ptr task, double delay_in_seconds, + const SourceLocation& location) override { + if (auto loop = loop_.lock()) { + loop->PostV8Task(std::move(task), true, delay_in_seconds); + } + } + + void PostNonNestableDelayedTaskImpl(std::unique_ptr task, double delay_in_seconds, + const SourceLocation& location) override { + if (auto loop = loop_.lock()) { + loop->PostV8Task(std::move(task), false, delay_in_seconds); + } + } + +private: + std::weak_ptr loop_; +}; + +void EventLoop::BindToCurrentThread() { + JEnv env; + std::lock_guard lock(mutex_); + if (looper_ != nullptr || stopped_) { + return; + } + + auto looper = ALooper_forThread(); + if (looper == nullptr) { + DEBUG_WRITE_FORCE("EventLoop: no ALooper on the binding thread"); + return; + } + + int eventFd = eventfd(0, EFD_SEMAPHORE | EFD_NONBLOCK | EFD_CLOEXEC); + if (eventFd == -1) { + DEBUG_WRITE_FORCE("EventLoop: eventfd failed: %s", strerror(errno)); + return; + } + int timerFd = timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK | TFD_CLOEXEC); + if (timerFd == -1) { + DEBUG_WRITE_FORCE("EventLoop: timerfd failed: %s", strerror(errno)); + close(eventFd); + return; + } + if (ALooper_addFd(looper, eventFd, ALOOPER_POLL_CALLBACK, ALOOPER_EVENT_INPUT, + EventLoop::EventFdCallback, this) != 1 || + ALooper_addFd(looper, timerFd, ALOOPER_POLL_CALLBACK, ALOOPER_EVENT_INPUT, + EventLoop::TimerFdCallback, this) != 1) { + DEBUG_WRITE_FORCE("EventLoop: ALooper_addFd failed"); + ALooper_removeFd(looper, eventFd); + close(eventFd); + close(timerFd); + return; + } + looper_ = looper; + ALooper_acquire(looper_); + eventFd_ = eventFd; + timerFd_ = timerFd; + + if (EVENT_LOOP_HANDLER_CLASS == nullptr) { + // JEnv::FindClass caches a global ref to the class + EVENT_LOOP_HANDLER_CLASS = env.FindClass("com/tns/EventLoopHandler"); + assert(EVENT_LOOP_HANDLER_CLASS != nullptr); + EVENT_LOOP_HANDLER_CTOR = env.GetMethodID(EVENT_LOOP_HANDLER_CLASS, "", "(J)V"); + EVENT_LOOP_HANDLER_POST = env.GetMethodID(EVENT_LOOP_HANDLER_CLASS, "post", "(J)V"); + EVENT_LOOP_HANDLER_RELEASE = env.GetMethodID(EVENT_LOOP_HANDLER_CLASS, "release", "()V"); + } + JniLocalRef handler(env.NewObject(EVENT_LOOP_HANDLER_CLASS, EVENT_LOOP_HANDLER_CTOR, + reinterpret_cast(this))); + assert(!handler.IsNull()); + handler_ = env.NewGlobalRef(handler); + + // flush work buffered before the home thread was known + auto now = now_ms(); + for (size_t i = 0; i < internal_.immediate.size(); i++) { + uint64_t value = 1; + write(eventFd_, &value, sizeof(value)); + } + ArmTimerLocked(now); + for (auto& entry : ordered_.immediate) { + env.CallVoidMethod(handler_, EVENT_LOOP_HANDLER_POST, (jlong) entry.time); + } + for (auto& pair : ordered_.delayed) { + env.CallVoidMethod(handler_, EVENT_LOOP_HANDLER_POST, (jlong) std::ceil(pair.first)); + } +} + +void EventLoop::Shutdown() { + // must run on the home thread: removing an fd concurrently with an + // in-flight ALooper callback dispatch is racy + JEnv env; + std::lock_guard lock(mutex_); + if (stopped_) { + return; + } + stopped_ = true; + internal_.immediate.clear(); + internal_.delayed.clear(); + ordered_.immediate.clear(); + ordered_.delayed.clear(); + if (eventFd_ != -1) { + ALooper_removeFd(looper_, eventFd_); + close(eventFd_); + eventFd_ = -1; + } + if (timerFd_ != -1) { + ALooper_removeFd(looper_, timerFd_); + close(timerFd_); + timerFd_ = -1; + } + if (looper_ != nullptr) { + ALooper_release(looper_); + looper_ = nullptr; + } + if (handler_ != nullptr) { + // the global ref stays alive until the destructor, but the released + // handler ignores any token already in (or racing into) its queue + env.CallVoidMethod(handler_, EVENT_LOOP_HANDLER_RELEASE); + } +} + +EventLoop::~EventLoop() { + // normally a no-op: DestroyRuntime already shut the loop down + Shutdown(); + std::lock_guard lock(mutex_); + if (handler_ != nullptr) { + JEnv env; + env.DeleteGlobalRef(handler_); + handler_ = nullptr; + } +} + +void EventLoop::PostInternalLocked(Entry entry, double delayMs) { + auto now = now_ms(); + if (delayMs <= 0) { + entry.time = now; + internal_.immediate.push_back(std::move(entry)); + if (eventFd_ != -1) { + uint64_t value = 1; + write(eventFd_, &value, sizeof(value)); + } + } else { + auto due = now + delayMs; + entry.time = due; + internal_.delayed.emplace(due, std::move(entry)); + if (timerFd_ != -1) { + ArmTimerLocked(now); + } + } +} + +void EventLoop::PostOrderedLocked(Entry entry, double delayMs) { + auto now = now_ms(); + if (delayMs <= 0) { + entry.time = now; + ordered_.immediate.push_back(std::move(entry)); + if (handler_ != nullptr) { + // Handler.sendMessageAtTime only enqueues, so the JNI call is + // cheap enough to keep under the lock, which in turn keeps posts + // from overlapping Shutdown/destruction + JEnv env; + env.CallVoidMethod(handler_, EVENT_LOOP_HANDLER_POST, (jlong) now); + } + } else { + auto due = now + delayMs; + entry.time = due; + ordered_.delayed.emplace(due, std::move(entry)); + if (handler_ != nullptr) { + // ceil so the token never arrives before the due time + JEnv env; + env.CallVoidMethod(handler_, EVENT_LOOP_HANDLER_POST, (jlong) std::ceil(due)); + } + } +} + +void EventLoop::PostInternal(std::function fn) { + std::lock_guard lock(mutex_); + if (stopped_) { + return; + } + PostInternalLocked(Entry{nullptr, std::move(fn), true, 0}, 0); +} + +void EventLoop::PostInternalDelayed(std::function fn, double delayMs) { + std::lock_guard lock(mutex_); + if (stopped_) { + return; + } + PostInternalLocked(Entry{nullptr, std::move(fn), true, 0}, delayMs); +} + +void EventLoop::PostOrdered(std::function fn) { + std::lock_guard lock(mutex_); + if (stopped_) { + return; + } + PostOrderedLocked(Entry{nullptr, std::move(fn), true, 0}, 0); +} + +void EventLoop::PostOrderedDelayed(std::function fn, double delayMs) { + std::lock_guard lock(mutex_); + if (stopped_) { + return; + } + PostOrderedLocked(Entry{nullptr, std::move(fn), true, 0}, delayMs); +} + +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, 0}, + delaySeconds * 1000.0); +} + +std::shared_ptr EventLoop::V8TaskRunner() { + std::lock_guard lock(mutex_); + if (v8Runner_ == nullptr) { + v8Runner_ = std::make_shared(weak_from_this()); + } + return v8Runner_; +} + +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; +} + +void EventLoop::ArmTimerLocked(double now) { + if (timerFd_ == -1) { + return; + } + // earliest delayed entry that hasn't had its eventfd unit issued yet; + // signaled entries are just waiting to be consumed + double due = -1; + for (auto& pair : internal_.delayed) { + if (!pair.second.signaled) { + due = pair.first; + break; + } + } + struct itimerspec spec = {}; + if (due >= 0) { + // a due time already in the past fires immediately, except an exact 0 + // would disarm - clamp to 1ns + due = std::max(due, now - 1); + spec.it_value.tv_sec = (time_t) (due / 1000.0); + spec.it_value.tv_nsec = std::max(1L, (long) (std::fmod(due, 1000.0) * 1e6)); + } + timerfd_settime(timerFd_, TFD_TIMER_ABSTIME, &spec, nullptr); +} + +void EventLoop::RunEntry(Entry& entry) { + v8::Locker locker(isolate_); + v8::Isolate::Scope isolate_scope(isolate_); + v8::HandleScope handleScope(isolate_); + 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(); +} + +void EventLoop::RunOneInternal() { + std::unique_ptr entry; + { + std::lock_guard lock(mutex_); + if (stopped_) { + return; + } + entry = TakeDueLocked(internal_, false, false, now_ms()); + } + if (entry == nullptr) { + // leftover unit: the work it represented ran early from a nested loop + // drain + return; + } + 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, now_ms()); + } + if (entry == nullptr) { + return; + } + RunEntry(*entry); + } +} + +void EventLoop::RunOrderedTask() { + std::unique_ptr entry; + { + std::lock_guard lock(mutex_); + if (stopped_) { + return; + } + entry = TakeDueLocked(ordered_, false, false, now_ms()); + } + if (entry == nullptr) { + // leftover token: the earliest delayed entry isn't due yet + return; + } + RunEntry(*entry); +} + +int EventLoop::EventFdCallback(int fd, int events, void* data) { + uint64_t value; + // EFD_SEMAPHORE: consumes exactly one unit; while more remain the fd stays + // readable and ALooper calls back next poll, interleaving with Java + // messages instead of draining in one go + read(fd, &value, sizeof(value)); + RunGuarded([&] { static_cast(data)->RunOneInternal(); }); + return 1; +} + +int EventLoop::TimerFdCallback(int fd, int events, void* data) { + uint64_t expirations; + read(fd, &expirations, sizeof(expirations)); + auto self = static_cast(data); + { + std::lock_guard lock(self->mutex_); + if (self->stopped_) { + return 0; + } + auto now = now_ms(); + uint64_t due = 0; + for (auto& pair : self->internal_.delayed) { + if (pair.first > now) { + break; + } + if (!pair.second.signaled) { + pair.second.signaled = true; + due++; + } + } + if (due > 0 && self->eventFd_ != -1) { + // plain (non-semaphore) write of N adds N one-unit reads + write(self->eventFd_, &due, sizeof(due)); + } + self->ArmTimerLocked(now); + } + return 1; +} + +} // namespace tns + +extern "C" JNIEXPORT void JNICALL Java_com_tns_EventLoopHandler_nativeRunTask( + JNIEnv* env, jclass clazz, jlong nativeLoopPtr) { + try { + reinterpret_cast(nativeLoopPtr)->RunOrderedTask(); + } catch (tns::NativeScriptException& e) { + e.ReThrowToJava(); + } catch (std::exception& e) { + std::string msg = std::string("Error: c++ exception: ") + e.what(); + tns::NativeScriptException nsEx(msg); + nsEx.ReThrowToJava(); + } catch (...) { + tns::NativeScriptException nsEx(std::string("Error: c++ exception!")); + nsEx.ReThrowToJava(); + } +} diff --git a/test-app/runtime/src/main/cpp/EventLoop.h b/test-app/runtime/src/main/cpp/EventLoop.h new file mode 100644 index 000000000..1ed26775a --- /dev/null +++ b/test-app/runtime/src/main/cpp/EventLoop.h @@ -0,0 +1,157 @@ +#ifndef TEST_APP_EVENTLOOP_H +#define TEST_APP_EVENTLOOP_H + +#include +#include +#include +#include +#include +#include +#include +#include "v8.h" +#include "v8-platform.h" + +namespace tns { + +/** + * Per-runtime scheduler for work that must run on the runtime's home thread - + * the Android analogue of the iOS runtime's ExecuteOnRunLoop. Two lanes, split + * by ordering contract: + * + * Ordered lane - work whose ordering is observable against app-level Java + * messages (future spec'd macrotasks such as performance-observer callbacks). + * Rides the Java MessageQueue via a dedicated com.tns.EventLoopHandler using + * anonymous "task due" tokens (the Timers scheme), so it is strictly FIFO with + * Handler.post runnables and JS timers on the same looper. + * + * Internal lane - work in its own ordering domain: v8 platform foreground + * tasks (WASM finalization, Atomics.waitAsync wakeups, GC tasks), worker + * channel messages and exception drains. Rides an eventfd (+ one timerfd for + * delayed work) on the thread's ALooper: no JNI on the post path, so v8's + * non-JVM worker threads can post without attaching to the JVM. The eventfd is + * EFD_SEMAPHORE and carries one unit per runnable entry, so each ALooper + * callback runs exactly one unit of work and the loop stays fair with Java + * messages. fd callbacks are serviced between Java messages with no mutual + * ordering - acceptable here precisely because this lane has no ordering + * contract with them. + * + * 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 silently dropped, preserving the old LooperTasks "message to a + * terminated runtime" semantics; leftover wakeups (tokens or eventfd units + * whose work was drained early) are no-ops. + */ +class EventLoop : public std::enable_shared_from_this { +public: + explicit EventLoop(v8::Isolate* isolate) : isolate_(isolate) {} + + ~EventLoop(); + + /** + * Attaches both lanes to the calling thread (Java handler on the thread's + * Looper, eventfd/timerfd on its ALooper) and flushes work buffered before + * the bind. Must run on the runtime's home thread, before its looper + * starts dispatching. + */ + void BindToCurrentThread(); + + /** + * Drops all queued work and detaches both lanes; posts after this are + * silently dropped. Must run on the home thread (removing ALooper fds + * concurrently with a callback dispatch is racy), before the isolate is + * disposed. + */ + void Shutdown(); + + // ordered lane: strictly FIFO with Java messages on the home looper + void PostOrdered(std::function fn); + void PostOrderedDelayed(std::function fn, double delayMs); + + // internal lane: runs on the home thread as soon as the looper polls + void PostInternal(std::function fn); + void PostInternalDelayed(std::function fn, double delayMs); + + /** + * The v8::TaskRunner this isolate's foreground tasks post through; tasks + * land in the internal lane. Safe to call from any thread. + */ + std::shared_ptr V8TaskRunner(); + + /** + * 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 looper 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 entry, then performs a microtask + * checkpoint. Invoked by Java EventLoopHandler.handleMessage once per + * token, on the home thread. + */ + void RunOrderedTask(); + +private: + struct Entry { + // exactly one of task/fn is set; fn entries are never drained by + // RunNestableV8Tasks (plain posts didn't run during debugger pauses + // under LooperTasks either) + std::unique_ptr task; + std::function fn; + bool nestable; + // enqueue time for immediate entries, due time for delayed ones (both + // CLOCK_MONOTONIC ms) so one comparison orders both queues + double time; + // delayed internal entries only: an eventfd unit has been issued for + // this entry (written when its timerfd deadline fired), so a later + // timer fire must not issue a second one + bool signaled = false; + }; + struct Lane { + std::deque immediate; + std::multimap delayed; + }; + + friend class V8TaskRunnerAdapter; + void PostV8Task(std::unique_ptr task, bool nestable, double delaySeconds); + + // all *Locked members require mutex_ to be held + void PostInternalLocked(Entry entry, double delayMs); + void PostOrderedLocked(Entry entry, double delayMs); + static std::unique_ptr TakeDueLocked(Lane& lane, bool nestableOnly, bool v8Only, + double now); + void ArmTimerLocked(double now); + void RunEntry(Entry& entry); + void RunOneInternal(); + + static int EventFdCallback(int fd, int events, void* data); + static int TimerFdCallback(int fd, int events, void* data); + + v8::Isolate* isolate_; + std::mutex mutex_; + Lane internal_; + Lane ordered_; + std::shared_ptr v8Runner_; // adapter; holds a weak_ptr back + // ordered lane: global ref to this thread's com.tns.EventLoopHandler + jobject handler_ = nullptr; + // internal lane: EFD_SEMAPHORE eventfd (one unit = run one due entry) and + // a timerfd armed to the earliest delayed due time + ALooper* looper_ = nullptr; + int eventFd_ = -1; + int timerFd_ = -1; + bool stopped_ = false; + + // process-wide JNI cache, written once under the first bind's lock (the + // main runtime binds before any worker thread exists) + static jclass EVENT_LOOP_HANDLER_CLASS; + static jmethodID EVENT_LOOP_HANDLER_CTOR; + static jmethodID EVENT_LOOP_HANDLER_POST; + static jmethodID EVENT_LOOP_HANDLER_RELEASE; +}; + +} // namespace tns + +#endif // TEST_APP_EVENTLOOP_H diff --git a/test-app/runtime/src/main/cpp/JsV8InspectorClient.cpp b/test-app/runtime/src/main/cpp/JsV8InspectorClient.cpp index fae47004d..174c4119f 100644 --- a/test-app/runtime/src/main/cpp/JsV8InspectorClient.cpp +++ b/test-app/runtime/src/main/cpp/JsV8InspectorClient.cpp @@ -384,11 +384,11 @@ void JsV8InspectorClient::runMessageLoopOnPause(int context_group_id) { doDispatchMessage(inspectorMessage); } - // JS frames are on the stack, so only nestable foreground tasks may - // run; non-nestable ones fire from their queue tokens after resume + // 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() - ->GetForegroundRunner(isolate_) - ->RunNestableTasks(); + ->GetEventLoop(isolate_) + ->RunNestableV8Tasks(); } isPausedNestedLoop_.store(false, std::memory_order_release); terminated_ = false; diff --git a/test-app/runtime/src/main/cpp/LooperTasks.cpp b/test-app/runtime/src/main/cpp/LooperTasks.cpp deleted file mode 100644 index 7b4f353ec..000000000 --- a/test-app/runtime/src/main/cpp/LooperTasks.cpp +++ /dev/null @@ -1,105 +0,0 @@ -#include "LooperTasks.h" - -#include -#include - -#include -#include -#include - -#include "NativeScriptAssert.h" -#include "NativeScriptException.h" - -namespace tns { - -void LooperTasks::Initialize(ALooper* looper) { - std::lock_guard lock(mutex_); - - int fd = eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC); - if (fd == -1) { - DEBUG_WRITE_FORCE("LooperTasks: eventfd failed: %s", strerror(errno)); - return; - } - - if (ALooper_addFd(looper, fd, ALOOPER_POLL_CALLBACK, ALOOPER_EVENT_INPUT, - LooperTasks::TasksReadyCallback, this) != 1) { - DEBUG_WRITE_FORCE("LooperTasks: ALooper_addFd failed"); - close(fd); - return; - } - - looper_ = looper; - ALooper_acquire(looper_); - fd_ = fd; -} - -void LooperTasks::Post(std::function task) { - std::lock_guard lock(mutex_); - if (terminated_) { - // The owning runtime is shutting down (or gone) - drop the task, - // matching the old "message to a terminated worker/main" semantics. - return; - } - - tasks_.push(std::move(task)); - - if (fd_ != -1) { - uint64_t value = 1; - write(fd_, &value, sizeof(value)); - } -} - -void LooperTasks::Terminate() { - // Must run on the looper's own thread: removing an fd concurrently with an - // in-flight callback dispatch is racy. - std::lock_guard lock(mutex_); - terminated_ = true; - - if (fd_ != -1) { - ALooper_removeFd(looper_, fd_); - close(fd_); - fd_ = -1; - } - - if (looper_ != nullptr) { - ALooper_release(looper_); - looper_ = nullptr; - } -} - -int LooperTasks::TasksReadyCallback(int fd, int events, void* data) { - uint64_t value; - read(fd, &value, sizeof(value)); - - static_cast(data)->Drain(); - return 1; -} - -void LooperTasks::Drain() { - std::vector> tasks; - { - std::lock_guard lock(mutex_); - while (!tasks_.empty()) { - tasks.push_back(std::move(tasks_.front())); - tasks_.pop(); - } - } - - for (auto& task : tasks) { - // A C++ exception must never propagate out of an ALooper callback. - // Tasks that need to surface JS/Java errors do so themselves - // (e.g. via NativeScriptException::ReThrowToJava, which only sets a - // pending Java exception). - try { - task(); - } catch (NativeScriptException& ex) { - ex.ReThrowToJava(); - } catch (std::exception& ex) { - DEBUG_WRITE_FORCE("Error: c++ exception in looper task: %s", ex.what()); - } catch (...) { - DEBUG_WRITE_FORCE("Error: unknown c++ exception in looper task!"); - } - } -} - -} // namespace tns diff --git a/test-app/runtime/src/main/cpp/LooperTasks.h b/test-app/runtime/src/main/cpp/LooperTasks.h deleted file mode 100644 index ba5409e6b..000000000 --- a/test-app/runtime/src/main/cpp/LooperTasks.h +++ /dev/null @@ -1,41 +0,0 @@ -#ifndef LOOPERTASKS_H_ -#define LOOPERTASKS_H_ - -#include -#include -#include -#include - -namespace tns { - -/* - * A task queue bound to one runtime's looper - the Android analogue of the - * iOS runtime's ExecuteOnRunLoop(runtime->RuntimeLoop(), ...). - * Each Runtime (main or worker) owns one; child workers post their outbound - * messages, errors and cleanup notifications to their parent runtime's queue. - * - * Post() may be called from any thread; tasks posted after Terminate() are - * dropped. Initialize()/Terminate() must run on the looper's own thread. - * Held via shared_ptr by the owning Runtime and via weak_ptr by child - * WorkerWrappers, so a child posting to an already-destroyed parent is safe. - */ -class LooperTasks { -public: - void Initialize(ALooper* looper); - void Post(std::function task); - void Terminate(); - -private: - static int TasksReadyCallback(int fd, int events, void* data); - void Drain(); - - std::mutex mutex_; - std::queue> tasks_; - ALooper* looper_ = nullptr; - int fd_ = -1; - bool terminated_ = false; -}; - -} // namespace tns - -#endif /* LOOPERTASKS_H_ */ diff --git a/test-app/runtime/src/main/cpp/NativeScriptException.cpp b/test-app/runtime/src/main/cpp/NativeScriptException.cpp index 2f02ba685..6061f70dc 100644 --- a/test-app/runtime/src/main/cpp/NativeScriptException.cpp +++ b/test-app/runtime/src/main/cpp/NativeScriptException.cpp @@ -6,7 +6,7 @@ #include "ArgConverter.h" #include "ErrorEvents.h" #include "Interop.h" -#include "LooperTasks.h" +#include "EventLoop.h" #include "NativeScriptAssert.h" #include "Runtime.h" #include "Util.h" @@ -611,18 +611,18 @@ void PromiseRejectionTracker::ScheduleDrain() { } drainScheduled_ = true; - auto looperTasks = runtime_->GetLooperTasks(); - if (looperTasks == nullptr) { + auto eventLoop = runtime_->GetEventLoop(); + if (eventLoop == nullptr) { drainScheduled_ = false; return; } // The task runs on the runtime's own looper thread, strictly after the // microtask checkpoint of the turn that produced the rejection. It is - // dropped (never runs) once the runtime's LooperTasks is terminated, so + // dropped (never runs) once the runtime's event loop is shut down, so // capturing the raw Runtime pointer is safe. Runtime* runtime = runtime_; - looperTasks->Post([runtime]() { + eventLoop->PostInternal([runtime]() { auto isolate = runtime->GetIsolate(); v8::Locker locker(isolate); Isolate::Scope isolateScope(isolate); diff --git a/test-app/runtime/src/main/cpp/NativeScriptException.h b/test-app/runtime/src/main/cpp/NativeScriptException.h index 8f3af83d0..c2425ffe0 100644 --- a/test-app/runtime/src/main/cpp/NativeScriptException.h +++ b/test-app/runtime/src/main/cpp/NativeScriptException.h @@ -185,7 +185,7 @@ class NativeScriptException : public std::exception { * Per-isolate tracker for unhandled promise rejections (ported from the iOS * runtime's PromiseRejectionTracker). All members are touched only while the * v8::Locker for the runtime's isolate is held: OnReject/OnHandlerAdded run - * inside V8 callbacks and Drain runs in a LooperTasks task that acquires the + * inside V8 callbacks and Drain runs in an event-loop task that acquires the * lock, so no extra synchronization is required. */ class PromiseRejectionTracker { @@ -204,9 +204,9 @@ class PromiseRejectionTracker { private: /* - * Posts a Drain task to the owning runtime's LooperTasks queue (at most one + * Posts a Drain task to the owning runtime's event loop (internal lane) (at most one * outstanding). Tasks posted during runtime teardown are dropped by - * LooperTasks itself. + * the event loop itself. */ void ScheduleDrain(); /* Drop weak handles the GC has already cleared. */ diff --git a/test-app/runtime/src/main/cpp/NativeScriptPlatform.cpp b/test-app/runtime/src/main/cpp/NativeScriptPlatform.cpp index 9d2679e1b..a4baeabdd 100644 --- a/test-app/runtime/src/main/cpp/NativeScriptPlatform.cpp +++ b/test-app/runtime/src/main/cpp/NativeScriptPlatform.cpp @@ -1,237 +1,9 @@ #include "NativeScriptPlatform.h" -#include -#include -#include -#include -#include -#include - -#include "JEnv.h" -#include "JniLocalRef.h" -#include "NativeScriptException.h" - using namespace v8; -namespace { - -// same clock as android.os.SystemClock.uptimeMillis() -double now_ms() { - struct timespec res; - clock_gettime(CLOCK_MONOTONIC, &res); - return 1000.0 * res.tv_sec + (double) res.tv_nsec / 1e6; -} - -} // namespace - namespace tns { -jclass ForegroundTaskRunner::EVENT_LOOP_HANDLER_CLASS = nullptr; -jmethodID ForegroundTaskRunner::EVENT_LOOP_HANDLER_CTOR = nullptr; -jmethodID ForegroundTaskRunner::EVENT_LOOP_HANDLER_POST = nullptr; -jmethodID ForegroundTaskRunner::EVENT_LOOP_HANDLER_RELEASE = nullptr; - -void ForegroundTaskRunner::BindToCurrentThread() { - JEnv env; - std::vector tokens; - jobject handler = nullptr; - { - std::lock_guard lock(mutex_); - if (handler_ != nullptr || stopped_) { - return; - } - if (EVENT_LOOP_HANDLER_CLASS == nullptr) { - // JEnv::FindClass caches a global ref to the class. The first bind - // happens on the main runtime's thread before any worker exists, - // so the one-time write is not racy. - EVENT_LOOP_HANDLER_CLASS = env.FindClass("com/tns/EventLoopHandler"); - assert(EVENT_LOOP_HANDLER_CLASS != nullptr); - EVENT_LOOP_HANDLER_CTOR = env.GetMethodID(EVENT_LOOP_HANDLER_CLASS, "", "(J)V"); - EVENT_LOOP_HANDLER_POST = env.GetMethodID(EVENT_LOOP_HANDLER_CLASS, "post", "(J)V"); - EVENT_LOOP_HANDLER_RELEASE = env.GetMethodID(EVENT_LOOP_HANDLER_CLASS, "release", "()V"); - } - JniLocalRef localHandler(env.NewObject(EVENT_LOOP_HANDLER_CLASS, EVENT_LOOP_HANDLER_CTOR, - reinterpret_cast(this))); - handler = env.NewGlobalRef(localHandler); - handler_ = handler; - // tasks posted before the home thread was known get their tokens now - for (auto& entry : immediate_) { - tokens.push_back((jlong) entry.time); - } - for (auto& pair : delayed_) { - tokens.push_back((jlong) std::ceil(pair.first)); - } - } - for (auto when : tokens) { - env.CallVoidMethod(handler, EVENT_LOOP_HANDLER_POST, when); - } -} - -void ForegroundTaskRunner::Shutdown() { - jobject handler; - { - std::lock_guard lock(mutex_); - if (stopped_) { - return; - } - stopped_ = true; - immediate_.clear(); - delayed_.clear(); - handler = handler_; - } - if (handler != nullptr) { - // the global ref stays alive until the runner is destroyed: an - // off-thread post may have read handler_ just before stopped_ was set - // and still be calling post() on it - released handlers ignore tokens - JEnv env; - env.CallVoidMethod(handler, EVENT_LOOP_HANDLER_RELEASE); - } -} - -ForegroundTaskRunner::~ForegroundTaskRunner() { - // normally a no-op: RuntimeDestroyed already shut the runner down - Shutdown(); - if (handler_ != nullptr) { - JEnv env; - env.DeleteGlobalRef(handler_); - handler_ = nullptr; - } -} - -void ForegroundTaskRunner::PostToken(jobject handler, jlong uptimeMillis) { - JEnv env; - env.CallVoidMethod(handler, EVENT_LOOP_HANDLER_POST, uptimeMillis); -} - -void ForegroundTaskRunner::PostImmediate(std::unique_ptr task, bool nestable) { - auto now = now_ms(); - jobject handler; - { - std::lock_guard lock(mutex_); - if (stopped_) { - return; - } - immediate_.push_back(Entry{std::move(task), nestable, now}); - handler = handler_; - } - if (handler != nullptr) { - PostToken(handler, (jlong) now); - } -} - -void ForegroundTaskRunner::PostDelayed(std::unique_ptr task, bool nestable, - double delay_in_seconds) { - auto now = now_ms(); - auto due = now + std::max(delay_in_seconds, 0.0) * 1000.0; - jobject handler; - { - std::lock_guard lock(mutex_); - if (stopped_) { - return; - } - delayed_.emplace(due, Entry{std::move(task), nestable, due}); - handler = handler_; - } - if (handler != nullptr) { - // ceil so the token never arrives before the due time - PostToken(handler, (jlong) std::ceil(due)); - } -} - -void ForegroundTaskRunner::PostTaskImpl(std::unique_ptr task, - const SourceLocation& location) { - PostImmediate(std::move(task), true); -} - -void ForegroundTaskRunner::PostNonNestableTaskImpl(std::unique_ptr task, - const SourceLocation& location) { - PostImmediate(std::move(task), false); -} - -void ForegroundTaskRunner::PostDelayedTaskImpl(std::unique_ptr task, - double delay_in_seconds, - const SourceLocation& location) { - PostDelayed(std::move(task), true, delay_in_seconds); -} - -void ForegroundTaskRunner::PostNonNestableDelayedTaskImpl(std::unique_ptr task, - double delay_in_seconds, - const SourceLocation& location) { - PostDelayed(std::move(task), false, delay_in_seconds); -} - -std::unique_ptr ForegroundTaskRunner::TakeDueTaskLocked(bool nestableOnly, double now) { - auto imIt = immediate_.begin(); - if (nestableOnly) { - while (imIt != immediate_.end() && !imIt->nestable) { - ++imIt; - } - } - auto delIt = delayed_.begin(); - if (nestableOnly) { - while (delIt != delayed_.end() && !delIt->second.nestable) { - ++delIt; - } - } - bool hasImmediate = imIt != immediate_.end(); - bool hasDelayed = delIt != delayed_.end() && delIt->first <= now; - if (hasImmediate && (!hasDelayed || imIt->time <= delIt->first)) { - auto task = std::move(imIt->task); - immediate_.erase(imIt); - return task; - } - if (hasDelayed) { - auto task = std::move(delIt->second.task); - delayed_.erase(delIt); - return task; - } - return nullptr; -} - -void ForegroundTaskRunner::RunTask() { - std::unique_ptr task; - { - std::lock_guard lock(mutex_); - if (stopped_) { - return; - } - task = TakeDueTaskLocked(false, now_ms()); - } - if (task == nullptr) { - // leftover token: the task ran early from a nested loop drain, or the - // earliest delayed task isn't due yet - return; - } - auto isolate = isolate_; - v8::Locker locker(isolate); - v8::Isolate::Scope isolate_scope(isolate); - v8::HandleScope handleScope(isolate); - task->Run(); - // a task may enqueue microtasks without entering JS (e.g. resolving the - // Atomics.waitAsync promise), which never reaches kAuto's depth-0 drain - isolate->PerformMicrotaskCheckpoint(); -} - -void ForegroundTaskRunner::RunNestableTasks() { - while (true) { - std::unique_ptr task; - { - std::lock_guard lock(mutex_); - if (stopped_) { - return; - } - task = TakeDueTaskLocked(true, now_ms()); - } - if (task == nullptr) { - return; - } - v8::Locker locker(isolate_); - v8::Isolate::Scope isolate_scope(isolate_); - v8::HandleScope handleScope(isolate_); - task->Run(); - } -} - NativeScriptPlatform* NativeScriptPlatform::instance_ = nullptr; NativeScriptPlatform::NativeScriptPlatform(std::unique_ptr defaultPlatform) @@ -239,33 +11,20 @@ NativeScriptPlatform::NativeScriptPlatform(std::unique_ptr defaultPlat instance_ = this; } -std::shared_ptr NativeScriptPlatform::GetForegroundRunner(Isolate* isolate) { - std::lock_guard lock(runnersMutex_); - auto it = runners_.find(isolate); - if (it != runners_.end()) { +std::shared_ptr NativeScriptPlatform::GetEventLoop(Isolate* isolate) { + std::lock_guard lock(loopsMutex_); + auto it = loops_.find(isolate); + if (it != loops_.end()) { return it->second; } - auto runner = std::make_shared(isolate); - runners_.emplace(isolate, runner); - return runner; -} - -void NativeScriptPlatform::RuntimeDestroyed(Isolate* isolate) { - std::shared_ptr runner; - { - std::lock_guard lock(runnersMutex_); - auto it = runners_.find(isolate); - if (it == runners_.end()) { - return; - } - runner = it->second; - } - runner->Shutdown(); + auto loop = std::make_shared(isolate); + loops_.emplace(isolate, loop); + return loop; } void NativeScriptPlatform::IsolateDisposed(Isolate* isolate) { - std::lock_guard lock(runnersMutex_); - runners_.erase(isolate); + std::lock_guard lock(loopsMutex_); + loops_.erase(isolate); } PageAllocator* NativeScriptPlatform::GetPageAllocator() { @@ -290,9 +49,9 @@ int NativeScriptPlatform::NumberOfWorkerThreads() { std::shared_ptr NativeScriptPlatform::GetForegroundTaskRunner( Isolate* isolate, TaskPriority priority) { - // one runner regardless of priority: the Java MessageQueue's FIFO order is - // the priority model of the runtime thread - return GetForegroundRunner(isolate); + // one runner regardless of priority: the home looper's FIFO order is the + // priority model of the runtime thread + return GetEventLoop(isolate)->V8TaskRunner(); } bool NativeScriptPlatform::IdleTasksEnabled(Isolate* isolate) { @@ -360,20 +119,3 @@ void NativeScriptPlatform::PostDelayedTaskOnWorkerThreadImpl( } } // namespace tns - -extern "C" JNIEXPORT void JNICALL Java_com_tns_EventLoopHandler_nativeRunTask( - JNIEnv* env, jclass clazz, jlong nativeRunnerPtr) { - try { - reinterpret_cast(nativeRunnerPtr)->RunTask(); - } catch (tns::NativeScriptException& e) { - e.ReThrowToJava(); - } catch (std::exception& e) { - std::stringstream ss; - ss << "Error: c++ exception: " << e.what() << std::endl; - tns::NativeScriptException nsEx(ss.str()); - nsEx.ReThrowToJava(); - } catch (...) { - tns::NativeScriptException nsEx(std::string("Error: c++ exception!")); - nsEx.ReThrowToJava(); - } -} diff --git a/test-app/runtime/src/main/cpp/NativeScriptPlatform.h b/test-app/runtime/src/main/cpp/NativeScriptPlatform.h index 566333914..d402254d6 100644 --- a/test-app/runtime/src/main/cpp/NativeScriptPlatform.h +++ b/test-app/runtime/src/main/cpp/NativeScriptPlatform.h @@ -1,136 +1,22 @@ #ifndef TEST_APP_NATIVESCRIPTPLATFORM_H #define TEST_APP_NATIVESCRIPTPLATFORM_H -#include -#include -#include #include #include #include "v8.h" #include "v8-platform.h" +#include "EventLoop.h" #include "robin_hood.h" namespace tns { -/** - * Foreground v8::TaskRunner for one isolate, delivering tasks on the - * isolate's home thread via a dedicated Java Handler (com.tns.EventLoopHandler) - * bound to that thread's Looper. - * - * Scheduling model (same as Timers): every posted task enqueues one anonymous - * "task due" token on the Java MessageQueue, so platform tasks are strictly - * FIFO-ordered with Handler.post runnables and JS timers on the same looper. - * The token doesn't name a task: each token runs the front of the native - * queue (or the earliest due delayed task); a token whose work was already - * drained (see RunNestableTasks) is a no-op. - * - * V8 may request this runner (and post to it) before the isolate's home - * thread is known - e.g. during Isolate::New - so the runner starts unbound - * and buffers tasks; BindToCurrentThread attaches the Java handler and flushes - * one token per buffered task. Posts are accepted from any thread. - */ -class ForegroundTaskRunner : public v8::TaskRunner { -public: - explicit ForegroundTaskRunner(v8::Isolate* isolate) : isolate_(isolate) {} - - ~ForegroundTaskRunner() override; - - /** - * Creates the Java handler bound to the calling thread's Looper and posts - * tokens for tasks buffered before the bind. Must be called on the - * isolate's home thread, before that thread's looper starts dispatching. - */ - void BindToCurrentThread(); - - /** - * Releases the Java handler (removing all pending tokens) and drops all - * queued tasks; posts after this are silently dropped. Must be called on - * the home thread, before the isolate is disposed. - */ - void Shutdown(); - - /** - * Runs at most one due task, then performs a microtask checkpoint. - * Invoked by Java EventLoopHandler.handleMessage once per token, on the - * home thread. - */ - void RunTask(); - - /** - * Runs all currently due nestable tasks without a microtask checkpoint. - * For nested message loops (inspector pause) where the Java looper isn't - * spinning: JS is on the stack, so non-nestable tasks stay queued and run - * from their tokens after the loop unwinds. - */ - void RunNestableTasks(); - - 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 v8::SourceLocation& location) override; - - void PostNonNestableTaskImpl(std::unique_ptr task, - const v8::SourceLocation& location) override; - - void PostDelayedTaskImpl(std::unique_ptr task, - double delay_in_seconds, - const v8::SourceLocation& location) override; - - void PostNonNestableDelayedTaskImpl(std::unique_ptr task, - double delay_in_seconds, - const v8::SourceLocation& location) override; - -private: - struct Entry { - std::unique_ptr task; - bool nestable; - // enqueue time for immediate tasks, due time for delayed ones (both - // CLOCK_MONOTONIC ms) so TakeDueTaskLocked has one comparable order - double time; - }; - - void PostImmediate(std::unique_ptr task, bool nestable); - void PostDelayed(std::unique_ptr task, bool nestable, - double delay_in_seconds); - // returns the earliest due task, or nullptr; caller must hold mutex_ - std::unique_ptr TakeDueTaskLocked(bool nestableOnly, double now); - static void PostToken(jobject handler, jlong uptimeMillis); - - v8::Isolate* isolate_; - std::mutex mutex_; - std::deque immediate_; - // delayed tasks keyed by absolute due time (CLOCK_MONOTONIC ms, the same - // clock as uptimeMillis); each posted its token at ceil(dueTime) - std::multimap delayed_; - // global ref to the com.tns.EventLoopHandler for the isolate's home thread - jobject handler_ = nullptr; - bool stopped_ = false; - - // process-wide JNI cache, written once under the first bind's lock - static jclass EVENT_LOOP_HANDLER_CLASS; - static jmethodID EVENT_LOOP_HANDLER_CTOR; - static jmethodID EVENT_LOOP_HANDLER_POST; - static jmethodID EVENT_LOOP_HANDLER_RELEASE; -}; - /** * v8::Platform that delegates worker-thread scheduling, time and tracing to * the default libplatform implementation but serves per-isolate foreground - * task runners riding each runtime thread's Java Looper. This is what makes - * v8's own foreground tasks (WASM async compilation, Atomics.waitAsync - * wakeups, GC finalization) actually run - nothing pumps the default - * platform's internal queues. + * task runners backed by each runtime's EventLoop. This is what makes v8's + * own foreground tasks (WASM async compilation, Atomics.waitAsync wakeups, + * GC finalization) actually run - nothing pumps the default platform's + * internal queues. */ class NativeScriptPlatform : public v8::Platform { public: @@ -141,21 +27,15 @@ class NativeScriptPlatform : public v8::Platform { } /** - * Returns the isolate's runner, creating an unbound one if v8 asks before - * Runtime::PrepareV8Runtime binds it to the isolate's home thread. - */ - std::shared_ptr GetForegroundRunner(v8::Isolate* isolate); - - /** - * Shuts down the isolate's runner (home thread only). The map entry - * survives until IsolateDisposed so late GetForegroundTaskRunner calls - * during teardown see the stopped runner instead of a fresh one. + * Returns the isolate's event loop, creating an unbound one if v8 asks + * before Runtime::PrepareV8Runtime binds it to the isolate's home thread. */ - void RuntimeDestroyed(v8::Isolate* isolate); + std::shared_ptr GetEventLoop(v8::Isolate* isolate); /** - * Drops the runner map entry. Call after v8::Isolate::Dispose, when the - * isolate pointer may be reused for a future isolate. + * Drops the loop registry entry. Call after the isolate can no longer + * post (EventLoop::Shutdown ran), and before the isolate pointer may be + * reused for a future isolate. */ void IsolateDisposed(v8::Isolate* isolate); @@ -194,8 +74,8 @@ class NativeScriptPlatform : public v8::Platform { private: std::unique_ptr default_; - std::mutex runnersMutex_; - robin_hood::unordered_map> runners_; + std::mutex loopsMutex_; + robin_hood::unordered_map> loops_; static NativeScriptPlatform* instance_; }; diff --git a/test-app/runtime/src/main/cpp/Runtime.cpp b/test-app/runtime/src/main/cpp/Runtime.cpp index 4de85f95f..5befc4dfc 100644 --- a/test-app/runtime/src/main/cpp/Runtime.cpp +++ b/test-app/runtime/src/main/cpp/Runtime.cpp @@ -297,8 +297,12 @@ void Runtime::Init(JNIEnv* env, jstring filesPath, jstring nativeLibDir, Runtime::~Runtime() { delete this->m_objectManager; // the isolate pointer may be reused by a future isolate once disposed, so - // the platform's runner entry has to go before this Runtime is forgotten - NativeScriptPlatform::Instance()->IsolateDisposed(m_isolate); + // the platform's loop entry has to go before this Runtime is forgotten; + // both may be null when construction failed before PrepareV8Runtime + auto* platformInstance = NativeScriptPlatform::Instance(); + if (platformInstance != nullptr && m_isolate != nullptr) { + platformInstance->IsolateDisposed(m_isolate); + } CallbackHandlers::RemoveIsolateEntries(m_isolate); if (m_isMainThread) { if (m_mainLooper_fd[0] != -1) { @@ -635,11 +639,10 @@ Isolate* Runtime::PrepareV8Runtime(const string& filesPath, std::lock_guard lock(s_runtimeCacheMutex); s_isolate2RuntimesCache[isolate] = this; } - // attach the isolate's foreground task runner to this thread's looper; - // tasks v8 buffered during Isolate::New start flowing from here on - NativeScriptPlatform::Instance() - ->GetForegroundRunner(isolate) - ->BindToCurrentThread(); + // attach the runtime's event loop to this thread's looper; v8 foreground + // tasks buffered during Isolate::New start flowing from here on + m_eventLoop = NativeScriptPlatform::Instance()->GetEventLoop(isolate); + m_eventLoop->BindToCurrentThread(); v8::Locker locker(isolate); Isolate::Scope isolate_scope(isolate); HandleScope handleScope(isolate); @@ -687,6 +690,11 @@ Isolate* Runtime::PrepareV8Runtime(const string& filesPath, globalTemplate->Set( ArgConverter::ConvertToV8String(isolate, "__drainMicrotaskQueue"), FunctionTemplate::New(isolate, CallbackHandlers::DrainMicrotaskCallback)); + // TODO: remove the __ns__ prefix once the event loop's ordered lane backs + // public macrotask APIs (performance observers etc.) + globalTemplate->Set( + ArgConverter::ConvertToV8String(isolate, "__ns__queueMacrotask"), + FunctionTemplate::New(isolate, CallbackHandlers::QueueMacrotaskCallback)); globalTemplate->Set( ArgConverter::ConvertToV8String(isolate, "__enableVerboseLogging"), FunctionTemplate::New( @@ -831,17 +839,8 @@ Isolate* Runtime::PrepareV8Runtime(const string& filesPath, workerFuncTemplate); } - /* - * Per-runtime task queue used by child workers to deliver messages, errors - * and cleanup notifications to this runtime's thread. The looper exists for - * every runtime: Java prepares it before initNativeScript on both the main - * and worker threads. - */ - m_looperTasks = std::make_shared(); - m_looperTasks->Initialize(ALooper_forThread()); - // Unhandled-promise-rejection tracker; fed by the SetPromiseRejectCallback - // above and drained via a LooperTasks task once per looper turn. + // above and drained via an event-loop task once per looper turn. m_promiseRejections = std::make_unique(this); SimpleProfiler::Init(isolate, globalTemplate); @@ -974,14 +973,11 @@ void Runtime::DestroyRuntime() { s_id2RuntimeCache.erase(m_id); s_isolate2RuntimesCache.erase(m_isolate); } - if (m_looperTasks != nullptr) { + if (m_eventLoop != nullptr) { // runs on this runtime's own thread; children still holding a weak_ptr - // will have their posts dropped from now on - m_looperTasks->Terminate(); + // and v8 teardown posts have their work dropped from now on + m_eventLoop->Shutdown(); } - // stop the foreground task runner before the isolate goes away; v8 posts - // made during teardown are dropped - NativeScriptPlatform::Instance()->RuntimeDestroyed(m_isolate); // The events state holds v8::Global handles (backing event target, dispatch // closures and tracked promise rejections) - reset them while the isolate // is still alive. diff --git a/test-app/runtime/src/main/cpp/Runtime.h b/test-app/runtime/src/main/cpp/Runtime.h index 64f06ffc7..6d43f15e6 100644 --- a/test-app/runtime/src/main/cpp/Runtime.h +++ b/test-app/runtime/src/main/cpp/Runtime.h @@ -11,7 +11,7 @@ #include "ModuleInternal.h" #include "File.h" #include "Timers.h" -#include "LooperTasks.h" +#include "EventLoop.h" #include #include #include @@ -106,12 +106,12 @@ class Runtime { } /* - * Task queue bound to this runtime's looper. Child workers hold a - * weak_ptr to their parent runtime's queue for worker -> parent + * Scheduler bound to this runtime's looper. Child workers hold a + * weak_ptr to their parent runtime's loop for worker -> parent * delivery (messages, errors, cleanup notifications). */ - std::shared_ptr GetLooperTasks() const { - return m_looperTasks; + std::shared_ptr GetEventLoop() const { + return m_eventLoop; } /* @@ -193,7 +193,7 @@ class Runtime { Profiler m_profiler; - std::shared_ptr m_looperTasks; + std::shared_ptr m_eventLoop; v8::Global m_globalEventTarget; v8::Global m_dispatchErrorEventFunc; diff --git a/test-app/runtime/src/main/cpp/WorkerInspectorClient.cpp b/test-app/runtime/src/main/cpp/WorkerInspectorClient.cpp index 214b8b21e..fbe7a7192 100644 --- a/test-app/runtime/src/main/cpp/WorkerInspectorClient.cpp +++ b/test-app/runtime/src/main/cpp/WorkerInspectorClient.cpp @@ -219,11 +219,11 @@ void WorkerInspectorClient::runMessageLoopOnPause(int contextGroupId) { this->DispatchOne(message); } - // JS frames are on the stack, so only nestable foreground tasks may - // run; non-nestable ones fire from their queue tokens after resume + // 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() - ->GetForegroundRunner(isolate_) - ->RunNestableTasks(); + ->GetEventLoop(isolate_) + ->RunNestableV8Tasks(); if (shouldWait && !pauseTerminated_ && !dying_) { std::unique_lock lock(messageArrivedMutex_); diff --git a/test-app/runtime/src/main/cpp/WorkerWrapper.cpp b/test-app/runtime/src/main/cpp/WorkerWrapper.cpp index 2412d77ff..90e229e9e 100644 --- a/test-app/runtime/src/main/cpp/WorkerWrapper.cpp +++ b/test-app/runtime/src/main/cpp/WorkerWrapper.cpp @@ -29,7 +29,7 @@ WorkerWrapper::WorkerWrapper(Isolate* parentIsolate, int workerId, std::string w Local workerObject) : parentIsolate_(parentIsolate), // runs on the parent's thread, where the parent runtime is alive - parentTasks_(Runtime::GetRuntime(parentIsolate)->GetLooperTasks()), + parentTasks_(Runtime::GetRuntime(parentIsolate)->GetEventLoop()), workerIsolate_(nullptr), runtime_(nullptr), workerId_(workerId), @@ -70,7 +70,7 @@ void WorkerWrapper::PostMessageToParent(std::shared_ptr message } int workerId = workerId_; - parentTasks->Post([workerId, message]() { + parentTasks->PostInternal([workerId, message]() { WorkerWrapper::FireMessageOnParentWorkerObject(workerId, message); }); } @@ -248,7 +248,7 @@ void WorkerWrapper::PassUncaughtExceptionFromWorkerToParent(const std::string& m std::string threadName = threadName_; Isolate* parentIsolate = parentIsolate_; - parentTasks->Post([workerId, message, filename, stackTrace, lineno, threadName, + parentTasks->PostInternal([workerId, message, filename, stackTrace, lineno, threadName, parentIsolate]() { v8::Locker locker(parentIsolate); Isolate::Scope isolate_scope(parentIsolate); @@ -486,7 +486,7 @@ void WorkerWrapper::BackgroundLooper(std::shared_ptr self) { // own shutdown already cleared them). if (auto parentTasks = parentTasks_.lock()) { int workerId = workerId_; - parentTasks->Post([workerId]() { + parentTasks->PostInternal([workerId]() { WorkerWrapper::ClearWorkerOnParent(workerId); }); } diff --git a/test-app/runtime/src/main/cpp/WorkerWrapper.h b/test-app/runtime/src/main/cpp/WorkerWrapper.h index 464145d2f..fd12bbcb2 100644 --- a/test-app/runtime/src/main/cpp/WorkerWrapper.h +++ b/test-app/runtime/src/main/cpp/WorkerWrapper.h @@ -21,7 +21,7 @@ namespace tns { -class LooperTasks; +class EventLoop; class Runtime; #ifdef APPLICATION_IN_DEBUG class WorkerInspectorClient; @@ -36,7 +36,7 @@ class WorkerInspectorClient; * * Messaging is done entirely in C++ with V8 ValueSerializer payloads: * - parent -> worker: queue_ + eventfd wakeup on the worker looper - * - worker -> parent: the parent runtime's LooperTasks queue + * - worker -> parent: the parent runtime's event loop (internal lane) * * The parent may be the main thread or another worker (nested workers); a * worker's children are terminated when the worker itself shuts down. @@ -154,7 +154,7 @@ class WorkerWrapper : public std::enable_shared_from_this { v8::Isolate* parentIsolate_; // The parent runtime's task queue; weak so a child outliving its parent // just drops its posts instead of touching a dead runtime. - std::weak_ptr parentTasks_; + std::weak_ptr parentTasks_; std::atomic workerIsolate_; Runtime* runtime_; diff --git a/test-app/runtime/src/main/java/com/tns/EventLoopHandler.java b/test-app/runtime/src/main/java/com/tns/EventLoopHandler.java index c1a18022d..b2992543a 100644 --- a/test-app/runtime/src/main/java/com/tns/EventLoopHandler.java +++ b/test-app/runtime/src/main/java/com/tns/EventLoopHandler.java @@ -5,16 +5,14 @@ import android.os.Message; /** - * Dedicated per-runtime Handler that delivers V8 platform foreground tasks - * (and, in the future, any runtime work that must run as a macrotask) on the - * runtime thread's Looper. One instance per isolate, created and used - * exclusively from native code (NativeScriptPlatform.cpp). + * Dedicated per-runtime Handler carrying the EventLoop's ordered lane: work + * that must run as a macrotask on the runtime thread, strictly FIFO-ordered + * with Handler.post runnables and JS timers on the same looper. One instance + * per isolate, created and used exclusively from native code (EventLoop.cpp). * * Messages are anonymous "task due" tokens: native code owns the actual task * queue and runs at most one due task per token, so a token never names a - * task and a leftover token is a cheap no-op. Riding the Java MessageQueue - * (rather than an ALooper fd) keeps tasks strictly FIFO-ordered with - * Handler.post runnables and JS timers on the same looper. + * task and a leftover token is a cheap no-op. */ final class EventLoopHandler extends Handler { private static final int MSG_RUN_TASK = 1; @@ -22,12 +20,21 @@ final class EventLoopHandler extends Handler { private final long nativeRunnerPtr; private boolean released; - // constructed from native code (ForegroundTaskRunner::BindToCurrentThread) + // constructed from native code (EventLoop::BindToCurrentThread) EventLoopHandler(long nativeRunnerPtr) { - super(Looper.myLooper()); + super(requireLooper()); this.nativeRunnerPtr = nativeRunnerPtr; } + private static Looper requireLooper() { + Looper looper = Looper.myLooper(); + if (looper == null) { + throw new IllegalStateException( + "EventLoopHandler requires a prepared Looper on the runtime thread"); + } + return looper; + } + /** * Enqueues an anonymous "task due" token at an absolute uptimeMillis. * Immediate tasks pass the current uptime; delayed tasks pass their due @@ -39,9 +46,8 @@ void post(long uptimeMillis) { } /** - * Called from ForegroundTaskRunner::Shutdown on this handler's own - * thread. After this no token can fire into the (about to be freed) - * native runner. + * Called from EventLoop::Shutdown on this handler's own thread. After + * this no token can fire into the (about to be freed) native loop. */ @RuntimeCallable void release() { From 6b6cfc7e0332758e9ee6d5f8f2af7629a3ccc27c Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Tue, 11 Aug 2026 17:28:56 -0300 Subject: [PATCH 3/5] fix(event-loop): unit accounting and isolate-reuse hardening from design review Two defects found by deep review of the scheduler: - internal-lane unit starvation: an eventfd unit written for an immediate entry could be consumed by a due-but-unsignaled delayed entry (whose own timerfd unit hadn't been issued yet); the timer fire then found nothing due and issued nothing, leaving the lane permanently off-by-one - the newest entry always waited for a future post. The unit-consuming path now skips unsignaled delayed entries; nested (unit-free) drains and the ordered lane are unaffected, since ordered entries carry their token from post time. - stale loop registry across isolate-pointer reuse: the registry erase ran in ~Runtime, several JNI calls after Isolate::Dispose freed the address. A concurrently created worker isolate could reuse the pointer, inherit the dead runtime's stopped loop (silently dropping all its work), and then lose its own entry to the late destructor. The erase now happens immediately after Dispose and only while the entry still maps to the disposing runtime's loop; PrepareV8Runtime refreshes a stopped loop found under its key; and the v8 task runner resolves the loop through the registry on every post, so a refresh also redirects runners v8 already holds. Also from review: the inspector-pause drain no longer lets C++ exceptions unwind through v8 inspector frames, and fd callbacks ignore spurious wakeups instead of consuming an entry. Tests: worker reply racing an overdue Atomics.waitAsync timeout (unit accounting), worker churn smoke, and __ns__queueMacrotask posted from a background JS thread landing on the main thread (multithreaded JS). --- .../assets/app/tests/eventLoopEchoWorker.js | 3 + .../main/assets/app/tests/testEventLoop.js | 57 +++++++++++ test-app/runtime/src/main/cpp/EventLoop.cpp | 96 +++++------------- test-app/runtime/src/main/cpp/EventLoop.h | 29 ++++-- .../src/main/cpp/NativeScriptPlatform.cpp | 99 +++++++++++++++++-- .../src/main/cpp/NativeScriptPlatform.h | 37 ++++++- test-app/runtime/src/main/cpp/Runtime.cpp | 17 ++-- .../runtime/src/main/cpp/WorkerWrapper.cpp | 7 ++ 8 files changed, 246 insertions(+), 99 deletions(-) create mode 100644 test-app/app/src/main/assets/app/tests/eventLoopEchoWorker.js diff --git a/test-app/app/src/main/assets/app/tests/eventLoopEchoWorker.js b/test-app/app/src/main/assets/app/tests/eventLoopEchoWorker.js new file mode 100644 index 000000000..dd42a8746 --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/eventLoopEchoWorker.js @@ -0,0 +1,3 @@ +onmessage = function (msg) { + postMessage(msg.data); +}; diff --git a/test-app/app/src/main/assets/app/tests/testEventLoop.js b/test-app/app/src/main/assets/app/tests/testEventLoop.js index a2d3d642b..e78409949 100644 --- a/test-app/app/src/main/assets/app/tests/testEventLoop.js +++ b/test-app/app/src/main/assets/app/tests/testEventLoop.js @@ -101,4 +101,61 @@ describe("event loop ordered macrotasks", function () { expect(() => __ns__queueMacrotask("nope")).toThrowError(TypeError); expect(() => __ns__queueMacrotask()).toThrowError(TypeError); }); + + it("runs on the main thread when posted from a background JS thread", function (done) { + const mainThreadId = java.lang.Thread.currentThread().getId(); + new java.lang.Thread(new java.lang.Runnable({ + run() { + expect(java.lang.Thread.currentThread().getId()).not.toEqual(mainThreadId); + __ns__queueMacrotask(() => { + expect(java.lang.Thread.currentThread().getId()).toEqual(mainThreadId); + done(); + }); + } + })).start(); + }); +}); + +describe("event loop internal lane", function () { + // Regression for the eventfd unit-accounting bug: a worker reply's wakeup + // arriving while an overdue waitAsync timeout is still unsignaled must not + // be spent on the timeout entry, or the reply starves. + 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 looper until both the timeout and the reply are + // pending, so their wakeups are serviced from the same poll + 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("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/test-app/runtime/src/main/cpp/EventLoop.cpp b/test-app/runtime/src/main/cpp/EventLoop.cpp index 0c16465d8..aad50b638 100644 --- a/test-app/runtime/src/main/cpp/EventLoop.cpp +++ b/test-app/runtime/src/main/cpp/EventLoop.cpp @@ -51,60 +51,6 @@ jmethodID EventLoop::EVENT_LOOP_HANDLER_CTOR = nullptr; jmethodID EventLoop::EVENT_LOOP_HANDLER_POST = nullptr; jmethodID EventLoop::EVENT_LOOP_HANDLER_RELEASE = nullptr; -/** - * Adapter v8 holds (via shared_ptr) as the isolate's foreground task runner. - * Holds the EventLoop weakly: the loop is owned by the platform's registry and - * the Runtime; once it shuts down and is released, late posts from v8 lock() - * to nullptr and drop, matching the loop's own post-after-Shutdown behavior. - */ -class V8TaskRunnerAdapter : public v8::TaskRunner { -public: - explicit V8TaskRunnerAdapter(std::weak_ptr loop) : loop_(std::move(loop)) {} - - 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 { - if (auto loop = loop_.lock()) { - loop->PostV8Task(std::move(task), true, 0); - } - } - - void PostNonNestableTaskImpl(std::unique_ptr task, - const SourceLocation& location) override { - if (auto loop = loop_.lock()) { - loop->PostV8Task(std::move(task), false, 0); - } - } - - void PostDelayedTaskImpl(std::unique_ptr task, double delay_in_seconds, - const SourceLocation& location) override { - if (auto loop = loop_.lock()) { - loop->PostV8Task(std::move(task), true, delay_in_seconds); - } - } - - void PostNonNestableDelayedTaskImpl(std::unique_ptr task, double delay_in_seconds, - const SourceLocation& location) override { - if (auto loop = loop_.lock()) { - loop->PostV8Task(std::move(task), false, delay_in_seconds); - } - } - -private: - std::weak_ptr loop_; -}; - void EventLoop::BindToCurrentThread() { JEnv env; std::lock_guard lock(mutex_); @@ -207,7 +153,12 @@ void EventLoop::Shutdown() { } EventLoop::~EventLoop() { - // normally a no-op: DestroyRuntime already shut the loop down + // Normally a no-op: DestroyRuntime already shut the loop down and this + // runs on the home thread via ~Runtime. In the pathological case where a + // transient shared_ptr taken on a v8 pool thread is the last reference, + // the JEnv below permanently attaches that thread to ART (which aborts if + // it later exits attached) - accepted, since those pool threads live for + // the process lifetime. Shutdown(); std::lock_guard lock(mutex_); if (handler_ != nullptr) { @@ -301,16 +252,15 @@ void EventLoop::PostV8Task(std::unique_ptr task, bool nestable, double del delaySeconds * 1000.0); } -std::shared_ptr EventLoop::V8TaskRunner() { +bool EventLoop::IsStopped() { std::lock_guard lock(mutex_); - if (v8Runner_ == nullptr) { - v8Runner_ = std::make_shared(weak_from_this()); - } - return v8Runner_; + return stopped_; } std::unique_ptr EventLoop::TakeDueLocked(Lane& lane, bool nestableOnly, - bool v8Only, double now) { + bool v8Only, + bool requireSignaledDelayed, + double now) { auto matches = [&](const Entry& e) { return (!nestableOnly || e.nestable) && (!v8Only || e.task != nullptr); }; @@ -319,7 +269,8 @@ std::unique_ptr EventLoop::TakeDueLocked(Lane& lane, bool nest ++imIt; } auto delIt = lane.delayed.begin(); - while (delIt != lane.delayed.end() && !matches(delIt->second)) { + while (delIt != lane.delayed.end() && + (!matches(delIt->second) || (requireSignaledDelayed && !delIt->second.signaled))) { ++delIt; } bool hasImmediate = imIt != lane.immediate.end(); @@ -382,7 +333,7 @@ void EventLoop::RunOneInternal() { if (stopped_) { return; } - entry = TakeDueLocked(internal_, false, false, now_ms()); + entry = TakeDueLocked(internal_, false, false, true, now_ms()); } if (entry == nullptr) { // leftover unit: the work it represented ran early from a nested loop @@ -407,12 +358,14 @@ void EventLoop::RunNestableV8Tasks() { if (stopped_) { return; } - entry = TakeDueLocked(internal_, true, true, now_ms()); + entry = TakeDueLocked(internal_, true, true, false, now_ms()); } if (entry == nullptr) { return; } - RunEntry(*entry); + // the pause loops call this from inside v8 inspector frames - a C++ + // exception must not unwind through them + RunGuarded([&] { RunEntry(*entry); }); } } @@ -423,7 +376,7 @@ void EventLoop::RunOrderedTask() { if (stopped_) { return; } - entry = TakeDueLocked(ordered_, false, false, now_ms()); + entry = TakeDueLocked(ordered_, false, false, false, now_ms()); } if (entry == nullptr) { // leftover token: the earliest delayed entry isn't due yet @@ -436,15 +389,20 @@ int EventLoop::EventFdCallback(int fd, int events, void* data) { uint64_t value; // EFD_SEMAPHORE: consumes exactly one unit; while more remain the fd stays // readable and ALooper calls back next poll, interleaving with Java - // messages instead of draining in one go - read(fd, &value, sizeof(value)); + // messages instead of draining in one go. A spurious wakeup with nothing + // to read must not consume an entry, or its real unit becomes a leftover. + if (read(fd, &value, sizeof(value)) != sizeof(value)) { + return 1; + } RunGuarded([&] { static_cast(data)->RunOneInternal(); }); return 1; } int EventLoop::TimerFdCallback(int fd, int events, void* data) { uint64_t expirations; - read(fd, &expirations, sizeof(expirations)); + if (read(fd, &expirations, sizeof(expirations)) != sizeof(expirations)) { + return 1; + } auto self = static_cast(data); { std::lock_guard lock(self->mutex_); diff --git a/test-app/runtime/src/main/cpp/EventLoop.h b/test-app/runtime/src/main/cpp/EventLoop.h index 1ed26775a..542187bd8 100644 --- a/test-app/runtime/src/main/cpp/EventLoop.h +++ b/test-app/runtime/src/main/cpp/EventLoop.h @@ -42,7 +42,7 @@ namespace tns { * terminated runtime" semantics; leftover wakeups (tokens or eventfd units * whose work was drained early) are no-ops. */ -class EventLoop : public std::enable_shared_from_this { +class EventLoop { public: explicit EventLoop(v8::Isolate* isolate) : isolate_(isolate) {} @@ -73,10 +73,16 @@ class EventLoop : public std::enable_shared_from_this { void PostInternalDelayed(std::function fn, double delayMs); /** - * The v8::TaskRunner this isolate's foreground tasks post through; tasks - * land in the internal lane. Safe to call from any thread. + * Posts a v8 foreground task into the internal lane. Called by the + * platform's per-isolate v8::TaskRunner adapter, from any thread. */ - std::shared_ptr V8TaskRunner(); + 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 @@ -115,14 +121,18 @@ class EventLoop : public std::enable_shared_from_this { std::multimap delayed; }; - friend class V8TaskRunnerAdapter; - void PostV8Task(std::unique_ptr task, bool nestable, double delaySeconds); - - // all *Locked members require mutex_ to be held + // all *Locked members require mutex_ to be held. + // requireSignaledDelayed must be true on the eventfd unit-consuming path: + // a due delayed entry whose timerfd unit hasn't been issued yet is not + // this unit's work - consuming it would strand the entry the unit was + // written for (the timerfd fire then finds nothing due and issues no + // replacement unit). Ordered-lane and nested (unit-free) drains pass + // false: ordered entries carry their token from post time, and nested + // drains consume no units at all. void PostInternalLocked(Entry entry, double delayMs); void PostOrderedLocked(Entry entry, double delayMs); static std::unique_ptr TakeDueLocked(Lane& lane, bool nestableOnly, bool v8Only, - double now); + bool requireSignaledDelayed, double now); void ArmTimerLocked(double now); void RunEntry(Entry& entry); void RunOneInternal(); @@ -134,7 +144,6 @@ class EventLoop : public std::enable_shared_from_this { std::mutex mutex_; Lane internal_; Lane ordered_; - std::shared_ptr v8Runner_; // adapter; holds a weak_ptr back // ordered lane: global ref to this thread's com.tns.EventLoopHandler jobject handler_ = nullptr; // internal lane: EFD_SEMAPHORE eventfd (one unit = run one due entry) and diff --git a/test-app/runtime/src/main/cpp/NativeScriptPlatform.cpp b/test-app/runtime/src/main/cpp/NativeScriptPlatform.cpp index a4baeabdd..bdae97db6 100644 --- a/test-app/runtime/src/main/cpp/NativeScriptPlatform.cpp +++ b/test-app/runtime/src/main/cpp/NativeScriptPlatform.cpp @@ -6,25 +6,107 @@ 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; } -std::shared_ptr NativeScriptPlatform::GetEventLoop(Isolate* isolate) { - std::lock_guard lock(loopsMutex_); +NativeScriptPlatform::IsolateEntry& NativeScriptPlatform::GetEntryLocked(Isolate* isolate) { auto it = loops_.find(isolate); if (it != loops_.end()) { return it->second; } - auto loop = std::make_shared(isolate); - loops_.emplace(isolate, loop); - return loop; + auto emplaced = loops_.emplace( + isolate, IsolateEntry{std::make_shared(isolate), + std::make_shared(isolate)}); + return emplaced.first->second; } -void NativeScriptPlatform::IsolateDisposed(Isolate* isolate) { +std::shared_ptr NativeScriptPlatform::GetEventLoop(Isolate* isolate) { std::lock_guard lock(loopsMutex_); - loops_.erase(isolate); + 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() { @@ -51,7 +133,8 @@ std::shared_ptr NativeScriptPlatform::GetForegroundTaskRunner( Isolate* isolate, TaskPriority priority) { // one runner regardless of priority: the home looper's FIFO order is the // priority model of the runtime thread - return GetEventLoop(isolate)->V8TaskRunner(); + std::lock_guard lock(loopsMutex_); + return GetEntryLocked(isolate).runner; } bool NativeScriptPlatform::IdleTasksEnabled(Isolate* isolate) { diff --git a/test-app/runtime/src/main/cpp/NativeScriptPlatform.h b/test-app/runtime/src/main/cpp/NativeScriptPlatform.h index d402254d6..89b33fd41 100644 --- a/test-app/runtime/src/main/cpp/NativeScriptPlatform.h +++ b/test-app/runtime/src/main/cpp/NativeScriptPlatform.h @@ -33,11 +33,29 @@ class NativeScriptPlatform : public v8::Platform { std::shared_ptr GetEventLoop(v8::Isolate* isolate); /** - * Drops the loop registry entry. Call after the isolate can no longer - * post (EventLoop::Shutdown ran), and before the isolate pointer may be - * reused for a future isolate. + * GetEventLoop, but replaces a stopped loop with a fresh one first. Used + * by PrepareV8Runtime: 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. */ - void IsolateDisposed(v8::Isolate* isolate); + 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. Call right after + * v8::Isolate::Dispose (and again from ~Runtime as an idempotent + * backstop). + */ + void IsolateDisposed(v8::Isolate* isolate, const std::shared_ptr& loop); // v8::Platform v8::PageAllocator* GetPageAllocator() override; @@ -73,9 +91,18 @@ class NativeScriptPlatform : public v8::Platform { 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; + }; + std::unique_ptr default_; std::mutex loopsMutex_; - robin_hood::unordered_map> loops_; + robin_hood::unordered_map loops_; + + IsolateEntry& GetEntryLocked(v8::Isolate* isolate); static NativeScriptPlatform* instance_; }; diff --git a/test-app/runtime/src/main/cpp/Runtime.cpp b/test-app/runtime/src/main/cpp/Runtime.cpp index 5befc4dfc..a9eef5ea4 100644 --- a/test-app/runtime/src/main/cpp/Runtime.cpp +++ b/test-app/runtime/src/main/cpp/Runtime.cpp @@ -296,12 +296,13 @@ void Runtime::Init(JNIEnv* env, jstring filesPath, jstring nativeLibDir, Runtime::~Runtime() { delete this->m_objectManager; - // the isolate pointer may be reused by a future isolate once disposed, so - // the platform's loop entry has to go before this Runtime is forgotten; - // both may be null when construction failed before PrepareV8Runtime + // idempotent backstop for the matched erase WorkerWrapper does right after + // Isolate::Dispose (the match keeps this from evicting a new isolate that + // reused the pointer); instance/isolate may be null when construction + // failed before PrepareV8Runtime auto* platformInstance = NativeScriptPlatform::Instance(); - if (platformInstance != nullptr && m_isolate != nullptr) { - platformInstance->IsolateDisposed(m_isolate); + if (platformInstance != nullptr && m_isolate != nullptr && m_eventLoop != nullptr) { + platformInstance->IsolateDisposed(m_isolate, m_eventLoop); } CallbackHandlers::RemoveIsolateEntries(m_isolate); if (m_isMainThread) { @@ -640,8 +641,10 @@ Isolate* Runtime::PrepareV8Runtime(const string& filesPath, s_isolate2RuntimesCache[isolate] = this; } // attach the runtime's event loop to this thread's looper; v8 foreground - // tasks buffered during Isolate::New start flowing from here on - m_eventLoop = NativeScriptPlatform::Instance()->GetEventLoop(isolate); + // tasks buffered during Isolate::New start flowing from here on. Refresh + // rather than Get: a reused isolate pointer may still map to the previous + // tenant's stopped loop + m_eventLoop = NativeScriptPlatform::Instance()->RefreshEventLoop(isolate); m_eventLoop->BindToCurrentThread(); v8::Locker locker(isolate); Isolate::Scope isolate_scope(isolate); diff --git a/test-app/runtime/src/main/cpp/WorkerWrapper.cpp b/test-app/runtime/src/main/cpp/WorkerWrapper.cpp index 90e229e9e..f576b8484 100644 --- a/test-app/runtime/src/main/cpp/WorkerWrapper.cpp +++ b/test-app/runtime/src/main/cpp/WorkerWrapper.cpp @@ -11,6 +11,7 @@ #include "JniLocalRef.h" #include "NativeScriptAssert.h" #include "NativeScriptException.h" +#include "NativeScriptPlatform.h" #include "Runtime.h" #include @@ -464,6 +465,12 @@ void WorkerWrapper::BackgroundLooper(std::shared_ptr self) { runtime_->DestroyRuntime(); } isolate->Dispose(); + // Dispose freed the isolate's memory, so its address can be reused by + // a concurrent Isolate::New - drop the platform's loop entry now, not + // in ~Runtime (which still runs JNI calls first). The matched erase + // means the late ~Runtime backstop can't evict a new tenant. + NativeScriptPlatform::Instance()->IsolateDisposed(isolate, + runtime_->GetEventLoop()); // The Runtime destructor still makes JNI calls - it must run before // DetachCurrentThread below. From 33d75476da21d02292853fab58b7686710e020f5 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Tue, 11 Aug 2026 18:05:49 -0300 Subject: [PATCH 4/5] feat(event-loop): merge timers into the ordered lane; route __runOnMainThread through the internal lane Timers merge (with tombstones): - Timers no longer owns a Java Handler: each scheduled timer posts one anonymous token through the EventLoop's ordered lane, and the token drain runs the earliest due item across timers and ordered macrotasks - one due-ordered domain, still strictly FIFO with Handler.post on the same looper. Token 'when' computation is unchanged, so the quiescent setTimeout-vs-Handler.post contract is preserved exactly. - clearTimeout/clearInterval tombstone the sorted entry instead of erasing it: the cleared timer's already-queued token consumes its own slot as a no-op, so no token gains surplus capacity to run a later-scheduled item (timer or macrotask) ahead of foreign Java messages queued between the two token positions. This also fixes the pre-existing congestion deviation where a leftover token could fire a later timer early. - FireTimer's internals (sub-ms sorted list, chromium-style interval catch-up, nesting clamp, TryCatch discipline) are untouched; the check-and-run happens in one OrderedTaskSource::RunIfEarliest call under a single Locker acquisition, because background threads mutate the timer bookkeeping through setTimeout under multithreaded JS. - TimerHandler.java is deleted. __runOnMainThread promotion: - The 2MB main-looper pipe and RunOnMainThreadFdCallback are replaced by bare internal-lane entries on the main runtime's EventLoop. Bare entries skip the loop's Locker/checkpoint: the closure locks the CALLER's isolate (a worker's, under multithreaded JS), and taking the main isolate's Locker first would nest Lockers across isolates and can deadlock against worker->main JNI entry paths. Delivery stays one-per-poll, matching the old fd callback. - The callback cache is now mutex-guarded: it was written from arbitrary threads under different isolates' Lockers, which provide no mutual exclusion; RemoveIsolateEntries also no longer erases while range-iterating. - Uncaught exceptions in the callbacks now surface as pending Java exceptions via the loop's guard instead of unwinding C++ through the ALooper callback frame. Tests: tombstone ordering specs (cleared timer's token vs java posts, for both a later timer and a queued macrotask), against the native __ns__ timers - the test app's global setTimeout is an old Handler-based polyfill with colliding ids, not the runtime timers. --- .../main/assets/app/tests/testEventLoop.js | 42 ++++++- .../runtime/src/main/cpp/CallbackHandlers.cpp | 68 ++++++---- .../runtime/src/main/cpp/CallbackHandlers.h | 9 +- test-app/runtime/src/main/cpp/EventLoop.cpp | 84 +++++++++++-- test-app/runtime/src/main/cpp/EventLoop.h | 57 +++++++++ test-app/runtime/src/main/cpp/Runtime.cpp | 43 +------ test-app/runtime/src/main/cpp/Runtime.h | 15 +-- test-app/runtime/src/main/cpp/Timers.cpp | 116 ++++++++---------- test-app/runtime/src/main/cpp/Timers.h | 50 ++++---- .../src/main/java/com/tns/TimerHandler.java | 54 -------- 10 files changed, 308 insertions(+), 230 deletions(-) delete mode 100644 test-app/runtime/src/main/java/com/tns/TimerHandler.java diff --git a/test-app/app/src/main/assets/app/tests/testEventLoop.js b/test-app/app/src/main/assets/app/tests/testEventLoop.js index e78409949..445e13532 100644 --- a/test-app/app/src/main/assets/app/tests/testEventLoop.js +++ b/test-app/app/src/main/assets/app/tests/testEventLoop.js @@ -86,10 +86,12 @@ describe("event loop ordered macrotasks", function () { Promise.resolve().then(() => order.push("microtask")); }); - it("stays FIFO-ordered with setTimeout(0)", function (done) { + // native timers (__ns__*): the app-level `setTimeout` global in this test + // app is an old Handler-based polyfill, not the runtime timers + it("stays FIFO-ordered with native setTimeout(0)", function (done) { const order = []; __ns__queueMacrotask(() => order.push("macro1")); - setTimeout(() => order.push("timeout"), 0); + __ns__setTimeout(() => order.push("timeout"), 0); __ns__queueMacrotask(() => { order.push("macro2"); expect(order).toEqual(["macro1", "timeout", "macro2"]); @@ -116,6 +118,42 @@ describe("event loop ordered macrotasks", function () { }); }); +// clearTimeout leaves a tombstone in the merged ordered domain, so the +// cleared timer's already-queued token consumes its own slot as a no-op +// instead of running a later-scheduled item ahead of Java messages queued +// between the two tokens' positions. +describe("event loop ordered tombstones", function () { + it("cleared timeout's token does not run a later timer ahead of java posts", function (done) { + const order = []; + const handler = new android.os.Handler(android.os.Looper.myLooper()); + const t1 = __ns__setTimeout(() => order.push("cleared"), 0); + __ns__clearTimeout(t1); + handler.post(new java.lang.Runnable({ + run: () => order.push("java") + })); + __ns__setTimeout(() => { + order.push("t2"); + expect(order).toEqual(["java", "t2"]); + done(); + }, 0); + }); + + it("cleared timeout's token does not run a queued macrotask ahead of java posts", function (done) { + const order = []; + const handler = new android.os.Handler(android.os.Looper.myLooper()); + const t1 = __ns__setTimeout(() => order.push("cleared"), 0); + __ns__clearTimeout(t1); + handler.post(new java.lang.Runnable({ + run: () => order.push("java") + })); + __ns__queueMacrotask(() => { + order.push("macro"); + expect(order).toEqual(["java", "macro"]); + done(); + }); + }); +}); + describe("event loop internal lane", function () { // Regression for the eventfd unit-accounting bug: a worker reply's wakeup // arriving while an overdue waitAsync timeout is still unsignaled must not diff --git a/test-app/runtime/src/main/cpp/CallbackHandlers.cpp b/test-app/runtime/src/main/cpp/CallbackHandlers.cpp index 0663ef414..216db71c1 100644 --- a/test-app/runtime/src/main/cpp/CallbackHandlers.cpp +++ b/test-app/runtime/src/main/cpp/CallbackHandlers.cpp @@ -684,37 +684,50 @@ void CallbackHandlers::RunOnMainThreadCallback(const FunctionCallbackInfo callback = args[0].As(); - bool inserted; - std::tie(std::ignore, inserted) = cache_.try_emplace(key, isolate, callback); - assert(inserted && "Main thread callback ID should not be duplicated"); + { + std::lock_guard lock(cacheMutex_); + bool inserted; + std::tie(std::ignore, inserted) = cache_.try_emplace(key, isolate, callback); + assert(inserted && "Main thread callback ID should not be duplicated"); + } - auto value = Callback(key); - auto size = sizeof(Callback); - auto wrote = write(Runtime::GetWriter(),&value , size); + auto mainLoop = Runtime::GetMainEventLoop(); + if (mainLoop == nullptr) { + return; + } + // bare entry: the closure locks the CALLER's isolate (possibly a + // worker's), so the loop must not take the main isolate's Locker first - + // nesting the two can deadlock against multithreaded-JS entry paths + mainLoop->PostInternalBare([key]() { RunMainThreadEntry(key); }); } -int CallbackHandlers::RunOnMainThreadFdCallback(int fd, int events, void *data) { - struct Callback value; - auto size = sizeof(Callback); - ssize_t nr = read(fd, &value, sizeof(value)); - - auto key = value.id_; - - auto it = cache_.find(key); - if (it == cache_.end()) { - return 1; +void CallbackHandlers::RunMainThreadEntry(uint64_t key) { + Isolate *isolate; + { + std::lock_guard lock(cacheMutex_); + auto it = cache_.find(key); + if (it == cache_.end()) { + return; + } + isolate = it->second.isolate_; } - Isolate *isolate = it->second.isolate_; v8::Locker locker(isolate); Isolate::Scope isolate_scope(isolate); HandleScope handle_scope(isolate); - Local cb = it->second.callback_.Get(isolate); + Local cb; + { + std::lock_guard lock(cacheMutex_); + auto it = cache_.find(key); + if (it == cache_.end()) { + return; + } + cb = it->second.callback_.Get(isolate); + cache_.erase(it); + } Runtime* runtime = Runtime::GetRuntime(isolate); v8::Local context = runtime->GetContext(); Context::Scope context_scope(context); - // erase the it here as we're already done with its values and the callback might invalidate the iterator - cache_.erase(it); v8::TryCatch tc(isolate); @@ -722,10 +735,9 @@ int CallbackHandlers::RunOnMainThreadFdCallback(int fd, int events, void *data) if (tc.HasCaught() && !NativeScriptException::ContainUncaughtCallbackException(isolate, tc)) { + // surfaces via the event loop's guard as a pending Java exception throw NativeScriptException(tc); } - - return 1; } void CallbackHandlers::LogMethodCallback(const v8::FunctionCallbackInfo &args) { @@ -1588,9 +1600,14 @@ void CallbackHandlers::CallWorkerScopeOnErrorHandle(Isolate *isolate, TryCatch & } void CallbackHandlers::RemoveIsolateEntries(v8::Isolate *isolate) { - for (auto &item: cache_) { - if (item.second.isolate_ == isolate) { - cache_.erase(item.first); + { + std::lock_guard lock(cacheMutex_); + for (auto it = cache_.begin(); it != cache_.end();) { + if (it->second.isolate_ == isolate) { + it = cache_.erase(it); + } else { + ++it; + } } } @@ -1755,6 +1772,7 @@ void CallbackHandlers::InitChoreographer() { robin_hood::unordered_map CallbackHandlers::cache_; +std::mutex CallbackHandlers::cacheMutex_; robin_hood::unordered_map CallbackHandlers::frameCallbackCache_; diff --git a/test-app/runtime/src/main/cpp/CallbackHandlers.h b/test-app/runtime/src/main/cpp/CallbackHandlers.h index 23d271727..6be5df398 100644 --- a/test-app/runtime/src/main/cpp/CallbackHandlers.h +++ b/test-app/runtime/src/main/cpp/CallbackHandlers.h @@ -3,6 +3,7 @@ #include #include +#include #include #include "JEnv.h" #include "ArgsWrapper.h" @@ -73,7 +74,9 @@ namespace tns { static void RunOnMainThreadCallback(const v8::FunctionCallbackInfo &args); - static int RunOnMainThreadFdCallback(int fd, int events, void* data); + // runs one cached __runOnMainThread callback on the main thread, + // entering the CALLER's isolate (posted as a bare event-loop entry) + static void RunMainThreadEntry(uint64_t key); static void LogMethodCallback(const v8::FunctionCallbackInfo &args); @@ -271,6 +274,10 @@ namespace tns { }; static robin_hood::unordered_map cache_; + // guards cache_: __runOnMainThread is callable from any runtime's + // thread (multithreaded JS, workers), each under a different + // isolate's Locker, so the Lockers provide no mutual exclusion + static std::mutex cacheMutex_; static std::atomic_uint64_t frameCallbackCount_; diff --git a/test-app/runtime/src/main/cpp/EventLoop.cpp b/test-app/runtime/src/main/cpp/EventLoop.cpp index aad50b638..77b37b0f8 100644 --- a/test-app/runtime/src/main/cpp/EventLoop.cpp +++ b/test-app/runtime/src/main/cpp/EventLoop.cpp @@ -116,6 +116,10 @@ void EventLoop::BindToCurrentThread() { for (auto& pair : ordered_.delayed) { env.CallVoidMethod(handler_, EVENT_LOOP_HANDLER_POST, (jlong) std::ceil(pair.first)); } + for (auto when : pendingTokens_) { + env.CallVoidMethod(handler_, EVENT_LOOP_HANDLER_POST, when); + } + pendingTokens_.clear(); } void EventLoop::Shutdown() { @@ -216,7 +220,7 @@ void EventLoop::PostInternal(std::function fn) { if (stopped_) { return; } - PostInternalLocked(Entry{nullptr, std::move(fn), true, 0}, 0); + PostInternalLocked(Entry{nullptr, std::move(fn), true, false, 0}, 0); } void EventLoop::PostInternalDelayed(std::function fn, double delayMs) { @@ -224,7 +228,15 @@ void EventLoop::PostInternalDelayed(std::function fn, double delayMs) { if (stopped_) { return; } - PostInternalLocked(Entry{nullptr, std::move(fn), true, 0}, delayMs); + PostInternalLocked(Entry{nullptr, std::move(fn), true, false, 0}, delayMs); +} + +void EventLoop::PostInternalBare(std::function fn) { + std::lock_guard lock(mutex_); + if (stopped_) { + return; + } + PostInternalLocked(Entry{nullptr, std::move(fn), true, true, 0}, 0); } void EventLoop::PostOrdered(std::function fn) { @@ -232,7 +244,7 @@ void EventLoop::PostOrdered(std::function fn) { if (stopped_) { return; } - PostOrderedLocked(Entry{nullptr, std::move(fn), true, 0}, 0); + PostOrderedLocked(Entry{nullptr, std::move(fn), true, false, 0}, 0); } void EventLoop::PostOrderedDelayed(std::function fn, double delayMs) { @@ -240,7 +252,25 @@ void EventLoop::PostOrderedDelayed(std::function fn, double delayMs) { if (stopped_) { return; } - PostOrderedLocked(Entry{nullptr, std::move(fn), true, 0}, delayMs); + PostOrderedLocked(Entry{nullptr, std::move(fn), true, false, 0}, delayMs); +} + +void EventLoop::PostOrderedToken(jlong uptimeMillis) { + std::lock_guard lock(mutex_); + if (stopped_) { + return; + } + if (handler_ != nullptr) { + JEnv env; + env.CallVoidMethod(handler_, EVENT_LOOP_HANDLER_POST, uptimeMillis); + } else { + pendingTokens_.push_back(uptimeMillis); + } +} + +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) { @@ -248,7 +278,7 @@ void EventLoop::PostV8Task(std::unique_ptr task, bool nestable, double del if (stopped_) { return; } - PostInternalLocked(Entry{std::move(task), nullptr, nestable, 0}, + PostInternalLocked(Entry{std::move(task), nullptr, nestable, false, 0}, delaySeconds * 1000.0); } @@ -288,6 +318,17 @@ std::unique_ptr EventLoop::TakeDueLocked(Lane& lane, bool nest 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; +} + void EventLoop::ArmTimerLocked(double now) { if (timerFd_ == -1) { return; @@ -313,6 +354,12 @@ void EventLoop::ArmTimerLocked(double now) { } void EventLoop::RunEntry(Entry& entry) { + if (entry.bare) { + // the fn locks its own (possibly different) isolate; taking this + // loop's Locker here would nest Lockers across isolates + entry.fn(); + return; + } v8::Locker locker(isolate_); v8::Isolate::Scope isolate_scope(isolate_); v8::HandleScope handleScope(isolate_); @@ -370,6 +417,27 @@ void EventLoop::RunNestableV8Tasks() { } 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 = now_ms(); + 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_); @@ -378,11 +446,9 @@ void EventLoop::RunOrderedTask() { } entry = TakeDueLocked(ordered_, false, false, false, now_ms()); } - if (entry == nullptr) { - // leftover token: the earliest delayed entry isn't due yet - return; + if (entry != nullptr) { + RunEntry(*entry); } - RunEntry(*entry); } int EventLoop::EventFdCallback(int fd, int events, void* data) { diff --git a/test-app/runtime/src/main/cpp/EventLoop.h b/test-app/runtime/src/main/cpp/EventLoop.h index 542187bd8..e4eb6d946 100644 --- a/test-app/runtime/src/main/cpp/EventLoop.h +++ b/test-app/runtime/src/main/cpp/EventLoop.h @@ -8,11 +8,35 @@ #include #include #include +#include #include "v8.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: background + * threads mutate it through setTimeout under multithreaded JS). + */ + 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 - * the Android analogue of the iOS runtime's ExecuteOnRunLoop. Two lanes, split @@ -68,10 +92,34 @@ class EventLoop { void PostOrdered(std::function fn); void PostOrderedDelayed(std::function fn, double delayMs); + /** + * Posts a bare ordered token at an absolute uptime 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(jlong uptimeMillis); + + /** + * 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 looper polls void PostInternal(std::function fn); void 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 (__runOnMainThread + * closures lock the caller's isolate) - taking this loop's Locker first + * would nest Lockers across isolates and can deadlock against + * multithreaded-JS entry paths. + */ + void 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. @@ -108,6 +156,9 @@ class EventLoop { std::unique_ptr task; std::function fn; bool nestable; + // bare entries run without the loop's Locker/scopes/checkpoint (see + // PostInternalBare) + bool bare = false; // enqueue time for immediate entries, due time for delayed ones (both // CLOCK_MONOTONIC ms) so one comparison orders both queues double time; @@ -133,6 +184,8 @@ class EventLoop { void PostOrderedLocked(Entry entry, double delayMs); static std::unique_ptr TakeDueLocked(Lane& lane, bool nestableOnly, bool v8Only, bool requireSignaledDelayed, double now); + // earliest due entry time in the lane, or a negative value if none is due + static double PeekDueLocked(Lane& lane, double now); void ArmTimerLocked(double now); void RunEntry(Entry& entry); void RunOneInternal(); @@ -144,6 +197,10 @@ class EventLoop { std::mutex mutex_; Lane internal_; Lane ordered_; + // ordered-lane source with its own bookkeeping (Timers); home-thread only + OrderedTaskSource* timerSource_ = nullptr; + // bare ordered tokens posted before the bind; flushed by Bind + std::vector pendingTokens_; // ordered lane: global ref to this thread's com.tns.EventLoopHandler jobject handler_ = nullptr; // internal lane: EFD_SEMAPHORE eventfd (one unit = run one due entry) and diff --git a/test-app/runtime/src/main/cpp/Runtime.cpp b/test-app/runtime/src/main/cpp/Runtime.cpp index a9eef5ea4..b78057bc5 100644 --- a/test-app/runtime/src/main/cpp/Runtime.cpp +++ b/test-app/runtime/src/main/cpp/Runtime.cpp @@ -305,20 +305,6 @@ Runtime::~Runtime() { platformInstance->IsolateDisposed(m_isolate, m_eventLoop); } CallbackHandlers::RemoveIsolateEntries(m_isolate); - if (m_isMainThread) { - if (m_mainLooper_fd[0] != -1) { - ALooper_removeFd(m_mainLooper, m_mainLooper_fd[0]); - } - ALooper_release(m_mainLooper); - - if (m_mainLooper_fd[0] != -1) { - close(m_mainLooper_fd[0]); - } - - if (m_mainLooper_fd[1] != -1) { - close(m_mainLooper_fd[1]); - } - } } std::string Runtime::ReadFileText(const std::string& filePath) { @@ -773,27 +759,8 @@ Isolate* Runtime::PrepareV8Runtime(const string& filesPath, if (!s_mainThreadInitialized) { m_isMainThread = true; - pipe2(m_mainLooper_fd, O_NONBLOCK | O_CLOEXEC); - m_mainLooper = ALooper_forThread(); - - ALooper_acquire(m_mainLooper); - - // try using 2MB - int ret = fcntl(m_mainLooper_fd[1], F_SETPIPE_SZ, 2 * (1024 * 1024)); - - // try using 1MB - if (ret != 0) { - ret = fcntl(m_mainLooper_fd[1], F_SETPIPE_SZ, 1 * (1024 * 1024)); - } - - // try using 512KB - if (ret != 0) { - ret = fcntl(m_mainLooper_fd[1], F_SETPIPE_SZ, (512 * 1024)); - } - - ALooper_addFd(m_mainLooper, m_mainLooper_fd[0], ALOOPER_POLL_CALLBACK, - ALOOPER_EVENT_INPUT, - CallbackHandlers::RunOnMainThreadFdCallback, nullptr); + // __runOnMainThread closures from any runtime's thread land on this loop + s_mainEventLoop = m_eventLoop; } /* * Emulate a `WorkerGlobalScope` @@ -999,9 +966,6 @@ Local Runtime::GetContext() { int Runtime::GetId() { return this->m_id; } -int Runtime::GetWriter() { return m_mainLooper_fd[1]; } - -int Runtime::GetReader() { return m_mainLooper_fd[0]; } JavaVM* Runtime::s_jvm = nullptr; jmethodID Runtime::GET_USED_MEMORY_METHOD_ID = nullptr; @@ -1011,5 +975,4 @@ std::mutex Runtime::s_runtimeCacheMutex; bool Runtime::s_mainThreadInitialized = false; v8::Platform* Runtime::platform = nullptr; int Runtime::m_androidVersion = Runtime::GetAndroidVersion(); -ALooper* Runtime::m_mainLooper = nullptr; -int Runtime::m_mainLooper_fd[2]; +std::shared_ptr Runtime::s_mainEventLoop; diff --git a/test-app/runtime/src/main/cpp/Runtime.h b/test-app/runtime/src/main/cpp/Runtime.h index 6d43f15e6..3496a47b3 100644 --- a/test-app/runtime/src/main/cpp/Runtime.h +++ b/test-app/runtime/src/main/cpp/Runtime.h @@ -96,10 +96,13 @@ class Runtime { std::string ReadFileText(const std::string& filePath); - static int GetWriter(); - static int GetReader(); - static ALooper* GetMainLooper() { - return m_mainLooper; + /* + * The main runtime's event loop, set once when the main runtime + * initializes. __runOnMainThread posts its (own-isolate) closures + * here from any runtime's thread. + */ + static std::shared_ptr GetMainEventLoop() { + return s_mainEventLoop; } static JavaVM* GetJVM() { return s_jvm; @@ -245,9 +248,7 @@ class Runtime { static bool s_mainThreadInitialized; - static ALooper* m_mainLooper; - - static int m_mainLooper_fd[2]; + static std::shared_ptr s_mainEventLoop; #ifdef APPLICATION_IN_DEBUG std::mutex m_fileWriteMutex; diff --git a/test-app/runtime/src/main/cpp/Timers.cpp b/test-app/runtime/src/main/cpp/Timers.cpp index 10739a371..bcaa880cb 100644 --- a/test-app/runtime/src/main/cpp/Timers.cpp +++ b/test-app/runtime/src/main/cpp/Timers.cpp @@ -4,8 +4,7 @@ #include "NativeScriptException.h" #include "ModuleBinding.h" #include "IsolateDisposer.h" -#include "JEnv.h" -#include "JniLocalRef.h" +#include "NativeScriptPlatform.h" #include "Util.h" #include #include @@ -15,16 +14,18 @@ * Overall rules when modifying this file: * Everything runs on the isolate's thread (or under its v8::Locker): there are * no background threads and no locking. `sortedTimers_` must always be sorted - * by dueTime (stable for equal dueTimes) and in sync with `timerMap_`. + * by dueTime (stable for equal dueTimes) and in sync with `timerMap_` (except + * tombstones, which only live in `sortedTimers_`). * - * Scheduling model: every scheduled timer enqueues one anonymous "due token" - * message on a dedicated Java Handler bound to this thread's Looper, at - * uptimeMillis >= the timer's due time. Timers therefore share one queue with - * Handler.post/postDelayed and interleave with Java messages in exact - * MessageQueue order. Because the Java queue is millisecond-quantized, the - * token does not name a timer: each token fires the front of `sortedTimers_` - * (the earliest due timer by exact sub-millisecond time). A token whose front - * timer is not yet due is a leftover from a cleared timer and is dropped. + * Scheduling model: every scheduled timer posts one anonymous "due token" + * through the runtime EventLoop's ordered lane (a Java Handler bound to this + * thread's Looper), at uptimeMillis >= the timer's due time. Timers therefore + * share one queue with Handler.post/postDelayed and ordered macrotasks, and + * interleave with Java messages in exact MessageQueue order. Because the Java + * queue is millisecond-quantized, 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. * ALL changes and scheduling of a TimerTask MUST be done when locked in an isolate to ensure consistency */ @@ -57,11 +58,6 @@ static double now_ms() { namespace tns { -jclass Timers::TIMER_HANDLER_CLASS = nullptr; -jmethodID Timers::TIMER_HANDLER_CTOR = nullptr; -jmethodID Timers::TIMER_HANDLER_POST = nullptr; -jmethodID Timers::TIMER_HANDLER_RELEASE = nullptr; - void Timers::Init(v8::Isolate *isolate, v8::Local &globalObjectTemplate) { isolate_ = isolate; // TODO: remove the __ns__ prefix once this is validated @@ -70,20 +66,10 @@ void Timers::Init(v8::Isolate *isolate, v8::Local &globalObj SetMethod(isolate, globalObjectTemplate, "__ns__clearTimeout", ClearTimer, External::New(isolate, this, v8::kExternalPointerTypeTagDefault)); SetMethod(isolate, globalObjectTemplate, "__ns__clearInterval", ClearTimer, External::New(isolate, this, v8::kExternalPointerTypeTagDefault)); - JEnv env; - if (TIMER_HANDLER_CLASS == nullptr) { - // JEnv::FindClass caches a global ref to the class - TIMER_HANDLER_CLASS = env.FindClass("com/tns/TimerHandler"); - assert(TIMER_HANDLER_CLASS != nullptr); - TIMER_HANDLER_CTOR = env.GetMethodID(TIMER_HANDLER_CLASS, "", "(J)V"); - TIMER_HANDLER_POST = env.GetMethodID(TIMER_HANDLER_CLASS, "post", "(J)V"); - TIMER_HANDLER_RELEASE = env.GetMethodID(TIMER_HANDLER_CLASS, "release", "()V"); - } - // the handler binds to the current thread's Looper, which Runtime.java - // prepares before initNativeScript on both the main and worker threads - JniLocalRef handler(env.NewObject(TIMER_HANDLER_CLASS, TIMER_HANDLER_CTOR, - reinterpret_cast(this))); - handler_ = env.NewGlobalRef(handler); + // PrepareV8Runtime bound the loop to this thread's looper before any + // builtin initialization runs + eventLoop_ = NativeScriptPlatform::Instance()->GetEventLoop(isolate); + eventLoop_->SetTimerSource(this); stopped_ = false; } @@ -117,8 +103,7 @@ void Timers::postTimer(const std::shared_ptr &task, double now) { // a Handler.postDelayed(0) made in the same millisecond; future timers // post at ceil(dueTime) so the token never arrives before the due time. auto when = task->dueTime_ <= now ? (jlong) now : (jlong) std::ceil(task->dueTime_); - JEnv env; - env.CallVoidMethod(handler_, TIMER_HANDLER_POST, when); + eventLoop_->PostOrderedToken(when); } void Timers::removeTask(const std::shared_ptr &task) { @@ -128,8 +113,10 @@ void Timers::removeTask(const std::shared_ptr &task) { void Timers::removeTask(const int &taskId) { auto it = timerMap_.find(taskId); if (it != timerMap_.end()) { - // if still scheduled, drop the sorted entry; the token already in the - // java queue will find a not-yet-due (or no) front timer and no-op + // if still scheduled, tombstone the sorted entry: its token then + // consumes this slot as a no-op instead of running whatever item + // happens to be due next, which could jump foreign Java messages + // queued between the two tokens' positions if (it->second->queued_) { auto dueTime = it->second->dueTime_; auto sit = std::lower_bound(sortedTimers_.begin(), sortedTimers_.end(), dueTime, @@ -138,7 +125,7 @@ void Timers::removeTask(const int &taskId) { }); while (sit != sortedTimers_.end() && sit->dueTime == dueTime) { if (sit->id == taskId) { - sortedTimers_.erase(sit); + sit->cancelled = true; break; } ++sit; @@ -154,11 +141,12 @@ void Timers::Destroy() { return; } stopped_ = true; - if (handler_ != nullptr) { - JEnv env; - env.CallVoidMethod(handler_, TIMER_HANDLER_RELEASE); - env.DeleteGlobalRef(handler_); - handler_ = nullptr; + if (eventLoop_ != nullptr) { + // the loop is already shut down by DestroyRuntime at this point (its + // handler dropped every pending token), but the source pointer must + // not outlive this object + eventLoop_->SetTimerSource(nullptr); + eventLoop_.reset(); } timerMap_.clear(); sortedTimers_.clear(); @@ -233,30 +221,37 @@ void Timers::SetTimer(const v8::FunctionCallbackInfo &args, bool repe } /** - * Invoked by Java TimerHandler.handleMessage on the isolate's thread, once per - * scheduled "due token". Fires the earliest due timer (exact sub-millisecond - * order), which is not necessarily the timer that enqueued this token. + * Invoked by the EventLoop's ordered-lane token drain on the isolate's + * thread. Under one Locker acquisition (sortedTimers_ is mutated through + * setTimeout from background threads under multithreaded JS): 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. */ -void Timers::FireTimer() { +bool Timers::RunIfEarliest(double now, double otherDue) { auto isolate = isolate_; if (stopped_ || isolate == nullptr || isolate->IsDead()) { - return; + return false; } // thread safety is important! v8::Locker locker(isolate); v8::Isolate::Scope isolate_scope(isolate); v8::HandleScope handleScope(isolate); if (sortedTimers_.empty()) { - // leftover token of a cleared timer - return; + return false; } auto ref = sortedTimers_.front(); - if (ref.dueTime > now_ms()) { - // the earliest timer isn't due yet, so this token is a leftover from a - // cleared timer; the front timer's own token will arrive at its due time - return; + 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 so later items can't jump foreign Java messages + return true; + } auto it = timerMap_.find(ref.id); if (it != timerMap_.end()) { auto task = it->second; @@ -305,6 +300,8 @@ void Timers::FireTimer() { } + // the slot was consumed (front popped) even if the map had no task + return true; } void Timers::InitStatic(v8::Isolate* isolate, v8::Local globalObjectTemplate) { @@ -316,20 +313,3 @@ void Timers::InitStatic(v8::Isolate* isolate, v8::Local glob }; NODE_BINDING_PER_ISOLATE_INIT_OBJ(timers, tns::Timers::InitStatic); - -extern "C" JNIEXPORT void JNICALL Java_com_tns_TimerHandler_nativeFireTimer( - JNIEnv *env, jclass clazz, jlong timersPtr) { - try { - reinterpret_cast(timersPtr)->FireTimer(); - } catch (tns::NativeScriptException &e) { - e.ReThrowToJava(); - } catch (std::exception &e) { - std::stringstream ss; - ss << "Error: c++ exception: " << e.what() << std::endl; - tns::NativeScriptException nsEx(ss.str()); - nsEx.ReThrowToJava(); - } catch (...) { - tns::NativeScriptException nsEx(std::string("Error: c++ exception!")); - nsEx.ReThrowToJava(); - } -} diff --git a/test-app/runtime/src/main/cpp/Timers.h b/test-app/runtime/src/main/cpp/Timers.h index f9bf24ebe..3b141af95 100644 --- a/test-app/runtime/src/main/cpp/Timers.h +++ b/test-app/runtime/src/main/cpp/Timers.h @@ -3,6 +3,7 @@ #include #include "v8.h" +#include "EventLoop.h" #include "ObjectManager.h" #include "robin_hood.h" @@ -69,13 +70,25 @@ namespace tns { 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 Java messages queued between the two token positions + bool cancelled = false; }; - class Timers { + /** + * Timers are the ordered lane's external source: each scheduled timer + * posts one anonymous token through the runtime's EventLoop, and the + * loop's token drain runs the earliest due item across timers and ordered + * macrotasks - ONE due-ordered domain, strictly FIFO with Handler.post + * runnables on the same looper. + */ + class Timers : public OrderedTaskSource { public: /** * Initializes the global functions setTimeout, setInterval, clearTimeout and clearInterval - * also creates the dedicated Java Handler bound to the executing thread's Looper + * and registers this instance as the runtime EventLoop's timer source * @param isolate target isolate * @param globalObjectTemplate global template */ @@ -84,8 +97,7 @@ namespace tns { static void InitStatic(v8::Isolate* isolate, v8::Local globalObjectTemplate); /** - * Disposes the timers. This will clear all references and remove all - * pending timer messages from the Java message queue. + * Disposes the timers and unregisters from the EventLoop. * MUST be called in the same thread Init was called * This method doesn't need to be called most of the time as it's called on object destruction * Reusing this class is not advised @@ -95,13 +107,10 @@ namespace tns { /** * Calls Destroy */ - ~Timers(); + ~Timers() override; - /** - * Fires the earliest due timer, if any (invoked by Java - * TimerHandler.handleMessage once per scheduled "due token") - */ - void FireTimer(); + // OrderedTaskSource (called by the EventLoop's token drain) + bool RunIfEarliest(double now, double otherDue) override; private: static void SetTimeoutCallback(const v8::FunctionCallbackInfo &args); @@ -127,22 +136,15 @@ namespace tns { #endif // stores the map of timer tasks robin_hood::unordered_map> timerMap_; - // scheduled timers sorted by exact (sub-millisecond) dueTime, stable for - // equal dueTimes. Only ever touched under the isolate lock, no mutex. - // The Java message queue is millisecond-quantized, so this preserves the - // relative order of JS timers; each Java message is an anonymous token - // that fires the front of this list. + // scheduled timers (and tombstones) sorted by exact (sub-millisecond) + // dueTime, stable for equal dueTimes. Only ever touched on the + // isolate's home thread, no mutex. The Java message queue is + // millisecond-quantized, so this preserves the relative order of JS + // timers; each anonymous EventLoop token consumes the front slot. std::vector sortedTimers_; - // global ref to the dedicated com.tns.TimerHandler for this isolate's thread - jobject handler_ = nullptr; + // the runtime's event loop, carrier of the ordered-lane tokens + std::shared_ptr eventLoop_; bool stopped_ = false; - - // process-wide JNI cache, written once on the first Timers::Init (main - // runtime), which happens-before any worker thread is spawned - static jclass TIMER_HANDLER_CLASS; - static jmethodID TIMER_HANDLER_CTOR; - static jmethodID TIMER_HANDLER_POST; - static jmethodID TIMER_HANDLER_RELEASE; }; } diff --git a/test-app/runtime/src/main/java/com/tns/TimerHandler.java b/test-app/runtime/src/main/java/com/tns/TimerHandler.java deleted file mode 100644 index b8d85d082..000000000 --- a/test-app/runtime/src/main/java/com/tns/TimerHandler.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.tns; - -import android.os.Handler; -import android.os.Looper; -import android.os.Message; - -/** - * Dedicated per-runtime Handler for JS timers. One instance per isolate, bound - * to the runtime thread's Looper. Timer messages are ordinary Java Messages, - * so setTimeout(0) is strictly FIFO-ordered with any other Handler.post on the - * same looper. Created and used exclusively from native code (Timers.cpp). - */ -final class TimerHandler extends Handler { - private static final int MSG_FIRE_TIMER = 1; - - private final long nativeTimersPtr; - private boolean released; - - // constructed from native code (Timers::Init) - TimerHandler(long nativeTimersPtr) { - super(Looper.myLooper()); - this.nativeTimersPtr = nativeTimersPtr; - } - - /** - * Enqueues an anonymous "timer due" token at an absolute uptimeMillis. - * Native code keeps the exact (sub-millisecond) timer order and fires the - * earliest due timer per token, so the token doesn't carry a timer id. - */ - @RuntimeCallable - void post(long uptimeMillis) { - sendMessageAtTime(obtainMessage(MSG_FIRE_TIMER), uptimeMillis); - } - - /** - * Called from Timers::Destroy on this handler's own thread. After this no - * timer message can fire into the (about to be freed) native Timers object. - */ - @RuntimeCallable - void release() { - released = true; - removeCallbacksAndMessages(null); // safe: this handler is timers-only - } - - @Override - public void handleMessage(Message msg) { - if (released || msg.what != MSG_FIRE_TIMER) { - return; - } - nativeFireTimer(nativeTimersPtr); - } - - private static native void nativeFireTimer(long nativeTimersPtr); -} From 375eecd9bcea8b8ab9a532d22b845e5a7d543b77 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 12 Aug 2026 14:35:27 -0300 Subject: [PATCH 5/5] perf(event-loop): cancellable timer tokens (claim cells + @CriticalNative gate, identified long-timer removal) Cancelled timers no longer leave stale wakeups. Two tiers by remaining delay, both preserving exact clear semantics from any thread (multithreaded JS can schedule and clear on non-looper threads): - short timers (<32ms): the token carries a native claim cell - a slot in a fixed per-loop atomic table indexed by timer id, with the id embedded in the cell word so cancellation can never hit a recycled cell. clearTimeout is a single native CAS (zero JNI): winning proves the token dead everywhere, so the sorted entry is erased outright; losing means dispatch owns the token, so a tombstone is left for it. EventLoopHandler claims cells through a @CriticalNative CAS (the annotation is public API in current SDKs; where ART doesn't apply it the method degrades to a plain JNI call with identical semantics) before entering the runtime, so a cancelled token dies in Java in nanoseconds - without acquiring the isolate Locker, which previously let a stale token park the main thread behind a long background JS turn. Only the gate retires cells, and cell tokens are never removeMessages()ed, so each cell sees exactly one gate pass; a busy slot (interval re-arm racing its previous token, or id collision beyond 1024 in-flight) just downgrades the token to plain+tombstone. - long timers (>=32ms, debounce territory): the token carries a Java AtomicBoolean peer, claimed in handleMessage. clearTimeout CASes the peer and on winning removeMessages()es the queued token: a cleared debounce timer produces no wakeup at all. The peer and its Message are GC-owned, which makes the removal-vs-in-flight-dequeue race harmless - a lost race costs at most one no-op wakeup, never an ordering violation. Below the cutoff a stale wakeup lands within two frames of the interaction that scheduled it (the app is provably awake), so the zero-allocation cell path applies instead. Only the newest token of an interval is cancellable; older tokens orphaned by a re-arm keep functioning anonymously through their own carriers, so token/slot parity holds under the anonymous-dispatch shuffle. SetTimer now converts a failed token post into a JS exception instead of unwinding a NativeScriptException through the V8 callback frame. Verified on device: ordering probes 100% across all scenarios (timer FIFO ties, clear-vs-Handler.post in both orders, orphan gap, triple-clear, clearInterval-from-callback, starvation), and the full suite (78 suites / 668 specs) green, including new specs for identified clear, background-thread clear racing dispatch, and interval stop. --- .../main/assets/app/tests/testEventLoop.js | 58 ++++++++++ test-app/runtime/src/main/cpp/EventLoop.cpp | 107 +++++++++++++++++- test-app/runtime/src/main/cpp/EventLoop.h | 72 +++++++++++- test-app/runtime/src/main/cpp/Timers.cpp | 67 +++++++++-- test-app/runtime/src/main/cpp/Timers.h | 14 +++ .../main/java/com/tns/EventLoopHandler.java | 83 +++++++++++++- 6 files changed, 382 insertions(+), 19 deletions(-) diff --git a/test-app/app/src/main/assets/app/tests/testEventLoop.js b/test-app/app/src/main/assets/app/tests/testEventLoop.js index 445e13532..3c3f2cb47 100644 --- a/test-app/app/src/main/assets/app/tests/testEventLoop.js +++ b/test-app/app/src/main/assets/app/tests/testEventLoop.js @@ -154,6 +154,64 @@ describe("event loop ordered tombstones", function () { }); }); +// Long (>=32ms) timers carry an identified token whose clear removes the +// queued wakeup; short timers carry a native claim cell whose clear is a +// single CAS. Both must keep exact clear semantics under any thread. +describe("event loop token cancellation", function () { + it("cleared identified (long) timeout never fires and later timers are unaffected", function (done) { + let fired = false; + const t = __ns__setTimeout(() => { fired = true; }, 100); + __ns__clearTimeout(t); + __ns__setTimeout(() => { + expect(fired).toBe(false); + done(); + }, 150); + }); + + it("background-thread clear racing dispatch neither jumps java posts nor ghost-fires", function (done) { + let remaining = 30; + (function iter() { + const order = []; + const handler = new android.os.Handler(android.os.Looper.myLooper()); + const t1 = __ns__setTimeout(() => order.push("t1"), 0); + new java.lang.Thread(new java.lang.Runnable({ + run() { + __ns__clearTimeout(t1); + } + })).start(); + handler.post(new java.lang.Runnable({ + run: () => order.push("java") + })); + __ns__setTimeout(() => { + order.push("t2"); + const observed = order.join(">"); + // t1 either fired before the clear landed (at its own legal + // slot, ahead of "java") or never; t2 must never jump "java" + expect(observed === "java>t2" || observed === "t1>java>t2").toBe(true); + if (--remaining === 0) { + done(); + } else { + iter(); + } + }, 5); + })(); + }); + + it("clearing an identified interval stops it", function (done) { + let ticks = 0; + const iv = __ns__setInterval(() => { + ticks++; + if (ticks === 2) { + __ns__clearInterval(iv); + __ns__setTimeout(() => { + expect(ticks).toBe(2); + done(); + }, 120); + } + }, 40); + }); +}); + describe("event loop internal lane", function () { // Regression for the eventfd unit-accounting bug: a worker reply's wakeup // arriving while an overdue waitAsync timeout is still unsignaled must not diff --git a/test-app/runtime/src/main/cpp/EventLoop.cpp b/test-app/runtime/src/main/cpp/EventLoop.cpp index 77b37b0f8..400ad3b3b 100644 --- a/test-app/runtime/src/main/cpp/EventLoop.cpp +++ b/test-app/runtime/src/main/cpp/EventLoop.cpp @@ -49,6 +49,9 @@ namespace tns { jclass EventLoop::EVENT_LOOP_HANDLER_CLASS = nullptr; jmethodID EventLoop::EVENT_LOOP_HANDLER_CTOR = nullptr; jmethodID EventLoop::EVENT_LOOP_HANDLER_POST = nullptr; +jmethodID EventLoop::EVENT_LOOP_HANDLER_POST_TOKEN = nullptr; +jmethodID EventLoop::EVENT_LOOP_HANDLER_POST_IDENTIFIED = nullptr; +jmethodID EventLoop::EVENT_LOOP_HANDLER_CANCEL_IDENTIFIED = nullptr; jmethodID EventLoop::EVENT_LOOP_HANDLER_RELEASE = nullptr; void EventLoop::BindToCurrentThread() { @@ -96,7 +99,21 @@ void EventLoop::BindToCurrentThread() { assert(EVENT_LOOP_HANDLER_CLASS != nullptr); EVENT_LOOP_HANDLER_CTOR = env.GetMethodID(EVENT_LOOP_HANDLER_CLASS, "", "(J)V"); EVENT_LOOP_HANDLER_POST = env.GetMethodID(EVENT_LOOP_HANDLER_CLASS, "post", "(J)V"); + EVENT_LOOP_HANDLER_POST_TOKEN = + env.GetMethodID(EVENT_LOOP_HANDLER_CLASS, "postToken", "(JII)V"); + EVENT_LOOP_HANDLER_POST_IDENTIFIED = env.GetMethodID( + EVENT_LOOP_HANDLER_CLASS, "postIdentified", "(J)Ljava/lang/Object;"); + EVENT_LOOP_HANDLER_CANCEL_IDENTIFIED = env.GetMethodID( + EVENT_LOOP_HANDLER_CLASS, "cancelIdentified", "(Ljava/lang/Object;)Z"); EVENT_LOOP_HANDLER_RELEASE = env.GetMethodID(EVENT_LOOP_HANDLER_CLASS, "release", "()V"); + // the @CriticalNative gate must be bound explicitly (name resolution + // doesn't apply to the critical calling convention on older ART) + static const JNINativeMethod claimMethod = { + const_cast("nativeClaimToken"), const_cast("(JJ)Z"), + reinterpret_cast(EventLoop::ClaimTokenCritical)}; + JNIEnv* rawEnv = env; + jint registered = rawEnv->RegisterNatives(EVENT_LOOP_HANDLER_CLASS, &claimMethod, 1); + assert(registered == 0); } JniLocalRef handler(env.NewObject(EVENT_LOOP_HANDLER_CLASS, EVENT_LOOP_HANDLER_CTOR, reinterpret_cast(this))); @@ -255,17 +272,95 @@ void EventLoop::PostOrderedDelayed(std::function fn, double delayMs) { PostOrderedLocked(Entry{nullptr, std::move(fn), true, false, 0}, delayMs); } -void EventLoop::PostOrderedToken(jlong uptimeMillis) { +uint64_t EventLoop::PostTimerToken(jlong uptimeMillis, int timerId) { std::lock_guard lock(mutex_); if (stopped_) { - return; + return 0; } - if (handler_ != nullptr) { - JEnv env; - env.CallVoidMethod(handler_, EVENT_LOOP_HANDLER_POST, uptimeMillis); - } else { + if (handler_ == nullptr) { pendingTokens_.push_back(uptimeMillis); + return 0; + } + uint64_t word = 0; + auto& cell = claimCells_[((uint32_t) timerId) & (kClaimCells - 1)]; + uint64_t expected = 0; + uint64_t candidate = (((uint64_t) (uint32_t) timerId) << 2) | kCellActive; + if (cell.compare_exchange_strong(expected, candidate, std::memory_order_acq_rel)) { + word = candidate; + } + // a busy slot (previous token of the same interval still in flight, or an + // id collision) downgrades this token to plain; clear then uses tombstones + JEnv env; + env.CallVoidMethod(handler_, EVENT_LOOP_HANDLER_POST_TOKEN, uptimeMillis, + (jint) (word >> 32), (jint) (word & 0xffffffffull)); + return word; +} + +jobject EventLoop::PostIdentifiedTimerToken(jlong uptimeMillis) { + JEnv env; + std::lock_guard lock(mutex_); + if (stopped_) { + return nullptr; + } + if (handler_ == nullptr) { + pendingTokens_.push_back(uptimeMillis); + return nullptr; + } + JniLocalRef peer(env.CallObjectMethod(handler_, EVENT_LOOP_HANDLER_POST_IDENTIFIED, + uptimeMillis)); + if (peer.IsNull()) { + return nullptr; } + return env.NewGlobalRef(peer); +} + +bool EventLoop::CancelClaimCell(uint64_t cellWord) { + auto& cell = claimCells_[(cellWord >> 2) & (kClaimCells - 1)]; + uint64_t expected = cellWord; // id|ACTIVE + return cell.compare_exchange_strong(expected, (cellWord & ~3ull) | kCellCancelled, + std::memory_order_acq_rel); +} + +bool EventLoop::CancelIdentifiedToken(jobject peer) { + JEnv env; + bool won = false; + { + std::lock_guard lock(mutex_); + if (!stopped_ && handler_ != nullptr) { + won = env.CallBooleanMethod(handler_, EVENT_LOOP_HANDLER_CANCEL_IDENTIFIED, peer) == + JNI_TRUE; + } + } + env.DeleteGlobalRef(peer); + return won; +} + +void EventLoop::ReleaseIdentifiedToken(jobject peer) { + JEnv env; + env.DeleteGlobalRef(peer); +} + +jboolean EventLoop::ClaimTokenCritical(jlong loopPtr, jlong cellWord) { + // @CriticalNative: no JNIEnv, thread stays runnable - a single CAS, no + // locks, no allocation, no exceptions. The loop pointer is valid for the + // same reason nativeRunTask's is: the handler is released before the loop + // is destroyed, and released handlers never reach this gate. + auto* loop = reinterpret_cast(loopPtr); + auto word = (uint64_t) cellWord; + auto& cell = loop->claimCells_[(word >> 2) & (kClaimCells - 1)]; + uint64_t expected = word; // id|ACTIVE + if (cell.compare_exchange_strong(expected, 0, std::memory_order_acq_rel)) { + // claimed and retired in one step: proceed to the fat path + return JNI_TRUE; + } + if (expected == ((word & ~3ull) | kCellCancelled)) { + // the timer was cleared; retire the cell and drop the token here + cell.store(0, std::memory_order_release); + return JNI_FALSE; + } + // defensive: a mismatched word can't occur while this token is in flight + // (only the gate retires cells), but the fat path is always safe + return JNI_TRUE; } void EventLoop::SetTimerSource(OrderedTaskSource* source) { diff --git a/test-app/runtime/src/main/cpp/EventLoop.h b/test-app/runtime/src/main/cpp/EventLoop.h index e4eb6d946..130a8fcdb 100644 --- a/test-app/runtime/src/main/cpp/EventLoop.h +++ b/test-app/runtime/src/main/cpp/EventLoop.h @@ -3,6 +3,8 @@ #include #include +#include +#include #include #include #include @@ -97,8 +99,48 @@ class EventLoop { * 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. + * + * The token carries a claim cell when one is free for `timerId` (returns + * the non-zero cell word to cancel with): EventLoopHandler claims the + * cell through a @CriticalNative CAS before entering the runtime, so a + * token whose timer was cancelled dies in Java without acquiring the + * isolate Locker, and CancelClaimCell neutralizes a queued token with no + * JNI at all. Returns 0 when the token is plain (cell slot busy, or the + * loop is unbound/stopped) - the caller must then use tombstones. */ - void PostOrderedToken(jlong uptimeMillis); + uint64_t PostTimerToken(jlong uptimeMillis, int timerId); + + /** + * Posts an ordered token carrying a Java AtomicBoolean claim peer, for + * timers long enough that the stale wakeup itself is worth removing. + * Returns a global ref to the peer (null when unbound/stopped - caller + * falls back to tombstones). CancelIdentifiedToken later CASes the peer + * and, on winning, removeMessages()es the queued token: a cleared long + * timer produces no wakeup. The peer and its Message are GC-owned, which + * is what makes the remove-vs-in-flight-dispatch race harmless. + */ + jobject PostIdentifiedTimerToken(jlong uptimeMillis); + + /** + * Neutralizes a cell-carrying token (no JNI, single CAS). True = the + * token is guaranteed dead wherever it is, so the caller may delete the + * item outright; false = dispatch already claimed it, so the caller must + * leave a tombstone for it to consume. + */ + bool CancelClaimCell(uint64_t cellWord); + + /** + * Neutralizes an identified token (one JNI crossing; releases the peer + * ref). Same true/false contract as CancelClaimCell. + */ + bool CancelIdentifiedToken(jobject peer); + + /** + * Releases an identified token's peer ref without cancelling (the timer + * fired or is being torn down; the Java Message keeps the peer alive for + * its own dispatch). + */ + void ReleaseIdentifiedToken(jobject peer); /** * Registers the ordered lane's external source. Home thread only; pass @@ -193,10 +235,35 @@ class EventLoop { static int EventFdCallback(int fd, int events, void* data); static int TimerFdCallback(int fd, int events, void* data); + /** + * Claim cells for cell-carrying timer tokens, indexed by timer id. A cell + * word is (id << 2) | state so a token can prove the cell is still its + * own; a busy slot (>kClaimCells timers in flight, or an interval's + * previous token still pending) just downgrades the new token to plain. + * Lifecycle: 0 (free) -> id|ACTIVE (posted, under mutex_) -> + * id|CANCELLED (by CancelClaimCell, any thread) -> 0 (retired by the + * dispatch gate, which runs exactly once per cell token since cell tokens + * are never removeMessages()ed). Only the gate stores 0, so a cell is + * never reused while its token is in flight, and cancellation can never + * hit a recycled cell. + */ + static constexpr int kClaimCells = 1024; + static constexpr uint64_t kCellActive = 1; + static constexpr uint64_t kCellCancelled = 2; + + /** + * The @CriticalNative body behind EventLoopHandler.nativeClaimToken: one + * CAS, no JNIEnv, and the thread stays runnable - it must never block, + * allocate or throw. Returns false when the token's timer was cancelled + * (token dies in Java); true otherwise (proceed to nativeRunTask). + */ + static jboolean ClaimTokenCritical(jlong loopPtr, jlong cellWord); + v8::Isolate* isolate_; std::mutex mutex_; Lane internal_; Lane ordered_; + std::atomic claimCells_[kClaimCells] = {}; // ordered-lane source with its own bookkeeping (Timers); home-thread only OrderedTaskSource* timerSource_ = nullptr; // bare ordered tokens posted before the bind; flushed by Bind @@ -215,6 +282,9 @@ class EventLoop { static jclass EVENT_LOOP_HANDLER_CLASS; static jmethodID EVENT_LOOP_HANDLER_CTOR; static jmethodID EVENT_LOOP_HANDLER_POST; + static jmethodID EVENT_LOOP_HANDLER_POST_TOKEN; + static jmethodID EVENT_LOOP_HANDLER_POST_IDENTIFIED; + static jmethodID EVENT_LOOP_HANDLER_CANCEL_IDENTIFIED; static jmethodID EVENT_LOOP_HANDLER_RELEASE; }; diff --git a/test-app/runtime/src/main/cpp/Timers.cpp b/test-app/runtime/src/main/cpp/Timers.cpp index bcaa880cb..2903f218e 100644 --- a/test-app/runtime/src/main/cpp/Timers.cpp +++ b/test-app/runtime/src/main/cpp/Timers.cpp @@ -97,13 +97,38 @@ void Timers::addTask(std::shared_ptr task) { postTimer(task, now); } +// Above this remaining delay a timer's token gets an identified peer so a +// clear removes the queued message outright: the stale wakeup is the cost +// worth paying JNI to avoid (debounce-style long timers on a possibly idle +// device). Below it the wakeup lands within two frames of the interaction +// that scheduled it - the app is provably awake - so the timer takes the +// zero-overhead claim-cell path instead. +static constexpr double kIdentifiedCutoffMs = 32; + void Timers::postTimer(const std::shared_ptr &task, double now) { // uptimeMillis is the integer part of the same CLOCK_MONOTONIC clock as // now_ms(). Due-now timers post at (jlong) now so they tie (and FIFO) with // a Handler.postDelayed(0) made in the same millisecond; future timers // post at ceil(dueTime) so the token never arrives before the due time. auto when = task->dueTime_ <= now ? (jlong) now : (jlong) std::ceil(task->dueTime_); - eventLoop_->PostOrderedToken(when); + // an interval re-arm orphans the previous token's carriers: the old token + // stays valid anonymously, only the newest one is cancellable + releaseTokenCarriers(task); + if (task->dueTime_ - now >= kIdentifiedCutoffMs) { + task->tokenPeer_ = eventLoop_->PostIdentifiedTimerToken(when); + if (task->tokenPeer_ != nullptr) { + return; + } + } + task->tokenCell_ = eventLoop_->PostTimerToken(when, task->id_); +} + +void Timers::releaseTokenCarriers(const std::shared_ptr &task) { + if (task->tokenPeer_ != nullptr) { + eventLoop_->ReleaseIdentifiedToken(task->tokenPeer_); + task->tokenPeer_ = nullptr; + } + task->tokenCell_ = 0; } void Timers::removeTask(const std::shared_ptr &task) { @@ -113,11 +138,24 @@ void Timers::removeTask(const std::shared_ptr &task) { void Timers::removeTask(const int &taskId) { auto it = timerMap_.find(taskId); if (it != timerMap_.end()) { - // if still scheduled, tombstone the sorted entry: its token then - // consumes this slot as a no-op instead of running whatever item - // happens to be due next, which could jump foreign Java messages - // queued between the two tokens' positions if (it->second->queued_) { + // First try to neutralize the pending token itself. Winning the + // claim means the token is guaranteed dead wherever it is, so the + // sorted entry can be erased outright - token and slot leave + // together and no wakeup work remains. Losing means dispatch + // already owns the token (in flight past the claim gate), so + // leave a tombstone: the owned token consumes it as a no-op + // instead of running whatever item happens to be due next, which + // could jump foreign Java messages queued between the two tokens' + // positions. + bool tokenNeutralized = false; + if (it->second->tokenPeer_ != nullptr) { + tokenNeutralized = eventLoop_->CancelIdentifiedToken(it->second->tokenPeer_); + it->second->tokenPeer_ = nullptr; // ref released by the cancel + } else if (it->second->tokenCell_ != 0) { + tokenNeutralized = eventLoop_->CancelClaimCell(it->second->tokenCell_); + it->second->tokenCell_ = 0; + } auto dueTime = it->second->dueTime_; auto sit = std::lower_bound(sortedTimers_.begin(), sortedTimers_.end(), dueTime, [](const TimerReference &ref, const double &value) { @@ -125,12 +163,17 @@ void Timers::removeTask(const int &taskId) { }); while (sit != sortedTimers_.end() && sit->dueTime == dueTime) { if (sit->id == taskId) { - sit->cancelled = true; + if (tokenNeutralized) { + sortedTimers_.erase(sit); + } else { + sit->cancelled = true; + } break; } ++sit; } } + releaseTokenCarriers(it->second); it->second->Unschedule(); timerMap_.erase(it); } @@ -142,6 +185,9 @@ void Timers::Destroy() { } stopped_ = true; if (eventLoop_ != nullptr) { + for (auto &pair : timerMap_) { + releaseTokenCarriers(pair.second); + } // the loop is already shut down by DestroyRuntime at this point (its // handler dropped every pending token), but the source pointer must // not outlive this object @@ -214,7 +260,14 @@ void Timers::SetTimer(const v8::FunctionCallbackInfo &args, bool repe auto task = std::make_shared(isolate, ctx, handler, timeout, repeatable, argArray, id, now_ms()); - thiz->addTask(task); + try { + thiz->addTask(task); + } catch (NativeScriptException &e) { + // a failed JNI token post must surface as a JS exception, not + // unwind through the V8 callback frame + e.ReThrowToV8(); + return; + } } args.GetReturnValue().Set(id); diff --git a/test-app/runtime/src/main/cpp/Timers.h b/test-app/runtime/src/main/cpp/Timers.h index 3b141af95..c877b0561 100644 --- a/test-app/runtime/src/main/cpp/Timers.h +++ b/test-app/runtime/src/main/cpp/Timers.h @@ -50,6 +50,17 @@ namespace tns { #ifdef NS_TIMERS_NESTING_CLAMP int nestingLevel_ = 0; #endif + /** + * Cancellation carrier of this task's NEWEST pending token (at most + * one is set): a claim-cell word for short timers, or a global ref to + * the Java AtomicBoolean peer for long ones. An older token orphaned + * by an interval re-arm keeps functioning anonymously through its own + * carrier - only the newest token is cancellable, matching clear + * semantics. The peer ref is released by Timers (needs JEnv), never + * by Unschedule. + */ + uint64_t tokenCell_ = 0; + jobject tokenPeer_ = nullptr; v8::Isolate *isolate_; v8::Persistent callback_; std::shared_ptr>>> args_; @@ -123,6 +134,9 @@ namespace tns { void addTask(std::shared_ptr task); + // releases the task's peer ref (if any) without cancelling + void releaseTokenCarriers(const std::shared_ptr &task); + void removeTask(const std::shared_ptr &task); void removeTask(const int &taskId); diff --git a/test-app/runtime/src/main/java/com/tns/EventLoopHandler.java b/test-app/runtime/src/main/java/com/tns/EventLoopHandler.java index b2992543a..266bd22f2 100644 --- a/test-app/runtime/src/main/java/com/tns/EventLoopHandler.java +++ b/test-app/runtime/src/main/java/com/tns/EventLoopHandler.java @@ -4,6 +4,10 @@ import android.os.Looper; import android.os.Message; +import java.util.concurrent.atomic.AtomicBoolean; + +import dalvik.annotation.optimization.CriticalNative; + /** * Dedicated per-runtime Handler carrying the EventLoop's ordered lane: work * that must run as a macrotask on the runtime thread, strictly FIFO-ordered @@ -11,8 +15,19 @@ * per isolate, created and used exclusively from native code (EventLoop.cpp). * * Messages are anonymous "task due" tokens: native code owns the actual task - * queue and runs at most one due task per token, so a token never names a - * task and a leftover token is a cheap no-op. + * queue and runs at most one due item per token, so a token never names the + * work it will run. Two optional cancellation carriers ride a token: + * + * - a native claim cell (arg1/arg2 = packed 64-bit cell word) for short + * timers: handleMessage claims it through a @CriticalNative CAS before + * entering the runtime, so a token whose timer was cleared dies here in + * nanoseconds without acquiring the isolate Locker; + * - an AtomicBoolean peer (obj) for long timers: claimed here in Java, and + * usable as removeMessages identity so a cleared long timer produces no + * wakeup at all. The peer and its Message are GC-owned, which is what makes + * the remove-vs-in-flight race harmless. + * + * Exactly one of the two (or neither) is set per token. */ final class EventLoopHandler extends Handler { private static final int MSG_RUN_TASK = 1; @@ -37,14 +52,53 @@ private static Looper requireLooper() { /** * Enqueues an anonymous "task due" token at an absolute uptimeMillis. - * Immediate tasks pass the current uptime; delayed tasks pass their due - * time. Callable from any thread. + * Callable from any thread. */ @RuntimeCallable void post(long uptimeMillis) { sendMessageAtTime(obtainMessage(MSG_RUN_TASK), uptimeMillis); } + /** + * Token carrying a native claim cell word (split across arg1/arg2; a + * Message has no long field). Callable from any thread. + */ + @RuntimeCallable + void postToken(long uptimeMillis, int cellHi, int cellLo) { + Message msg = obtainMessage(MSG_RUN_TASK); + msg.arg1 = cellHi; + msg.arg2 = cellLo; + sendMessageAtTime(msg, uptimeMillis); + } + + /** + * Token carrying an AtomicBoolean claim peer, returned so native code can + * cancel it later. Callable from any thread. + */ + @RuntimeCallable + Object postIdentified(long uptimeMillis) { + AtomicBoolean peer = new AtomicBoolean(false); + Message msg = obtainMessage(MSG_RUN_TASK, peer); + sendMessageAtTime(msg, uptimeMillis); + return peer; + } + + /** + * Cancels an identified token: the CAS atomically decides against a + * concurrent dispatch claim, and on success the queued message (if it is + * still queued - removal may race a dequeue, which the claim makes + * harmless) is removed so no wakeup happens. Returns whether this call + * won the token. Callable from any thread. + */ + @RuntimeCallable + boolean cancelIdentified(Object peer) { + if (((AtomicBoolean) peer).compareAndSet(false, true)) { + removeMessages(MSG_RUN_TASK, peer); + return true; + } + return false; + } + /** * Called from EventLoop::Shutdown on this handler's own thread. After * this no token can fire into the (about to be freed) native loop. @@ -52,7 +106,7 @@ void post(long uptimeMillis) { @RuntimeCallable void release() { released = true; - removeCallbacksAndMessages(null); // safe: this handler is tasks-only + removeCallbacksAndMessages(null); // safe: this handler is tokens-only } @Override @@ -60,8 +114,27 @@ public void handleMessage(Message msg) { if (released || msg.what != MSG_RUN_TASK) { return; } + Object peer = msg.obj; + if (peer != null && !((AtomicBoolean) peer).compareAndSet(false, true)) { + // cancelIdentified won this token + return; + } + long cellWord = (((long) msg.arg1) << 32) | (msg.arg2 & 0xffffffffL); + if (cellWord != 0 && !nativeClaimToken(nativeRunnerPtr, cellWord)) { + // the timer this token was posted for was cleared; the token dies + // without touching the isolate + return; + } nativeRunTask(nativeRunnerPtr); } private static native void nativeRunTask(long nativeRunnerPtr); + + /** + * Registered via RegisterNatives from EventLoop::BindToCurrentThread. + * Runs without a JNIEnv or thread state transition - a single atomic CAS + * against the loop's claim-cell table. + */ + @CriticalNative + private static native boolean nativeClaimToken(long nativeRunnerPtr, long cellWord); }