Skip to content

Commit 375eecd

Browse files
committed
perf(event-loop): cancellable timer tokens (claim cells + @CriticalNative gate, identified long-timer removal)
Cancelled timers no longer leave stale wakeups. Two tiers by remaining delay, both preserving exact clear semantics from any thread (multithreaded JS can schedule and clear on non-looper threads): - short timers (<32ms): the token carries a native claim cell - a slot in a fixed per-loop atomic table indexed by timer id, with the id embedded in the cell word so cancellation can never hit a recycled cell. clearTimeout is a single native CAS (zero JNI): winning proves the token dead everywhere, so the sorted entry is erased outright; losing means dispatch owns the token, so a tombstone is left for it. EventLoopHandler claims cells through a @CriticalNative CAS (the annotation is public API in current SDKs; where ART doesn't apply it the method degrades to a plain JNI call with identical semantics) before entering the runtime, so a cancelled token dies in Java in nanoseconds - without acquiring the isolate Locker, which previously let a stale token park the main thread behind a long background JS turn. Only the gate retires cells, and cell tokens are never removeMessages()ed, so each cell sees exactly one gate pass; a busy slot (interval re-arm racing its previous token, or id collision beyond 1024 in-flight) just downgrades the token to plain+tombstone. - long timers (>=32ms, debounce territory): the token carries a Java AtomicBoolean peer, claimed in handleMessage. clearTimeout CASes the peer and on winning removeMessages()es the queued token: a cleared debounce timer produces no wakeup at all. The peer and its Message are GC-owned, which makes the removal-vs-in-flight-dequeue race harmless - a lost race costs at most one no-op wakeup, never an ordering violation. Below the cutoff a stale wakeup lands within two frames of the interaction that scheduled it (the app is provably awake), so the zero-allocation cell path applies instead. Only the newest token of an interval is cancellable; older tokens orphaned by a re-arm keep functioning anonymously through their own carriers, so token/slot parity holds under the anonymous-dispatch shuffle. SetTimer now converts a failed token post into a JS exception instead of unwinding a NativeScriptException through the V8 callback frame. Verified on device: ordering probes 100% across all scenarios (timer FIFO ties, clear-vs-Handler.post in both orders, orphan gap, triple-clear, clearInterval-from-callback, starvation), and the full suite (78 suites / 668 specs) green, including new specs for identified clear, background-thread clear racing dispatch, and interval stop.
1 parent 33d7547 commit 375eecd

6 files changed

Lines changed: 382 additions & 19 deletions

File tree

‎test-app/app/src/main/assets/app/tests/testEventLoop.js‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,64 @@ describe("event loop ordered tombstones", function () {
154154
});
155155
});
156156

