Skip to content

Commit 345f16f

Browse files
authored
feat: WHATWG performance API (hr-time, user timing, performance timeline) (#2001)
* feat: WHATWG performance API (hr-time, user timing, performance timeline) Replaces the bare native {now, timeOrigin} object with a spec-shaped implementation of High Resolution Time, User Timing Level 3 and the Performance Timeline with PerformanceObserver. performance, Performance, PerformanceEntry, PerformanceMark, PerformanceMeasure, PerformanceObserver and PerformanceObserverEntryList are globals in the main and worker isolates alike, with Performance extends EventTarget and WebIDL-shaped descriptors and brands. All spec logic lives in the internal/performance.js builtin, which the native side feeds exactly two values -- binding.now() and binding.timeOrigin -- so the file is shared verbatim with the iOS runtime. tns::Performance::NowMillis(isolate) is the single native clock hook a future requestAnimationFrame must read, so every JS-visible timestamp shares performance.timeOrigin as its base. Time origins stay per-Runtime, captured in PrepareV8Runtime, so each worker keeps its own. mark/measure detail is structured-cloned at entry creation through the structuredClone global installed by StructuredClone::Init, so entries hold snapshots and an uncloneable detail throws the DataCloneError-named error; the builtin keeps an identity fallback for a runtime that ships the Performance API before structuredClone. Mirrors NativeScript/ios#430 and 6dd55238d. * 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. * fix: guard the frame-callback registry and measure()'s null options The registry holds entries for every isolate in the process, so its lookups now take a mutex -- never held across the JS call, which a self-rescheduling callback re-enters -- and the two lazy-init blocks (the AChoreographer dlsym, the FrameCallbacks method ids) go through call_once. Entries are identified to the platform by id rather than by address, so a frame arriving after its entry was retired resolves to nothing instead of to freed memory, and teardown detaches entries under the mutex and destroys them after releasing it, since the destructor calls into Java. The registry was shared and unguarded before it moved out of CallbackHandlers; this is not a regression from the move. Dispatch no longer throws. On the NDK path it runs inside a C callback in libandroid, which a C++ exception may not unwind through, so a JS exception the runtime still owns goes to Java the way Timers::FireTimer does. com.tns.FrameCallbacks.released becomes volatile: it is set from runtime teardown, which is not necessarily the frame thread. measure() treats a null startOrMeasureOptions as absent. WebIDL converts null for a (DOMString or PerformanceMeasureOptions) union to an empty dictionary, so it means "no options", not the mark name "null". A null endMark keeps throwing: that parameter is a plain DOMString, neither a union nor nullable, so null stringifies per WebIDL. * test: isolate the null end mark spec and bump the shared suite The spec measured from "the-start", which it never created, so the SyntaxError it asserts could have come from that missing mark rather than from the null end mark it is about. The shared suite moves to 0baab7c, which measures the timeOrigin anchor as a min-of-N offset against a loose bound: reconstructing Date.now() from timeOrigin + now() races three clock reads, and a stall between them read as an anchoring error on a contended host.
1 parent 73df473 commit 345f16f

21 files changed

Lines changed: 1686 additions & 355 deletions

docs/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Runtime documentation
22

3+
- [Performance API](performance.md) — WHATWG `performance` (hr-time, user
4+
timing, performance timeline with `PerformanceObserver`), per-isolate time
5+
origins for workers, the native clock hook that future `requestAnimationFrame`
6+
work must share, and the documented spec deviations.
37
- [Error handling](error-handling.md) — global `error`/`unhandledrejection` events, `reportError`, catching Java exceptions in JS (`error.nativeException`), forwarding JS throws to Java callers (`interop.escapeException`), JS stacks on Java exceptions (`com.tns.JavaScriptStackTrace`), configuration flags, and crash-reporter integration.
48
- [structuredClone](structured-clone.md) — the WHATWG `structuredClone(value, { transfer })` global: what clones, how graph identity and cycles are preserved, `ArrayBuffer` transfer, and the `DataCloneError`-named `Error` that stands in for `DOMException`.
59
- [Implementing additional Chrome DevTools protocol Domains](extending-inspector.md)

docs/performance.md

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# Performance API
2+
3+
The runtime implements the WHATWG/WinterTC-standard Performance surface: [High
4+
Resolution Time](https://w3c.github.io/hr-time/), [User Timing Level
5+
3](https://w3c.github.io/user-timing/) and the [Performance
6+
Timeline](https://w3c.github.io/performance-timeline/) with
7+
`PerformanceObserver`.
8+
9+
## Surface
10+
11+
Globals (own, writable, enumerable, configurable properties of `globalThis`,
12+
in main and worker isolates alike): `performance`, `Performance`,
13+
`PerformanceEntry`, `PerformanceMark`, `PerformanceMeasure`,
14+
`PerformanceObserver`, `PerformanceObserverEntryList`.
15+
16+
- `performance.now()` — double milliseconds since the isolate's time origin,
17+
monotonic (V8's platform clock, `CLOCK_MONOTONIC`-based: it does not tick
18+
while the device is suspended), full double precision with no coarsening.
19+
- `performance.timeOrigin` — readonly accessor; wall-clock milliseconds since
20+
the Unix epoch, sampled once when the isolate's runtime is created. Each
21+
worker gets its own time origin at worker-thread start, so
22+
`timeOrigin + now()` approximates `Date.now()` per isolate while the device
23+
stays awake; it drifts behind after device suspend (the monotonic clock does
24+
not tick then) and diverges under wall-clock adjustments.
25+
- `performance.toJSON()`, `Symbol.toStringTag`, and `Performance extends
26+
EventTarget` per spec; `performance`, `PerformanceEntry`,
27+
`PerformanceMeasure` and `PerformanceObserverEntryList` are not
28+
user-constructible (`new` throws `TypeError`); `new PerformanceMark(name,
29+
options)` is constructible per spec but does not buffer the entry.
30+
- User timing: `mark(name, {startTime, detail})`, `measure(name,
31+
startOrOptions, endMark)` with the full Level 3 options algebra (`{start,
32+
end, duration, detail}`, mark names or timestamps, over-/under-constraint
33+
errors), `clearMarks(name?)`, `clearMeasures(name?)`.
34+
- Timeline: `getEntries()`, `getEntriesByType(type)`, `getEntriesByName(name,
35+
type?)` return copies sorted chronologically by `startTime` (stable for
36+
ties).
37+
- Observers: `new PerformanceObserver(cb)`, `observe({entryTypes})` or
38+
`observe({type, buffered})`, `disconnect()`, `takeRecords()`, static frozen
39+
`PerformanceObserver.supportedEntryTypes`, which is `["mark", "measure"]`.
40+
41+
## Architecture
42+
43+
All spec logic lives in the `internal/performance.js` builtin
44+
(`test-app/runtime/src/main/cpp/js/performance.js`), shared with the iOS
45+
runtime: the native side hands it only `{ now(), timeOrigin }`, so the same
46+
file runs unchanged on both runtimes and should be kept in sync with iOS's
47+
copy.
48+
49+
The native clock is owned by `Runtime` (`Runtime::PerformanceNowMillis()`,
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.
83+
84+
## Deviations from the specs
85+
86+
- **Buffers are unbounded.** Per spec for user timing. `detail` is
87+
structured-cloned at entry creation (per spec — an uncloneable `detail`
88+
throws the `DataCloneError`-named error, see
89+
[structuredClone](structured-clone.md)), so entries hold snapshots, but a
90+
long-lived app marking in a loop should still clear entries periodically.
91+
- **Observer callbacks run from a microtask**, not a queued task: delivery is
92+
asynchronous relative to `mark()`/`measure()` but precedes timer callbacks
93+
scheduled in the same turn. Callback exceptions are routed to
94+
`reportError`, so one throwing observer does not starve the others.
95+
- **No `DOMException`.** Errors the specs express as `DOMException` — the
96+
`SyntaxError` for a missing mark name, the `InvalidModificationError` for
97+
switching an observer between the `entryTypes` and `type` forms — are
98+
`Error` instances with `name` patched. `err.name` checks work;
99+
`instanceof DOMException` does not.
100+
- Browser-only surface is absent: no resource/navigation timing, no
101+
`eventCounts`, and no `PerformanceTiming`-attribute resolution in
102+
`measure()`.

eslint.config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const capturedStatics = [
1515
['Array', 'isArray', 'ArrayIsArray'],
1616
['ArrayBuffer', 'isView', 'ArrayBufferIsView'],
1717
['JSON', 'stringify', 'JSONStringify'],
18+
['Number', 'isFinite', 'NumberIsFinite'],
1819
['Number', 'isNaN', 'NumberIsNaN'],
1920
['Number', 'parseFloat', 'NumberParseFloat'],
2021
['Number', 'parseInt', 'NumberParseInt'],

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ shared.runRequireTests();
1818
shared.runWeakRefTests();
1919
shared.runRuntimeTests();
2020
shared.runWorkerTests();
21+
shared.runPerformanceTests();
2122
shared.runStructuredCloneTests();
2223
require("./tests/testWebAssembly");
2324
require("./tests/testMultithreadedJavascript");
@@ -71,10 +72,10 @@ require("./tests/testPackagePrivate");
7172
require("./tests/kotlin/properties/testPropertiesSupport.js");
7273
require('./tests/testNativeTimers');
7374
require("./tests/testPostFrameCallback");
75+
require("./tests/testPerformance");
7476
require("./tests/console/logTests.js");
7577
require('./tests/testURLImpl.js');
7678
require('./tests/testURLSearchParamsImpl.js');
77-
require('./tests/testPerformanceNow');
7879
require('./tests/testQueueMicrotask');
7980
require('./tests/testErrorEvents');
8081
require('./tests/testUnhandledRejections');
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Performance specs local to this runtime; the cross-runtime coverage lives in
2+
// the shared suite (app/shared/Performance).
3+
describe("Performance measure argument coercion", function () {
4+
beforeEach(function () {
5+
performance.clearMarks();
6+
performance.clearMeasures();
7+
});
8+
9+
// The startOrMeasureOptions parameter is a (DOMString or
10+
// PerformanceMeasureOptions) union, and WebIDL converts null for such a
11+
// union to an empty dictionary -- so null means "no options", never the mark
12+
// name "null".
13+
it("Should treat a null options argument as no argument at all", function () {
14+
performance.mark("null-start");
15+
16+
const fromNull = performance.measure("from-null", null);
17+
const fromOmitted = performance.measure("from-omitted");
18+
19+
expect(fromNull.startTime).toBe(0);
20+
expect(fromNull.startTime).toBe(fromOmitted.startTime);
21+
expect(fromNull.duration).toBeGreaterThan(0);
22+
expect(fromNull.detail).toBeNull();
23+
});
24+
25+
it("Should pair a null options argument with an end mark", function () {
26+
performance.mark("the-end");
27+
const endTime = performance.getEntriesByName("the-end")[0].startTime;
28+
29+
const measure = performance.measure("null-and-end", null, "the-end");
30+
31+
expect(measure.startTime).toBe(0);
32+
expect(measure.duration).toBe(endTime);
33+
});
34+
35+
// endMark is a plain optional DOMString, not a union and not nullable, so
36+
// null stringifies and names a mark that does not exist.
37+
it("Should reject a null end mark", function () {
38+
// Present so the only mark this can fail to resolve is the null one.
39+
performance.mark("the-start");
40+
41+
let thrown = null;
42+
try {
43+
performance.measure("null-end", "the-start", null);
44+
} catch (e) {
45+
thrown = e;
46+
}
47+
expect(thrown).not.toBeNull();
48+
expect(thrown && thrown.name).toBe("SyntaxError");
49+
});
50+
});

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

Lines changed: 0 additions & 21 deletions
This file was deleted.

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

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,3 +140,98 @@ 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+
// The two clocks cannot be read at once, so nanoTime is bracketed
196+
// and compared against the midpoint: the tolerance then only has to
197+
// cover the sampling window, not whatever pause lands between them.
198+
const beforeNow = performance.now();
199+
const sampledNanos = java.lang.System.nanoTime();
200+
const afterNow = performance.now();
201+
const originFromFrame = frameTimeNanos / 1e6 - performanceMillis;
202+
const originFromClock = sampledNanos / 1e6 - (beforeNow + afterNow) / 2;
203+
expect(Math.abs(originFromFrame - originFromClock)).toBeLessThan(
204+
5 + (afterNow - beforeNow)
205+
);
206+
done();
207+
});
208+
});
209+
210+
it("advances across consecutive frames", (done) => {
211+
if (!withImpl(impl)) {
212+
pending("this runtime cannot select the " + impl + " implementation");
213+
return;
214+
}
215+
216+
const frames = [];
217+
const callback = (frameTimeNanos, performanceMillis) => {
218+
frames.push({ nanos: frameTimeNanos, millis: performanceMillis });
219+
if (frames.length === 1) {
220+
global.__postFrameCallback(callback);
221+
}
222+
};
223+
global.__postFrameCallback(callback);
224+
225+
setTimeout(() => {
226+
expect(frames.length).toBe(2);
227+
expect(frames[1].nanos).not.toBeLessThan(frames[0].nanos);
228+
expect(frames[1].millis).not.toBeLessThan(frames[0].millis);
229+
// The origin the two arguments imply is a constant of the isolate.
230+
const origin = (f) => f.nanos / 1e6 - f.millis;
231+
expect(Math.abs(origin(frames[1]) - origin(frames[0]))).toBeLessThan(1);
232+
done();
233+
}, defaultWaitTime);
234+
});
235+
});
236+
});
237+
});

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,16 @@ describe("Runtime exposes", function () {
3232
});
3333
});
3434

