Visitar URL original
feat: per-runtime EventLoop - v8 platform tasks + two-lane scheduler (Java MessageQueue / ALooper fd) by edusperoni · Pull Request #2003 · NativeScript/android · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
fix(event-loop): claim-gate ABI below API 26 and failure-path rollbac…
…ks from review

- @CriticalNative is ignored below API 26, where ART calls the method
  through the standard JNI ABI - binding the critical-convention
  function there would misread its arguments (minSdk is 21).
  Registration now binds a standard-ABI twin on api < 26, so the gate
  behaves identically on every supported API level. RegisterNatives
  failure no longer asserts: it clears the pending exception and gates
  PostTimerToken to plain tokens, so the unbound native can never be
  reached.
- a failed JNI token post no longer leaks state: PostTimerToken
  releases the claim cell (no dispatch gate will ever retire it), and
  addTask erases the just-inserted sorted slot and map entry before
  rethrowing - a tokenless slot would otherwise consume another
  token's dispatch (live) or starve the item behind it (tombstoned).
- RunOnMainThreadCallback resolves the main event loop before caching
  the callback, so a pre-init call can't pin the closure in the cache
  with no post to consume it.
- tests: done.fail does not exist in the pinned jasmine 2.0.1 (it
  would TypeError inside the rejection handler and time out silently) -
  replaced with record-then-done; the background-clear race spec now
  counts only iterations whose clear provably ran (AtomicBoolean
  signal, bounded attempts), so it can't pass without racing.

The RunMainThreadEntry isolate-liveness window flagged by review is
byte-for-byte the removed pipe implementation's behavior and needs
teardown-spanning liveness; deferred to the teardown-coordination work
queued with the kExplicit follow-up.
  • Loading branch information
edusperoni committed Aug 13, 2026
commit d89d08efe535ff8e8dafa64981528b5f0d7b4ef0
24 changes: 20 additions & 4 deletions test-app/app/src/main/assets/app/tests/testEventLoop.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ describe("event loop foreground tasks", function () {
expect(value).toBe("ok");
done();
}).catch(e => {
done.fail("Atomics.waitAsync promise rejected: " + e);
// jasmine 2.0.1: done has no .fail - record the failure, then complete
expect("resolved").toBe("rejected: " + e);
done();
});

const woken = Atomics.notify(i32, 0);
Expand All @@ -30,7 +32,9 @@ describe("event loop foreground tasks", function () {
expect(value).toBe("timed-out");
done();
}).catch(e => {
done.fail("Atomics.waitAsync promise rejected: " + e);
// jasmine 2.0.1: done has no .fail - record the failure, then complete
expect("resolved").toBe("rejected: " + e);
done();
});
});

