Skip to content

Commit fd7b6c6

Browse files
authored
feat: per-runtime EventLoop - v8 platform tasks + two-lane scheduler (Java MessageQueue / ALooper fd) (#2003)
* feat: run v8 platform foreground tasks on the runtime looper V8 platform foreground tasks (async WASM compilation callbacks, Atomics.waitAsync wakeups, GC finalization tasks) sat in the default platform's internal queues, which nothing pumped outside the WASM-scoped MessageLoopTimer (an ALooper fd fed by a detached 100ms-polling thread) and the inspector pause loops. Atomics.waitAsync promises never resolved at all. Wrap the default platform in NativeScriptPlatform: worker-thread scheduling, jobs, time and tracing still delegate to libplatform, but GetForegroundTaskRunner serves a per-isolate ForegroundTaskRunner that delivers tasks through a dedicated com.tns.EventLoopHandler bound to the runtime thread's Looper - the same anonymous-token scheme Timers use, so platform tasks are strictly FIFO-ordered with Handler.post runnables and JS timers on the same looper: - each posted task enqueues into a native queue (immediate deque plus a due-time-sorted delayed map) and posts one "task due" token; a token runs the earliest due task, then performs a microtask checkpoint, since a task may resolve promises without entering JS (e.g. Atomics.waitAsync), which kAuto's depth-0 drain never sees - delayed tasks ride sendMessageAtTime at ceil(dueTime), so a token never arrives before its due time - v8 requests the runner during Isolate::New, before the home thread is known, so the runner starts unbound and buffers; PrepareV8Runtime binds it to the thread's Looper and flushes one token per buffered task; posts are accepted from any thread - inspector pause loops can't receive tokens (the Java looper isn't spinning), so they drain nestable tasks directly; non-nestable tasks keep their queued tokens until the pause unwinds, and leftover tokens no-op like cleared-timer tokens - the runner shuts down in DestroyRuntime and is unregistered after isolate disposal, so workers can churn without leaking map entries MessageLoopTimer, its polling thread and the WebAssembly method proxies in message-loop-timer.js are removed: async WASM promises now resolve promptly through the runner with no start/stop windows. The runner is also the seam for future macrotask dispatch (e.g. performance API observer callbacks). Microtask policy is deliberately untouched. Adds Atomics.waitAsync regression tests (notify, timeout, sync mismatch, promise-chain ordering); the async cases hang without this change. * refactor: two-lane EventLoop scheduler (ordered Java lane, internal fd lane) Restructure the foreground task runner into a per-runtime EventLoop, the Android analogue of the iOS runtime's ExecuteOnRunLoop seam, routing work by ordering contract: - ordered lane: work whose ordering is observable against app-level Java messages rides the Java MessageQueue via EventLoopHandler tokens, strictly FIFO with Handler.post and JS timers. First producer: __ns__queueMacrotask(cb), the seam future spec'd macrotasks (performance observers etc.) will use. - internal lane: work in its own ordering domain - v8 platform foreground tasks, worker->parent messages, unhandled-rejection drains - rides an EFD_SEMAPHORE eventfd plus a timerfd for delayed tasks on the thread's ALooper. No JNI on the post path, so v8's non-JVM worker threads post without attaching to the JVM. One eventfd unit runs one entry per looper callback, keeping bursts fair with Java messages. LooperTasks is consolidated into the internal lane (worker messaging and exception-drain call sites ported 1:1, keeping the weak_ptr child semantics and drop-after-shutdown behavior). Timers stays separate: it is the ordered lane specialized with sub-millisecond ordering machinery. Also addresses review findings: ordered-lane token posts and destructor now synchronize on the loop mutex; the inspector pause drain is bounded to the entries present at call time so a self-reposting task cannot wedge the CDP read; ~Runtime guards the platform instance and isolate against early construction failure; EventLoopHandler fails loudly when constructed on a thread with no prepared Looper; the async waitAsync test chain got its missing rejection handler. Adds ordered-lane tests: async delivery, runs-after-microtasks, FIFO interleaving with setTimeout(0), TypeError on non-function. * fix(event-loop): unit accounting and isolate-reuse hardening from design review Two defects found by deep review of the scheduler: - internal-lane unit starvation: an eventfd unit written for an immediate entry could be consumed by a due-but-unsignaled delayed entry (whose own timerfd unit hadn't been issued yet); the timer fire then found nothing due and issued nothing, leaving the lane permanently off-by-one - the newest entry always waited for a future post. The unit-consuming path now skips unsignaled delayed entries; nested (unit-free) drains and the ordered lane are unaffected, since ordered entries carry their token from post time. - stale loop registry across isolate-pointer reuse: the registry erase ran in ~Runtime, several JNI calls after Isolate::Dispose freed the address. A concurrently created worker isolate could reuse the pointer, inherit the dead runtime's stopped loop (silently dropping all its work), and then lose its own entry to the late destructor. The erase now happens immediately after Dispose and only while the entry still maps to the disposing runtime's loop; PrepareV8Runtime refreshes a stopped loop found under its key; and the v8 task runner resolves the loop through the registry on every post, so a refresh also redirects runners v8 already holds. Also from review: the inspector-pause drain no longer lets C++ exceptions unwind through v8 inspector frames, and fd callbacks ignore spurious wakeups instead of consuming an entry. Tests: worker reply racing an overdue Atomics.waitAsync timeout (unit accounting), worker churn smoke, and __ns__queueMacrotask posted from a background JS thread landing on the main thread (multithreaded JS). * feat(event-loop): merge timers into the ordered lane; route __runOnMainThread through the internal lane Timers merge (with tombstones): - Timers no longer owns a Java Handler: each scheduled timer posts one anonymous token through the EventLoop's ordered lane, and the token drain runs the earliest due item across timers and ordered macrotasks - one due-ordered domain, still strictly FIFO with Handler.post on the same looper. Token 'when' computation is unchanged, so the quiescent setTimeout-vs-Handler.post contract is preserved exactly. - clearTimeout/clearInterval tombstone the sorted entry instead of erasing it: the cleared timer's already-queued token consumes its own slot as a no-op, so no token gains surplus capacity to run a later-scheduled item (timer or macrotask) ahead of foreign Java messages queued between the two token positions. This also fixes the pre-existing congestion deviation where a leftover token could fire a later timer early. - FireTimer's internals (sub-ms sorted list, chromium-style interval catch-up, nesting clamp, TryCatch discipline) are untouched; the check-and-run happens in one OrderedTaskSource::RunIfEarliest call under a single Locker acquisition, because background threads mutate the timer bookkeeping through setTimeout under multithreaded JS. - TimerHandler.java is deleted. __runOnMainThread promotion: - The 2MB main-looper pipe and RunOnMainThreadFdCallback are replaced by bare internal-lane entries on the main runtime's EventLoop. Bare entries skip the loop's Locker/checkpoint: the closure locks the CALLER's isolate (a worker's, under multithreaded JS), and taking the main isolate's Locker first would nest Lockers across isolates and can deadlock against worker->main JNI entry paths. Delivery stays one-per-poll, matching the old fd callback. - The callback cache is now mutex-guarded: it was written from arbitrary threads under different isolates' Lockers, which provide no mutual exclusion; RemoveIsolateEntries also no longer erases while range-iterating. - Uncaught exceptions in the callbacks now surface as pending Java exceptions via the loop's guard instead of unwinding C++ through the ALooper callback frame. Tests: tombstone ordering specs (cleared timer's token vs java posts, for both a later timer and a queued macrotask), against the native __ns__ timers - the test app's global setTimeout is an old Handler-based polyfill with colliding ids, not the runtime timers. * 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. * fix(event-loop): claim-gate ABI below API 26 and failure-path rollbacks 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.
1 parent 345f16f commit fd7b6c6

