Skip to content

Commit 67dd91e

Browse files
committed
feat: put frame callbacks on the performance timeline
__postFrameCallback now hands its callback two arguments, (frameTimeNanos, performanceMillis). The first is unchanged -- the platform's raw CLOCK_MONOTONIC frame time, which shipped app code divides by 1e6 -- and the second is that same instant on the isolate's performance timeline, so it compares directly with performance.now(). Choreographer stamps frames on the clock the time origin is captured on, so the conversion (Performance::MonotonicNanosToTimelineMillis, subtracting the new Runtime::TimeOriginMonotonicMillis) is exact rather than a resampling. The machinery moves out of CallbackHandlers into FrameCallbacks.{h,cpp}, which now covers the whole minSdk range: AChoreographer only exists from API 24, so below it __postFrameCallback silently never fired. API 21-23 now goes through android.view.Choreographer via com.tns.FrameCallbacks, holding the same entry and producing the same two arguments. Entries are stored behind unique_ptr so both implementations can hand the platform a stable pointer, isolate teardown no longer erases while iterating, and the frame time from the pre-API-29 AChoreographer entry point is widened to 64 bits, which it is not on the 32-bit ABIs. Debug runtimes expose __setFrameCallbackImpl so the Java bridge is selectable on a modern device; both implementations are covered by specs.
1 parent 0dfc9b7 commit 67dd91e

12 files changed

Lines changed: 717 additions & 304 deletions

File tree

docs/performance.md

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,39 @@ file runs unchanged on both runtimes and should be kept in sync with iOS's
4747
copy.
4848

4949
The native clock is owned by `Runtime` (`Runtime::PerformanceNowMillis()`,
50-
`Runtime::TimeOriginMillis()`, captured in `Runtime::PrepareV8Runtime`) and
51-
exposed to native callers through `tns::Performance::NowMillis(isolate)`
52-
(`test-app/runtime/src/main/cpp/Performance.h`). Any future native producer of
53-
JS-visible timestamps — `requestAnimationFrame` in particular — must read the
54-
clock through that hook rather than sampling its own, so every timestamp
55-
shares `performance.timeOrigin` as its base.
50+
`Runtime::TimeOriginMillis()`, `Runtime::TimeOriginMonotonicMillis()`,
51+
captured in `Runtime::PrepareV8Runtime`) and exposed to native callers through
52+
`tns::Performance::NowMillis(isolate)`
53+
(`test-app/runtime/src/main/cpp/Performance.h`). Any native producer of
54+
JS-visible timestamps must read the clock through that hook rather than
55+
sampling its own, so every timestamp shares `performance.timeOrigin` as its
56+
base.
57+
58+
## Frame callbacks
59+
60+
`__postFrameCallback(fn[, delayMillis])` / `__removeFrameCallback(fn)`
61+
(`test-app/runtime/src/main/cpp/FrameCallbacks.h`) schedule `fn` for the next
62+
display frame. `fn` receives **two** arguments:
63+
64+
```js
65+
__postFrameCallback((frameTimeNanos, performanceMillis) => { … });
66+
```
67+
68+
- `frameTimeNanos` — the platform's raw frame time: `CLOCK_MONOTONIC`
69+
nanoseconds, the `System.nanoTime()` base. Unchanged from earlier runtimes,
70+
which passed it as the only argument.
71+
- `performanceMillis` — the same instant on this isolate's performance
72+
timeline, so it compares directly with `performance.now()`. Converted
73+
natively through `Performance::MonotonicNanosToTimelineMillis()`, which
74+
subtracts `Runtime::TimeOriginMonotonicMillis()` — Choreographer stamps
75+
frames on the very clock the time origin is captured on, so the mapping is
76+
exact rather than an approximation resampled in JS.
77+
78+
Two implementations sit behind that one surface: the NDK's `AChoreographer`
79+
(API 24+, resolved with `dlsym`) and `android.view.Choreographer` through
80+
`com.tns.FrameCallbacks` for API 21–23, where the NDK API does not exist.
81+
Scheduling is per calling thread, so a worker schedules against its own looper.
82+
Both paths produce the same two arguments with the same exactness.
5683

5784
## Deviations from the specs
5885