Expand All @@ -57,7 +61,8 @@ describe("event loop foreground tasks", function () {
expect(order).toEqual(["waitAsync", "chained"]);
done();
}).catch(e => {
done.fail("promise chain failed: " + e);
expect("resolved").toBe("rejected: " + e);
done();
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Atomics.notify(i32, 0);
Expand Down Expand Up @@ -169,14 +174,25 @@ describe("event loop token cancellation", function () {
});

it("background-thread clear racing dispatch neither jumps java posts nor ghost-fires", function (done) {
// only iterations whose clear provably ran count toward the quota, so
// the spec can't pass on 30 runs where the thread never raced at all
let remaining = 30;
let attempts = 0;
(function iter() {
if (++attempts > 300) {
expect("background clears raced " + (30 - remaining) + "/30 times")
.toBe("background clears raced 30/30 times");
done();
return;
}
const order = [];
const cleared = new java.util.concurrent.atomic.AtomicBoolean(false);
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);
cleared.set(true);
}
})).start();
handler.post(new java.lang.Runnable({
Expand All @@ -188,7 +204,7 @@ describe("event loop token cancellation", function () {
// 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) {
if (cleared.get() && --remaining === 0) {
done();
} else {
iter();
Expand Down
11 changes: 7 additions & 4 deletions test-app/runtime/src/main/cpp/CallbackHandlers.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -684,17 +684,20 @@ void CallbackHandlers::RunOnMainThreadCallback(const FunctionCallbackInfo<v8::Va
uint64_t key = ++count_;
Local<v8::Function> callback = args[0].As<v8::Function>();

// resolve the loop before inserting: an entry cached with no post to
// consume it would pin the callback until isolate teardown
auto mainLoop = Runtime::GetMainEventLoop();
if (mainLoop == nullptr) {
return;
}

{
std::lock_guard<std::mutex> 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 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
Expand Down
55 changes: 41 additions & 14 deletions test-app/runtime/src/main/cpp/EventLoop.cpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#include "EventLoop.h"

#include <android/api-level.h>
#include <sys/eventfd.h>
#include <sys/timerfd.h>
#include <unistd.h>
Expand Down Expand Up @@ -46,6 +47,7 @@ void RunGuarded(F&& body) {

namespace tns {

bool EventLoop::claimGateRegistered_ = false;
jclass EventLoop::EVENT_LOOP_HANDLER_CLASS = nullptr;
jmethodID EventLoop::EVENT_LOOP_HANDLER_CTOR = nullptr;
jmethodID EventLoop::EVENT_LOOP_HANDLER_POST = nullptr;
Expand Down Expand Up @@ -106,14 +108,23 @@ void EventLoop::BindToCurrentThread() {
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 = {
// The @CriticalNative gate must be bound explicitly (name resolution
// doesn't apply to the critical calling convention on older ART).
// Below API 26 ART ignores the annotation and calls through the
// normal JNI ABI, so bind the standard-convention twin there.
const bool criticalAbi = android_get_device_api_level() >= 26;
const JNINativeMethod claimMethod = {
const_cast<char*>("nativeClaimToken"), const_cast<char*>("(JJ)Z"),
reinterpret_cast<void*>(EventLoop::ClaimTokenCritical)};
criticalAbi ? reinterpret_cast<void*>(EventLoop::ClaimTokenCritical)
: reinterpret_cast<void*>(EventLoop::ClaimTokenLegacy)};
JNIEnv* rawEnv = env;
jint registered = rawEnv->RegisterNatives(EVENT_LOOP_HANDLER_CLASS, &claimMethod, 1);
assert(registered == 0);
if (rawEnv->RegisterNatives(EVENT_LOOP_HANDLER_CLASS, &claimMethod, 1) == 0) {
claimGateRegistered_ = true;
} else {
rawEnv->ExceptionClear();
DEBUG_WRITE_FORCE(
"EventLoop: claim gate registration failed; timer tokens stay plain");
}
}
JniLocalRef handler(env.NewObject(EVENT_LOOP_HANDLER_CLASS, EVENT_LOOP_HANDLER_CTOR,
reinterpret_cast<jlong>(this)));
Expand Down Expand Up @@ -283,16 +294,28 @@ uint64_t EventLoop::PostTimerToken(jlong uptimeMillis, int timerId) {
}
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;
if (claimGateRegistered_) {
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
}
// 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));
try {
env.CallVoidMethod(handler_, EVENT_LOOP_HANDLER_POST_TOKEN, uptimeMillis,
(jint) (word >> 32), (jint) (word & 0xffffffffull));
} catch (...) {
// no token reached the queue, so no dispatch gate will ever retire
// the cell - release it here or the slot is burned for the process
if (word != 0) {
cell.store(0, std::memory_order_release);
}
throw;
}
return word;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

Expand Down Expand Up @@ -340,6 +363,10 @@ void EventLoop::ReleaseIdentifiedToken(jobject peer) {
env.DeleteGlobalRef(peer);
}

jboolean EventLoop::ClaimTokenLegacy(JNIEnv* env, jclass clazz, jlong loopPtr, jlong cellWord) {
return ClaimTokenCritical(loopPtr, cellWord);
}

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
Expand Down
11 changes: 11 additions & 0 deletions test-app/runtime/src/main/cpp/EventLoop.h
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,17 @@ class EventLoop {
*/
static jboolean ClaimTokenCritical(jlong loopPtr, jlong cellWord);

/**
* Standard-ABI twin registered on devices below API 26, where ART ignores
* @CriticalNative and calls through the normal JNI convention - binding
* the critical-convention function there would misread its arguments.
*/
static jboolean ClaimTokenLegacy(JNIEnv* env, jclass clazz, jlong loopPtr, jlong cellWord);

// false when the claim gate couldn't be registered: PostTimerToken then
// never emits cell words, so nativeClaimToken is never invoked
static bool claimGateRegistered_;

v8::Isolate* isolate_;
std::mutex mutex_;
Lane internal_;
Expand Down
22 changes: 21 additions & 1 deletion test-app/runtime/src/main/cpp/Timers.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,27 @@ void Timers::addTask(std::shared_ptr<TimerTask> task) {
return ref.dueTime > value;
});
sortedTimers_.insert(it, TimerReference{task->id_, task->dueTime_});
postTimer(task, now);
try {
postTimer(task, now);
} catch (...) {
// No token reached the queue: the slot must not linger, tokenless -
// as a live entry it would consume some other token's slot, and as a
// tombstone it would starve the item behind it. Erase it outright.
auto sit = std::lower_bound(sortedTimers_.begin(), sortedTimers_.end(), task->dueTime_,
[](const TimerReference &ref, const double &value) {
return ref.dueTime < value;
});
while (sit != sortedTimers_.end() && sit->dueTime == task->dueTime_) {
if (sit->id == task->id_) {
sortedTimers_.erase(sit);
break;
}
++sit;
}
timerMap_.erase(task->id_);
task->Unschedule();
throw;
}
}

// Above this remaining delay a timer's token gets an identified peer so a
Expand Down