27 files changed

Lines changed: 2015 additions & 581 deletions

test-app/app/src/main/assets/app/mainpage.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ shared.runWorkerTests();
2121
shared.runPerformanceTests();
2222
shared.runStructuredCloneTests();
2323
require("./tests/testWebAssembly");
24+
require("./tests/testEventLoop");
2425
require("./tests/testMultithreadedJavascript");
2526
require("./tests/testWorkerTerminateDuringLoad");
2627
require("./tests/testInterfaceDefaultMethods");
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
onmessage = function (msg) {
2+
postMessage(msg.data);
3+
};
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
// V8 delivers these resolutions as platform foreground tasks, so they only
2+
// settle if the runtime pumps its foreground task runner (EventLoopHandler).
3+
describe("event loop foreground tasks", function () {
4+
it("resolves Atomics.waitAsync when notified on the same thread", function (done) {
5+
const sab = new SharedArrayBuffer(4);
6+
const i32 = new Int32Array(sab);
7+
8+
const result = Atomics.waitAsync(i32, 0, 0);
9+
expect(result.async).toBe(true);
10+
11+
result.value.then(value => {
12+
expect(value).toBe("ok");
13+
done();
14+
}).catch(e => {
15+
// jasmine 2.0.1: done has no .fail - record the failure, then complete
16+
expect("resolved").toBe("rejected: " + e);
17+
done();
18+
});
19+
20+
const woken = Atomics.notify(i32, 0);
21+
expect(woken).toBe(1);
22+
});
23+
24+
it("resolves Atomics.waitAsync with 'timed-out' after the timeout", function (done) {
25+
const sab = new SharedArrayBuffer(4);
26+
const i32 = new Int32Array(sab);
27+
28+
const result = Atomics.waitAsync(i32, 0, 0, 50);
29+
expect(result.async).toBe(true);
30+
31+
result.value.then(value => {
32+
expect(value).toBe("timed-out");
33+
done();
34+
}).catch(e => {
35+
// jasmine 2.0.1: done has no .fail - record the failure, then complete
36+
expect("resolved").toBe("rejected: " + e);
37+
done();
38+
});
39+
});
40+
41+
it("resolves Atomics.waitAsync synchronously on value mismatch", function () {
42+
const sab = new SharedArrayBuffer(4);
43+
const i32 = new Int32Array(sab);
44+
i32[0] = 42;
45+
46+
const result = Atomics.waitAsync(i32, 0, 0);
47+
expect(result.async).toBe(false);
48+
expect(result.value).toBe("not-equal");
49+
});
50+
51+
it("keeps ordinary promise chains working alongside foreground tasks", function (done) {
52+
const sab = new SharedArrayBuffer(4);
53+
const i32 = new Int32Array(sab);
54+
const order = [];
55+
56+
Atomics.waitAsync(i32, 0, 0).value.then(() => {
57+
order.push("waitAsync");
58+
return Promise.resolve();
59+
}).then(() => {
60+
order.push("chained");
61+
expect(order).toEqual(["waitAsync", "chained"]);
62+
done();
63+
}).catch(e => {
64+
expect("resolved").toBe("rejected: " + e);
65+
done();
66+
});
67+
68+
Atomics.notify(i32, 0);
69+
});
70+
});
71+
72+
// The ordered lane rides the Java MessageQueue, so these callbacks must be
73+
// strict macrotasks: after the current turn's microtasks, FIFO with timers.
74+
describe("event loop ordered macrotasks", function () {
75+
it("__ns__queueMacrotask runs the callback asynchronously", function (done) {
76+
let ran = false;
77+
__ns__queueMacrotask(() => {
78+
ran = true;
79+
done();
80+
});
81+
expect(ran).toBe(false);
82+
});
83+
84+
it("runs after the current turn's microtasks", function (done) {
85+
const order = [];
86+
__ns__queueMacrotask(() => {
87+
order.push("macrotask");
88+
expect(order).toEqual(["microtask", "macrotask"]);
89+
done();
90+
});
91+
Promise.resolve().then(() => order.push("microtask"));
92+
});
93+
94+
// native timers (__ns__*): the app-level `setTimeout` global in this test
95+
// app is an old Handler-based polyfill, not the runtime timers
96+
it("stays FIFO-ordered with native setTimeout(0)", function (done) {
97+
const order = [];
98+
__ns__queueMacrotask(() => order.push("macro1"));
99+
__ns__setTimeout(() => order.push("timeout"), 0);
100+
__ns__queueMacrotask(() => {
101+
order.push("macro2");
102+
expect(order).toEqual(["macro1", "timeout", "macro2"]);
103+
done();
104+
});
105+
});
106+
107+
it("rejects non-function arguments", function () {
108+
expect(() => __ns__queueMacrotask("nope")).toThrowError(TypeError);
109+
expect(() => __ns__queueMacrotask()).toThrowError(TypeError);
110+
});
111+
112+
it("runs on the main thread when posted from a background JS thread", function (done) {
113+
const mainThreadId = java.lang.Thread.currentThread().getId();
114+
new java.lang.Thread(new java.lang.Runnable({
115+
run() {
116+
expect(java.lang.Thread.currentThread().getId()).not.toEqual(mainThreadId);
117+
__ns__queueMacrotask(() => {
118+
expect(java.lang.Thread.currentThread().getId()).toEqual(mainThreadId);
119+
done();
120+
});
121+
}
122+
})).start();
123+
});
124+
});
125+
126+
// clearTimeout leaves a tombstone in the merged ordered domain, so the
127+
// cleared timer's already-queued token consumes its own slot as a no-op
128+
// instead of running a later-scheduled item ahead of Java messages queued
129+
// between the two tokens' positions.
130+
describe("event loop ordered tombstones", function () {
131+
it("cleared timeout's token does not run a later timer ahead of java posts", function (done) {
132+
const order = [];
133+
const handler = new android.os.Handler(android.os.Looper.myLooper());
134+
const t1 = __ns__setTimeout(() => order.push("cleared"), 0);
135+
__ns__clearTimeout(t1);
136+
handler.post(new java.lang.Runnable({
137+
run: () => order.push("java")
138+
}));
139+
__ns__setTimeout(() => {
140+
order.push("t2");
141+
expect(order).toEqual(["java", "t2"]);
142+
done();
143+
}, 0);
144+
});
145+
146+
it("cleared timeout's token does not run a queued macrotask ahead of java posts", function (done) {
147+
const order = [];
148+
const handler = new android.os.Handler(android.os.Looper.myLooper());
149+
const t1 = __ns__setTimeout(() => order.push("cleared"), 0);
150+
__ns__clearTimeout(t1);
151+
handler.post(new java.lang.Runnable({
152+
run: () => order.push("java")
153+
}));
154+
__ns__queueMacrotask(() => {
155+
order.push("macro");
156+
expect(order).toEqual(["java", "macro"]);
157+
done();
158+
});
159+
});
160+
});
161+
162+
// Long (>=32ms) timers carry an identified token whose clear removes the
163+
// queued wakeup; short timers carry a native claim cell whose clear is a
164+
// single CAS. Both must keep exact clear semantics under any thread.
165+
describe("event loop token cancellation", function () {
166+
it("cleared identified (long) timeout never fires and later timers are unaffected", function (done) {
167+
let fired = false;
168+
const t = __ns__setTimeout(() => { fired = true; }, 100);
169+
__ns__clearTimeout(t);
170+
__ns__setTimeout(() => {
171+
expect(fired).toBe(false);
172+
done();
173+
}, 150);
174+
});
175+
176+
it("background-thread clear racing dispatch neither jumps java posts nor ghost-fires", function (done) {
177+
// only iterations whose clear provably ran count toward the quota, so
178+
// the spec can't pass on 30 runs where the thread never raced at all
179+
let remaining = 30;
180+
let attempts = 0;
181+
(function iter() {
182+
if (++attempts > 300) {
183+
expect("background clears raced " + (30 - remaining) + "/30 times")
184+
.toBe("background clears raced 30/30 times");
185+
done();
186+
return;
187+
}
188+
const order = [];
189+
const cleared = new java.util.concurrent.atomic.AtomicBoolean(false);
190+
const handler = new android.os.Handler(android.os.Looper.myLooper());
191+
const t1 = __ns__setTimeout(() => order.push("t1"), 0);
192+
new java.lang.Thread(new java.lang.Runnable({
193+
run() {
194+
__ns__clearTimeout(t1);
195+
cleared.set(true);
196+
}
197+
})).start();
198+
handler.post(new java.lang.Runnable({
199+
run: () => order.push("java")
200+
}));
201+
__ns__setTimeout(() => {
202+
order.push("t2");
203+
const observed = order.join(">");
204+
// t1 either fired before the clear landed (at its own legal
205+
// slot, ahead of "java") or never; t2 must never jump "java"
206+
expect(observed === "java>t2" || observed === "t1>java>t2").toBe(true);
207+
if (cleared.get() && --remaining === 0) {
208+
done();
209+
} else {
210+
iter();
211+
}
212+
}, 5);
213+
})();
214+
});
215+
216+
it("clearing an identified interval stops it", function (done) {
217+
let ticks = 0;
218+
const iv = __ns__setInterval(() => {
219+
ticks++;
220+
if (ticks === 2) {
221+
__ns__clearInterval(iv);
222+
__ns__setTimeout(() => {
223+
expect(ticks).toBe(2);
224+
done();
225+
}, 120);
226+
}
227+
}, 40);
228+
});
229+
});
230+
231+
describe("event loop internal lane", function () {
232+
// Regression for the eventfd unit-accounting bug: a worker reply's wakeup
233+
// arriving while an overdue waitAsync timeout is still unsignaled must not
234+
// be spent on the timeout entry, or the reply starves.
235+
it("delivers worker messages whose wakeup raced an overdue waitAsync timeout", function (done) {
236+
const worker = new Worker("./eventLoopEchoWorker.js");
237+
let warm = false;
238+
worker.onmessage = function (msg) {
239+
if (msg.data === "warmup") {
240+
warm = true;
241+
const i32 = new Int32Array(new SharedArrayBuffer(4));
242+
Atomics.waitAsync(i32, 0, 0, 50);
243+
worker.postMessage("ping");
244+
// block the looper until both the timeout and the reply are
245+
// pending, so their wakeups are serviced from the same poll
246+
const start = Date.now();
247+
while (Date.now() - start < 150) { }
248+
} else {
249+
expect(warm).toBe(true);
250+
expect(msg.data).toBe("ping");
251+
worker.terminate();
252+
done();
253+
}
254+
};
255+
worker.postMessage("warmup");
256+
});
257+
258+
it("keeps event loops healthy across worker churn", function (done) {
259+
let remaining = 8;
260+
(function cycle() {
261+
const worker = new Worker("./eventLoopEchoWorker.js");
262+
worker.onmessage = function () {
263+
worker.terminate();
264+
if (--remaining === 0) {
265+
__ns__queueMacrotask(done);
266+
} else {
267+
cycle();
268+
}
269+
};
270+
worker.postMessage("alive");
271+
})();
272+
});
273+
});