35+
// The shared Performance suite (submodule) gates itself on the API being
36+
// present and skips otherwise; this unguarded canary makes absence on THIS
37+
// runtime a failure rather than a silent skip.
38+
describe("Performance API canary", function () {
39+
it("implements the Performance API", function () {
40+
expect(typeof performance.mark).toBe("function");
41+
expect(typeof PerformanceObserver).toBe("function");
42+
});
43+
});
44+
3545
// The shared StructuredClone suite skips itself where the API is missing, which
3646
// would turn this runtime losing structuredClone into a green run. This spec is
3747
// deliberately unguarded so that regression fails instead.

test-app/runtime/CMakeLists.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ set(RUNTIME_BUILTIN_JS
7272
${RUNTIME_BUILTIN_JS_DIR}/message-loop-timer.js
7373
${RUNTIME_BUILTIN_JS_DIR}/node-util.js
7474
${RUNTIME_BUILTIN_JS_DIR}/ns-util.js
75+
${RUNTIME_BUILTIN_JS_DIR}/performance.js
7576
${RUNTIME_BUILTIN_JS_DIR}/primordials.js
7677
${RUNTIME_BUILTIN_JS_DIR}/require-factory.js
7778
${RUNTIME_BUILTIN_JS_DIR}/structured-clone.js
@@ -151,6 +152,7 @@ add_library(
151152
src/main/cpp/Constants.cpp
152153
src/main/cpp/DirectBuffer.cpp
153154
src/main/cpp/ErrorEvents.cpp
155+
src/main/cpp/FrameCallbacks.cpp
154156
src/main/cpp/Events.cpp
155157
src/main/cpp/FieldAccessor.cpp
156158
src/main/cpp/File.cpp
@@ -180,6 +182,7 @@ add_library(
180182
src/main/cpp/NsBuiltinModules.cpp
181183
src/main/cpp/NumericCasts.cpp
182184
src/main/cpp/ObjectManager.cpp
185+
src/main/cpp/Performance.cpp
183186
src/main/cpp/Profiler.cpp
184187
src/main/cpp/ReadWriteLock.cpp
185188
src/main/cpp/Runtime.cpp

0 commit comments

Comments
 (0)