diff --git a/docs/README.md b/docs/README.md index 2e8bc411a..7c5d73d8c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,5 +1,9 @@ # Runtime documentation +- [Performance API](performance.md) — WHATWG `performance` (hr-time, user + timing, performance timeline with `PerformanceObserver`), per-isolate time + origins for workers, the native clock hook that future `requestAnimationFrame` + work must share, and the documented spec deviations. - [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. - [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`. - [Implementing additional Chrome DevTools protocol Domains](extending-inspector.md) diff --git a/docs/performance.md b/docs/performance.md new file mode 100644 index 000000000..09da26515 --- /dev/null +++ b/docs/performance.md @@ -0,0 +1,102 @@ +# Performance API + +The runtime implements the WHATWG/WinterTC-standard Performance surface: [High +Resolution Time](https://w3c.github.io/hr-time/), [User Timing Level +3](https://w3c.github.io/user-timing/) and the [Performance +Timeline](https://w3c.github.io/performance-timeline/) with +`PerformanceObserver`. + +## Surface + +Globals (own, writable, enumerable, configurable properties of `globalThis`, +in main and worker isolates alike): `performance`, `Performance`, +`PerformanceEntry`, `PerformanceMark`, `PerformanceMeasure`, +`PerformanceObserver`, `PerformanceObserverEntryList`. + +- `performance.now()` — double milliseconds since the isolate's time origin, + monotonic (V8's platform clock, `CLOCK_MONOTONIC`-based: it does not tick + while the device is suspended), full double precision with no coarsening. +- `performance.timeOrigin` — readonly accessor; wall-clock milliseconds since + the Unix epoch, sampled once when the isolate's runtime is created. Each + worker gets its own time origin at worker-thread start, so + `timeOrigin + now()` approximates `Date.now()` per isolate while the device + stays awake; it drifts behind after device suspend (the monotonic clock does + not tick then) and diverges under wall-clock adjustments. +- `performance.toJSON()`, `Symbol.toStringTag`, and `Performance extends + EventTarget` per spec; `performance`, `PerformanceEntry`, + `PerformanceMeasure` and `PerformanceObserverEntryList` are not + user-constructible (`new` throws `TypeError`); `new PerformanceMark(name, + options)` is constructible per spec but does not buffer the entry. +- User timing: `mark(name, {startTime, detail})`, `measure(name, + startOrOptions, endMark)` with the full Level 3 options algebra (`{start, + end, duration, detail}`, mark names or timestamps, over-/under-constraint + errors), `clearMarks(name?)`, `clearMeasures(name?)`. +- Timeline: `getEntries()`, `getEntriesByType(type)`, `getEntriesByName(name, + type?)` return copies sorted chronologically by `startTime` (stable for + ties). +- Observers: `new PerformanceObserver(cb)`, `observe({entryTypes})` or + `observe({type, buffered})`, `disconnect()`, `takeRecords()`, static frozen + `PerformanceObserver.supportedEntryTypes`, which is `["mark", "measure"]`. + +## Architecture + +All spec logic lives in the `internal/performance.js` builtin +(`test-app/runtime/src/main/cpp/js/performance.js`), shared with the iOS +runtime: the native side hands it only `{ now(), timeOrigin }`, so the same +file runs unchanged on both runtimes and should be kept in sync with iOS's +copy. + +The native clock is owned by `Runtime` (`Runtime::PerformanceNowMillis()`, +`Runtime::TimeOriginMillis()`, `Runtime::TimeOriginMonotonicMillis()`, +captured in `Runtime::PrepareV8Runtime`) and exposed to native callers through +`tns::Performance::NowMillis(isolate)` +(`test-app/runtime/src/main/cpp/Performance.h`). Any native producer of +JS-visible timestamps must read the clock through that hook rather than +sampling its own, so every timestamp shares `performance.timeOrigin` as its +base. + +## Frame callbacks + +`__postFrameCallback(fn[, delayMillis])` / `__removeFrameCallback(fn)` +(`test-app/runtime/src/main/cpp/FrameCallbacks.h`) schedule `fn` for the next +display frame. `fn` receives **two** arguments: + +```js +__postFrameCallback((frameTimeNanos, performanceMillis) => { … }); +``` + +- `frameTimeNanos` — the platform's raw frame time: `CLOCK_MONOTONIC` + nanoseconds, the `System.nanoTime()` base. Unchanged from earlier runtimes, + which passed it as the only argument. +- `performanceMillis` — the same instant on this isolate's performance + timeline, so it compares directly with `performance.now()`. Converted + natively through `Performance::MonotonicNanosToTimelineMillis()`, which + subtracts `Runtime::TimeOriginMonotonicMillis()` — Choreographer stamps + frames on the very clock the time origin is captured on, so the mapping is + exact rather than an approximation resampled in JS. + +Two implementations sit behind that one surface: the NDK's `AChoreographer` +(API 24+, resolved with `dlsym`) and `android.view.Choreographer` through +`com.tns.FrameCallbacks` for API 21–23, where the NDK API does not exist. +Scheduling is per calling thread, so a worker schedules against its own looper. +Both paths produce the same two arguments with the same exactness. + +## Deviations from the specs + +- **Buffers are unbounded.** Per spec for user timing. `detail` is + structured-cloned at entry creation (per spec — an uncloneable `detail` + throws the `DataCloneError`-named error, see + [structuredClone](structured-clone.md)), so entries hold snapshots, but a + long-lived app marking in a loop should still clear entries periodically. +- **Observer callbacks run from a microtask**, not a queued task: delivery is + asynchronous relative to `mark()`/`measure()` but precedes timer callbacks + scheduled in the same turn. Callback exceptions are routed to + `reportError`, so one throwing observer does not starve the others. +- **No `DOMException`.** Errors the specs express as `DOMException` — the + `SyntaxError` for a missing mark name, the `InvalidModificationError` for + switching an observer between the `entryTypes` and `type` forms — are + `Error` instances with `name` patched. `err.name` checks work; + `instanceof DOMException` does not. +- Browser-only surface is absent: no resource/navigation timing, no + `eventCounts`, and no `PerformanceTiming`-attribute resolution in + `measure()`. diff --git a/eslint.config.mjs b/eslint.config.mjs index 057dca193..7e80b3653 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -15,6 +15,7 @@ const capturedStatics = [ ['Array', 'isArray', 'ArrayIsArray'], ['ArrayBuffer', 'isView', 'ArrayBufferIsView'], ['JSON', 'stringify', 'JSONStringify'], + ['Number', 'isFinite', 'NumberIsFinite'], ['Number', 'isNaN', 'NumberIsNaN'], ['Number', 'parseFloat', 'NumberParseFloat'], ['Number', 'parseInt', 'NumberParseInt'], diff --git a/test-app/app/src/main/assets/app/mainpage.js b/test-app/app/src/main/assets/app/mainpage.js index b0795d190..ea5494fbc 100644 --- a/test-app/app/src/main/assets/app/mainpage.js +++ b/test-app/app/src/main/assets/app/mainpage.js @@ -18,6 +18,7 @@ shared.runRequireTests(); shared.runWeakRefTests(); shared.runRuntimeTests(); shared.runWorkerTests(); +shared.runPerformanceTests(); shared.runStructuredCloneTests(); require("./tests/testWebAssembly"); require("./tests/testMultithreadedJavascript"); @@ -70,10 +71,10 @@ require("./tests/testPackagePrivate"); require("./tests/kotlin/properties/testPropertiesSupport.js"); require('./tests/testNativeTimers'); require("./tests/testPostFrameCallback"); +require("./tests/testPerformance"); require("./tests/console/logTests.js"); require('./tests/testURLImpl.js'); require('./tests/testURLSearchParamsImpl.js'); -require('./tests/testPerformanceNow'); require('./tests/testQueueMicrotask'); require('./tests/testErrorEvents'); require('./tests/testUnhandledRejections'); diff --git a/test-app/app/src/main/assets/app/shared b/test-app/app/src/main/assets/app/shared index 8be1d9f53..0baab7cce 160000 --- a/test-app/app/src/main/assets/app/shared +++ b/test-app/app/src/main/assets/app/shared @@ -1 +1 @@ -Subproject commit 8be1d9f539ef48861a889bef7216dad84e05b843 +Subproject commit 0baab7cceaca2bb5fdb7b697c08b19be1e46d925 diff --git a/test-app/app/src/main/assets/app/tests/testPerformance.js b/test-app/app/src/main/assets/app/tests/testPerformance.js new file mode 100644 index 000000000..37e5bda36 --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/testPerformance.js @@ -0,0 +1,50 @@ +// Performance specs local to this runtime; the cross-runtime coverage lives in +// the shared suite (app/shared/Performance). +describe("Performance measure argument coercion", function () { + beforeEach(function () { + performance.clearMarks(); + performance.clearMeasures(); + }); + + // The startOrMeasureOptions parameter is a (DOMString or + // PerformanceMeasureOptions) union, and WebIDL converts null for such a + // union to an empty dictionary -- so null means "no options", never the mark + // name "null". + it("Should treat a null options argument as no argument at all", function () { + performance.mark("null-start"); + + const fromNull = performance.measure("from-null", null); + const fromOmitted = performance.measure("from-omitted"); + + expect(fromNull.startTime).toBe(0); + expect(fromNull.startTime).toBe(fromOmitted.startTime); + expect(fromNull.duration).toBeGreaterThan(0); + expect(fromNull.detail).toBeNull(); + }); + + it("Should pair a null options argument with an end mark", function () { + performance.mark("the-end"); + const endTime = performance.getEntriesByName("the-end")[0].startTime; + + const measure = performance.measure("null-and-end", null, "the-end"); + + expect(measure.startTime).toBe(0); + expect(measure.duration).toBe(endTime); + }); + + // endMark is a plain optional DOMString, not a union and not nullable, so + // null stringifies and names a mark that does not exist. + it("Should reject a null end mark", function () { + // Present so the only mark this can fail to resolve is the null one. + performance.mark("the-start"); + + let thrown = null; + try { + performance.measure("null-end", "the-start", null); + } catch (e) { + thrown = e; + } + expect(thrown).not.toBeNull(); + expect(thrown && thrown.name).toBe("SyntaxError"); + }); +}); diff --git a/test-app/app/src/main/assets/app/tests/testPerformanceNow.js b/test-app/app/src/main/assets/app/tests/testPerformanceNow.js deleted file mode 100644 index 0b4e70363..000000000 --- a/test-app/app/src/main/assets/app/tests/testPerformanceNow.js +++ /dev/null @@ -1,21 +0,0 @@ -describe('performance.now()', () => { - it('returns increasing high-resolution time', () => { - const t1 = performance.now(); - const t2 = performance.now(); - expect(typeof t1).toBe('number'); - expect(isNaN(t1)).toBe(false); - expect(t2).not.toBeLessThan(t1); // non-decreasing - // Should be relative (well below 1h after startup) - expect(t1).toBeLessThan(60 * 60 * 1000); - }); - - it('advances over real time', (done) => { - const t1 = performance.now(); - setTimeout(() => { - const t2 = performance.now(); - // 8ms threshold accounts for timer clamping on some devices - expect(t2 - t1).not.toBeLessThan(8); - done(); - }, 10); - }); -}); diff --git a/test-app/app/src/main/assets/app/tests/testPostFrameCallback.js b/test-app/app/src/main/assets/app/tests/testPostFrameCallback.js index 59ead004d..31518a176 100644 --- a/test-app/app/src/main/assets/app/tests/testPostFrameCallback.js +++ b/test-app/app/src/main/assets/app/tests/testPostFrameCallback.js @@ -140,3 +140,98 @@ describe("test PostFrameCallback", function () { }, defaultWaitTime); }); }); + +// The two implementations behind __postFrameCallback (NDK AChoreographer, +// android.view.Choreographer for API < 24) must be indistinguishable from JS. +// A modern device always selects the NDK one, so the Java bridge is only +// reachable through __setFrameCallbackImpl, which debug runtimes expose for +// exactly this. +describe("frame callback timestamps", function () { + const defaultWaitTime = 300; + const impls = ["native", "java"]; + + afterEach(() => { + if (typeof global.__setFrameCallbackImpl === "function") { + global.__setFrameCallbackImpl("auto"); + } + }); + + function withImpl(impl) { + if (typeof global.__setFrameCallbackImpl !== "function") { + return impl === "native"; + } + return global.__setFrameCallbackImpl(impl) === impl; + } + + impls.forEach((impl) => { + describe(impl + " implementation", function () { + it("passes the raw frame time and a performance-timeline timestamp", (done) => { + if (!withImpl(impl)) { + pending("this runtime cannot select the " + impl + " implementation"); + return; + } + + global.__postFrameCallback((frameTimeNanos, performanceMillis) => { + const now = performance.now(); + + expect(typeof frameTimeNanos).toBe("number"); + expect(typeof performanceMillis).toBe("number"); + + // Uptime-scale nanoseconds, not epoch-scale milliseconds. + expect(frameTimeNanos).toBeGreaterThan(1e9); + expect(frameTimeNanos / 1e6).toBeLessThan(Date.now()); + + // The frame is stamped just before the callback runs, so its + // timeline position sits a frame or two behind the reading taken + // inside it, never ahead of it. + expect(performanceMillis).toBeGreaterThan(0); + expect(performanceMillis).not.toBeGreaterThan(now); + expect(now - performanceMillis).toBeLessThan(250); + + // Both arguments describe the same instant, so their difference is + // the timeline's monotonic origin. System.nanoTime() is on that same + // clock, so the pair (nanoTime, now) must yield the same origin -- + // this is what would break if either argument moved off the base. + // The two clocks cannot be read at once, so nanoTime is bracketed + // and compared against the midpoint: the tolerance then only has to + // cover the sampling window, not whatever pause lands between them. + const beforeNow = performance.now(); + const sampledNanos = java.lang.System.nanoTime(); + const afterNow = performance.now(); + const originFromFrame = frameTimeNanos / 1e6 - performanceMillis; + const originFromClock = sampledNanos / 1e6 - (beforeNow + afterNow) / 2; + expect(Math.abs(originFromFrame - originFromClock)).toBeLessThan( + 5 + (afterNow - beforeNow) + ); + done(); + }); + }); + + it("advances across consecutive frames", (done) => { + if (!withImpl(impl)) { + pending("this runtime cannot select the " + impl + " implementation"); + return; + } + + const frames = []; + const callback = (frameTimeNanos, performanceMillis) => { + frames.push({ nanos: frameTimeNanos, millis: performanceMillis }); + if (frames.length === 1) { + global.__postFrameCallback(callback); + } + }; + global.__postFrameCallback(callback); + + setTimeout(() => { + expect(frames.length).toBe(2); + expect(frames[1].nanos).not.toBeLessThan(frames[0].nanos); + expect(frames[1].millis).not.toBeLessThan(frames[0].millis); + // The origin the two arguments imply is a constant of the isolate. + const origin = (f) => f.nanos / 1e6 - f.millis; + expect(Math.abs(origin(frames[1]) - origin(frames[0]))).toBeLessThan(1); + done(); + }, defaultWaitTime); + }); + }); + }); +}); diff --git a/test-app/app/src/main/assets/app/tests/testRuntimeImplementedAPIs.js b/test-app/app/src/main/assets/app/tests/testRuntimeImplementedAPIs.js index db176a109..d1f08f365 100644 --- a/test-app/app/src/main/assets/app/tests/testRuntimeImplementedAPIs.js +++ b/test-app/app/src/main/assets/app/tests/testRuntimeImplementedAPIs.js @@ -32,6 +32,16 @@ describe("Runtime exposes", function () { }); }); +// The shared Performance suite (submodule) gates itself on the API being +// present and skips otherwise; this unguarded canary makes absence on THIS +// runtime a failure rather than a silent skip. +describe("Performance API canary", function () { + it("implements the Performance API", function () { + expect(typeof performance.mark).toBe("function"); + expect(typeof PerformanceObserver).toBe("function"); + }); +}); + // The shared StructuredClone suite skips itself where the API is missing, which // would turn this runtime losing structuredClone into a green run. This spec is // deliberately unguarded so that regression fails instead. diff --git a/test-app/runtime/CMakeLists.txt b/test-app/runtime/CMakeLists.txt index ca0833edb..1346bc6cb 100644 --- a/test-app/runtime/CMakeLists.txt +++ b/test-app/runtime/CMakeLists.txt @@ -72,6 +72,7 @@ set(RUNTIME_BUILTIN_JS ${RUNTIME_BUILTIN_JS_DIR}/message-loop-timer.js ${RUNTIME_BUILTIN_JS_DIR}/node-util.js ${RUNTIME_BUILTIN_JS_DIR}/ns-util.js + ${RUNTIME_BUILTIN_JS_DIR}/performance.js ${RUNTIME_BUILTIN_JS_DIR}/primordials.js ${RUNTIME_BUILTIN_JS_DIR}/require-factory.js ${RUNTIME_BUILTIN_JS_DIR}/structured-clone.js @@ -151,6 +152,7 @@ add_library( src/main/cpp/Constants.cpp src/main/cpp/DirectBuffer.cpp src/main/cpp/ErrorEvents.cpp + src/main/cpp/FrameCallbacks.cpp src/main/cpp/Events.cpp src/main/cpp/FieldAccessor.cpp src/main/cpp/File.cpp @@ -180,6 +182,7 @@ add_library( src/main/cpp/NsBuiltinModules.cpp src/main/cpp/NumericCasts.cpp src/main/cpp/ObjectManager.cpp + src/main/cpp/Performance.cpp src/main/cpp/Profiler.cpp src/main/cpp/ReadWriteLock.cpp src/main/cpp/Runtime.cpp diff --git a/test-app/runtime/src/main/cpp/CallbackHandlers.cpp b/test-app/runtime/src/main/cpp/CallbackHandlers.cpp index 84d5623a9..2cc415f26 100644 --- a/test-app/runtime/src/main/cpp/CallbackHandlers.cpp +++ b/test-app/runtime/src/main/cpp/CallbackHandlers.cpp @@ -1573,173 +1573,11 @@ void CallbackHandlers::RemoveIsolateEntries(v8::Isolate *isolate) { cache_.erase(item.first); } } - - for (auto &item: frameCallbackCache_) { - if (item.second.isolate_ == isolate) { - frameCallbackCache_.erase(item.first); - } - } - -} -CallbackHandlers::func_AChoreographer_getInstance AChoreographer_getInstance_; - -CallbackHandlers::func_AChoreographer_postFrameCallback AChoreographer_postFrameCallback_; -CallbackHandlers::func_AChoreographer_postFrameCallbackDelayed AChoreographer_postFrameCallbackDelayed_; - -CallbackHandlers::func_AChoreographer_postFrameCallback64 AChoreographer_postFrameCallback64_; -CallbackHandlers::func_AChoreographer_postFrameCallbackDelayed64 AChoreographer_postFrameCallbackDelayed64_; - -void CallbackHandlers::PostCallback(const FunctionCallbackInfo &args, CallbackHandlers::FrameCallbackCacheEntry* entry, v8::Local context){ - ALooper_prepare(0); - auto instance = AChoreographer_getInstance_(); - auto delay = args[1]; - if(android_get_device_api_level() >= 29){ - if(!delay.IsEmpty() && delay->IsNumber()){ - AChoreographer_postFrameCallbackDelayed64_(instance, entry->frameCallback64_, entry, delay->Uint32Value(context).FromMaybe(0)); - }else { - AChoreographer_postFrameCallback64_(instance, entry->frameCallback64_, entry); - } - }else { - if(!delay.IsEmpty() && delay->IsNumber()){ - AChoreographer_postFrameCallbackDelayed_(instance, entry->frameCallback_, entry, (long)delay->IntegerValue(context).FromMaybe(0)); - }else { - AChoreographer_postFrameCallback_(instance, entry->frameCallback_, entry); - } - } -} - - -void CallbackHandlers::PostFrameCallback(const FunctionCallbackInfo &args) { - if (android_get_device_api_level() >= 24) { - InitChoreographer(); - Isolate *isolate = args.GetIsolate(); - - v8::Locker locker(isolate); - Isolate::Scope isolate_scope(isolate); - HandleScope handle_scope(isolate); - auto context = isolate->GetCurrentContext(); - Context::Scope context_scope(context); - - if (args.Length() < 1 || !args[0]->IsFunction()) { - isolate->ThrowException(v8::Exception::TypeError(v8::String::NewFromUtf8Literal(isolate, "Frame callback argument is not a function"))); - return; - } - - auto func = args[0].As(); - - auto idKey = ArgConverter::ConvertToV8String(isolate, "_postFrameCallbackId"); - - Local pId; - bool success = V8GetPrivateValue(isolate, func, idKey, pId); - - if (success && pId->IsNumber()){ - auto id = pId->IntegerValue(context).FromMaybe(0); - auto cb = frameCallbackCache_.find(id); - if (cb != frameCallbackCache_.end()) { - // check if it's already scheduled first, we don't want to schedule it twice - bool shouldReschedule = !cb->second.isScheduled(); - // always mark as scheduled, as that will also mark it as not removed anymore - cb->second.markScheduled(); - if (shouldReschedule) { - PostCallback(args, &cb->second, context); - } - return; - } - } - - Local callback = func; - uint64_t key = ++frameCallbackCount_; - - V8SetPrivateValue(isolate, func, idKey, v8::Number::New(isolate, (double) key)); - - robin_hood::unordered_map::iterator val; - bool inserted; - std::tie(val, inserted) = frameCallbackCache_.try_emplace(key, isolate, callback, key); - assert(inserted && "Frame callback ID should not be duplicated"); - - val->second.markScheduled(); - PostCallback(args, &val->second, context); - } -} - -void CallbackHandlers::RemoveFrameCallback(const FunctionCallbackInfo &args) { - - if (android_get_device_api_level() >= 24) { - InitChoreographer(); - Isolate *isolate = args.GetIsolate(); - v8::Locker locker(isolate); - Isolate::Scope isolate_scope(isolate); - HandleScope handle_scope(isolate); - auto context = isolate->GetCurrentContext(); - Context::Scope context_scope(context); - - if (args.Length() < 1 || !args[0]->IsFunction()) { - isolate->ThrowException(v8::Exception::TypeError(v8::String::NewFromUtf8Literal(isolate, "Frame callback argument is not a function"))); - return; - } - auto func = args[0].As(); - - auto idKey = ArgConverter::ConvertToV8String(isolate, "_postFrameCallbackId"); - - Local pId; - bool success = V8GetPrivateValue(isolate, func, idKey, pId); - - if (success && pId->IsNumber()){ - auto id = pId->IntegerValue(context).FromMaybe(0); - auto cb = frameCallbackCache_.find(id); - if (cb != frameCallbackCache_.end()) { - cb->second.markRemoved(); - } - } - - } - } - -void CallbackHandlers::InitChoreographer() { - if(AChoreographer_getInstance_ == nullptr){ - void* lib = dlopen("libandroid.so", RTLD_NOW | RTLD_LOCAL); - if (lib != nullptr) { - - // Retrieve function pointers from shared object. - AChoreographer_getInstance_ = - reinterpret_cast( - dlsym(lib, "AChoreographer_getInstance")); - AChoreographer_postFrameCallback_ = - reinterpret_cast( - dlsym(lib, "AChoreographer_postFrameCallback")); - - AChoreographer_postFrameCallbackDelayed_ = - reinterpret_cast( - dlsym(lib, "AChoreographer_postFrameCallbackDelayed")); - - assert(AChoreographer_getInstance_); - assert(AChoreographer_postFrameCallback_); - assert(AChoreographer_postFrameCallbackDelayed_); - - if(android_get_device_api_level() >= 29){ - AChoreographer_postFrameCallback64_ = - reinterpret_cast( - dlsym(lib, "AChoreographer_postFrameCallback64")); - - AChoreographer_postFrameCallbackDelayed64_ = - reinterpret_cast( - dlsym(lib, "AChoreographer_postFrameCallbackDelayed64")); - - assert(AChoreographer_postFrameCallback64_); - assert(AChoreographer_postFrameCallbackDelayed64_); - } - } - } -} - - robin_hood::unordered_map CallbackHandlers::cache_; -robin_hood::unordered_map CallbackHandlers::frameCallbackCache_; std::atomic_int64_t CallbackHandlers::count_ = {0}; -std::atomic_uint64_t CallbackHandlers::frameCallbackCount_ = {0}; short CallbackHandlers::MAX_JAVA_STRING_ARRAY_LENGTH = 100; diff --git a/test-app/runtime/src/main/cpp/CallbackHandlers.h b/test-app/runtime/src/main/cpp/CallbackHandlers.h index f62eeef7d..44dc08a73 100644 --- a/test-app/runtime/src/main/cpp/CallbackHandlers.h +++ b/test-app/runtime/src/main/cpp/CallbackHandlers.h @@ -159,36 +159,6 @@ namespace tns { static void RemoveIsolateEntries(v8::Isolate *isolate); - static void PostFrameCallback(const v8::FunctionCallbackInfo &args); - - static void RemoveFrameCallback(const v8::FunctionCallbackInfo &args); - - struct AChoreographer; - - - typedef void (*AChoreographer_frameCallback)(long frameTimeNanos, void* data); - - typedef void (*AChoreographer_frameCallback64)(int64_t frameTimeNanos, void* data); - - typedef AChoreographer* (*func_AChoreographer_getInstance)(); - - typedef void (*func_AChoreographer_postFrameCallback)( - AChoreographer* choreographer, AChoreographer_frameCallback callback, - void* data); - - typedef void (*func_AChoreographer_postFrameCallback64)( - AChoreographer* choreographer, AChoreographer_frameCallback64 callback, - void* data); - - typedef void (*func_AChoreographer_postFrameCallbackDelayed)( - AChoreographer* choreographer, AChoreographer_frameCallback callback, - void* data, long delayMillis); - - typedef void (*func_AChoreographer_postFrameCallbackDelayed64)( - AChoreographer* choreographer, AChoreographer_frameCallback64 callback, - void* data, uint32_t delayMillis); - - private: CallbackHandlers() { } @@ -271,106 +241,6 @@ namespace tns { static robin_hood::unordered_map cache_; - static std::atomic_uint64_t frameCallbackCount_; - - struct FrameCallbackCacheEntry { - FrameCallbackCacheEntry(v8::Isolate *isolate, v8::Local callback, uint64_t aId) - : isolate_(isolate), - callback_(isolate, callback), - id(aId) { - } - - ~FrameCallbackCacheEntry() { - callback_.Reset(); - } - - v8::Isolate *isolate_; - v8::Global callback_; - uint64_t id; - - bool isScheduled() { - return scheduled; - } - - void markScheduled() { - scheduled = true; - removed = false; - } - void markRemoved() { - // we can never unschedule a callback, so we just mark it as removed - removed = true; - } - - AChoreographer_frameCallback frameCallback_ = [](long ts, void *data) { - execute((double)ts, data); - }; - - AChoreographer_frameCallback64 frameCallback64_ = [](int64_t ts, void *data) { - execute((double)ts, data); - }; - - static void execute(double ts, void *data){ - if (data != nullptr) { - auto entry = static_cast(data); - if (entry->shouldRemoveBeforeCall()) { - frameCallbackCache_.erase(entry->id); // invalidates *entry - return; - } - v8::Isolate *isolate = entry->isolate_; - - v8::Locker locker(isolate); - v8::Isolate::Scope isolate_scope(isolate); - v8::HandleScope handle_scope(isolate); - v8::Local cb = entry->callback_.Get(isolate); - Runtime* runtime = Runtime::GetRuntime(isolate); - v8::Local context = runtime->GetContext(); - v8::Context::Scope context_scope(context); - // we're running the callback now, so it's not scheduled anymore - entry->markUnscheduled(); - - v8::Local args[1] = {v8::Number::New(isolate, ts)}; - - v8::TryCatch tc(isolate); - - cb->Call(context, context->Global(), 1, args); // ignore JS return value - - // check if we should remove it (it should be both unscheduled and removed) - if (entry->shouldRemoveAfterCall()) { - frameCallbackCache_.erase(entry->id); // invalidates *entry - } - - - if (tc.HasCaught() && - !NativeScriptException::ContainUncaughtCallbackException(isolate, tc)) { - throw NativeScriptException(tc); - } - - } - } - private: - bool removed = false; - bool scheduled = false; - void markUnscheduled() { - scheduled = false; - removed = true; - } - - bool shouldRemoveBeforeCall() { - return removed; - } - - bool shouldRemoveAfterCall() { - return !scheduled && removed; - } - - }; - - static robin_hood::unordered_map frameCallbackCache_; - - static void InitChoreographer(); - - static void PostCallback(const v8::FunctionCallbackInfo &args, - FrameCallbackCacheEntry *entry, v8::Local context); }; } diff --git a/test-app/runtime/src/main/cpp/FrameCallbacks.cpp b/test-app/runtime/src/main/cpp/FrameCallbacks.cpp new file mode 100644 index 000000000..661edbe6b --- /dev/null +++ b/test-app/runtime/src/main/cpp/FrameCallbacks.cpp @@ -0,0 +1,513 @@ +#include "FrameCallbacks.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "ArgConverter.h" +#include "JEnv.h" +#include "JniLocalRef.h" +#include "NativeScriptException.h" +#include "Performance.h" +#include "Runtime.h" +#include "V8GlobalHelpers.h" +#include "robin_hood.h" + +using namespace v8; + +namespace tns { + +namespace { + +// ---- AChoreographer (NDK, API 24+), resolved through dlsym. + +struct AChoreographer; + +typedef void (*AChoreographer_frameCallback)(long frameTimeNanos, void* data); +typedef void (*AChoreographer_frameCallback64)(int64_t frameTimeNanos, + void* data); +typedef AChoreographer* (*func_getInstance)(); +typedef void (*func_postFrameCallback)(AChoreographer*, + AChoreographer_frameCallback, void*); +typedef void (*func_postFrameCallback64)(AChoreographer*, + AChoreographer_frameCallback64, void*); +typedef void (*func_postFrameCallbackDelayed)(AChoreographer*, + AChoreographer_frameCallback, + void*, long); +typedef void (*func_postFrameCallbackDelayed64)(AChoreographer*, + AChoreographer_frameCallback64, + void*, uint32_t); + +func_getInstance getInstance_ = nullptr; +func_postFrameCallback postFrameCallback_ = nullptr; +func_postFrameCallbackDelayed postFrameCallbackDelayed_ = nullptr; +func_postFrameCallback64 postFrameCallback64_ = nullptr; +func_postFrameCallbackDelayed64 postFrameCallbackDelayed64_ = nullptr; + +void ResolveChoreographer() { + static std::once_flag once; + std::call_once(once, [] { + if (android_get_device_api_level() < 24) { + return; + } + + void* lib = dlopen("libandroid.so", RTLD_NOW | RTLD_LOCAL); + if (lib == nullptr) { + return; + } + + getInstance_ = reinterpret_cast( + dlsym(lib, "AChoreographer_getInstance")); + postFrameCallback_ = reinterpret_cast( + dlsym(lib, "AChoreographer_postFrameCallback")); + postFrameCallbackDelayed_ = reinterpret_cast( + dlsym(lib, "AChoreographer_postFrameCallbackDelayed")); + + if (android_get_device_api_level() >= 29) { + postFrameCallback64_ = reinterpret_cast( + dlsym(lib, "AChoreographer_postFrameCallback64")); + postFrameCallbackDelayed64_ = + reinterpret_cast( + dlsym(lib, "AChoreographer_postFrameCallbackDelayed64")); + } + }); +} + +bool NativeChoreographerAvailable() { + ResolveChoreographer(); + return getInstance_ != nullptr && postFrameCallback_ != nullptr && + postFrameCallbackDelayed_ != nullptr; +} + +// ---- Implementation selection. + +enum class Impl { kAuto, kNative, kJava }; + +Impl forcedImpl_ = Impl::kAuto; + +bool UseNativeChoreographer() { + if (forcedImpl_ == Impl::kJava) { + return false; + } + return NativeChoreographerAvailable(); +} + +/* + * AChoreographer_postFrameCallback hands the frame time as `long`, which is 32 + * bits on the 32-bit ABIs and so wraps about every 4.3 s -- the reason + * postFrameCallback64 exists from API 29. The frame time is always within a + * frame of now, so the high bits are recoverable from the monotonic clock. + */ +int64_t WidenFrameTimeNanos(long ts) { + if constexpr (sizeof(long) == sizeof(int64_t)) { + return (int64_t) ts; + } else { + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + int64_t nowNanos = (int64_t) now.tv_sec * 1000000000LL + now.tv_nsec; + int64_t candidate = + (nowNanos & ~0xFFFFFFFFLL) | (int64_t)(uint32_t) ts; + if (candidate > nowNanos) { + candidate -= 0x100000000LL; + } + return candidate; + } +} + +// ---- Scheduled callbacks. + +jclass FRAME_CALLBACKS_CLASS = nullptr; +jmethodID FRAME_CALLBACKS_CTOR = nullptr; +jmethodID FRAME_CALLBACKS_POST = nullptr; +jmethodID FRAME_CALLBACKS_RELEASE = nullptr; + +void ResolveFrameCallbacksClass(JEnv& env) { + static std::once_flag once; + std::call_once(once, [&env] { + // JEnv::FindClass caches a global ref to the class + FRAME_CALLBACKS_CLASS = env.FindClass("com/tns/FrameCallbacks"); + assert(FRAME_CALLBACKS_CLASS != nullptr); + FRAME_CALLBACKS_CTOR = env.GetMethodID(FRAME_CALLBACKS_CLASS, "", "(J)V"); + FRAME_CALLBACKS_POST = env.GetMethodID(FRAME_CALLBACKS_CLASS, "post", "(J)V"); + FRAME_CALLBACKS_RELEASE = + env.GetMethodID(FRAME_CALLBACKS_CLASS, "release", "()V"); + }); +} + +/* + * Entries are identified to the platform by id rather than by address: an id + * that no longer resolves is simply a frame arriving after its entry went + * away, where a stale pointer would be a use-after-free. + */ +using EntryId = uintptr_t; + +struct FrameCallbackEntry { + FrameCallbackEntry(Isolate* isolate, Local callback, EntryId id) + : isolate_(isolate), callback_(isolate, callback), id_(id) { + } + + ~FrameCallbackEntry() { + callback_.Reset(); + ReleaseJavaCallback(); + } + + void ReleaseJavaCallback() { + if (javaCallback_ == nullptr) { + return; + } + JEnv env; + env.CallVoidMethod(javaCallback_, FRAME_CALLBACKS_RELEASE); + env.DeleteGlobalRef(javaCallback_); + javaCallback_ = nullptr; + } + + bool IsScheduled() const { + return scheduled_; + } + + void MarkScheduled() { + scheduled_ = true; + removed_ = false; + } + + // A posted frame callback cannot be recalled, so removal only marks. + void MarkRemoved() { + removed_ = true; + } + + void MarkUnscheduled() { + scheduled_ = false; + removed_ = true; + } + + bool ShouldRemoveBeforeCall() const { + return removed_; + } + + bool ShouldRemoveAfterCall() const { + return !scheduled_ && removed_; + } + + Isolate* isolate_; + Global callback_; + EntryId id_; + jobject javaCallback_ = nullptr; + +private: + bool removed_ = false; + bool scheduled_ = false; +}; + +/* + * One registry for every isolate in the process, so each of the two lookups + * below takes the mutex. It must never be held across the JS call: a callback + * that reschedules itself re-enters PostFrameCallback and would deadlock. + * Creating, erasing and dispatching an entry all happen on the thread that + * owns its isolate, so a pointer resolved under the mutex stays valid after + * releasing it -- only this thread can retire it. + */ +robin_hood::unordered_map> entries_; +std::mutex entriesMutex_; +std::atomic entryCount_ = {0}; + +FrameCallbackEntry* FindEntryById(EntryId id) { + std::lock_guard lock(entriesMutex_); + auto found = entries_.find(id); + return found == entries_.end() ? nullptr : found->second.get(); +} + +/* + * Detaches the entry from the registry and hands back its owner, so the + * destructor -- which calls into Java -- runs with the mutex released. + */ +std::unique_ptr TakeEntry(EntryId id) { + std::lock_guard lock(entriesMutex_); + auto found = entries_.find(id); + if (found == entries_.end()) { + return nullptr; + } + auto owner = std::move(found->second); + entries_.erase(found); + return owner; +} + +/* + * Never throws: on the NDK path this runs inside a C callback in libandroid, + * which a C++ exception may not unwind through. A JS exception the runtime + * still owns is handed to Java the way Timers::FireTimer does -- the frame + * dispatch is driven by the thread's looper, so a pending Java exception is + * picked up when control returns to Looper.loop(). + */ +void Dispatch(EntryId id, int64_t frameTimeNanos) { + FrameCallbackEntry* entry = FindEntryById(id); + if (entry == nullptr) { + return; // the entry was retired before this frame arrived + } + if (entry->ShouldRemoveBeforeCall()) { + TakeEntry(id); + return; + } + + Isolate* isolate = entry->isolate_; + Runtime* runtime = static_cast( + isolate->GetData((uint32_t) Runtime::IsolateData::RUNTIME)); + if (runtime == nullptr) { + return; + } + + Locker locker(isolate); + Isolate::Scope isolateScope(isolate); + HandleScope handleScope(isolate); + + Local cb = entry->callback_.Get(isolate); + Local context = runtime->GetContext(); + Context::Scope contextScope(context); + + entry->MarkUnscheduled(); + + Local args[2] = { + Number::New(isolate, (double) frameTimeNanos), + Number::New(isolate, Performance::MonotonicNanosToTimelineMillis( + isolate, frameTimeNanos)), + }; + + TryCatch tc(isolate); + + cb->Call(context, context->Global(), 2, args); // ignore JS return value + + // Re-resolve: the callback may have rescheduled or removed itself. + entry = FindEntryById(id); + if (entry != nullptr && entry->ShouldRemoveAfterCall()) { + TakeEntry(id); + } + + if (tc.HasCaught() && + !NativeScriptException::ContainUncaughtCallbackException(isolate, tc)) { + NativeScriptException(tc).ReThrowToJava(); + } +} + +void OnNativeFrame32(long ts, void* data) { + Dispatch((EntryId) (uintptr_t) data, WidenFrameTimeNanos(ts)); +} + +void OnNativeFrame64(int64_t ts, void* data) { + Dispatch((EntryId) (uintptr_t) data, ts); +} + +void PostNative(EntryId id, long delayMillis) { + ALooper_prepare(0); + AChoreographer* instance = getInstance_(); + void* data = reinterpret_cast((uintptr_t) id); + + if (postFrameCallback64_ != nullptr && postFrameCallbackDelayed64_ != nullptr) { + if (delayMillis > 0) { + postFrameCallbackDelayed64_(instance, &OnNativeFrame64, data, + (uint32_t) delayMillis); + } else { + postFrameCallback64_(instance, &OnNativeFrame64, data); + } + return; + } + + if (delayMillis > 0) { + postFrameCallbackDelayed_(instance, &OnNativeFrame32, data, delayMillis); + } else { + postFrameCallback_(instance, &OnNativeFrame32, data); + } +} + +void PostJava(FrameCallbackEntry* entry, long delayMillis) { + JEnv env; + ResolveFrameCallbacksClass(env); + + if (entry->javaCallback_ == nullptr) { + JniLocalRef instance(env.NewObject(FRAME_CALLBACKS_CLASS, FRAME_CALLBACKS_CTOR, + (jlong) entry->id_)); + entry->javaCallback_ = env.NewGlobalRef(instance); + } + + env.CallVoidMethod(entry->javaCallback_, FRAME_CALLBACKS_POST, + (jlong) delayMillis); +} + +void Post(FrameCallbackEntry* entry, long delayMillis) { + if (UseNativeChoreographer()) { + PostNative(entry->id_, delayMillis); + } else { + PostJava(entry, delayMillis); + } +} + +long ReadDelay(const FunctionCallbackInfo& args, Local context) { + if (args.Length() < 2 || !args[1]->IsNumber()) { + return 0; + } + return (long) args[1]->IntegerValue(context).FromMaybe(0); +} + +EntryId ReadEntryId(Isolate* isolate, Local func, Local context) { + Local id; + if (!V8GetPrivateValue(isolate, func, + ArgConverter::ConvertToV8String(isolate, + "_postFrameCallbackId"), + id) || + !id->IsNumber()) { + return 0; + } + return (EntryId) id->IntegerValue(context).FromMaybe(0); +} + +} // namespace + +void FrameCallbacks::PostFrameCallback(const FunctionCallbackInfo& args) { + Isolate* isolate = args.GetIsolate(); + Locker locker(isolate); + Isolate::Scope isolateScope(isolate); + HandleScope handleScope(isolate); + Local context = isolate->GetCurrentContext(); + Context::Scope contextScope(context); + + if (args.Length() < 1 || !args[0]->IsFunction()) { + isolate->ThrowException(Exception::TypeError(String::NewFromUtf8Literal( + isolate, "Frame callback argument is not a function"))); + return; + } + + Local func = args[0].As(); + long delayMillis = ReadDelay(args, context); + + FrameCallbackEntry* existing = FindEntryById(ReadEntryId(isolate, func, context)); + if (existing != nullptr) { + // Always mark, which also clears a pending removal; only post when it + // is not already waiting on a frame. + bool shouldPost = !existing->IsScheduled(); + existing->MarkScheduled(); + if (shouldPost) { + Post(existing, delayMillis); + } + return; + } + + EntryId id = ++entryCount_; + V8SetPrivateValue(isolate, func, + ArgConverter::ConvertToV8String(isolate, "_postFrameCallbackId"), + Number::New(isolate, (double) id)); + + FrameCallbackEntry* entry; + { + std::lock_guard lock(entriesMutex_); + auto inserted = entries_.emplace( + id, std::make_unique(isolate, func, id)); + assert(inserted.second && "Frame callback ID should not be duplicated"); + entry = inserted.first->second.get(); + } + + entry->MarkScheduled(); + Post(entry, delayMillis); +} + +void FrameCallbacks::RemoveFrameCallback(const FunctionCallbackInfo& args) { + Isolate* isolate = args.GetIsolate(); + Locker locker(isolate); + Isolate::Scope isolateScope(isolate); + HandleScope handleScope(isolate); + Local context = isolate->GetCurrentContext(); + Context::Scope contextScope(context); + + if (args.Length() < 1 || !args[0]->IsFunction()) { + isolate->ThrowException(Exception::TypeError(String::NewFromUtf8Literal( + isolate, "Frame callback argument is not a function"))); + return; + } + + FrameCallbackEntry* entry = + FindEntryById(ReadEntryId(isolate, args[0].As(), context)); + if (entry != nullptr) { + entry->MarkRemoved(); + } +} + +void FrameCallbacks::RemoveIsolateEntries(Isolate* isolate) { + // Detached first, destroyed after the mutex is released: the destructors + // call into Java. + std::vector> doomed; + { + std::lock_guard lock(entriesMutex_); + for (auto it = entries_.begin(); it != entries_.end();) { + if (it->second->isolate_ == isolate) { + doomed.push_back(std::move(it->second)); + it = entries_.erase(it); + } else { + ++it; + } + } + } +} + +void FrameCallbacks::Init(Isolate* isolate, Local globalTemplate) { + globalTemplate->Set( + ArgConverter::ConvertToV8String(isolate, "__postFrameCallback"), + FunctionTemplate::New(isolate, PostFrameCallback)); + globalTemplate->Set( + ArgConverter::ConvertToV8String(isolate, "__removeFrameCallback"), + FunctionTemplate::New(isolate, RemoveFrameCallback)); + +#ifdef APPLICATION_IN_DEBUG + /* + * Test-only override, absent from release runtimes: the NDK path is the + * only one a modern device would ever select, so the Java bridge would + * otherwise ship without device coverage. Takes "auto", "native" or + * "java" and returns the implementation subsequent posts will use. + */ + globalTemplate->Set( + ArgConverter::ConvertToV8String(isolate, "__setFrameCallbackImpl"), + FunctionTemplate::New( + isolate, [](const FunctionCallbackInfo& args) { + Isolate* isolate = args.GetIsolate(); + std::string requested = + args.Length() > 0 && args[0]->IsString() + ? ArgConverter::ConvertToString( + args[0].As()) + : "auto"; + if (requested == "java") { + forcedImpl_ = Impl::kJava; + } else if (requested == "native") { + forcedImpl_ = Impl::kNative; + } else { + forcedImpl_ = Impl::kAuto; + } + args.GetReturnValue().Set(ArgConverter::ConvertToV8String( + isolate, + UseNativeChoreographer() ? "native" : "java")); + })); +#endif +} + +} // namespace tns + +extern "C" JNIEXPORT void JNICALL Java_com_tns_FrameCallbacks_nativeDoFrame( + JNIEnv* env, jclass clazz, jlong entryId, jlong frameTimeNanos) { + try { + tns::Dispatch((tns::EntryId) entryId, (int64_t) frameTimeNanos); + } catch (tns::NativeScriptException& e) { + e.ReThrowToJava(); + } catch (std::exception& e) { + std::stringstream ss; + ss << "Error: c++ exception: " << e.what() << std::endl; + tns::NativeScriptException nsEx(ss.str()); + nsEx.ReThrowToJava(); + } catch (...) { + tns::NativeScriptException nsEx(std::string("Error: c++ exception!")); + nsEx.ReThrowToJava(); + } +} diff --git a/test-app/runtime/src/main/cpp/FrameCallbacks.h b/test-app/runtime/src/main/cpp/FrameCallbacks.h new file mode 100644 index 000000000..dcd381fd5 --- /dev/null +++ b/test-app/runtime/src/main/cpp/FrameCallbacks.h @@ -0,0 +1,51 @@ +#ifndef FRAMECALLBACKS_H_ +#define FRAMECALLBACKS_H_ + +#include + +#include + +#include "v8.h" + +namespace tns { + +/* + * __postFrameCallback(fn[, delayMillis]) / __removeFrameCallback(fn): schedule + * a JS function for the next display frame. + * + * fn receives two arguments: + * fn(frameTimeNanos, performanceMillis) + * frameTimeNanos is the platform's raw frame time -- CLOCK_MONOTONIC + * nanoseconds, the System.nanoTime() base -- and performanceMillis is the same + * instant on this isolate's performance timeline, so it compares directly with + * performance.now(). Both are exact on either implementation below. + * + * Two implementations sit behind the one JS surface: + * - AChoreographer (NDK, API 24+), reached through dlsym. + * - android.view.Choreographer (com.tns.FrameCallbacks), for API 21-23, + * where the NDK API does not exist. + * Scheduling is per calling thread, so a worker schedules against its own + * looper. Rescheduling a callback that is already pending is a no-op, and + * removal only marks the entry: a posted frame callback cannot be recalled, so + * the dispatch drops it instead. + */ +class FrameCallbacks { +public: + static void Init(v8::Isolate* isolate, + v8::Local globalTemplate); + + /* + * Drops every entry belonging to this isolate. Called during runtime + * teardown, before the isolate is disposed, so a frame that arrives + * afterwards finds nothing to run. + */ + static void RemoveIsolateEntries(v8::Isolate* isolate); + +private: + static void PostFrameCallback(const v8::FunctionCallbackInfo& args); + static void RemoveFrameCallback(const v8::FunctionCallbackInfo& args); +}; + +} // namespace tns + +#endif /* FRAMECALLBACKS_H_ */ diff --git a/test-app/runtime/src/main/cpp/Performance.cpp b/test-app/runtime/src/main/cpp/Performance.cpp new file mode 100644 index 000000000..45edccbe6 --- /dev/null +++ b/test-app/runtime/src/main/cpp/Performance.cpp @@ -0,0 +1,89 @@ +#include "Performance.h" + +#include "ArgConverter.h" +#include "BuiltinLoader.h" +#include "NativeScriptException.h" +#include "Runtime.h" + +using namespace v8; + +namespace tns { + +namespace { + +/* + * Non-throwing runtime lookup, safe from V8 callbacks that may fire while a + * runtime is being torn down (Runtime::GetRuntime throws in that window). + */ +Runtime* GetRuntimeOrNull(Isolate* isolate) { + return static_cast( + isolate->GetData((uint32_t) Runtime::IsolateData::RUNTIME)); +} + +} // namespace + +void Performance::Init(Local context) { + Isolate* isolate = Isolate::GetCurrent(); + + /* + * kThrow so `new performance.now()` throws, as WebIDL operations must; + * kHasNoSideEffect so the debugger can call it during side-effect-free + * evaluation. + */ + Local now; + if (!v8::Function::New(context, NowCallback, Local(), 0, + ConstructorBehavior::kThrow, + SideEffectType::kHasNoSideEffect) + .ToLocal(&now)) { + throw NativeScriptException("Performance::Init: failed to create now"); + } + + Local binding = Object::New(isolate); + binding->Set(context, ArgConverter::ConvertToV8String(isolate, "now"), now) + .Check(); + binding->Set(context, + ArgConverter::ConvertToV8String(isolate, "timeOrigin"), + Number::New(isolate, TimeOriginMillis(isolate))) + .Check(); + + Local result; + if (!BuiltinLoader::RunBuiltin(context, BuiltinId::kPerformance, binding) + .ToLocal(&result)) { + throw NativeScriptException( + "Performance::Init: the performance bootstrap failed"); + } +} + +double Performance::NowMillis(Isolate* isolate) { + Runtime* runtime = GetRuntimeOrNull(isolate); + if (runtime == nullptr) { + return 0.0; + } + + return runtime->PerformanceNowMillis(); +} + +double Performance::TimeOriginMillis(Isolate* isolate) { + Runtime* runtime = GetRuntimeOrNull(isolate); + if (runtime == nullptr) { + return 0.0; + } + + return runtime->TimeOriginMillis(); +} + +double Performance::MonotonicNanosToTimelineMillis(Isolate* isolate, + int64_t nanos) { + Runtime* runtime = GetRuntimeOrNull(isolate); + if (runtime == nullptr) { + return 0.0; + } + + return (double) nanos / 1e6 - runtime->TimeOriginMonotonicMillis(); +} + +void Performance::NowCallback(const FunctionCallbackInfo& info) { + info.GetReturnValue().Set(NowMillis(info.GetIsolate())); +} + +} // namespace tns diff --git a/test-app/runtime/src/main/cpp/Performance.h b/test-app/runtime/src/main/cpp/Performance.h new file mode 100644 index 000000000..c6a74a8fa --- /dev/null +++ b/test-app/runtime/src/main/cpp/Performance.h @@ -0,0 +1,54 @@ +#ifndef PERFORMANCE_H_ +#define PERFORMANCE_H_ + +#include "v8.h" + +namespace tns { + +class Performance { +public: + /* + * Installs the WHATWG Performance API by evaluating + * internal/performance.js with a bag of natives {now, timeOrigin}. + * Evaluated once per isolate during PrepareV8Runtime, for the main and + * worker isolates alike; each isolate carries its own time origin. Must + * run after Events::Init (Performance extends EventTarget), after + * ErrorEvents::Init (observer callback failures are reported through + * reportError) and after StructuredClone::Init (mark/measure `detail` is + * cloned through structuredClone). + */ + static void Init(v8::Local context); + + /* + * Milliseconds elapsed on this isolate's performance timeline: monotonic + * and zero at the time origin. Native producers of JS-visible timestamps + * (a future requestAnimationFrame) must read the clock through here + * instead of sampling one of their own, so every such timestamp shares + * performance.timeOrigin as its base. Returns 0.0 for an isolate with no + * runtime. + */ + static double NowMillis(v8::Isolate* isolate); + + /* + * Wall-clock milliseconds since the Unix epoch at the isolate's time + * origin, on the same base as Date.now(); this is + * performance.timeOrigin. Returns 0.0 for an isolate with no runtime. + */ + static double TimeOriginMillis(v8::Isolate* isolate); + + /* + * Maps a CLOCK_MONOTONIC timestamp in nanoseconds -- Choreographer's + * frameTimeNanos, System.nanoTime() -- onto this isolate's performance + * timeline, so the result is directly comparable with performance.now(). + * Returns 0.0 for an isolate with no runtime. + */ + static double MonotonicNanosToTimelineMillis(v8::Isolate* isolate, + int64_t nanos); + +private: + static void NowCallback(const v8::FunctionCallbackInfo& info); +}; + +} // namespace tns + +#endif /* PERFORMANCE_H_ */ diff --git a/test-app/runtime/src/main/cpp/Runtime.cpp b/test-app/runtime/src/main/cpp/Runtime.cpp index 69ce59328..84bc4aef0 100644 --- a/test-app/runtime/src/main/cpp/Runtime.cpp +++ b/test-app/runtime/src/main/cpp/Runtime.cpp @@ -18,6 +18,7 @@ #include "ErrorEvents.h" #include "Events.h" #include "File.h" +#include "FrameCallbacks.h" #include "Interop.h" #include "IsolateDisposer.h" #include "JType.h" @@ -30,6 +31,7 @@ #include "ModuleInternalCallbacks.h" #include "NativeScriptAssert.h" #include "NativeScriptException.h" +#include "Performance.h" #include "SimpleAllocator.h" #include "SimpleProfiler.h" #include "StructuredClone.h" @@ -299,6 +301,7 @@ Runtime::~Runtime() { delete this->m_objectManager; delete this->m_loopTimer; CallbackHandlers::RemoveIsolateEntries(m_isolate); + FrameCallbacks::RemoveIsolateEntries(m_isolate); if (m_isMainThread) { if (m_mainLooper_fd[0] != -1) { ALooper_removeFd(m_mainLooper, m_mainLooper_fd[0]); @@ -620,11 +623,9 @@ Isolate* Runtime::PrepareV8Runtime(const string& filesPath, tns::instrumentation::Frame isolateFrame; auto isolate = Isolate::New(create_params); - // Capture start and realtime origin - // MonotonicallyIncreasingTime returns seconds as double; store for - // performance.now() - m_startTime = platform->MonotonicallyIncreasingTime(); - m_realtimeOrigin = platform->CurrentClockTimeMillis(); + // MonotonicallyIncreasingTime returns seconds as a double. + m_timeOriginMonotonic = platform->MonotonicallyIncreasingTime(); + m_timeOriginRealtimeMs = platform->CurrentClockTimeMillis(); isolateFrame.log("Isolate.New"); { @@ -697,19 +698,6 @@ Isolate* Runtime::PrepareV8Runtime(const string& filesPath, ArgConverter::ConvertToV8String(isolate, "__time"), FunctionTemplate::New(isolate, CallbackHandlers::TimeCallback)); - // performance object (performance.now() + timeOrigin) - { - auto performanceTemplate = ObjectTemplate::New(isolate); - auto nowFunc = - FunctionTemplate::New(isolate, Runtime::PerformanceNowCallback); - performanceTemplate->Set(ArgConverter::ConvertToV8String(isolate, "now"), - nowFunc); - performanceTemplate->Set( - ArgConverter::ConvertToV8String(isolate, "timeOrigin"), - Number::New(isolate, m_realtimeOrigin)); - globalTemplate->Set(ArgConverter::ConvertToV8String(isolate, "performance"), - performanceTemplate); - } // queueMicrotask(callback) per spec: // https://developer.mozilla.org/en-US/docs/Web/API/Window/queueMicrotask globalTemplate->Set( @@ -737,12 +725,7 @@ Isolate* Runtime::PrepareV8Runtime(const string& filesPath, ArgConverter::ConvertToV8String(isolate, "__runOnMainThread"), FunctionTemplate::New(isolate, CallbackHandlers::RunOnMainThreadCallback)); - globalTemplate->Set( - ArgConverter::ConvertToV8String(isolate, "__postFrameCallback"), - FunctionTemplate::New(isolate, CallbackHandlers::PostFrameCallback)); - globalTemplate->Set( - ArgConverter::ConvertToV8String(isolate, "__removeFrameCallback"), - FunctionTemplate::New(isolate, CallbackHandlers::RemoveFrameCallback)); + FrameCallbacks::Init(isolate, globalTemplate); globalTemplate->Set(ArgConverter::ConvertToV8String(isolate, "URL"), URLImpl::GetCtor(isolate)); globalTemplate->Set( @@ -856,6 +839,10 @@ Isolate* Runtime::PrepareV8Runtime(const string& filesPath, StructuredClone::Init(context); + // The WHATWG performance surface. After StructuredClone::Init: + // mark/measure `detail` is cloned through the structuredClone global. + Performance::Init(context); + // The `interop` global (interop.escapeException), mirroring iOS. Interop::Init(context); @@ -953,14 +940,9 @@ void Runtime::SetManualInstrumentationMode(jstring mode) { } } -void Runtime::PerformanceNowCallback( - const v8::FunctionCallbackInfo& args) { - auto isolate = args.GetIsolate(); - auto runtime = Runtime::GetRuntime(isolate); - // Difference in seconds * 1000 for ms - double ms = - (platform->MonotonicallyIncreasingTime() - runtime->m_startTime) * 1000.0; - args.GetReturnValue().Set(ms); +double Runtime::PerformanceNowMillis() { + return (platform->MonotonicallyIncreasingTime() - m_timeOriginMonotonic) * + 1000.0; } void Runtime::DestroyRuntime() { diff --git a/test-app/runtime/src/main/cpp/Runtime.h b/test-app/runtime/src/main/cpp/Runtime.h index ec80975f9..b1a5b970d 100644 --- a/test-app/runtime/src/main/cpp/Runtime.h +++ b/test-app/runtime/src/main/cpp/Runtime.h @@ -115,6 +115,32 @@ class Runtime { return m_looperTasks; } + /* + * Milliseconds since this runtime's time origin, on the monotonic + * clock. Not inline: v8::Platform is only forward-declared through + * v8.h here. + */ + double PerformanceNowMillis(); + + /* + * Wall-clock milliseconds since the Unix epoch at the moment the time + * origin was captured, on the same base as Date.now(); this is + * performance.timeOrigin. + */ + double TimeOriginMillis() const { + return m_timeOriginRealtimeMs; + } + + /* + * The time origin as a CLOCK_MONOTONIC reading in milliseconds -- the + * same clock and epoch Choreographer stamps frames with. Subtracting + * it from such a timestamp maps it onto this isolate's performance + * timeline. + */ + double TimeOriginMonotonicMillis() const { + return m_timeOriginMonotonic * 1000.0; + } + /* * WHATWG events state, the Android analogue of the iOS runtime's * Caches members of the same names. The backing event target is set @@ -218,14 +244,11 @@ class Runtime { bool m_isMainThread; - // High resolution timing origin values - // m_startTime: monotonic clock time captured at isolate creation - // m_realtimeOrigin: wall-clock time origin (milliseconds) captured at isolate creation - double m_startTime {0}; - double m_realtimeOrigin {0}; - - // performance.now() callback - static void PerformanceNowCallback(const v8::FunctionCallbackInfo& args); + // This isolate's performance time origin, captured at isolate + // creation: the monotonic clock reading now() is relative to, and the + // wall-clock milliseconds that reading corresponds to. + double m_timeOriginMonotonic {0}; + double m_timeOriginRealtimeMs {0}; v8::Isolate* PrepareV8Runtime(const std::string& filesPath, const std::string& nativeLibsDir, const std::string& packageName, bool isDebuggable, const std::string& callingDir, const std::string& profilerOutputDir, const int maxLogcatObjectSize, const bool forceLog); jobject ConvertJsValueToJavaObject(JEnv& env, const v8::Local& value, int classReturnType); diff --git a/test-app/runtime/src/main/cpp/js/performance.js b/test-app/runtime/src/main/cpp/js/performance.js new file mode 100644 index 000000000..9059f251a --- /dev/null +++ b/test-app/runtime/src/main/cpp/js/performance.js @@ -0,0 +1,609 @@ +"use strict"; +// High Resolution Time + User Timing Level 3 + Performance Timeline: the +// `performance` global and its interface objects. The native side contributes +// exactly two things through the binding bag — `now()` (double ms since this +// isolate's time origin, monotonic) and `timeOrigin` (wall-clock ms since the +// Unix epoch, sampled when the isolate's time origin was taken) — everything +// else is portable JS, kept in sync with the iOS runtime's copy of this file, +// which runs against the same bag. +// +// Deliberate deviations from the specs: +// - Observer callbacks are delivered from a microtask rather than a queued +// task. Delivery is still asynchronous relative to mark()/measure(), but it +// precedes timer callbacks scheduled in the same turn. +// - Failures the specs express as DOMException (SyntaxError, +// InvalidModificationError) are Error instances with `name` patched — +// DOMException does not exist in this runtime. +const { now, timeOrigin } = binding; +const { + ArrayPrototypeIndexOf, + ArrayPrototypePush, + ArrayPrototypeSlice, + ArrayPrototypeSort, + ArrayPrototypeSplice, + Error, + FunctionPrototypeCall, + Number, + NumberIsFinite, + ObjectDefineProperty, + ObjectFreeze, + ObjectGetOwnPropertyDescriptor, + String, + SymbolIterator, + SymbolToStringTag, + TypeError, +} = primordials; +var g = globalThis; +// Init order (Runtime::PrepareV8Runtime) guarantees events.js and +// error-events.js ran first; captured before user code can replace them. +const EventTarget = g.EventTarget; +const enqueueMicrotask = g.queueMicrotask; +const reportException = g.reportError; +// mark/measure `detail` is structured-cloned per spec, so an entry holds a +// snapshot and an uncloneable detail throws DataCloneError. The identity +// fallback keeps this file portable to a runtime that ships the Performance +// API before structuredClone — there, detail degrades to by-reference; the +// user-timing buffers are unbounded either way, so entries retain their +// detail until clearMarks()/clearMeasures(). +const cloneDetail = + typeof g.structuredClone === "function" + ? g.structuredClone + : function (value) { + return value; + }; + +// Construction token: interfaces whose constructors the spec marks as not +// user-invocable accept instances only from factories inside this module. +const kInternal = {}; + +function illegalConstructor() { + return new TypeError("Illegal constructor"); +} + +// SyntaxError / InvalidModificationError stand-in (see header). +function domException(message, name) { + const e = new Error(message); + e.name = name; + return e; +} + +// WebIDL sequence conversion: only an object with a callable +// @@iterator converts (so a Set works and a string primitive does not); +// anything else is a TypeError, never a silent no-op. +function convertStringSequence(value, context) { + if ( + value === null || + (typeof value !== "object" && typeof value !== "function") || + typeof value[SymbolIterator] !== "function" + ) { + throw new TypeError(context + " is not iterable"); + } + const result = []; + for (const item of value) { + ArrayPrototypePush(result, String(item)); + } + return result; +} + +class PerformanceEntry { + #name; + #entryType; + #startTime; + #duration; + constructor(token, name, entryType, startTime, duration) { + if (token !== kInternal) { + throw illegalConstructor(); + } + this.#name = name; + this.#entryType = entryType; + this.#startTime = startTime; + this.#duration = duration; + } + get name() { + return this.#name; + } + get entryType() { + return this.#entryType; + } + get startTime() { + return this.#startTime; + } + get duration() { + return this.#duration; + } + toJSON() { + return { + name: this.#name, + entryType: this.#entryType, + startTime: this.#startTime, + duration: this.#duration, + }; + } +} + +class PerformanceMark extends PerformanceEntry { + #detail; + constructor(markName, markOptions) { + if (arguments.length < 1) { + throw new TypeError("PerformanceMark: 1 argument required, but only 0 present"); + } + const name = String(markName); + let startTime; + let detail = null; + if (markOptions !== undefined && markOptions !== null) { + if (typeof markOptions !== "object" && typeof markOptions !== "function") { + throw new TypeError("PerformanceMark: options must be an object"); + } + if (markOptions.startTime !== undefined) { + startTime = Number(markOptions.startTime); + if (!NumberIsFinite(startTime)) { + throw new TypeError("PerformanceMark: startTime must be a finite number"); + } + if (startTime < 0) { + throw new TypeError("PerformanceMark: startTime cannot be negative"); + } + } + if (markOptions.detail !== undefined && markOptions.detail !== null) { + detail = cloneDetail(markOptions.detail); + } + } + super(kInternal, name, "mark", startTime === undefined ? now() : startTime, 0); + this.#detail = detail; + } + get detail() { + return this.#detail; + } + toJSON() { + const json = super.toJSON(); + json.detail = this.#detail; + return json; + } +} + +class PerformanceMeasure extends PerformanceEntry { + #detail; + constructor(token, name, startTime, duration, detail) { + if (token !== kInternal) { + throw illegalConstructor(); + } + super(token, name, "measure", startTime, duration); + this.#detail = detail; + } + get detail() { + return this.#detail; + } + toJSON() { + const json = super.toJSON(); + json.detail = this.#detail; + return json; + } +} + +// ---- Performance timeline (per-isolate; this module runs once per isolate). + +const entries = []; // marks + measures, insertion order + +// Spec ordering for every query result: chronological by startTime; V8's +// stable sort preserves insertion order for ties. A copy is always returned — +// a measure may be inserted with a startTime that precedes buffered marks. +function chronological(list) { + const copy = ArrayPrototypeSlice(list); + ArrayPrototypeSort(copy, function (a, b) { + return a.startTime - b.startTime; + }); + return copy; +} + +function queryEntries(name, type) { + const result = []; + for (let i = 0; i < entries.length; i++) { + const e = entries[i]; + if (type !== undefined && e.entryType !== type) { + continue; + } + if (name !== undefined && e.name !== name) { + continue; + } + ArrayPrototypePush(result, e); + } + return chronological(result); +} + +function clearEntries(type, name) { + for (let i = entries.length - 1; i >= 0; i--) { + const e = entries[i]; + if (e.entryType !== type) { + continue; + } + if (name !== undefined && e.name !== name) { + continue; + } + ArrayPrototypeSplice(entries, i, 1); + } +} + +function bufferEntry(entry) { + ArrayPrototypePush(entries, entry); + notifyObservers(entry); +} + +// User Timing "convert a mark to a timestamp": numbers are timestamps +// (negative is a TypeError), everything else names the most recent mark. +function convertMarkToTimestamp(value) { + if (typeof value === "number") { + if (!NumberIsFinite(value)) { + throw new TypeError("Given timestamp must be a finite number"); + } + if (value < 0) { + throw new TypeError("Given timestamp cannot be negative"); + } + return value; + } + const name = String(value); + for (let i = entries.length - 1; i >= 0; i--) { + const e = entries[i]; + if (e.entryType === "mark" && e.name === name) { + return e.startTime; + } + } + throw domException("The mark '" + name + "' does not exist", "SyntaxError"); +} + +// ---- Performance observers. + +const SUPPORTED_ENTRY_TYPES = ObjectFreeze(["mark", "measure"]); + +// Registration order is the spec's delivery order. Each element is an +// observer's internal record ({ observer, callback, types, queue, mode }), +// reachable only from its PerformanceObserver instance and this list. +const observers = []; +let flushScheduled = false; + +function scheduleFlush() { + if (flushScheduled) { + return; + } + flushScheduled = true; + enqueueMicrotask(flushObservers); +} + +function flushObservers() { + flushScheduled = false; + const snapshot = ArrayPrototypeSlice(observers); + for (let i = 0; i < snapshot.length; i++) { + const record = snapshot[i]; + if (record.queue.length === 0) { + continue; + } + const taken = chronological(record.queue); + record.queue = []; + const list = new PerformanceObserverEntryList(kInternal, taken); + try { + FunctionPrototypeCall(record.callback, record.observer, list, record.observer); + } catch (e) { + // Spec: report the exception; one throwing observer must not starve + // the observers behind it. + reportException(e); + } + } +} + +function notifyObservers(entry) { + const type = entry.entryType; + let queued = false; + for (let i = 0; i < observers.length; i++) { + const record = observers[i]; + if (ArrayPrototypeIndexOf(record.types, type) === -1) { + continue; + } + ArrayPrototypePush(record.queue, entry); + queued = true; + } + if (queued) { + scheduleFlush(); + } +} + +class PerformanceObserverEntryList { + #entries; + constructor(token, list) { + if (token !== kInternal) { + throw illegalConstructor(); + } + this.#entries = list; + } + getEntries() { + return ArrayPrototypeSlice(this.#entries); + } + getEntriesByType(type) { + if (arguments.length < 1) { + throw new TypeError("getEntriesByType: 1 argument required, but only 0 present"); + } + return filterList(this.#entries, undefined, String(type)); + } + getEntriesByName(name, type) { + if (arguments.length < 1) { + throw new TypeError("getEntriesByName: 1 argument required, but only 0 present"); + } + return filterList(this.#entries, String(name), type === undefined ? undefined : String(type)); + } +} + +function filterList(list, name, type) { + const result = []; + for (let i = 0; i < list.length; i++) { + const e = list[i]; + if (type !== undefined && e.entryType !== type) { + continue; + } + if (name !== undefined && e.name !== name) { + continue; + } + ArrayPrototypePush(result, e); + } + return result; +} + +class PerformanceObserver { + #record; + constructor(callback) { + if (typeof callback !== "function") { + throw new TypeError("PerformanceObserver: the callback is not a function"); + } + this.#record = { + observer: this, + callback: callback, + types: [], + queue: [], + // Locked to "multiple" (entryTypes) or "single" (type) by the first + // observe() call, permanently — the spec forbids switching forms. + mode: null, + }; + } + observe(options) { + const record = this.#record; + if (options === null || typeof options !== "object") { + throw new TypeError("observe: options must be an object"); + } + const hasEntryTypes = options.entryTypes !== undefined; + const hasType = options.type !== undefined; + if (!hasEntryTypes && !hasType) { + throw new TypeError("observe: an observe() call must include either entryTypes or type"); + } + if (hasEntryTypes && (hasType || options.buffered !== undefined)) { + throw new TypeError("observe: entryTypes cannot be combined with type or buffered"); + } + const mode = hasEntryTypes ? "multiple" : "single"; + if (record.mode !== null && record.mode !== mode) { + throw domException( + "observe: this observer already used the " + + (record.mode === "multiple" ? "entryTypes" : "type") + + " form and cannot switch", + "InvalidModificationError" + ); + } + if (hasEntryTypes) { + const requested = convertStringSequence(options.entryTypes, "entryTypes"); + const supported = []; + for (let i = 0; i < requested.length; i++) { + const t = requested[i]; + if ( + ArrayPrototypeIndexOf(SUPPORTED_ENTRY_TYPES, t) !== -1 && + ArrayPrototypeIndexOf(supported, t) === -1 + ) { + ArrayPrototypePush(supported, t); + } + } + // Spec: no supported types → abort without throwing (and without + // registering). + if (supported.length === 0) { + return; + } + record.mode = mode; + record.types = supported; // the entryTypes form replaces the set + } else { + const t = String(options.type); + if (ArrayPrototypeIndexOf(SUPPORTED_ENTRY_TYPES, t) === -1) { + return; + } + record.mode = mode; + if (ArrayPrototypeIndexOf(record.types, t) === -1) { + ArrayPrototypePush(record.types, t); // the type form accumulates + } + if (options.buffered) { + for (let i = 0; i < entries.length; i++) { + if (entries[i].entryType === t) { + ArrayPrototypePush(record.queue, entries[i]); + } + } + if (record.queue.length !== 0) { + scheduleFlush(); + } + } + } + if (ArrayPrototypeIndexOf(observers, record) === -1) { + ArrayPrototypePush(observers, record); + } + } + disconnect() { + const record = this.#record; + const idx = ArrayPrototypeIndexOf(observers, record); + if (idx !== -1) { + ArrayPrototypeSplice(observers, idx, 1); + } + record.queue = []; // pending records are dropped; takeRecords() first to keep them + } + takeRecords() { + const record = this.#record; + const taken = chronological(record.queue); + record.queue = []; + return taken; + } + static get supportedEntryTypes() { + return SUPPORTED_ENTRY_TYPES; + } +} + +class Performance extends EventTarget { + constructor(token) { + if (token !== kInternal) { + throw illegalConstructor(); + } + super(); + // The EventTarget base installs `_listeners` as an own enumerable field; + // keep it out of Object.keys(performance)/JSON.stringify(performance). + ObjectDefineProperty(this, "_listeners", { + value: this._listeners, + writable: true, + enumerable: false, + configurable: true, + }); + } + get timeOrigin() { + return timeOrigin; + } + now() { + return now(); + } + toJSON() { + return { timeOrigin: timeOrigin }; + } + mark(markName, markOptions) { + if (arguments.length < 1) { + throw new TypeError("mark: 1 argument required, but only 0 present"); + } + const entry = new PerformanceMark(markName, markOptions); + bufferEntry(entry); + return entry; + } + measure(measureName, startOrMeasureOptions, endMark) { + if (arguments.length < 1) { + throw new TypeError("measure: 1 argument required, but only 0 present"); + } + const name = String(measureName); + const isOptionsObject = + startOrMeasureOptions !== null && typeof startOrMeasureOptions === "object"; + let startTime; + let endTime; + let detail = null; + if ( + isOptionsObject && + (startOrMeasureOptions.start !== undefined || + startOrMeasureOptions.end !== undefined || + startOrMeasureOptions.duration !== undefined || + startOrMeasureOptions.detail !== undefined) + ) { + const o = startOrMeasureOptions; + if (endMark !== undefined) { + throw new TypeError("measure: endMark cannot be combined with a measure options object"); + } + if (o.start === undefined && o.end === undefined) { + throw new TypeError("measure: the options object must specify start and/or end"); + } + if (o.start !== undefined && o.end !== undefined && o.duration !== undefined) { + throw new TypeError("measure: cannot specify start, end and duration together"); + } + let duration; + if (o.duration !== undefined) { + duration = Number(o.duration); + if (!NumberIsFinite(duration)) { + throw new TypeError("measure: duration must be a finite number"); + } + } + if (o.end !== undefined) { + endTime = convertMarkToTimestamp(o.end); + } else if (o.start !== undefined && duration !== undefined) { + endTime = convertMarkToTimestamp(o.start) + duration; + } else { + endTime = now(); + } + if (o.start !== undefined) { + startTime = convertMarkToTimestamp(o.start); + } else if (duration !== undefined) { + startTime = endTime - duration; + } else { + startTime = 0; + } + if (o.detail !== undefined && o.detail !== null) { + detail = cloneDetail(o.detail); + } + } else { + endTime = endMark !== undefined ? convertMarkToTimestamp(endMark) : now(); + // A members-free options object means "no start given", not a mark name, + // and so does null: the WebIDL union converts it to an empty dictionary + // rather than to the string "null". + startTime = + startOrMeasureOptions !== undefined && + startOrMeasureOptions !== null && + !isOptionsObject + ? convertMarkToTimestamp(startOrMeasureOptions) + : 0; + } + const entry = new PerformanceMeasure(kInternal, name, startTime, endTime - startTime, detail); + bufferEntry(entry); + return entry; + } + clearMarks(markName) { + clearEntries("mark", markName === undefined ? undefined : String(markName)); + } + clearMeasures(measureName) { + clearEntries("measure", measureName === undefined ? undefined : String(measureName)); + } + getEntries() { + return chronological(entries); + } + getEntriesByType(type) { + if (arguments.length < 1) { + throw new TypeError("getEntriesByType: 1 argument required, but only 0 present"); + } + return queryEntries(undefined, String(type)); + } + getEntriesByName(name, type) { + if (arguments.length < 1) { + throw new TypeError("getEntriesByName: 1 argument required, but only 0 present"); + } + return queryEntries(String(name), type === undefined ? undefined : String(type)); + } +} + +// WebIDL shape: interface members are enumerable prototype properties and the +// class string is a configurable, non-writable Symbol.toStringTag; class +// syntax alone yields non-enumerable members. +function finishInterface(ctor, tag, members) { + const proto = ctor.prototype; + ObjectDefineProperty(proto, SymbolToStringTag, { + value: tag, + writable: false, + enumerable: false, + configurable: true, + }); + for (let i = 0; i < members.length; i++) { + const desc = ObjectGetOwnPropertyDescriptor(proto, members[i]); + desc.enumerable = true; + ObjectDefineProperty(proto, members[i], desc); + } +} +finishInterface(PerformanceEntry, "PerformanceEntry", [ + "name", "entryType", "startTime", "duration", "toJSON", +]); +finishInterface(PerformanceMark, "PerformanceMark", ["detail", "toJSON"]); +finishInterface(PerformanceMeasure, "PerformanceMeasure", ["detail", "toJSON"]); +finishInterface(PerformanceObserverEntryList, "PerformanceObserverEntryList", [ + "getEntries", "getEntriesByType", "getEntriesByName", +]); +finishInterface(PerformanceObserver, "PerformanceObserver", [ + "observe", "disconnect", "takeRecords", +]); +finishInterface(Performance, "Performance", [ + "timeOrigin", "now", "toJSON", "mark", "measure", + "clearMarks", "clearMeasures", + "getEntries", "getEntriesByType", "getEntriesByName", +]); + +g.Performance = Performance; +g.PerformanceEntry = PerformanceEntry; +g.PerformanceMark = PerformanceMark; +g.PerformanceMeasure = PerformanceMeasure; +g.PerformanceObserver = PerformanceObserver; +g.PerformanceObserverEntryList = PerformanceObserverEntryList; +g.performance = new Performance(kInternal); diff --git a/test-app/runtime/src/main/cpp/js/primordials.js b/test-app/runtime/src/main/cpp/js/primordials.js index a1f75b0ff..8970295e9 100644 --- a/test-app/runtime/src/main/cpp/js/primordials.js +++ b/test-app/runtime/src/main/cpp/js/primordials.js @@ -32,6 +32,7 @@ const intrinsics = { // Well-known symbols. SymbolIterator: Symbol.iterator, + SymbolToStringTag: Symbol.toStringTag, // Namespaces / prototypes. ObjectPrototype: Object.prototype, @@ -40,6 +41,7 @@ const intrinsics = { ArrayBufferIsView: ArrayBuffer.isView, ArrayIsArray: Array.isArray, JSONStringify: JSON.stringify, + NumberIsFinite: Number.isFinite, NumberIsNaN: Number.isNaN, NumberParseFloat: Number.parseFloat, NumberParseInt: Number.parseInt, @@ -57,6 +59,7 @@ const intrinsics = { ArrayPrototypeIndexOf: uncurryThis(Array.prototype.indexOf), ArrayPrototypePush: uncurryThis(Array.prototype.push), ArrayPrototypeSlice: uncurryThis(Array.prototype.slice), + ArrayPrototypeSort: uncurryThis(Array.prototype.sort), ArrayPrototypeSplice: uncurryThis(Array.prototype.splice), DatePrototypeGetTime: uncurryThis(Date.prototype.getTime), DatePrototypeToISOString: uncurryThis(Date.prototype.toISOString), diff --git a/test-app/runtime/src/main/java/com/tns/FrameCallbacks.java b/test-app/runtime/src/main/java/com/tns/FrameCallbacks.java new file mode 100644 index 000000000..7fb305931 --- /dev/null +++ b/test-app/runtime/src/main/java/com/tns/FrameCallbacks.java @@ -0,0 +1,54 @@ +package com.tns; + +import android.view.Choreographer; + +/** + * Frame callback bridge for API levels without the NDK's AChoreographer (which + * arrived in API 24). One instance per scheduled JS callback, bound to the + * Choreographer of the thread that scheduled it -- the thread that owns the + * isolate -- so doFrame arrives on that thread. Created and used exclusively + * from native code (FrameCallbacks.cpp). + */ +final class FrameCallbacks implements Choreographer.FrameCallback { + private final long entryId; + private final Choreographer choreographer; + // Read on the frame thread, set from Runtime teardown, which is not + // guaranteed to be that thread. + private volatile boolean released; + + // constructed from native code (FrameCallbacks::PostJava) + FrameCallbacks(long entryId) { + this.entryId = entryId; + this.choreographer = Choreographer.getInstance(); + } + + @RuntimeCallable + void post(long delayMillis) { + if (delayMillis > 0) { + choreographer.postFrameCallbackDelayed(this, delayMillis); + } else { + choreographer.postFrameCallback(this); + } + } + + /** + * Called from native when the entry is dropped. A frame already queued for + * this instance still arrives, so the flag -- not removeFrameCallback -- + * is what keeps it from reaching a retired native entry. + */ + @RuntimeCallable + void release() { + released = true; + choreographer.removeFrameCallback(this); + } + + @Override + public void doFrame(long frameTimeNanos) { + if (released) { + return; + } + nativeDoFrame(entryId, frameTimeNanos); + } + + private static native void nativeDoFrame(long entryId, long frameTimeNanos); +}