test-app/runtime/CMakeLists.txt

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,6 @@ set(RUNTIME_BUILTIN_JS
6969
${RUNTIME_BUILTIN_JS_DIR}/events.js
7070
${RUNTIME_BUILTIN_JS_DIR}/inspect.js
7171
${RUNTIME_BUILTIN_JS_DIR}/json-helper.js
72-
${RUNTIME_BUILTIN_JS_DIR}/message-loop-timer.js
7372
${RUNTIME_BUILTIN_JS_DIR}/node-util.js
7473
${RUNTIME_BUILTIN_JS_DIR}/ns-util.js
7574
${RUNTIME_BUILTIN_JS_DIR}/performance.js
@@ -152,8 +151,9 @@ add_library(
152151
src/main/cpp/Constants.cpp
153152
src/main/cpp/DirectBuffer.cpp
154153
src/main/cpp/ErrorEvents.cpp
155-
src/main/cpp/FrameCallbacks.cpp
154+
src/main/cpp/EventLoop.cpp
156155
src/main/cpp/Events.cpp
156+
src/main/cpp/FrameCallbacks.cpp
157157
src/main/cpp/FieldAccessor.cpp
158158
src/main/cpp/File.cpp
159159
src/main/cpp/Interop.cpp
@@ -166,9 +166,7 @@ add_library(
166166
src/main/cpp/JsArgToArrayConverter.cpp
167167
src/main/cpp/JSONObjectHelper.cpp
168168
src/main/cpp/Logger.cpp
169-
src/main/cpp/LooperTasks.cpp
170169
src/main/cpp/ManualInstrumentation.cpp
171-
src/main/cpp/MessageLoopTimer.cpp
172170
src/main/cpp/MetadataMethodInfo.cpp
173171
src/main/cpp/MetadataNode.cpp
174172
src/main/cpp/MetadataReader.cpp
@@ -179,6 +177,7 @@ add_library(
179177
src/main/cpp/ModuleInternal.cpp
180178
src/main/cpp/ModuleInternalCallbacks.cpp
181179
src/main/cpp/NativeScriptException.cpp
180+
src/main/cpp/NativeScriptPlatform.cpp
182181
src/main/cpp/NsBuiltinModules.cpp
183182
src/main/cpp/NumericCasts.cpp
184183
src/main/cpp/ObjectManager.cpp

0 commit comments

Comments
 (0)