157+
// Long (>=32ms) timers carry an identified token whose clear removes the
158+
// queued wakeup; short timers carry a native claim cell whose clear is a
159+
// single CAS. Both must keep exact clear semantics under any thread.
160+
describe("event loop token cancellation", function () {
161+
it("cleared identified (long) timeout never fires and later timers are unaffected", function (done) {
162+
let fired = false;
163+
const t = __ns__setTimeout(() => { fired = true; }, 100);
164+
__ns__clearTimeout(t);
165+
__ns__setTimeout(() => {
166+
expect(fired).toBe(false);
167+
done();
168+
}, 150);
169+
});
170+
171+
it("background-thread clear racing dispatch neither jumps java posts nor ghost-fires", function (done) {
172+
let remaining = 30;
173+
(function iter() {
174+
const order = [];
175+
const handler = new android.os.Handler(android.os.Looper.myLooper());
176+
const t1 = __ns__setTimeout(() => order.push("t1"), 0);
177+
new java.lang.Thread(new java.lang.Runnable({
178+
run() {
179+
__ns__clearTimeout(t1);
180+
}
181+
})).start();
182+
handler.post(new java.lang.Runnable({
183+
run: () => order.push("java")
184+
}));
185+
__ns__setTimeout(() => {
186+
order.push("t2");
187+
const observed = order.join(">");
188+
// t1 either fired before the clear landed (at its own legal
189+
// slot, ahead of "java") or never; t2 must never jump "java"
190+
expect(observed === "java>t2" || observed === "t1>java>t2").toBe(true);
191+
if (--remaining === 0) {
192+
done();
193+
} else {
194+
iter();
195+
}
196+
}, 5);
197+
})();
198+
});
199+
200+
it("clearing an identified interval stops it", function (done) {
201+
let ticks = 0;
202+
const iv = __ns__setInterval(() => {
203+
ticks++;
204+
if (ticks === 2) {
205+
__ns__clearInterval(iv);
206+
__ns__setTimeout(() => {
207+
expect(ticks).toBe(2);
208+
done();
209+
}, 120);
210+
}
211+
}, 40);
212+
});
213+
});
214+
157215
describe("event loop internal lane", function () {
158216
// Regression for the eventfd unit-accounting bug: a worker reply's wakeup
159217
// arriving while an overdue waitAsync timeout is still unsignaled must not

‎test-app/runtime/src/main/cpp/EventLoop.cpp‎

Lines changed: 101 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ namespace tns {
4949
jclass EventLoop::EVENT_LOOP_HANDLER_CLASS = nullptr;
5050
jmethodID EventLoop::EVENT_LOOP_HANDLER_CTOR = nullptr;
5151
jmethodID EventLoop::EVENT_LOOP_HANDLER_POST = nullptr;
52+
jmethodID EventLoop::EVENT_LOOP_HANDLER_POST_TOKEN = nullptr;
53+
jmethodID EventLoop::EVENT_LOOP_HANDLER_POST_IDENTIFIED = nullptr;
54+
jmethodID EventLoop::EVENT_LOOP_HANDLER_CANCEL_IDENTIFIED = nullptr;
5255
jmethodID EventLoop::EVENT_LOOP_HANDLER_RELEASE = nullptr;
5356

5457
void EventLoop::BindToCurrentThread() {
@@ -96,7 +99,21 @@ void EventLoop::BindToCurrentThread() {
9699
assert(EVENT_LOOP_HANDLER_CLASS != nullptr);
97100
EVENT_LOOP_HANDLER_CTOR = env.GetMethodID(EVENT_LOOP_HANDLER_CLASS, "<init>", "(J)V");
98101
EVENT_LOOP_HANDLER_POST = env.GetMethodID(EVENT_LOOP_HANDLER_CLASS, "post", "(J)V");
102+
EVENT_LOOP_HANDLER_POST_TOKEN =
103+
env.GetMethodID(EVENT_LOOP_HANDLER_CLASS, "postToken", "(JII)V");
104+
EVENT_LOOP_HANDLER_POST_IDENTIFIED = env.GetMethodID(
105+
EVENT_LOOP_HANDLER_CLASS, "postIdentified", "(J)Ljava/lang/Object;");
106+
EVENT_LOOP_HANDLER_CANCEL_IDENTIFIED = env.GetMethodID(
107+
EVENT_LOOP_HANDLER_CLASS, "cancelIdentified", "(Ljava/lang/Object;)Z");
99108
EVENT_LOOP_HANDLER_RELEASE = env.GetMethodID(EVENT_LOOP_HANDLER_CLASS, "release", "()V");
109+
// the @CriticalNative gate must be bound explicitly (name resolution
110+
// doesn't apply to the critical calling convention on older ART)
111+
static const JNINativeMethod claimMethod = {
112+
const_cast<char*>("nativeClaimToken"), const_cast<char*>("(JJ)Z"),
113+
reinterpret_cast<void*>(EventLoop::ClaimTokenCritical)};
114+
JNIEnv* rawEnv = env;
115+
jint registered = rawEnv->RegisterNatives(EVENT_LOOP_HANDLER_CLASS, &claimMethod, 1);
116+
assert(registered == 0);
100117
}
101118
JniLocalRef handler(env.NewObject(EVENT_LOOP_HANDLER_CLASS, EVENT_LOOP_HANDLER_CTOR,
102119
reinterpret_cast<jlong>(this)));
@@ -255,17 +272,95 @@ void EventLoop::PostOrderedDelayed(std::function<void()> fn, double delayMs) {
255272
PostOrderedLocked(Entry{nullptr, std::move(fn), true, false, 0}, delayMs);
256273
}
257274

258-
void EventLoop::PostOrderedToken(jlong uptimeMillis) {
275+
uint64_t EventLoop::PostTimerToken(jlong uptimeMillis, int timerId) {
259276
std::lock_guard<std::mutex> lock(mutex_);
260277
if (stopped_) {
261-
return;
278+
return 0;
262279
}
263-
if (handler_ != nullptr) {
264-
JEnv env;
265-
env.CallVoidMethod(handler_, EVENT_LOOP_HANDLER_POST, uptimeMillis);
266-
} else {
280+
if (handler_ == nullptr) {
267281
pendingTokens_.push_back(uptimeMillis);
282+
return 0;
283+
}
284+
uint64_t word = 0;
285+
auto& cell = claimCells_[((uint32_t) timerId) & (kClaimCells - 1)];
286+
uint64_t expected = 0;
287+
uint64_t candidate = (((uint64_t) (uint32_t) timerId) << 2) | kCellActive;
288+
if (cell.compare_exchange_strong(expected, candidate, std::memory_order_acq_rel)) {
289+
word = candidate;
290+
}
291+
// a busy slot (previous token of the same interval still in flight, or an
292+
// id collision) downgrades this token to plain; clear then uses tombstones
293+
JEnv env;
294+
env.CallVoidMethod(handler_, EVENT_LOOP_HANDLER_POST_TOKEN, uptimeMillis,
295+
(jint) (word >> 32), (jint) (word & 0xffffffffull));
296+
return word;
297+
}
298+
299+
jobject EventLoop::PostIdentifiedTimerToken(jlong uptimeMillis) {
300+
JEnv env;
301+
std::lock_guard<std::mutex> lock(mutex_);
302+
if (stopped_) {
303+
return nullptr;
304+
}
305+
if (handler_ == nullptr) {
306+
pendingTokens_.push_back(uptimeMillis);
307+
return nullptr;
308+
}
309+
JniLocalRef peer(env.CallObjectMethod(handler_, EVENT_LOOP_HANDLER_POST_IDENTIFIED,
310+
uptimeMillis));
311+
if (peer.IsNull()) {
312+
return nullptr;
268313
}
314+
return env.NewGlobalRef(peer);
315+
}
316+
317+
bool EventLoop::CancelClaimCell(uint64_t cellWord) {
318+
auto& cell = claimCells_[(cellWord >> 2) & (kClaimCells - 1)];
319+
uint64_t expected = cellWord; // id|ACTIVE
320+
return cell.compare_exchange_strong(expected, (cellWord & ~3ull) | kCellCancelled,
321+
std::memory_order_acq_rel);
322+
}
323+
324+
bool EventLoop::CancelIdentifiedToken(jobject peer) {
325+
JEnv env;
326+
bool won = false;
327+
{
328+
std::lock_guard<std::mutex> lock(mutex_);
329+
if (!stopped_ && handler_ != nullptr) {
330+
won = env.CallBooleanMethod(handler_, EVENT_LOOP_HANDLER_CANCEL_IDENTIFIED, peer) ==
331+
JNI_TRUE;
332+
}
333+
}
334+
env.DeleteGlobalRef(peer);
335+
return won;
336+
}
337+
338+
void EventLoop::ReleaseIdentifiedToken(jobject peer) {
339+
JEnv env;
340+
env.DeleteGlobalRef(peer);
341+
}
342+
343+
jboolean EventLoop::ClaimTokenCritical(jlong loopPtr, jlong cellWord) {
344+
// @CriticalNative: no JNIEnv, thread stays runnable - a single CAS, no
345+
// locks, no allocation, no exceptions. The loop pointer is valid for the
346+
// same reason nativeRunTask's is: the handler is released before the loop
347+
// is destroyed, and released handlers never reach this gate.
348+
auto* loop = reinterpret_cast<EventLoop*>(loopPtr);
349+
auto word = (uint64_t) cellWord;
350+
auto& cell = loop->claimCells_[(word >> 2) & (kClaimCells - 1)];
351+
uint64_t expected = word; // id|ACTIVE
352+
if (cell.compare_exchange_strong(expected, 0, std::memory_order_acq_rel)) {
353+
// claimed and retired in one step: proceed to the fat path
354+
return JNI_TRUE;
355+
}
356+
if (expected == ((word & ~3ull) | kCellCancelled)) {
357+
// the timer was cleared; retire the cell and drop the token here
358+
cell.store(0, std::memory_order_release);
359+
return JNI_FALSE;
360+
}
361+
// defensive: a mismatched word can't occur while this token is in flight
362+
// (only the gate retires cells), but the fat path is always safe
363+
return JNI_TRUE;
269364
}
270365

271366
void EventLoop::SetTimerSource(OrderedTaskSource* source) {

‎test-app/runtime/src/main/cpp/EventLoop.h‎

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33

44
#include <jni.h>
55
#include <android/looper.h>
6+
#include <atomic>
7+
#include <cstdint>
68
#include <deque>
79
#include <functional>
810
#include <map>
@@ -97,8 +99,48 @@ class EventLoop {
9799
* OrderedTaskSource keeps in its own bookkeeping (Timers). One token per
98100
* item; the drain picks the earliest due item across the source and the
99101
* ordered entries, so the token needn't name what it will run.
102+
*
103+
* The token carries a claim cell when one is free for `timerId` (returns
104+
* the non-zero cell word to cancel with): EventLoopHandler claims the
105+
* cell through a @CriticalNative CAS before entering the runtime, so a
106+
* token whose timer was cancelled dies in Java without acquiring the
107+
* isolate Locker, and CancelClaimCell neutralizes a queued token with no
108+
* JNI at all. Returns 0 when the token is plain (cell slot busy, or the
109+
* loop is unbound/stopped) - the caller must then use tombstones.
100110
*/
101-
void PostOrderedToken(jlong uptimeMillis);
111+
uint64_t PostTimerToken(jlong uptimeMillis, int timerId);
112+
113+
/**
114+
* Posts an ordered token carrying a Java AtomicBoolean claim peer, for
115+
* timers long enough that the stale wakeup itself is worth removing.
116+
* Returns a global ref to the peer (null when unbound/stopped - caller
117+
* falls back to tombstones). CancelIdentifiedToken later CASes the peer
118+
* and, on winning, removeMessages()es the queued token: a cleared long
119+
* timer produces no wakeup. The peer and its Message are GC-owned, which
120+
* is what makes the remove-vs-in-flight-dispatch race harmless.
121+
*/
122+
jobject PostIdentifiedTimerToken(jlong uptimeMillis);
123+
124+
/**
125+
* Neutralizes a cell-carrying token (no JNI, single CAS). True = the
126+
* token is guaranteed dead wherever it is, so the caller may delete the
127+
* item outright; false = dispatch already claimed it, so the caller must
128+
* leave a tombstone for it to consume.
129+
*/
130+
bool CancelClaimCell(uint64_t cellWord);
131+
132+
/**
133+
* Neutralizes an identified token (one JNI crossing; releases the peer
134+
* ref). Same true/false contract as CancelClaimCell.
135+
*/
136+
bool CancelIdentifiedToken(jobject peer);
137+
138+
/**
139+
* Releases an identified token's peer ref without cancelling (the timer
140+
* fired or is being torn down; the Java Message keeps the peer alive for
141+
* its own dispatch).
142+
*/
143+
void ReleaseIdentifiedToken(jobject peer);
102144

103145
/**
104146
* Registers the ordered lane's external source. Home thread only; pass
@@ -193,10 +235,35 @@ class EventLoop {
193235
static int EventFdCallback(int fd, int events, void* data);
194236
static int TimerFdCallback(int fd, int events, void* data);
195237

238+
/**
239+
* Claim cells for cell-carrying timer tokens, indexed by timer id. A cell
240+
* word is (id << 2) | state so a token can prove the cell is still its
241+
* own; a busy slot (>kClaimCells timers in flight, or an interval's
242+
* previous token still pending) just downgrades the new token to plain.
243+
* Lifecycle: 0 (free) -> id|ACTIVE (posted, under mutex_) ->
244+
* id|CANCELLED (by CancelClaimCell, any thread) -> 0 (retired by the
245+
* dispatch gate, which runs exactly once per cell token since cell tokens
246+
* are never removeMessages()ed). Only the gate stores 0, so a cell is
247+
* never reused while its token is in flight, and cancellation can never
248+
* hit a recycled cell.
249+
*/
250+
static constexpr int kClaimCells = 1024;
251+
static constexpr uint64_t kCellActive = 1;
252+
static constexpr uint64_t kCellCancelled = 2;
253+
254+
/**
255+
* The @CriticalNative body behind EventLoopHandler.nativeClaimToken: one
256+
* CAS, no JNIEnv, and the thread stays runnable - it must never block,
257+
* allocate or throw. Returns false when the token's timer was cancelled
258+
* (token dies in Java); true otherwise (proceed to nativeRunTask).
259+
*/
260+
static jboolean ClaimTokenCritical(jlong loopPtr, jlong cellWord);
261+
196262
v8::Isolate* isolate_;
197263
std::mutex mutex_;
198264
Lane internal_;
199265
Lane ordered_;
266+
std::atomic<uint64_t> claimCells_[kClaimCells] = {};
200267
// ordered-lane source with its own bookkeeping (Timers); home-thread only
201268
OrderedTaskSource* timerSource_ = nullptr;
202269
// bare ordered tokens posted before the bind; flushed by Bind
@@ -215,6 +282,9 @@ class EventLoop {
215282
static jclass EVENT_LOOP_HANDLER_CLASS;
216283
static jmethodID EVENT_LOOP_HANDLER_CTOR;
217284
static jmethodID EVENT_LOOP_HANDLER_POST;
285+
static jmethodID EVENT_LOOP_HANDLER_POST_TOKEN;
286+
static jmethodID EVENT_LOOP_HANDLER_POST_IDENTIFIED;
287+
static jmethodID EVENT_LOOP_HANDLER_CANCEL_IDENTIFIED;
218288
static jmethodID EVENT_LOOP_HANDLER_RELEASE;
219289
};
220290

0 commit comments

Comments
 (0)