test-app/app/src/main/assets/app/tests/testPostFrameCallback.js

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,3 +140,90 @@ describe("test PostFrameCallback", function () {
140140
}, defaultWaitTime);
141141
});
142142
});
143+
144+
// The two implementations behind __postFrameCallback (NDK AChoreographer,
145+
// android.view.Choreographer for API < 24) must be indistinguishable from JS.
146+
// A modern device always selects the NDK one, so the Java bridge is only
147+
// reachable through __setFrameCallbackImpl, which debug runtimes expose for
148+
// exactly this.
149+
describe("frame callback timestamps", function () {
150+
const defaultWaitTime = 300;
151+
const impls = ["native", "java"];
152+
153+
afterEach(() => {
154+
if (typeof global.__setFrameCallbackImpl === "function") {
155+
global.__setFrameCallbackImpl("auto");
156+
}
157+
});
158+
159+
function withImpl(impl) {
160+
if (typeof global.__setFrameCallbackImpl !== "function") {
161+
return impl === "native";
162+
}
163+
return global.__setFrameCallbackImpl(impl) === impl;
164+
}
165+
166+
impls.forEach((impl) => {
167+
describe(impl + " implementation", function () {
168+
it("passes the raw frame time and a performance-timeline timestamp", (done) => {
169+
if (!withImpl(impl)) {
170+
pending("this runtime cannot select the " + impl + " implementation");
171+
return;
172+
}
173+
174+
global.__postFrameCallback((frameTimeNanos, performanceMillis) => {
175+
const now = performance.now();
176+
177+
expect(typeof frameTimeNanos).toBe("number");
178+
expect(typeof performanceMillis).toBe("number");
179+
180+
// Uptime-scale nanoseconds, not epoch-scale milliseconds.
181+
expect(frameTimeNanos).toBeGreaterThan(1e9);
182+
expect(frameTimeNanos / 1e6).toBeLessThan(Date.now());
183+
184+
// The frame is stamped just before the callback runs, so its
185+
// timeline position sits a frame or two behind the reading taken
186+
// inside it, never ahead of it.
187+
expect(performanceMillis).toBeGreaterThan(0);
188+
expect(performanceMillis).not.toBeGreaterThan(now);
189+
expect(now - performanceMillis).toBeLessThan(250);
190+
191+
// Both arguments describe the same instant, so their difference is
192+
// the timeline's monotonic origin. System.nanoTime() is on that same
193+
// clock, so the pair (nanoTime, now) must yield the same origin --
194+
// this is what would break if either argument moved off the base.
195+
const originFromFrame = frameTimeNanos / 1e6 - performanceMillis;
196+
const originFromClock = java.lang.System.nanoTime() / 1e6 - performance.now();
197+
expect(Math.abs(originFromFrame - originFromClock)).toBeLessThan(5);
198+
done();
199+
});
200+
});
201+
202+
it("advances across consecutive frames", (done) => {
203+
if (!withImpl(impl)) {
204+
pending("this runtime cannot select the " + impl + " implementation");
205+
return;
206+
}
207+
208+
const frames = [];
209+
const callback = (frameTimeNanos, performanceMillis) => {
210+
frames.push({ nanos: frameTimeNanos, millis: performanceMillis });
211+
if (frames.length === 1) {
212+
global.__postFrameCallback(callback);
213+
}
214+
};
215+
global.__postFrameCallback(callback);
216+
217+
setTimeout(() => {
218+
expect(frames.length).toBe(2);
219+
expect(frames[1].nanos).not.toBeLessThan(frames[0].nanos);
220+
expect(frames[1].millis).not.toBeLessThan(frames[0].millis);
221+
// The origin the two arguments imply is a constant of the isolate.
222+
const origin = (f) => f.nanos / 1e6 - f.millis;
223+
expect(Math.abs(origin(frames[1]) - origin(frames[0]))).toBeLessThan(1);
224+
done();
225+
}, defaultWaitTime);
226+
});
227+
});
228+
});
229+
});

