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/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 new file mode 100644 index 000000000..3c3f2cb47 --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/testEventLoop.js @@ -0,0 +1,257 @@ +// 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(); + }).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")); + }); + + // 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")); + __ns__setTimeout(() => order.push("timeout"), 0); + __ns__queueMacrotask(() => { + order.push("macro2"); + expect(order).toEqual(["macro1", "timeout", "macro2"]); + done(); + }); + }); + + it("rejects non-function arguments", function () { + expect(() => __ns__queueMacrotask("nope")).toThrowError(TypeError); + expect(() => __ns__queueMacrotask()).toThrowError(TypeError); + }); + + 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(); + }); +}); + +// 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(); + }); + }); +}); + +// 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 + // 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/CMakeLists.txt b/test-app/runtime/CMakeLists.txt index f61bbd353..2aabd9f46 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 @@ -150,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 @@ -163,9 +163,7 @@ 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/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/CallbackHandlers.cpp b/test-app/runtime/src/main/cpp/CallbackHandlers.cpp index de1d0b345..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) { @@ -766,6 +778,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()); @@ -1547,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; + } } } @@ -1714,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 f62eeef7d..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); @@ -84,6 +87,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); @@ -269,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 new file mode 100644 index 000000000..400ad3b3b --- /dev/null +++ b/test-app/runtime/src/main/cpp/EventLoop.cpp @@ -0,0 +1,609 @@ +#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_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() { + 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_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))); + 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)); + } + for (auto when : pendingTokens_) { + env.CallVoidMethod(handler_, EVENT_LOOP_HANDLER_POST, when); + } + pendingTokens_.clear(); +} + +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 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) { + 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, false, 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, 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) { + std::lock_guard lock(mutex_); + if (stopped_) { + return; + } + PostOrderedLocked(Entry{nullptr, std::move(fn), true, false, 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, false, 0}, delayMs); +} + +uint64_t EventLoop::PostTimerToken(jlong uptimeMillis, int timerId) { + std::lock_guard lock(mutex_); + if (stopped_) { + return 0; + } + 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) { + // home thread only, like every consumer of timerSource_ + timerSource_ = source; +} + +void EventLoop::PostV8Task(std::unique_ptr task, bool nestable, double delaySeconds) { + std::lock_guard lock(mutex_); + if (stopped_) { + return; + } + PostInternalLocked(Entry{std::move(task), nullptr, nestable, false, 0}, + delaySeconds * 1000.0); +} + +bool EventLoop::IsStopped() { + std::lock_guard lock(mutex_); + return stopped_; +} + +std::unique_ptr EventLoop::TakeDueLocked(Lane& lane, bool nestableOnly, + bool v8Only, + bool requireSignaledDelayed, + 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) || (requireSignaledDelayed && !delIt->second.signaled))) { + ++delIt; + } + bool hasImmediate = imIt != lane.immediate.end(); + bool hasDelayed = delIt != lane.delayed.end() && delIt->first <= now; + if (hasImmediate && (!hasDelayed || imIt->time <= delIt->first)) { + auto entry = std::make_unique(std::move(*imIt)); + lane.immediate.erase(imIt); + return entry; + } + if (hasDelayed) { + auto entry = std::make_unique(std::move(delIt->second)); + lane.delayed.erase(delIt); + return entry; + } + return nullptr; +} + +double EventLoop::PeekDueLocked(Lane& lane, double now) { + // immediate entries are enqueued with monotonically increasing times, so + // the front is the earliest + double due = lane.immediate.empty() ? -1 : lane.immediate.front().time; + if (!lane.delayed.empty() && lane.delayed.begin()->first <= now && + (due < 0 || lane.delayed.begin()->first < due)) { + due = lane.delayed.begin()->first; + } + return due; +} + +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) { + 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_); + 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, true, 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, false, now_ms()); + } + if (entry == nullptr) { + return; + } + // the pause loops call this from inside v8 inspector frames - a C++ + // exception must not unwind through them + RunGuarded([&] { RunEntry(*entry); }); + } +} + +void EventLoop::RunOrderedTask() { + // one anonymous token = one due slot across the whole ordered domain: + // pick the earliest due item among the ordered entries and the timer + // source, whichever it is. Timers and entries only ever run on this + // thread, so the peeked winner can't be taken by anyone else before we + // re-lock (a concurrent post can only add later work). + auto now = 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_); + if (stopped_) { + return; + } + entry = TakeDueLocked(ordered_, false, false, false, now_ms()); + } + if (entry != nullptr) { + 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. 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; + if (read(fd, &expirations, sizeof(expirations)) != sizeof(expirations)) { + return 1; + } + 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..130a8fcdb --- /dev/null +++ b/test-app/runtime/src/main/cpp/EventLoop.h @@ -0,0 +1,293 @@ +#ifndef TEST_APP_EVENTLOOP_H +#define TEST_APP_EVENTLOOP_H + +#include +#include +#include +#include +#include +#include +#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 + * 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: + 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); + + /** + * 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. + * + * 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. + */ + 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 + * 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. + */ + void PostV8Task(std::unique_ptr task, bool nestable, double delaySeconds); + + /** + * True once Shutdown ran. A stopped loop found in the platform registry + * for a (reused) isolate pointer is stale and must be replaced. + */ + bool IsStopped(); + + /** + * Runs the internal-lane v8 tasks that are due and nestable, bounded to + * the entries present at call time. For nested message loops (inspector + * pause) where the 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; + // 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; + // 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; + }; + + // 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, + 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(); + + 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 + 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 + // 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_POST_TOKEN; + static jmethodID EVENT_LOOP_HANDLER_POST_IDENTIFIED; + static jmethodID EVENT_LOOP_HANDLER_CANCEL_IDENTIFIED; + 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 4487b327d..174c4119f 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 v8 foreground tasks + // may run; everything else fires from its own wakeup after resume + tns::NativeScriptPlatform::Instance() + ->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/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/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 new file mode 100644 index 000000000..bdae97db6 --- /dev/null +++ b/test-app/runtime/src/main/cpp/NativeScriptPlatform.cpp @@ -0,0 +1,204 @@ +#include "NativeScriptPlatform.h" + +using namespace v8; + +namespace tns { + +NativeScriptPlatform* NativeScriptPlatform::instance_ = nullptr; + +namespace { + +/** + * The v8::TaskRunner handed to v8 for one isolate. Stateless beyond the + * isolate pointer: every post resolves the current EventLoop through the + * platform registry, so a stale loop replaced by RefreshEventLoop is + * redirected transparently, and posts for a disposed isolate (registry entry + * gone) drop instead of reviving a dead pointer's entry. + */ +class V8TaskRunnerAdapter : public v8::TaskRunner { +public: + explicit V8TaskRunnerAdapter(Isolate* isolate) : isolate_(isolate) {} + + bool IdleTasksEnabled() override { + return false; + } + + bool NonNestableTasksEnabled() const override { + return true; + } + + bool NonNestableDelayedTasksEnabled() const override { + return true; + } + +protected: + void PostTaskImpl(std::unique_ptr task, const SourceLocation& location) override { + Post(std::move(task), true, 0); + } + + void PostNonNestableTaskImpl(std::unique_ptr task, + const SourceLocation& location) override { + Post(std::move(task), false, 0); + } + + void PostDelayedTaskImpl(std::unique_ptr task, double delay_in_seconds, + const SourceLocation& location) override { + Post(std::move(task), true, delay_in_seconds); + } + + void PostNonNestableDelayedTaskImpl(std::unique_ptr task, double delay_in_seconds, + const SourceLocation& location) override { + Post(std::move(task), false, delay_in_seconds); + } + +private: + void Post(std::unique_ptr task, bool nestable, double delaySeconds) { + auto loop = NativeScriptPlatform::Instance()->LookupEventLoop(isolate_); + if (loop != nullptr) { + loop->PostV8Task(std::move(task), nestable, delaySeconds); + } + } + + Isolate* isolate_; +}; + +} // namespace + +NativeScriptPlatform::NativeScriptPlatform(std::unique_ptr defaultPlatform) + : default_(std::move(defaultPlatform)) { + instance_ = this; +} + +NativeScriptPlatform::IsolateEntry& NativeScriptPlatform::GetEntryLocked(Isolate* isolate) { + auto it = loops_.find(isolate); + if (it != loops_.end()) { + return it->second; + } + auto emplaced = loops_.emplace( + isolate, IsolateEntry{std::make_shared(isolate), + std::make_shared(isolate)}); + return emplaced.first->second; +} + +std::shared_ptr NativeScriptPlatform::GetEventLoop(Isolate* isolate) { + std::lock_guard lock(loopsMutex_); + return GetEntryLocked(isolate).loop; +} + +std::shared_ptr NativeScriptPlatform::RefreshEventLoop(Isolate* isolate) { + std::lock_guard lock(loopsMutex_); + auto& entry = GetEntryLocked(isolate); + if (entry.loop->IsStopped()) { + entry.loop = std::make_shared(isolate); + } + return entry.loop; +} + +std::shared_ptr NativeScriptPlatform::LookupEventLoop(Isolate* isolate) { + std::lock_guard lock(loopsMutex_); + auto it = loops_.find(isolate); + return it != loops_.end() ? it->second.loop : nullptr; +} + +void NativeScriptPlatform::IsolateDisposed(Isolate* isolate, + const std::shared_ptr& loop) { + std::lock_guard lock(loopsMutex_); + auto it = loops_.find(isolate); + if (it != loops_.end() && it->second.loop == loop) { + loops_.erase(it); + } +} + +PageAllocator* NativeScriptPlatform::GetPageAllocator() { + return default_->GetPageAllocator(); +} + +ThreadIsolatedAllocator* NativeScriptPlatform::GetThreadIsolatedAllocator() { + return default_->GetThreadIsolatedAllocator(); +} + +size_t NativeScriptPlatform::GetZeroSegmentSize() { + return default_->GetZeroSegmentSize(); +} + +void NativeScriptPlatform::OnCriticalMemoryPressure() { + default_->OnCriticalMemoryPressure(); +} + +int NativeScriptPlatform::NumberOfWorkerThreads() { + return default_->NumberOfWorkerThreads(); +} + +std::shared_ptr NativeScriptPlatform::GetForegroundTaskRunner( + Isolate* isolate, TaskPriority priority) { + // one runner regardless of priority: the home looper's FIFO order is the + // priority model of the runtime thread + std::lock_guard lock(loopsMutex_); + return GetEntryLocked(isolate).runner; +} + +bool NativeScriptPlatform::IdleTasksEnabled(Isolate* isolate) { + return false; +} + +std::unique_ptr NativeScriptPlatform::CreateBoostablePriorityScope() { + return default_->CreateBoostablePriorityScope(); +} + +std::unique_ptr NativeScriptPlatform::CreateBlockingScope( + BlockingType blocking_type) { + return default_->CreateBlockingScope(blocking_type); +} + +double NativeScriptPlatform::MonotonicallyIncreasingTime() { + return default_->MonotonicallyIncreasingTime(); +} + +int64_t NativeScriptPlatform::CurrentClockTimeMilliseconds() { + return default_->CurrentClockTimeMilliseconds(); +} + +double NativeScriptPlatform::CurrentClockTimeMillis() { + return default_->CurrentClockTimeMillis(); +} + +double NativeScriptPlatform::CurrentClockTimeMillisecondsHighResolution() { + return default_->CurrentClockTimeMillisecondsHighResolution(); +} + +Platform::StackTracePrinter NativeScriptPlatform::GetStackTracePrinter() { + return default_->GetStackTracePrinter(); +} + +TracingController* NativeScriptPlatform::GetTracingController() { + return default_->GetTracingController(); +} + +void NativeScriptPlatform::DumpWithoutCrashing() { + default_->DumpWithoutCrashing(); +} + +HighAllocationThroughputObserver* NativeScriptPlatform::GetHighAllocationThroughputObserver() { + return default_->GetHighAllocationThroughputObserver(); +} + +std::unique_ptr NativeScriptPlatform::CreateJobImpl( + TaskPriority priority, std::unique_ptr job_task, + const SourceLocation& location) { + return default_->CreateJob(priority, std::move(job_task), location); +} + +void NativeScriptPlatform::PostTaskOnWorkerThreadImpl(TaskPriority priority, + std::unique_ptr task, + const SourceLocation& location) { + default_->PostTaskOnWorkerThread(priority, std::move(task), location); +} + +void NativeScriptPlatform::PostDelayedTaskOnWorkerThreadImpl( + TaskPriority priority, std::unique_ptr task, double delay_in_seconds, + const SourceLocation& location) { + default_->PostDelayedTaskOnWorkerThread(priority, std::move(task), delay_in_seconds, + location); +} + +} // namespace tns diff --git a/test-app/runtime/src/main/cpp/NativeScriptPlatform.h b/test-app/runtime/src/main/cpp/NativeScriptPlatform.h new file mode 100644 index 000000000..89b33fd41 --- /dev/null +++ b/test-app/runtime/src/main/cpp/NativeScriptPlatform.h @@ -0,0 +1,112 @@ +#ifndef TEST_APP_NATIVESCRIPTPLATFORM_H +#define TEST_APP_NATIVESCRIPTPLATFORM_H + +#include +#include +#include "v8.h" +#include "v8-platform.h" +#include "EventLoop.h" +#include "robin_hood.h" + +namespace tns { + +/** + * v8::Platform that delegates worker-thread scheduling, time and tracing to + * the default libplatform implementation but serves per-isolate foreground + * task runners backed by each runtime's EventLoop. This is what makes v8's + * own foreground tasks (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 event loop, creating an unbound one if v8 asks + * before Runtime::PrepareV8Runtime binds it to the isolate's home thread. + */ + std::shared_ptr GetEventLoop(v8::Isolate* isolate); + + /** + * GetEventLoop, but replaces a stopped loop with a fresh one first. Used + * by 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. + */ + 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; + v8::ThreadIsolatedAllocator* GetThreadIsolatedAllocator() override; + size_t GetZeroSegmentSize() override; + void OnCriticalMemoryPressure() override; + int NumberOfWorkerThreads() override; + std::shared_ptr GetForegroundTaskRunner( + v8::Isolate* isolate, v8::TaskPriority priority) override; + bool IdleTasksEnabled(v8::Isolate* isolate) override; + std::unique_ptr CreateBoostablePriorityScope() override; + std::unique_ptr CreateBlockingScope( + v8::BlockingType blocking_type) override; + double MonotonicallyIncreasingTime() override; + int64_t CurrentClockTimeMilliseconds() override; + double CurrentClockTimeMillis() override; + double CurrentClockTimeMillisecondsHighResolution() override; + StackTracePrinter GetStackTracePrinter() override; + v8::TracingController* GetTracingController() override; + void DumpWithoutCrashing() override; + v8::HighAllocationThroughputObserver* GetHighAllocationThroughputObserver() override; + +protected: + std::unique_ptr CreateJobImpl( + v8::TaskPriority priority, std::unique_ptr job_task, + const v8::SourceLocation& location) override; + void PostTaskOnWorkerThreadImpl(v8::TaskPriority priority, + std::unique_ptr task, + const v8::SourceLocation& location) override; + void PostDelayedTaskOnWorkerThreadImpl(v8::TaskPriority priority, + std::unique_ptr task, + double delay_in_seconds, + const v8::SourceLocation& location) override; + +private: + struct IsolateEntry { + std::shared_ptr loop; + // handed to v8 once per isolate; resolves the loop through the + // registry on every post so RefreshEventLoop redirects it + std::shared_ptr runner; + }; + + std::unique_ptr default_; + std::mutex loopsMutex_; + robin_hood::unordered_map loops_; + + IsolateEntry& GetEntryLocked(v8::Isolate* isolate); + + 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..b78057bc5 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,22 +296,15 @@ void Runtime::Init(JNIEnv* env, jstring filesPath, jstring nativeLibDir, Runtime::~Runtime() { delete this->m_objectManager; - delete this->m_loopTimer; - 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]); - } + // 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 && m_eventLoop != nullptr) { + platformInstance->IsolateDisposed(m_isolate, m_eventLoop); } + CallbackHandlers::RemoveIsolateEntries(m_isolate); } std::string Runtime::ReadFileText(const std::string& filePath) { @@ -591,7 +584,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 +626,12 @@ Isolate* Runtime::PrepareV8Runtime(const string& filesPath, std::lock_guard lock(s_runtimeCacheMutex); 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. 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); HandleScope handleScope(isolate); @@ -677,6 +679,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( @@ -752,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` @@ -821,17 +809,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); @@ -919,8 +898,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; @@ -966,10 +943,10 @@ 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(); } // The events state holds v8::Global handles (backing event target, dispatch // closures and tracked promise rejections) - reset them while the isolate @@ -989,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; @@ -1001,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 ec80975f9..3496a47b3 100644 --- a/test-app/runtime/src/main/cpp/Runtime.h +++ b/test-app/runtime/src/main/cpp/Runtime.h @@ -9,10 +9,9 @@ #include "ArrayBufferHelper.h" #include "Profiler.h" #include "ModuleInternal.h" -#include "MessageLoopTimer.h" #include "File.h" #include "Timers.h" -#include "LooperTasks.h" +#include "EventLoop.h" #include #include #include @@ -97,22 +96,25 @@ 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; } /* - * 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; } /* @@ -194,9 +196,7 @@ class Runtime { Profiler m_profiler; - MessageLoopTimer* m_loopTimer; - - std::shared_ptr m_looperTasks; + std::shared_ptr m_eventLoop; v8::Global m_globalEventTarget; v8::Global m_dispatchErrorEventFunc; @@ -248,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..2903f218e 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; } @@ -111,14 +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_); - JEnv env; - env.CallVoidMethod(handler_, TIMER_HANDLER_POST, 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) { @@ -128,9 +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, 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 (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) { @@ -138,12 +163,17 @@ void Timers::removeTask(const int &taskId) { }); while (sit != sortedTimers_.end() && sit->dueTime == dueTime) { if (sit->id == taskId) { - sortedTimers_.erase(sit); + if (tokenNeutralized) { + sortedTimers_.erase(sit); + } else { + sit->cancelled = true; + } break; } ++sit; } } + releaseTokenCarriers(it->second); it->second->Unschedule(); timerMap_.erase(it); } @@ -154,11 +184,15 @@ 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) { + 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 + eventLoop_->SetTimerSource(nullptr); + eventLoop_.reset(); } timerMap_.clear(); sortedTimers_.clear(); @@ -226,37 +260,51 @@ 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); } /** - * 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 +353,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 +366,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..c877b0561 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" @@ -49,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_; @@ -69,13 +81,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 +108,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 +118,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); @@ -114,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); @@ -127,22 +150,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/cpp/WorkerInspectorClient.cpp b/test-app/runtime/src/main/cpp/WorkerInspectorClient.cpp index 4ff8ae745..fbe7a7192 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 v8 foreground tasks + // may run; everything else fires from its own wakeup after resume + tns::NativeScriptPlatform::Instance() + ->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..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 @@ -29,7 +30,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 +71,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 +249,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); @@ -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. @@ -486,7 +493,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/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..266bd22f2 --- /dev/null +++ b/test-app/runtime/src/main/java/com/tns/EventLoopHandler.java @@ -0,0 +1,140 @@ +package com.tns; + +import android.os.Handler; +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 + * 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 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; + + private final long nativeRunnerPtr; + private boolean released; + + // constructed from native code (EventLoop::BindToCurrentThread) + EventLoopHandler(long nativeRunnerPtr) { + 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. + * 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. + */ + @RuntimeCallable + void release() { + released = true; + removeCallbacksAndMessages(null); // safe: this handler is tokens-only + } + + @Override + 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); +} 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); -}