test-app/runtime/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ add_library(
152152
src/main/cpp/Constants.cpp
153153
src/main/cpp/DirectBuffer.cpp
154154
src/main/cpp/ErrorEvents.cpp
155+
src/main/cpp/FrameCallbacks.cpp
155156
src/main/cpp/Events.cpp
156157
src/main/cpp/FieldAccessor.cpp
157158
src/main/cpp/File.cpp

test-app/runtime/src/main/cpp/CallbackHandlers.cpp

Lines changed: 0 additions & 162 deletions
Original file line numberDiff line numberDiff line change
@@ -1573,173 +1573,11 @@ void CallbackHandlers::RemoveIsolateEntries(v8::Isolate *isolate) {
15731573
cache_.erase(item.first);
15741574
}
15751575
}
1576-
1577-
for (auto &item: frameCallbackCache_) {
1578-
if (item.second.isolate_ == isolate) {
1579-
frameCallbackCache_.erase(item.first);
1580-
}
1581-
}
1582-
1583-
}
1584-
CallbackHandlers::func_AChoreographer_getInstance AChoreographer_getInstance_;
1585-
1586-
CallbackHandlers::func_AChoreographer_postFrameCallback AChoreographer_postFrameCallback_;
1587-
CallbackHandlers::func_AChoreographer_postFrameCallbackDelayed AChoreographer_postFrameCallbackDelayed_;
1588-
1589-
CallbackHandlers::func_AChoreographer_postFrameCallback64 AChoreographer_postFrameCallback64_;
1590-
CallbackHandlers::func_AChoreographer_postFrameCallbackDelayed64 AChoreographer_postFrameCallbackDelayed64_;
1591-
1592-
void CallbackHandlers::PostCallback(const FunctionCallbackInfo<v8::Value> &args, CallbackHandlers::FrameCallbackCacheEntry* entry, v8::Local<v8::Context> context){
1593-
ALooper_prepare(0);
1594-
auto instance = AChoreographer_getInstance_();
1595-
auto delay = args[1];
1596-
if(android_get_device_api_level() >= 29){
1597-
if(!delay.IsEmpty() && delay->IsNumber()){
1598-
AChoreographer_postFrameCallbackDelayed64_(instance, entry->frameCallback64_, entry, delay->Uint32Value(context).FromMaybe(0));
1599-
}else {
1600-
AChoreographer_postFrameCallback64_(instance, entry->frameCallback64_, entry);
1601-
}
1602-
}else {
1603-
if(!delay.IsEmpty() && delay->IsNumber()){
1604-
AChoreographer_postFrameCallbackDelayed_(instance, entry->frameCallback_, entry, (long)delay->IntegerValue(context).FromMaybe(0));
1605-
}else {
1606-
AChoreographer_postFrameCallback_(instance, entry->frameCallback_, entry);
1607-
}
1608-
}
1609-
}
1610-
1611-
1612-
void CallbackHandlers::PostFrameCallback(const FunctionCallbackInfo<v8::Value> &args) {
1613-
if (android_get_device_api_level() >= 24) {
1614-
InitChoreographer();
1615-
Isolate *isolate = args.GetIsolate();
1616-
1617-
v8::Locker locker(isolate);
1618-
Isolate::Scope isolate_scope(isolate);
1619-
HandleScope handle_scope(isolate);
1620-
auto context = isolate->GetCurrentContext();
1621-
Context::Scope context_scope(context);
1622-
1623-
if (args.Length() < 1 || !args[0]->IsFunction()) {
1624-
isolate->ThrowException(v8::Exception::TypeError(v8::String::NewFromUtf8Literal(isolate, "Frame callback argument is not a function")));
1625-
return;
1626-
}
1627-
1628-
auto func = args[0].As<Function>();
1629-
1630-
auto idKey = ArgConverter::ConvertToV8String(isolate, "_postFrameCallbackId");
1631-
1632-
Local<Value> pId;
1633-
bool success = V8GetPrivateValue(isolate, func, idKey, pId);
1634-
1635-
if (success && pId->IsNumber()){
1636-
auto id = pId->IntegerValue(context).FromMaybe(0);
1637-
auto cb = frameCallbackCache_.find(id);
1638-
if (cb != frameCallbackCache_.end()) {
1639-
// check if it's already scheduled first, we don't want to schedule it twice
1640-
bool shouldReschedule = !cb->second.isScheduled();
1641-
// always mark as scheduled, as that will also mark it as not removed anymore
1642-
cb->second.markScheduled();
1643-
if (shouldReschedule) {
1644-
PostCallback(args, &cb->second, context);
1645-
}
1646-
return;
1647-
}
1648-
}
1649-
1650-
Local<v8::Function> callback = func;
1651-
uint64_t key = ++frameCallbackCount_;
1652-
1653-
V8SetPrivateValue(isolate, func, idKey, v8::Number::New(isolate, (double) key));
1654-
1655-
robin_hood::unordered_map<uint64_t, FrameCallbackCacheEntry>::iterator val;
1656-
bool inserted;
1657-
std::tie(val, inserted) = frameCallbackCache_.try_emplace(key, isolate, callback, key);
1658-
assert(inserted && "Frame callback ID should not be duplicated");
1659-
1660-
val->second.markScheduled();
1661-
PostCallback(args, &val->second, context);
1662-
}
1663-
}
1664-
1665-
void CallbackHandlers::RemoveFrameCallback(const FunctionCallbackInfo<v8::Value> &args) {
1666-
1667-
if (android_get_device_api_level() >= 24) {
1668-
InitChoreographer();
1669-
Isolate *isolate = args.GetIsolate();
1670-
v8::Locker locker(isolate);
1671-
Isolate::Scope isolate_scope(isolate);
1672-
HandleScope handle_scope(isolate);
1673-
auto context = isolate->GetCurrentContext();
1674-
Context::Scope context_scope(context);
1675-
1676-
if (args.Length() < 1 || !args[0]->IsFunction()) {
1677-
isolate->ThrowException(v8::Exception::TypeError(v8::String::NewFromUtf8Literal(isolate, "Frame callback argument is not a function")));
1678-
return;
1679-
}
1680-
auto func = args[0].As<Function>();
1681-
1682-
auto idKey = ArgConverter::ConvertToV8String(isolate, "_postFrameCallbackId");
1683-
1684-
Local<Value> pId;
1685-
bool success = V8GetPrivateValue(isolate, func, idKey, pId);
1686-
1687-
if (success && pId->IsNumber()){
1688-
auto id = pId->IntegerValue(context).FromMaybe(0);
1689-
auto cb = frameCallbackCache_.find(id);
1690-
if (cb != frameCallbackCache_.end()) {
1691-
cb->second.markRemoved();
1692-
}
1693-
}
1694-
1695-
}
1696-
16971576
}
1698-
1699-
void CallbackHandlers::InitChoreographer() {
1700-
if(AChoreographer_getInstance_ == nullptr){
1701-
void* lib = dlopen("libandroid.so", RTLD_NOW | RTLD_LOCAL);
1702-
if (lib != nullptr) {
1703-
1704-
// Retrieve function pointers from shared object.
1705-
AChoreographer_getInstance_ =
1706-
reinterpret_cast<func_AChoreographer_getInstance>(
1707-
dlsym(lib, "AChoreographer_getInstance"));
1708-
AChoreographer_postFrameCallback_ =
1709-
reinterpret_cast<func_AChoreographer_postFrameCallback>(
1710-
dlsym(lib, "AChoreographer_postFrameCallback"));
1711-
1712-
AChoreographer_postFrameCallbackDelayed_ =
1713-
reinterpret_cast<func_AChoreographer_postFrameCallbackDelayed>(
1714-
dlsym(lib, "AChoreographer_postFrameCallbackDelayed"));
1715-
1716-
assert(AChoreographer_getInstance_);
1717-
assert(AChoreographer_postFrameCallback_);
1718-
assert(AChoreographer_postFrameCallbackDelayed_);
1719-
1720-
if(android_get_device_api_level() >= 29){
1721-
AChoreographer_postFrameCallback64_ =
1722-
reinterpret_cast<func_AChoreographer_postFrameCallback64>(
1723-
dlsym(lib, "AChoreographer_postFrameCallback64"));
1724-
1725-
AChoreographer_postFrameCallbackDelayed64_ =
1726-
reinterpret_cast<func_AChoreographer_postFrameCallbackDelayed64>(
1727-
dlsym(lib, "AChoreographer_postFrameCallbackDelayed64"));
1728-
1729-
assert(AChoreographer_postFrameCallback64_);
1730-
assert(AChoreographer_postFrameCallbackDelayed64_);
1731-
}
1732-
}
1733-
}
1734-
}
1735-
1736-
17371577
robin_hood::unordered_map<uint64_t, CallbackHandlers::CacheEntry> CallbackHandlers::cache_;
17381578

1739-
robin_hood::unordered_map<uint64_t, CallbackHandlers::FrameCallbackCacheEntry> CallbackHandlers::frameCallbackCache_;
17401579

17411580
std::atomic_int64_t CallbackHandlers::count_ = {0};
1742-
std::atomic_uint64_t CallbackHandlers::frameCallbackCount_ = {0};
17431581

17441582

17451583
short CallbackHandlers::MAX_JAVA_STRING_ARRAY_LENGTH = 100;

0 commit comments

Comments
 (0)