diff --git a/Cargo.lock b/Cargo.lock index 45f0219625..3ef2e223a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6409,6 +6409,7 @@ dependencies = [ "objc2", "objc2-core-foundation", "objc2-foundation", + "objc2-quartz-core", "objc2-ui-kit", "perry-runtime", "perry-ui", diff --git a/benchmarks/ios-ui/bench.ts b/benchmarks/ios-ui/bench.ts new file mode 100644 index 0000000000..d7cc0cf474 --- /dev/null +++ b/benchmarks/ios-ui/bench.ts @@ -0,0 +1,187 @@ +// iOS UI frame-time benchmark. +// +// Cycles through the workloads a cross-platform UI framework is normally +// judged on, printing a marker before each so the `[frame-stats]` lines the +// runtime emits can be attributed to a workload. +// +// Run with metrics on: +// +// PERRY_FRAME_STATS=1 perry run benchmarks/ios-ui/bench.ts --target ios +// +// `PERRY_FRAME_STATS_INTERVAL` (default 300 frames) sets the reporting +// cadence. The scroll and text-heavy phases need you to actually drag the +// list — nothing here injects touches, and a stationary list measures the +// idle path, not scrolling. + +import { + App, + VStack, + Text, + ScrollView, + scrollviewSetChild, + widgetAddChild, + widgetClearChildren, + widgetSetBackgroundColor, + textSetString, + onFrame, +} from "perry/ui" + +// Rows rebuilt per cycle in the add/remove phase. Large enough that the old +// O(children x every widget ever created) handle scan was visible, small +// enough to stay inside a frame when it isn't. +const CHURN_ROWS = 100 +// Labels mutated every frame in the property-update phase. +const LIVE_LABELS = 50 +// Rows built once for the text-heavy phase. +const TEXT_ROWS = 300 + +// 20s rather than 8s: the phases have to be long enough for a human to react +// to a phase marker and scroll inside the window, and long enough to yield +// several reports of steady state after the transition's own cost has washed +// out of the first one. +const PHASE_SECONDS = 20 + +type Phase = { + name: string + // Called once when the phase starts. + enter: () => void + // Called every frame while the phase is active. + tick: (frame: number) => void +} + +const content = VStack(4, []) +const scroll = ScrollView() +scrollviewSetChild(scroll, content) + +// Labels kept live across frames for the property-update and animation +// phases, so those measure mutation rather than construction. +const liveLabels: unknown[] = [] + +function clearContent(): void { + widgetClearChildren(content) + liveLabels.length = 0 +} + +function buildLabels(count: number, prefix: string): void { + for (let i = 0; i < count; i++) { + const row = Text(`${prefix} ${i}`) + liveLabels.push(row) + widgetAddChild(content, row) + } +} + +const phases: Phase[] = [ + { + // Baseline. Establishes the floor everything else is read against — a + // p99 that is already bad here is not a workload problem. + name: "idle", + enter: (): void => { + clearContent() + // 60, not 20: every phase must overflow the screen or it cannot be + // dragged, and a phase that cannot be dragged silently measures the + // static path while looking like a scroll result. + buildLabels(60, "idle row") + }, + tick: (): void => {}, + }, + { + // Frequent property updates: the label-text path, which is the + // cheapest possible cross-framework UI operation and therefore the + // clearest read on per-call dispatch overhead. + name: "property-updates", + enter: (): void => { + clearContent() + buildLabels(LIVE_LABELS, "live") + }, + tick: (frame: number): void => { + for (let i = 0; i < liveLabels.length; i++) { + textSetString(liveLabels[i] as never, `live ${i} @ ${frame}`) + } + }, + }, + { + // Adding/removing many elements. This is the phase the widget-table + // handle scan used to dominate, and it degraded as the run went on, + // so a flat p99 across the whole phase is the thing to check. + name: "add-remove", + enter: (): void => { + clearContent() + }, + tick: (frame: number): void => { + // Every 6th frame, so the rebuild cost is visible as a spike + // rather than smeared across every frame. + if (frame % 6 !== 0) return + clearContent() + buildLabels(CHURN_ROWS, "row") + }, + }, + { + // Animation: per-frame property churn across many widgets. + name: "animation", + enter: (): void => { + clearContent() + buildLabels(LIVE_LABELS, "anim") + }, + tick: (frame: number): void => { + for (let i = 0; i < liveLabels.length; i++) { + const t = ((frame + i * 3) % 60) / 60 + widgetSetBackgroundColor(liveLabels[i] as never, t, 0.4, 1 - t, 1) + } + }, + }, + { + // Text-heavy screen. Built once, then static — drag the list during + // this phase to measure scrolling. + name: "text-heavy (scroll me)", + enter: (): void => { + clearContent() + for (let i = 0; i < TEXT_ROWS; i++) { + widgetAddChild( + content, + Text( + `${i}. The quick brown fox jumps over the lazy dog, ` + + `and then keeps going so this line has to wrap.`, + ), + ) + } + }, + tick: (): void => {}, + }, +] + +let phaseIndex = -1 +let phaseStartMs = 0 +let frame = 0 + +function enterPhase(index: number, nowMs: number): void { + phaseIndex = index + phaseStartMs = nowMs + frame = 0 + const phase = phases[index] + // The runtime's [frame-stats] lines carry no workload label; this marker + // is what makes them attributable. + console.log(`=== phase: ${phase.name} ===`) + phase.enter() +} + +function loop(timestampMs: number): void { + if (phaseIndex < 0) { + enterPhase(0, timestampMs) + } else if (timestampMs - phaseStartMs >= PHASE_SECONDS * 1000) { + enterPhase((phaseIndex + 1) % phases.length, timestampMs) + } + + phases[phaseIndex].tick(frame) + frame++ + + onFrame(loop) +} + +onFrame(loop) + +App({ + title: "Perry UI Bench", + width: 400, + height: 800, + body: VStack(0, [scroll]), +}) diff --git a/benchmarks/ios-ui/isolate_autoscroll.ts b/benchmarks/ios-ui/isolate_autoscroll.ts new file mode 100644 index 0000000000..d7faefcdb0 --- /dev/null +++ b/benchmarks/ios-ui/isolate_autoscroll.ts @@ -0,0 +1,76 @@ +// Isolation probe: programmatic scrolling + label mutation, no human needed. +// +// #7763 has so far only reproduced with a person dragging the list, which makes +// it expensive to hunt. This drives the scroll offset from the frame callback +// instead, combining the two ingredients the crash needs — scroll-driven layout +// and live label mutation — without touch input. +// +// KNOWN LIMITATION: `setContentOffset` does not run UIScrollView's gesture +// recognizer and does not switch the run loop into UITrackingRunLoopMode, so +// this is not equivalent to a real drag. If it reproduces, we get a +// human-free repro. If it does not, that is itself informative: it narrows the +// trigger to something only a real touch sequence provides (tracking mode, +// deceleration, or the gesture recognizer's own layout work). + +import { + App, + VStack, + Text, + ScrollView, + scrollviewSetChild, + scrollviewSetOffset, + widgetAddChild, + textSetString, + onFrame, +} from "perry/ui" + +const ROWS = 200 +const MUTATE = 50 + +const content = VStack(4, []) +const scroll = ScrollView() +scrollviewSetChild(scroll, content) + +const labels: unknown[] = [] +for (let i = 0; i < ROWS; i++) { + const row = Text(`row ${i}`) + labels.push(row) + widgetAddChild(content, row) +} + +let frame = 0 +let offset = 0 +let direction = 1 + +function loop(): void { + frame++ + + // Sawtooth scroll, fast enough to keep layout continuously busy. + offset += direction * 40 + if (offset > 3000) { + direction = -1 + } else if (offset < 0) { + direction = 1 + } + scrollviewSetOffset(scroll, 0, offset) + + // Mutate labels while the scroll layout is in flight — the same + // combination the crashing benchmark phase performs. + for (let i = 0; i < MUTATE; i++) { + textSetString(labels[i] as never, `row ${i} f${frame}`) + } + + if (frame % 600 === 0) { + console.log(`frames: ${frame} offset: ${offset}`) + } + onFrame(loop) +} + +onFrame(loop) + +App({ + title: "Autoscroll Isolate", + width: 400, + height: 800, + body: VStack(0, [scroll]), +}) diff --git a/benchmarks/ios-ui/isolate_churn.ts b/benchmarks/ios-ui/isolate_churn.ts new file mode 100644 index 0000000000..83bf16dbdc --- /dev/null +++ b/benchmarks/ios-ui/isolate_churn.ts @@ -0,0 +1,41 @@ +// Isolation probe: child churn WITHOUT onFrame. +// +// The full bench crashes on device with +// `malloc: pointer being freed was not allocated`. It differs from the +// known-stable minimal app in two ways: it drives `onFrame` (the CADisplayLink +// driver) and it churns children (`widgetClearChildren`, which now releases the +// removed subtree). This probe keeps the churn and drops `onFrame`, so a crash +// here implicates the release path and a clean run implicates the frame driver. + +import { + App, + VStack, + Text, + widgetAddChild, + widgetClearChildren, +} from "perry/ui" + +const ROWS = 100 +const content = VStack(4, []) + +let round = 0 + +function churn(): void { + round++ + widgetClearChildren(content) + for (let i = 0; i < ROWS; i++) { + widgetAddChild(content, Text(`row ${i} / round ${round}`)) + } + if (round % 10 === 0) { + console.log(`churn rounds: ${round}`) + } +} + +setInterval(churn, 50) + +App({ + title: "Churn Isolate", + width: 400, + height: 800, + body: VStack(0, [content]), +}) diff --git a/benchmarks/ios-ui/isolate_churn_frame.ts b/benchmarks/ios-ui/isolate_churn_frame.ts new file mode 100644 index 0000000000..db20213ea8 --- /dev/null +++ b/benchmarks/ios-ui/isolate_churn_frame.ts @@ -0,0 +1,61 @@ +// Isolation probe: child churn driven from `onFrame` instead of `setInterval`. +// +// `isolate_churn.ts` ran 3,230 clear+rebuild rounds from a `setInterval` with no +// crash. The full bench does the same structural mutation from `onFrame` and +// dies with: +// +// NSInvalidArgumentException: -[__NSArrayM insertObject:atIndex:]: +// object cannot be nil +// +// The only difference between the two is *where in the run loop* the mutation +// happens: an NSTimer fires at a quiescent point, while a CADisplayLink fires +// inside CoreAnimation's pre-commit phase. This probe changes nothing but the +// driver, so a crash here pins the fault on mutating the view hierarchy from a +// display-link callback rather than on the churn itself. + +import { + App, + VStack, + Text, + ScrollView, + scrollviewSetChild, + widgetAddChild, + widgetClearChildren, + onFrame, +} from "perry/ui" + +const ROWS = 100 +const content = VStack(4, []) +// Variable under test: the bench churns a stack that lives inside a +// UIScrollView, this probe originally churned one parented directly to the +// root. Churn-from-onFrame alone did NOT reproduce (640 rounds clean). +const scroll = ScrollView() +scrollviewSetChild(scroll, content) + +let frame = 0 +let round = 0 + +function loop(): void { + // Same cadence as the bench's add-remove phase. + if (frame % 6 === 0) { + round++ + widgetClearChildren(content) + for (let i = 0; i < ROWS; i++) { + widgetAddChild(content, Text(`row ${i} / round ${round}`)) + } + if (round % 10 === 0) { + console.log(`churn rounds: ${round}`) + } + } + frame++ + onFrame(loop) +} + +onFrame(loop) + +App({ + title: "Churn+Frame Isolate", + width: 400, + height: 800, + body: VStack(0, [scroll]), +}) diff --git a/benchmarks/ios-ui/isolate_frame.ts b/benchmarks/ios-ui/isolate_frame.ts new file mode 100644 index 0000000000..46bc992678 --- /dev/null +++ b/benchmarks/ios-ui/isolate_frame.ts @@ -0,0 +1,33 @@ +// Isolation probe: onFrame WITHOUT child churn. +// +// Companion to `isolate_churn.ts`. That probe ran 3,230 clear+rebuild rounds +// (~323k widgets) with no crash, so the release path is not the fault. This one +// keeps the `onFrame` loop and the frame-metrics reporting and does no +// structural mutation at all, isolating the CADisplayLink driver. + +import { App, VStack, Text, textSetString, onFrame } from "perry/ui" + +const label = Text("frame 0") +const content = VStack(4, [label]) + +let frames = 0 + +function loop(timestampMs: number, deltaMs: number): void { + frames++ + // A cheap property write, so the callback does something observable + // without touching the view hierarchy's structure. + textSetString(label, `frame ${frames} dt=${deltaMs.toFixed(2)}`) + if (frames % 600 === 0) { + console.log(`frames: ${frames} t=${timestampMs.toFixed(0)}`) + } + onFrame(loop) +} + +onFrame(loop) + +App({ + title: "Frame Isolate", + width: 400, + height: 800, + body: VStack(0, [content]), +}) diff --git a/benchmarks/ios-ui/perry.toml b/benchmarks/ios-ui/perry.toml new file mode 100644 index 0000000000..aabca4155c --- /dev/null +++ b/benchmarks/ios-ui/perry.toml @@ -0,0 +1,10 @@ +[project] +name = "perry-ui-bench" +version = "1.0.0" +display_name = "Perry UI Bench" + +[ios] +# Emits CADisableMinimumFrameDurationOnPhone. Without it iOS caps an iPhone at +# 60 Hz no matter what the display link asks for, so a ProMotion device would +# report a hard 60 and read as "the framework cannot sustain 120". +high_refresh_rate = true diff --git a/changelog.d/7754-ios-displaylink-frame-metrics.md b/changelog.d/7754-ios-displaylink-frame-metrics.md new file mode 100644 index 0000000000..bcf533bc56 --- /dev/null +++ b/changelog.d/7754-ios-displaylink-frame-metrics.md @@ -0,0 +1,31 @@ +### Added + +- **iOS: `onFrame` is driven by a real `CADisplayLink`, and frame times are measurable.** `perry/ui`'s frame callbacks were previously pumped from the 8 ms repeating `NSTimer` in `perry-ui-ios/src/app.rs` via `js_frame_pump_default()`. That is wrong for anything frame-shaped in two independent ways, and together they made iOS frame pacing unmeasurable rather than merely imprecise. + + **Not vsync-aligned.** An 8 ms free-running `NSTimer` beats against a 8.333 ms (120 Hz) or 16.667 ms (60 Hz) refresh instead of landing on it, and is subject to run-loop coalescing. **Millisecond-quantized.** `js_timer_now()` is `elapsed().as_millis() as f64` — whole milliseconds, or ~12% of a 120 Hz frame budget. A p99 computed from that clock is noise. + + New `perry-ui-ios/src/frame_driver.rs` installs a `CADisplayLink` on the main run loop and drives `js_frame_tick` from it. The timer pump keeps driving `setTimeout`/`setInterval`, microtasks, the stdlib pump and the GC step; it no longer drives frames, and now only reconciles the link's paused state. + + Three details that are load-bearing rather than incidental: + + - **`NSRunLoopCommonModes`, not the default mode.** A `UIScrollView` drag switches the run loop to `UITrackingRunLoopMode`. A link registered only in the default mode goes silent for the entire gesture — exactly the window a scrolling benchmark exists to measure. + - **The link's timestamp is rebased, not passed through.** `CADisplayLink.timestamp` is a `CFTimeInterval` on the `CACurrentMediaTime` base (seconds since boot), while `onFrame`'s documented contract is monotonic milliseconds since app start. Handing the raw media time to JS would silently break every app doing `t - startTime`. The first tick pins the media clock against `js_timer_now()` and later ticks report an offset from that pin, keeping the documented epoch while carrying the link's sub-microsecond resolution. + - **Pause state is polled from the timer pump, not from the link.** A paused link receives no ticks, so it cannot observe a newly-registered `onFrame` callback and wake itself. + +- **Frame-time metrics (`PERRY_FRAME_STATS=1`).** New `perry-runtime/src/frame_metrics.rs` turns a display-link vsync stream into p50/p95/p99 frame times, longest frame, and a dropped-frame count over a rolling 8192-frame window. Off by default; `js_frame_metrics_set_enabled` flips it at runtime so a benchmark can scope collection to one workload without relaunching. + + Dropped frames are derived from the driver-supplied *nominal* frame duration (`CADisplayLink.duration`) rather than a hardcoded 60 Hz, which is what makes the same code correct on a 120 Hz ProMotion display: `dropped = round(interval / expected) - 1`, so jitter within half a frame is not a drop and a doubled interval is exactly one. + + Three cases that would otherwise produce confident wrong numbers are handled explicitly: + + - **Deliberate pauses are not 4-second frames.** Backgrounding stops the link; attributing the gap as an interval would report hundreds of phantom dropped frames and poison the p99. `js_frame_metrics_mark_discontinuity` drops the running baseline without discarding collected samples, and the driver calls it on every pause transition. + - **One hitch cannot dominate the drop count.** A genuine 10 s stall is recorded at full length as the longest frame, but its dropped-frame attribution is capped, so the count still describes the run rather than the stall. + - **`js_frame_metrics_report()` returns its sample count.** Zero distinguishes "every frame was fast" from "the display link never ticked" — the two are indistinguishable in a summary line, and the second is the failure mode where a probe reports success without its subject having run. + + Collection keeps the link awake on its own, so an app with **no** `onFrame` subscribers — a plain scrolling list — still produces a full frame trace. + + Covered by 11 unit tests in `perry-runtime --lib` (nearest-rank percentiles, steady 120 Hz, the doubled-interval case, sub-half-frame jitter, the attribution cap, discontinuity, non-monotonic driver timestamps, ring-buffer wrap, the disabled path, and the report-zero case). They are unit tests rather than an integration suite deliberately: per-PR CI runs `--lib --bins`, so coverage placed under `crates/*/tests/` would not gate this. + + **Known limitation, on-device 120 Hz.** With metrics enabled the link requests the top of the ProMotion range instead of the adaptive default. On iPhone that request is capped at 60 Hz unless the bundle sets `CADisableMinimumFrameDurationOnPhone = YES`, and Perry's generated `Info.plist` does not currently emit that key — so iPhone 120 Hz measurement needs that added first. iPad ProMotion has no such opt-in. Backgrounding an app *without* a pause transition the driver observes will still show one long interval; treat a lone multi-second `max` as a lifecycle artifact. + + macOS, tvOS, visionOS, Android, GTK4 and Windows still drive `onFrame` from their main-loop pumps and are unchanged; `docs/src/ui/on-frame.md` now states per-platform which clock backs `timestampMs`. diff --git a/changelog.d/7754-ios-widget-table-lifetime.md b/changelog.d/7754-ios-widget-table-lifetime.md new file mode 100644 index 0000000000..798e6c7f7d --- /dev/null +++ b/changelog.d/7754-ios-widget-table-lifetime.md @@ -0,0 +1,53 @@ +### Fixed + +- **iOS: the widget table no longer retains every widget ever created, and child removal is no longer quadratic.** `register_widget` (`perry-ui-ios/src/widgets/mod.rs`) only ever pushed. Nothing removed. `remove_child` unparented a view with `removeFromSuperview`, but the table's `Retained` kept it allocated for the lifetime of the process — so a list that rebuilds leaked its entire history of rows. + + On top of that, `clear_children` recovered handles for the subviews it was removing by **linear-scanning the whole table, once per subview**. That is O(children × every widget ever created), against a vector that only grew: k rebuilds of an N-row list cost O(k²N²) in total and got measurably worse the longer the app ran. Any list benchmark was measuring this rather than the framework. + + Two changes: + + - A `HANDLE_BY_PTR` reverse index makes handle recovery O(1), replacing the scan. + - `clear_children` and `remove_child` now release the removed subtree. Release is **recursive**: a list row is normally a container with children, and freeing only the directly-removed child would strand its descendants — each still holds a table entry, and that entry is a strong reference, so the subtree stays alive behind a parent nobody can reach. + + **Slots are tombstoned, never reused.** Handles are handed to TypeScript as plain NaN-boxed numbers and must stay inside the `< 0x100000` handle band (`HANDLE_BAND_MAX`, with Web Stream ids immediately above at `[0x100000, 0x200000)`), which leaves no spare bits for the generation counter that safe slot reuse would require. Without one, a recycled index lets a stale handle silently drive a *different* widget. A tombstone costs one `Option` niche and turns a stale handle into `None` — the honest answer for a handle whose widget is gone. + + Worth noting the old behaviour was also a latent *correctness* bug, not only a memory one: a long-running app that created more than ~1M widgets would have walked its handles out of the handle band and into the stream-id range. + + **Behaviour change:** using a handle after its widget has been removed is now a no-op instead of driving a still-retained view. `widgetRemoveChild` is documented as removal and the layout API has a separate `widgetReorderChild` for moves, so remove-then-re-add was not a supported re-parenting idiom. + + `perry-ui-ios` is `#![cfg(target_os = "ios")]`, so none of this is reachable from a host unit test and it carries no CI coverage; it was verified on-device. + +### Added + +- **`PERRY_*` environment variables now reach an app launched on a simulator or device.** A bundled app is launched by the system rather than inherited from the shell, and `perry run` passed no environment at all — so `PERRY_FRAME_STATS=1 perry run --device …` silently collected nothing. Simulator launches forward via `SIMCTL_CHILD_*`; device launches build a JSON object for `devicectl --environment-variables` (via `serde_json`, so a value containing a quote cannot produce a malformed argument). Scoped to the `PERRY_` prefix deliberately — forwarding the whole environment would push the developer's `PATH`, `HOME`, credentials and locale into a sandboxed process that has its own. + +- **`high_refresh_rate` in `perry.toml`** (`[ios]`, falling back to `[project]`) emits `CADisableMinimumFrameDurationOnPhone` into the generated Info.plist. Without that key iOS caps an iPhone at 60 Hz regardless of what `CADisplayLink` requests, so ProMotion hardware reports a hard 60 — which reads as "the framework cannot sustain 120" rather than "the bundle never opted in". iPad ProMotion has no such gate. Off by default: uncapped frame rates cost battery, and that is the app author's call rather than the compiler's. Covered by 5 unit tests. + +- **Frame stats report periodically** (`PERRY_FRAME_STATS_INTERVAL`, default 300 frames; `0` disables). An iOS app has no exit at which to print a summary and the metrics are not reachable from TypeScript, so a device run would otherwise collect numbers nobody could see. Each line covers the frames since the previous one, which is what makes "p99 while I was scrolling" a meaningful reading. The interval is also runtime-settable via `js_frame_metrics_set_report_interval`. + +- **`benchmarks/ios-ui/`** — a frame-time benchmark app cycling through idle, property-updates, add/remove, animation and text-heavy phases, printing a marker before each so `[frame-stats]` lines are attributable to a workload. Plus two isolation probes (`isolate_churn.ts`, `isolate_frame.ts`) that split structural churn from the frame driver; they are what localized the crash described below. + +### Known pre-existing bug, NOT introduced here + +- **Scrolling a `UIScrollView`-hosted `UIStackView` while JS mutates its labels aborts the app**: `NSInvalidArgumentException: -[__NSArrayM insertObject:atIndex:]: object cannot be nil`. The backtrace is `UIApplicationMain → CFRunLoop → CA transaction commit → UIView layout → UIStackView`, i.e. the throw lands in the layout pass at the end of a run-loop iteration, not inside any Perry callback — something earlier leaves a nil in a UIKit array and it detonates at commit. + + **It requires human touch input**, which is why no automated probe here reproduces it and why it went unseen until a device session with a person scrolling. + + Established as pre-existing by four single-variable device bisects, each with a human scrolling: + + | arm | result | + |---|---| + | deferred view release | crashes | + | widget release disabled entirely | crashes | + | display link in `NSDefaultRunLoopMode` | crashes | + | **display-link driver disabled, original NSTimer pump** | **crashes** | + + The last arm is the decisive one: with this PR's frame driver removed and `onFrame` back on the pre-PR `js_frame_pump_default()` path, the abort still occurs. The benchmark added here is simply the first workload that drives enough label mutation against a scrolled stack to expose it. + + Three hypotheses were tested and falsified along the way — that deallocating views inside CoreAnimation's pre-commit phase caused it, that releasing table entries at all caused it, and that running JS during `UITrackingRunLoopMode` caused it. A `PENDING_RELEASE` deferral queue built for the first of those was removed again rather than shipped, since its premise was disproved and immediate release is fine empirically (an isolation probe released ~323k widgets with no fault). + +### Note for platform UI crates + +A platform UI crate must reach `perry-runtime` through the **C ABI**, never as a Rust path. `perry-ui-ios` depends on `perry-runtime` as an rlib while the final binary also links `libperry_runtime.a`; calling a runtime function as `perry_runtime::foo()` instantiates a *second copy* of that code inside the UI crate, with its own thread-local arena, GC state and statics. Frame callbacks then register in one copy's queue while the driver drains the other's, and memory allocated by one allocator is freed by the other — which aborts on device with `malloc: pointer being freed was not allocated`. + +This was hit while developing the display-link driver above (it used `use perry_runtime::frame::…`) and cost a device-crash hunt to localize, because the failure is timing-dependent: an early build ran two full benchmark cycles cleanly before later builds aborted seconds after launch. Every runtime entry point in `perry-ui-ios/src/app.rs` was already declared `extern "C"` for exactly this reason; the convention is load-bearing, not stylistic. Type-only uses (`perry_runtime::string::StringHeader` for a pointer cast) are fine — they instantiate no code. diff --git a/crates/perry-runtime/src/frame_metrics.rs b/crates/perry-runtime/src/frame_metrics.rs new file mode 100644 index 0000000000..f9e1455c9d --- /dev/null +++ b/crates/perry-runtime/src/frame_metrics.rs @@ -0,0 +1,580 @@ +//! Frame-time metrics for display-link-driven UIs. +//! +//! A platform's display-link driver (CADisplayLink on Apple, Choreographer on +//! Android, `requestAnimationFrame` in WASM) calls +//! [`js_frame_metrics_record`] once per vsync with the vsync timestamp and the +//! display's *nominal* frame duration. This module turns that stream into the +//! distribution numbers a UI benchmark actually needs — p50/p95/p99 frame +//! times and a dropped-frame count — rather than an average FPS, which hides +//! exactly the stutter people care about. +//! +//! # Why this does not reuse `js_timer_now` +//! +//! [`crate::timer::js_timer_now`] truncates to whole milliseconds +//! (`as_millis() as f64`). At a 120 Hz frame budget of 8.33 ms that quantizes +//! every sample to ~12% of the budget, which cannot produce a meaningful p99. +//! Drivers must pass a high-resolution timestamp from their own clock +//! (`CADisplayLink.timestamp` is a `CFTimeInterval`, i.e. sub-microsecond). +//! +//! # Dropped frames +//! +//! A frame is dropped when the interval to the previous vsync spans more than +//! one nominal frame. `dropped = round(interval / expected) - 1`, so an +//! interval within half a frame of nominal counts as zero and a doubled +//! interval counts as one. Deriving this from the driver-supplied `expected` +//! rather than a hardcoded 60 Hz is what makes the same code correct on a +//! 120 Hz ProMotion display. +//! +//! # Discontinuities +//! +//! Backgrounding an app pauses its display link. The gap that follows is not a +//! 4000 ms frame — attributing it as one would report hundreds of phantom +//! dropped frames and poison the p99. Drivers must call +//! [`js_frame_metrics_mark_discontinuity`] around any deliberate pause so the +//! next vsync starts a fresh interval. + +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Mutex, OnceLock}; + +/// Retained frame-interval samples. 8192 covers ~68 s at 120 Hz / ~136 s at +/// 60 Hz; past that the oldest samples are overwritten. Percentiles are over +/// the retained window, which is what you want for a bounded-memory probe +/// running inside a shipping app. +const CAPACITY: usize = 8192; + +/// An interval this long is a stall, not a frame — but it is still recorded, +/// because a real 500 ms hitch is exactly what a benchmark is hunting. Only +/// the *dropped-frame* attribution is capped, so one hitch cannot dominate the +/// count. Deliberate pauses should use `mark_discontinuity` instead. +const MAX_DROPPED_ATTRIBUTION: u64 = 64; + +struct FrameMetrics { + /// Ring of frame intervals in milliseconds. + samples: Vec, + next: usize, + filled: bool, + last_ts_ms: Option, + total_frames: u64, + dropped_frames: u64, + longest_ms: f64, + /// Most recent nominal frame duration reported by the driver. + expected_ms: f64, + /// Frames recorded since the last automatic report. + frames_since_report: u64, +} + +impl FrameMetrics { + const fn new() -> Self { + Self { + samples: Vec::new(), + next: 0, + filled: false, + last_ts_ms: None, + total_frames: 0, + dropped_frames: 0, + longest_ms: 0.0, + expected_ms: 0.0, + frames_since_report: 0, + } + } + + fn reset(&mut self) { + self.reset_window(); + self.last_ts_ms = None; + self.expected_ms = 0.0; + } + + /// Clear the sample window and counters but keep the interval baseline and + /// the known frame budget. + /// + /// Used between automatic reports so each printed line describes a distinct + /// interval. Preserving `last_ts_ms` matters: clearing it would drop the + /// frame straddling the boundary, so a run reporting every N frames would + /// silently lose one interval per report. + fn reset_window(&mut self) { + self.samples.clear(); + self.next = 0; + self.filled = false; + self.total_frames = 0; + self.dropped_frames = 0; + self.longest_ms = 0.0; + self.frames_since_report = 0; + } + + fn push_sample(&mut self, interval_ms: f64) { + if self.samples.len() < CAPACITY { + self.samples.push(interval_ms); + self.next = self.samples.len() % CAPACITY; + if self.samples.len() == CAPACITY { + self.filled = true; + } + } else { + self.samples[self.next] = interval_ms; + self.next = (self.next + 1) % CAPACITY; + self.filled = true; + } + } + + fn record(&mut self, timestamp_ms: f64, expected_frame_ms: f64) { + if !timestamp_ms.is_finite() { + return; + } + if expected_frame_ms.is_finite() && expected_frame_ms > 0.0 { + self.expected_ms = expected_frame_ms; + } + + let previous = self.last_ts_ms.replace(timestamp_ms); + let Some(previous) = previous else { + // First vsync after start or after a discontinuity: establishes the + // baseline, but there is no interval to attribute yet. + return; + }; + let interval_ms = timestamp_ms - previous; + // A non-monotonic driver timestamp is a driver bug, not a 0 ms frame. + // Drop the sample and re-baseline rather than recording a negative. + if interval_ms <= 0.0 { + return; + } + + self.total_frames += 1; + self.frames_since_report += 1; + self.push_sample(interval_ms); + if interval_ms > self.longest_ms { + self.longest_ms = interval_ms; + } + + if self.expected_ms > 0.0 { + let spans = (interval_ms / self.expected_ms).round(); + if spans > 1.0 { + let dropped = (spans as u64).saturating_sub(1); + self.dropped_frames += dropped.min(MAX_DROPPED_ATTRIBUTION); + } + } + } + + fn sorted_samples(&self) -> Vec { + let mut out = self.samples.clone(); + out.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + out + } + + /// One-line summary of the current window, or `None` if it holds no frames. + fn summary_line(&self) -> Option { + let sorted = self.sorted_samples(); + if sorted.is_empty() { + return None; + } + Some(format!( + "[frame-stats] frames={} budget={:.2}ms p50={:.2}ms p95={:.2}ms p99={:.2}ms max={:.2}ms dropped={}", + self.total_frames, + self.expected_ms, + percentile_of(&sorted, 50.0), + percentile_of(&sorted, 95.0), + percentile_of(&sorted, 99.0), + self.longest_ms, + self.dropped_frames, + )) + } +} + +/// Nearest-rank percentile. `p` is in `[0, 100]`. +/// +/// Nearest-rank (rather than an interpolating definition) means every reported +/// value is a frame interval that actually occurred, which is the right +/// property for a latency distribution. +fn percentile_of(sorted: &[f64], p: f64) -> f64 { + if sorted.is_empty() { + return 0.0; + } + let p = p.clamp(0.0, 100.0); + let rank = (p / 100.0 * sorted.len() as f64).ceil() as usize; + let idx = rank.saturating_sub(1).min(sorted.len() - 1); + sorted[idx] +} + +static METRICS: Mutex = Mutex::new(FrameMetrics::new()); + +fn env_default_enabled() -> bool { + match std::env::var("PERRY_FRAME_STATS") { + Ok(v) => !matches!(v.as_str(), "" | "0" | "off" | "false"), + Err(_) => false, + } +} + +fn enabled_flag() -> &'static AtomicBool { + static FLAG: OnceLock = OnceLock::new(); + FLAG.get_or_init(|| AtomicBool::new(env_default_enabled())) +} + +/// Whether frame metrics are being collected. +/// +/// Defaults to the `PERRY_FRAME_STATS` env var and can be flipped at runtime +/// via [`js_frame_metrics_set_enabled`], so a benchmark app can scope +/// collection to one workload without relaunching under a different +/// environment. +pub fn frame_metrics_enabled() -> bool { + enabled_flag().load(Ordering::Relaxed) +} + +/// C-ABI view of [`frame_metrics_enabled`]. +/// +/// Platform UI crates must reach the runtime through the C ABI rather than as +/// a Rust path — see the note in `perry-ui-ios/src/frame_driver.rs` — so the +/// predicate needs an `extern "C"` form. +#[no_mangle] +pub extern "C" fn js_frame_metrics_enabled() -> i32 { + if frame_metrics_enabled() { + 1 + } else { + 0 + } +} + +/// Enable (`1`) or disable (`0`) collection. Disabling does not clear samples +/// already collected; pair with [`js_frame_metrics_reset`] to start clean. +#[no_mangle] +pub extern "C" fn js_frame_metrics_set_enabled(on: i32) { + enabled_flag().store(on != 0, Ordering::Relaxed); +} + +/// Record one vsync. +/// +/// `timestamp_ms` must come from a high-resolution monotonic clock and +/// `expected_frame_ms` is the display's nominal frame duration (e.g. 8.333 at +/// 120 Hz). A non-positive `expected_frame_ms` keeps the previously reported +/// value, so a driver that only knows the cadence later still gets correct +/// dropped-frame attribution once it does. +#[no_mangle] +pub extern "C" fn js_frame_metrics_record(timestamp_ms: f64, expected_frame_ms: f64) { + if !frame_metrics_enabled() { + return; + } + + let due = { + let mut m = METRICS.lock().unwrap_or_else(|p| p.into_inner()); + m.record(timestamp_ms, expected_frame_ms); + + let interval = auto_report_interval(); + if interval > 0 && m.frames_since_report >= interval { + let line = m.summary_line(); + m.reset_window(); + line + } else { + None + } + }; + + // Printed after the lock is released. A device launch streams stderr over + // USB, so this write can block; holding the metrics lock across it would + // stall the display-link callback that is supposed to be measuring frames. + if let Some(line) = due { + eprintln!("{line}"); + } +} + +/// Frames between automatic reports (`PERRY_FRAME_STATS_INTERVAL`, default +/// 300 — about 2.5 s at 120 Hz). `0` disables automatic reporting. +/// +/// An iOS app has no exit at which to print a summary, and `onFrame` metrics +/// are not reachable from TypeScript, so without this a device run collects +/// numbers nobody can see. Each line covers the frames since the previous one, +/// which is what makes "p99 while I was scrolling" a meaningful reading. +fn auto_report_interval() -> u64 { + report_interval_flag().load(Ordering::Relaxed) +} + +fn report_interval_flag() -> &'static AtomicU64 { + static FLAG: OnceLock = OnceLock::new(); + FLAG.get_or_init(|| { + AtomicU64::new( + std::env::var("PERRY_FRAME_STATS_INTERVAL") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(300), + ) + }) +} + +/// Set the automatic-report cadence in frames. `0` disables it. +/// +/// Runtime-settable rather than env-only so a benchmark can widen the window +/// for a long workload, and so a caller driving the metrics directly can turn +/// the periodic output off without controlling the process environment. +#[no_mangle] +pub extern "C" fn js_frame_metrics_set_report_interval(frames: f64) { + let frames = if frames.is_finite() && frames > 0.0 { + frames as u64 + } else { + 0 + }; + report_interval_flag().store(frames, Ordering::Relaxed); +} + +/// Drop the running interval baseline without discarding collected samples. +/// +/// Call this around a deliberate display-link pause (backgrounding, an +/// intentional stop/start) so the gap is not attributed as one enormous frame. +#[no_mangle] +pub extern "C" fn js_frame_metrics_mark_discontinuity() { + let mut m = METRICS.lock().unwrap_or_else(|p| p.into_inner()); + m.last_ts_ms = None; +} + +/// Discard all collected samples and counters. +#[no_mangle] +pub extern "C" fn js_frame_metrics_reset() { + let mut m = METRICS.lock().unwrap_or_else(|p| p.into_inner()); + m.reset(); +} + +/// Number of frame intervals recorded since the last reset. +#[no_mangle] +pub extern "C" fn js_frame_metrics_count() -> f64 { + let m = METRICS.lock().unwrap_or_else(|p| p.into_inner()); + m.total_frames as f64 +} + +/// Frames dropped since the last reset, derived from the driver-supplied +/// nominal frame duration. +#[no_mangle] +pub extern "C" fn js_frame_metrics_dropped() -> f64 { + let m = METRICS.lock().unwrap_or_else(|p| p.into_inner()); + m.dropped_frames as f64 +} + +/// Longest single frame interval, in milliseconds. +#[no_mangle] +pub extern "C" fn js_frame_metrics_longest_ms() -> f64 { + let m = METRICS.lock().unwrap_or_else(|p| p.into_inner()); + m.longest_ms +} + +/// The nominal frame budget most recently reported by the driver, in +/// milliseconds (8.333 at 120 Hz, 16.667 at 60 Hz). `0` if no vsync has been +/// recorded yet. +#[no_mangle] +pub extern "C" fn js_frame_metrics_expected_ms() -> f64 { + let m = METRICS.lock().unwrap_or_else(|p| p.into_inner()); + m.expected_ms +} + +/// Percentile of the retained frame-interval window, in milliseconds. +/// `p` is in `[0, 100]`; `0` if nothing has been recorded. +#[no_mangle] +pub extern "C" fn js_frame_metrics_percentile(p: f64) -> f64 { + let m = METRICS.lock().unwrap_or_else(|p| p.into_inner()); + percentile_of(&m.sorted_samples(), p) +} + +/// Print a one-line summary to stderr. Returns the number of samples the +/// summary covered, so a caller can tell "all frames were fast" apart from +/// "no frames were measured" — the failure mode where a probe reports success +/// without its subject ever having run. +#[no_mangle] +pub extern "C" fn js_frame_metrics_report() -> f64 { + let (line, n) = { + let m = METRICS.lock().unwrap_or_else(|p| p.into_inner()); + (m.summary_line(), m.samples.len()) + }; + match line { + Some(line) => { + eprintln!("{line}"); + n as f64 + } + None => { + eprintln!("[frame-stats] no frames recorded (display-link driver never ticked)"); + 0.0 + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // The metrics store is global, so tests that touch it must serialize. + static TEST_LOCK: Mutex<()> = Mutex::new(()); + + fn fresh() -> std::sync::MutexGuard<'static, ()> { + let guard = TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + js_frame_metrics_set_enabled(1); + // Automatic reporting resets the window, which would silently truncate + // any test feeding more frames than the cadence. + js_frame_metrics_set_report_interval(0.0); + js_frame_metrics_reset(); + guard + } + + /// Feed `count` vsyncs spaced exactly `step_ms` apart starting at `t0`. + fn feed(t0: f64, step_ms: f64, count: usize, expected: f64) -> f64 { + let mut t = t0; + js_frame_metrics_record(t, expected); + for _ in 0..count { + t += step_ms; + js_frame_metrics_record(t, expected); + } + t + } + + #[test] + fn percentile_is_nearest_rank_over_actual_samples() { + let sorted: Vec = (1..=100).map(|v| v as f64).collect(); + assert_eq!(percentile_of(&sorted, 50.0), 50.0); + assert_eq!(percentile_of(&sorted, 95.0), 95.0); + assert_eq!(percentile_of(&sorted, 99.0), 99.0); + assert_eq!(percentile_of(&sorted, 100.0), 100.0); + // p0 and the empty case must not panic or index out of bounds. + assert_eq!(percentile_of(&sorted, 0.0), 1.0); + assert_eq!(percentile_of(&[], 50.0), 0.0); + } + + #[test] + fn first_vsync_establishes_baseline_without_recording_a_frame() { + let _g = fresh(); + js_frame_metrics_record(1000.0, 8.333); + // One timestamp is not an interval. + assert_eq!(js_frame_metrics_count(), 0.0); + js_frame_metrics_record(1008.333, 8.333); + assert_eq!(js_frame_metrics_count(), 1.0); + } + + #[test] + fn steady_120hz_stream_reports_budget_and_no_drops() { + let _g = fresh(); + feed(0.0, 8.333, 240, 8.333); + assert_eq!(js_frame_metrics_count(), 240.0); + assert_eq!(js_frame_metrics_dropped(), 0.0); + assert!((js_frame_metrics_percentile(50.0) - 8.333).abs() < 1e-6); + assert!((js_frame_metrics_expected_ms() - 8.333).abs() < 1e-6); + } + + #[test] + fn a_doubled_interval_counts_as_exactly_one_dropped_frame() { + let _g = fresh(); + js_frame_metrics_record(0.0, 16.667); + js_frame_metrics_record(16.667, 16.667); + assert_eq!(js_frame_metrics_dropped(), 0.0); + // One missed vsync: 2x nominal. + js_frame_metrics_record(50.0, 16.667); + assert_eq!(js_frame_metrics_dropped(), 1.0); + } + + #[test] + fn jitter_within_half_a_frame_is_not_a_drop() { + let _g = fresh(); + // 60 Hz nominal, intervals wobbling by ±40% of a frame. + let expected = 16.667; + let mut t = 0.0; + js_frame_metrics_record(t, expected); + for i in 0..50 { + t += if i % 2 == 0 { 22.0 } else { 11.5 }; + js_frame_metrics_record(t, expected); + } + assert_eq!(js_frame_metrics_count(), 50.0); + assert_eq!(js_frame_metrics_dropped(), 0.0); + } + + #[test] + fn dropped_attribution_is_capped_so_one_hitch_cannot_dominate() { + let _g = fresh(); + js_frame_metrics_record(0.0, 8.333); + // A 10 s stall would otherwise attribute ~1200 dropped frames. + js_frame_metrics_record(10_000.0, 8.333); + assert_eq!(js_frame_metrics_dropped(), MAX_DROPPED_ATTRIBUTION as f64); + // ...but the interval itself is still visible as the longest frame. + assert!((js_frame_metrics_longest_ms() - 10_000.0).abs() < 1e-6); + } + + #[test] + fn discontinuity_suppresses_the_gap_but_keeps_history() { + let _g = fresh(); + feed(0.0, 8.333, 10, 8.333); + assert_eq!(js_frame_metrics_count(), 10.0); + + // App backgrounds for 4 seconds, driver marks the pause. + js_frame_metrics_mark_discontinuity(); + js_frame_metrics_record(4_000.0, 8.333); + + // The gap produced neither a frame nor any dropped frames... + assert_eq!(js_frame_metrics_count(), 10.0); + assert_eq!(js_frame_metrics_dropped(), 0.0); + // ...and the pre-pause samples survive. + assert!((js_frame_metrics_percentile(50.0) - 8.333).abs() < 1e-6); + + // Recording resumes normally afterwards. + js_frame_metrics_record(4_008.333, 8.333); + assert_eq!(js_frame_metrics_count(), 11.0); + } + + #[test] + fn non_monotonic_timestamps_are_discarded_not_recorded_as_negative() { + let _g = fresh(); + js_frame_metrics_record(100.0, 8.333); + js_frame_metrics_record(50.0, 8.333); // driver bug: time went backwards + assert_eq!(js_frame_metrics_count(), 0.0); + // Baseline re-established at the later timestamp, so recording recovers. + js_frame_metrics_record(58.333, 8.333); + assert_eq!(js_frame_metrics_count(), 1.0); + } + + #[test] + fn ring_buffer_wraps_and_retains_the_most_recent_window() { + let _g = fresh(); + // Fill with slow frames, then overwrite the whole window with fast ones. + feed(0.0, 100.0, CAPACITY, 16.667); + let t = (CAPACITY as f64) * 100.0; + assert!((js_frame_metrics_percentile(50.0) - 100.0).abs() < 1e-6); + + feed(t + 1_000_000.0, 5.0, CAPACITY, 16.667); + // Every retained sample is now from the fast run. + assert!((js_frame_metrics_percentile(50.0) - 5.0).abs() < 1e-6); + assert!((js_frame_metrics_percentile(99.0) - 5.0).abs() < 1e-6); + // Cumulative counters still span both runs. + assert_eq!(js_frame_metrics_count(), (CAPACITY as f64) * 2.0 + 1.0); + } + + #[test] + fn disabled_collection_records_nothing() { + let _g = fresh(); + js_frame_metrics_set_enabled(0); + feed(0.0, 8.333, 100, 8.333); + assert_eq!(js_frame_metrics_count(), 0.0); + // Re-enable so the flag does not leak into another test's run. + js_frame_metrics_set_enabled(1); + } + + #[test] + fn automatic_reporting_resets_the_window_but_not_the_baseline() { + let _g = fresh(); + js_frame_metrics_set_report_interval(10.0); + + // 25 frames at a 10-frame cadence: two reports fire, leaving 5. + feed(0.0, 8.333, 25, 8.333); + assert_eq!(js_frame_metrics_count(), 5.0); + + // The window still describes real frames — the baseline survived each + // report, so the straddling interval was not dropped. + assert!((js_frame_metrics_percentile(50.0) - 8.333).abs() < 1e-6); + + js_frame_metrics_set_report_interval(0.0); + } + + #[test] + fn a_zero_or_negative_report_interval_disables_automatic_reporting() { + let _g = fresh(); + js_frame_metrics_set_report_interval(-5.0); + feed(0.0, 8.333, 50, 8.333); + // Nothing reset the window, so every frame is still counted. + assert_eq!(js_frame_metrics_count(), 50.0); + } + + #[test] + fn report_returns_zero_when_no_frames_were_measured() { + let _g = fresh(); + // Distinguishes "fast" from "never ran" — a probe that reports success + // without its subject having executed is the failure mode this guards. + assert_eq!(js_frame_metrics_report(), 0.0); + feed(0.0, 8.333, 3, 8.333); + assert_eq!(js_frame_metrics_report(), 3.0); + } +} diff --git a/crates/perry-runtime/src/lib.rs b/crates/perry-runtime/src/lib.rs index 6ba4daadf6..f2bcabfaac 100644 --- a/crates/perry-runtime/src/lib.rs +++ b/crates/perry-runtime/src/lib.rs @@ -84,6 +84,7 @@ pub mod exception; pub mod fast_hash; pub mod ffi; pub mod frame; +pub mod frame_metrics; pub mod fs; pub mod gc; pub mod intl; diff --git a/crates/perry-ui-ios/Cargo.toml b/crates/perry-ui-ios/Cargo.toml index f1c4e7a27e..c6d80dbda3 100644 --- a/crates/perry-ui-ios/Cargo.toml +++ b/crates/perry-ui-ios/Cargo.toml @@ -34,6 +34,14 @@ objc2-foundation = { version = "0.3", features = [ objc2-core-foundation = { version = "0.3", features = [ "CFCGTypes", ] } +# `default-features = false` deliberately: the default feature set is the whole +# QuartzCore surface and drags in objc2-metal / -core-video / -core-graphics. +# The display link needs two types. +objc2-quartz-core = { version = "0.3", default-features = false, features = [ + "std", + "CADisplayLink", + "CAFrameRateRange", +] } objc2-ui-kit = { version = "0.3", features = [ "UIApplication", "UIWindow", diff --git a/crates/perry-ui-ios/src/app.rs b/crates/perry-ui-ios/src/app.rs index 698d138966..d1466d606b 100644 --- a/crates/perry-ui-ios/src/app.rs +++ b/crates/perry-ui-ios/src/app.rs @@ -315,6 +315,11 @@ unsafe extern "C" fn scene_will_connect( ]; std::mem::forget(pump_target); + // Vsync-aligned `onFrame` + frame-time metrics. Installed after the timer + // pump because `poll_pause_state` (driven from that pump) is what wakes + // the link when a callback is registered while it is idle. + crate::frame_driver::install(); + install_test_mode_exit_timer(); } @@ -620,7 +625,6 @@ extern "C" { fn js_closure_call0(closure: *const u8) -> f64; fn js_callback_timer_tick() -> i32; fn js_interval_timer_tick() -> i32; - fn js_frame_pump_default() -> i32; // perry-runtime's embedder GC stepper: spends up to `budget_us` // advancing an ACTIVE budgeted collection in bounded work units; a // cheap status probe when no cycle is active (out=null is allowed). @@ -651,8 +655,6 @@ define_class!( unsafe { js_callback_timer_tick(); js_interval_timer_tick(); - // Issue #1865: perry/ui `onFrame` display-link callbacks. - js_frame_pump_default(); js_promise_run_microtasks(); // Drain deferred promise resolutions from perry-stdlib // tokio workers (async fetch/network completions). Without @@ -673,6 +675,11 @@ define_class!( perry_geisterhand_pump(); } } + // `onFrame` itself is driven by the CADisplayLink installed in + // `frame_driver`, not from here. The pump only reconciles the + // link's paused state: a paused link receives no ticks, so it + // cannot notice a newly-registered callback and wake itself. + crate::frame_driver::poll_pause_state(); // Issue #5203 — root-window health check. Replays root // rebuilds deferred while a system modal was presented and // re-asserts the window if a dismissed modal left it detached diff --git a/crates/perry-ui-ios/src/crash_log.rs b/crates/perry-ui-ios/src/crash_log.rs index f525efa15c..b23c33e8b4 100644 --- a/crates/perry-ui-ios/src/crash_log.rs +++ b/crates/perry-ui-ios/src/crash_log.rs @@ -85,6 +85,70 @@ pub fn install_crash_hooks() { libc::signal(libc::SIGSEGV, signal_handler as libc::sighandler_t); libc::signal(libc::SIGBUS, signal_handler as libc::sighandler_t); libc::signal(libc::SIGABRT, signal_handler as libc::sighandler_t); + NSSetUncaughtExceptionHandler(Some(uncaught_objc_exception)); + } +} + +type UncaughtExceptionHandler = extern "C" fn(*mut objc2::runtime::AnyObject); + +extern "C" { + fn NSSetUncaughtExceptionHandler(handler: Option); +} + +/// Report an uncaught Objective-C exception with its **throw-time** backtrace. +/// +/// Without this the process aborts through `SIGABRT` and all that survives is +/// `libc++abi: terminating due to uncaught exception`, plus a list of raw +/// return addresses with no load address to symbolicate against. `NSException` +/// captures `callStackSymbols` at the moment it is *raised*, so reading it here +/// recovers the frames that actually threw — already symbolicated by the +/// Objective-C runtime, with no need for a matching dSYM or an offline pass +/// over an `.ips`. +/// +/// Written to stderr because `perry run` streams the device's stderr back over +/// `devicectl --console`, which is the only channel available during an +/// interactive device session. +extern "C" fn uncaught_objc_exception(exc: *mut objc2::runtime::AnyObject) { + use objc2::runtime::AnyObject; + if exc.is_null() { + eprintln!("[perry-crash] uncaught Objective-C exception (null)"); + return; + } + + // Everything below is best-effort: this runs while the process is already + // terminating, so a failure to read one field must not prevent the rest + // from being reported. + unsafe { + let ns_string_utf8 = |s: *mut AnyObject| -> String { + if s.is_null() { + return String::from(""); + } + let c: *const std::ffi::c_char = objc2::msg_send![s, UTF8String]; + if c.is_null() { + return String::from(""); + } + std::ffi::CStr::from_ptr(c).to_string_lossy().into_owned() + }; + + let name: *mut AnyObject = objc2::msg_send![exc, name]; + let reason: *mut AnyObject = objc2::msg_send![exc, reason]; + eprintln!( + "[perry-crash] uncaught {}: {}", + ns_string_utf8(name), + ns_string_utf8(reason) + ); + + let symbols: *mut AnyObject = objc2::msg_send![exc, callStackSymbols]; + if symbols.is_null() { + eprintln!("[perry-crash] no callStackSymbols available"); + return; + } + let count: usize = objc2::msg_send![symbols, count]; + eprintln!("[perry-crash] throw-time backtrace ({count} frames):"); + for i in 0..count { + let frame: *mut AnyObject = objc2::msg_send![symbols, objectAtIndex: i]; + eprintln!("[perry-crash] {}", ns_string_utf8(frame)); + } } } diff --git a/crates/perry-ui-ios/src/frame_driver.rs b/crates/perry-ui-ios/src/frame_driver.rs new file mode 100644 index 0000000000..0655eebc20 --- /dev/null +++ b/crates/perry-ui-ios/src/frame_driver.rs @@ -0,0 +1,220 @@ +//! CADisplayLink frame driver for iOS. +//! +//! Before this, `onFrame` callbacks were driven from the 8 ms `NSTimer` pump in +//! [`crate::app`] via `js_frame_pump_default()`, which timestamps with +//! `js_timer_now()`. That has two defects for anything frame-shaped: +//! +//! 1. **Not vsync-aligned.** An 8 ms repeating `NSTimer` free-runs against a +//! 8.333 ms (120 Hz) or 16.667 ms (60 Hz) display cadence and is subject to +//! run-loop coalescing, so callbacks beat against the refresh rather than +//! landing on it. +//! 2. **Millisecond-quantized.** `js_timer_now()` truncates to whole +//! milliseconds, which is ~12% of a 120 Hz frame budget — enough to make a +//! frame-time distribution meaningless. +//! +//! This module drives `js_frame_tick` from a real `CADisplayLink` instead, and +//! feeds [`perry_runtime::frame_metrics`] the vsync stream so frame-time +//! percentiles and dropped-frame counts can be measured on-device. +//! +//! # Run-loop mode +//! +//! The link is added in `NSRunLoopCommonModes`, not the default mode. During a +//! `UIScrollView` drag the run loop switches to `UITrackingRunLoopMode`; a link +//! registered only in the default mode goes silent for the whole gesture — +//! which is precisely the window a scrolling benchmark exists to measure. +//! +//! # Frame rate on ProMotion +//! +//! `CADisplayLink` defaults to an adaptive cadence on ProMotion displays, so a +//! mostly-static screen ticks well below 120 Hz. When metrics are enabled the +//! link requests the display's maximum rate so a benchmark measures the +//! framework rather than ProMotion's throttling. +//! +//! **On iPhone that request is capped at 60 Hz unless the app bundle sets +//! `CADisableMinimumFrameDurationOnPhone` to `YES` in its `Info.plist`.** +//! Perry's generated plist does not currently emit that key, so on-device +//! 120 Hz measurement needs it added first. iPad ProMotion is not subject to +//! that opt-in. + +use objc2::rc::Retained; +use objc2::runtime::AnyObject; +use objc2::{define_class, msg_send, sel, AnyThread}; +use objc2_foundation::{NSObject, NSRunLoop, NSRunLoopCommonModes}; +use objc2_quartz_core::{CADisplayLink, CAFrameRateRange}; +use std::cell::{Cell, RefCell}; + +// Declared through the C ABI, NOT as `use perry_runtime::…` Rust calls, and +// this is load-bearing rather than stylistic — it is the same convention every +// runtime entry point in `crate::app` follows. +// +// `perry-ui-ios` depends on `perry-runtime` as an rlib, while the final binary +// also links `libperry_runtime.a`. Calling a runtime function as a Rust path +// instantiates a SECOND copy of that code inside this crate, with its own +// thread-local arena, its own GC state and its own statics. Frame callbacks +// then register in one copy's queue while the driver drains the other's, and a +// pointer allocated by one allocator is freed by the other — which on device +// aborts with `malloc: pointer being freed was not allocated`. +// +// Going through `extern "C"` binds to the one runtime in the linked image. +extern "C" { + fn js_frame_tick(timestamp_ms: f64) -> i32; + fn js_frame_has_pending() -> i32; + fn js_frame_metrics_record(timestamp_ms: f64, expected_frame_ms: f64); + fn js_frame_metrics_mark_discontinuity(); + fn js_frame_metrics_enabled() -> i32; + fn js_timer_now() -> f64; +} + +thread_local! { + /// The installed link, retained so pause state can be updated from the + /// timer pump. `None` until `install()` runs. + static DISPLAY_LINK: RefCell>> = const { RefCell::new(None) }; + + /// `(media_time_baseline_ms, runtime_clock_offset_ms)`, captured on the + /// first tick. See `to_app_timeline_ms`. + static TIME_BASELINE: Cell> = const { Cell::new(None) }; + + /// Last pause state we applied, so `poll_pause_state` only touches the + /// link (and only marks a discontinuity) on an actual transition. + static PAUSED: Cell = const { Cell::new(false) }; +} + +/// Convert a `CADisplayLink` timestamp into the timeline `onFrame` documents. +/// +/// `CADisplayLink.timestamp` is a `CFTimeInterval` on the `CACurrentMediaTime` +/// base — seconds since boot — while `onFrame`'s contract is "monotonic +/// milliseconds since app start". Handing the raw media time to JS would be a +/// silent contract break for any app doing `t - startTime`. +/// +/// So the first tick pins the media clock to whatever `js_timer_now()` reads at +/// that moment, and every later tick is reported as an offset from that pin. +/// The result keeps the documented epoch while carrying the display link's +/// sub-microsecond resolution instead of `js_timer_now`'s whole milliseconds. +fn to_app_timeline_ms(media_time_ms: f64) -> f64 { + let (baseline, offset) = TIME_BASELINE.with(|b| { + if let Some(pair) = b.get() { + pair + } else { + let pair = (media_time_ms, unsafe { js_timer_now() }); + b.set(Some(pair)); + pair + } + }); + (media_time_ms - baseline) + offset +} + +pub struct PerryFrameLinkTargetIvars; + +define_class!( + #[unsafe(super(NSObject))] + #[name = "PerryFrameLinkTarget"] + #[ivars = PerryFrameLinkTargetIvars] + pub struct PerryFrameLinkTarget; + + impl PerryFrameLinkTarget { + #[unsafe(method(frameTick:))] + fn frame_tick(&self, sender: &AnyObject) { + // The link fires straight into user JS via `js_frame_tick`, so it + // needs the same panic guard the timer pump uses: a Rust panic + // unwinding across the ObjC frame is undefined behaviour. + crate::catch_callback_panic("frameTick", std::panic::AssertUnwindSafe(|| { + let link: &CADisplayLink = unsafe { &*(sender as *const AnyObject as *const CADisplayLink) }; + + let timestamp_ms = link.timestamp() * 1000.0; + // `duration` is the display's nominal frame duration. It reads + // 0 before the first tick completes; `js_frame_metrics_record` + // keeps the last positive value, so an early 0 costs nothing. + let expected_ms = link.duration() * 1000.0; + + let now_ms = to_app_timeline_ms(timestamp_ms); + unsafe { + js_frame_metrics_record(now_ms, expected_ms); + js_frame_tick(now_ms); + } + })); + } + } +); + +impl PerryFrameLinkTarget { + fn new() -> Retained { + let this = Self::alloc().set_ivars(PerryFrameLinkTargetIvars); + unsafe { msg_send![super(this), init] } + } +} + +/// Whether the link should be ticking right now. +/// +/// Metrics collection needs every vsync even with no `onFrame` subscribers — +/// a scrolling benchmark has zero frame callbacks and is exactly the workload +/// worth measuring — so enabled metrics keep the link awake on their own. +fn should_run() -> bool { + unsafe { js_frame_has_pending() != 0 || js_frame_metrics_enabled() != 0 } +} + +/// Install the display link on the main run loop. Call once, from app startup, +/// on the main thread. +pub fn install() { + DISPLAY_LINK.with(|slot| { + if slot.borrow().is_some() { + return; + } + + let target = PerryFrameLinkTarget::new(); + let link = + unsafe { CADisplayLink::displayLinkWithTarget_selector(&target, sel!(frameTick:)) }; + + // Common modes, so scroll tracking does not silence the link. See the + // module docs. + unsafe { + link.addToRunLoop_forMode(&NSRunLoop::mainRunLoop(), NSRunLoopCommonModes); + } + + // Under measurement, ask for the display's maximum cadence rather than + // ProMotion's adaptive default. + if unsafe { js_frame_metrics_enabled() != 0 } { + // Ask for the top of the ProMotion range and let the system clamp + // to what the display can actually do — a 60 Hz device clamps this + // to 60. Expressed as a range rather than read off `UIScreen` + // because `UIScreen.mainScreen` is deprecated and the + // instance-based replacement needs a view we do not have here. + link.setPreferredFrameRateRange(CAFrameRateRange { + minimum: 60.0, + maximum: 120.0, + preferred: 120.0, + }); + } + + let paused = !should_run(); + link.setPaused(paused); + PAUSED.with(|p| p.set(paused)); + + // The target is referenced only weakly by the link, so it must outlive + // this scope. The link runs for the process lifetime. + std::mem::forget(target); + *slot.borrow_mut() = Some(link); + }); +} + +/// Reconcile the link's paused state with whether there is anything to do. +/// +/// Driven from the timer pump rather than from the link itself: a paused link +/// gets no ticks, so it cannot observe a newly-registered `onFrame` callback +/// and wake itself up. +pub fn poll_pause_state() { + let want_paused = !should_run(); + if PAUSED.with(|p| p.get()) == want_paused { + return; + } + + DISPLAY_LINK.with(|slot| { + if let Some(link) = slot.borrow().as_ref() { + link.setPaused(want_paused); + PAUSED.with(|p| p.set(want_paused)); + // The idle gap across a pause is not a frame. Without this the + // first tick after resuming reports the whole idle period as one + // enormous interval and poisons the p99. + unsafe { js_frame_metrics_mark_discontinuity() }; + } + }); +} diff --git a/crates/perry-ui-ios/src/lib.rs b/crates/perry-ui-ios/src/lib.rs index b37b4d4c59..33d44cfdc7 100644 --- a/crates/perry-ui-ios/src/lib.rs +++ b/crates/perry-ui-ios/src/lib.rs @@ -10,6 +10,7 @@ pub mod crash_log; pub mod deeplinks; pub mod drag_drop; pub mod file_dialog; +pub mod frame_driver; pub mod geolocation; pub mod image_picker; pub mod keyboard; diff --git a/crates/perry-ui-ios/src/widgets/mod.rs b/crates/perry-ui-ios/src/widgets/mod.rs index 8da480325a..2a134f151a 100644 --- a/crates/perry-ui-ios/src/widgets/mod.rs +++ b/crates/perry-ui-ios/src/widgets/mod.rs @@ -47,19 +47,43 @@ use std::cell::RefCell; use std::ffi::c_void; thread_local! { - /// Map from widget handle (1-based) to UIView - static WIDGETS: RefCell>> = RefCell::new(Vec::new()); + /// Map from widget handle (1-based) to UIView. + /// + /// A slot holds `None` once its widget has been released by + /// [`release_widget`]. **Indices are never reused.** Two reasons, and the + /// second is the load-bearing one: + /// + /// - Handles are handed to TypeScript as plain NaN-boxed numbers and must + /// stay inside the `< 0x100000` handle band (`HANDLE_BAND_MAX`), which + /// leaves no spare bits for the generation counter that safe slot reuse + /// would require. + /// - Without a generation counter, a recycled index lets a stale handle + /// address a *different* widget — silently driving the wrong view. + /// + /// A tombstoned slot costs one `Option` niche and turns a stale handle into + /// a safe `None`, which is the honest failure for a handle whose widget is + /// gone. + static WIDGETS: RefCell>>> = const { RefCell::new(Vec::new()) }; + /// Reverse index: `UIView` pointer → handle. + /// + /// `clear_children` used to recover handles by scanning the whole of + /// `WIDGETS` once per removed subview — O(children × every widget ever + /// created), against a vector that only grew. Repeated list rebuilds were + /// therefore quadratic and got worse the longer the app ran. + static HANDLE_BY_PTR: RefCell> = RefCell::new(std::collections::HashMap::new()); /// Stored height constraints per widget handle, so set_height can update instead of duplicate. static HEIGHT_CONSTRAINTS: RefCell>> = RefCell::new(std::collections::HashMap::new()); } /// Store a UIView and return its handle (1-based i64). pub fn register_widget(view: Retained) -> i64 { + let ptr = Retained::as_ptr(&view) as usize; let handle = WIDGETS.with(|w| { let mut widgets = w.borrow_mut(); - widgets.push(view.clone()); + widgets.push(Some(view.clone())); widgets.len() as i64 }); + HANDLE_BY_PTR.with(|m| m.borrow_mut().insert(ptr, handle)); #[cfg(feature = "geisterhand")] { use objc2_foundation::NSString; @@ -134,9 +158,15 @@ pub extern "C" fn perry_ui_query_widget_tree(out_len: *mut usize) -> *mut u8 { let json = WIDGETS.with(|w| { let widgets = w.borrow(); let mut s = String::from("["); - for (i, view) in widgets.iter().enumerate() { + let mut emitted = 0usize; + for (i, slot) in widgets.iter().enumerate() { + // Released widgets leave a tombstone; skip it. The separator keys + // off how many entries were actually emitted, not the slot index, + // or a leading tombstone would produce `[,{...}]`. + let Some(view) = slot.as_ref() else { continue }; let handle = (i + 1) as i64; - if i > 0 { s.push(','); } + if emitted > 0 { s.push(','); } + emitted += 1; unsafe { let hidden: bool = objc2::msg_send![&**view, isHidden]; let visible = !hidden; @@ -167,14 +197,70 @@ pub extern "C" fn perry_ui_query_widget_tree(out_len: *mut usize) -> *mut u8 { } /// Retrieve the UIView for a given handle. +/// +/// Returns `None` for an unknown handle and for a handle whose widget has been +/// released — a stale handle no-ops rather than driving some other view. pub fn get_widget(handle: i64) -> Option> { WIDGETS.with(|w| { let widgets = w.borrow(); + // `handle` is 1-based; 0 and negatives wrap to a huge index and miss, + // which is the intended "unknown handle" answer. let idx = (handle - 1) as usize; - widgets.get(idx).cloned() + widgets.get(idx).and_then(|slot| slot.clone()) }) } +/// Recover the handle for a live `UIView`, in O(1). +fn handle_for_view(view: *const UIView) -> Option { + HANDLE_BY_PTR.with(|m| m.borrow().get(&(view as usize)).copied()) +} + +/// Drop the widget table's strong reference to `handle`'s view and forget its +/// bookkeeping. +/// +/// The table is what keeps a `UIView` alive after it leaves the hierarchy: it +/// holds a `Retained`, so a removed widget stayed allocated for the +/// lifetime of the process. Releasing here hands ownership back to UIKit, where +/// a view with no superview and no other reference deallocates normally. +pub fn release_widget(handle: i64) { + let idx = (handle - 1) as usize; + let released = WIDGETS.with(|w| { + let mut widgets = w.borrow_mut(); + widgets.get_mut(idx).and_then(|slot| slot.take()) + }); + + if let Some(view) = released { + HANDLE_BY_PTR.with(|m| m.borrow_mut().remove(&(Retained::as_ptr(&view) as usize))); + } + + HEIGHT_CONSTRAINTS.with(|hc| { + if let Some(old) = hc.borrow_mut().remove(&handle) { + unsafe { + let _: () = objc2::msg_send![&*old, setActive: false]; + } + } + }); +} + +/// Release `view` and every registered widget beneath it, depth-first. +/// +/// Releasing only the directly-removed child would strand its descendants: each +/// still holds a table entry, and that entry is a strong reference, so an +/// entire subtree stays alive behind a parent nobody can reach. A list row is +/// normally a container with children, which is exactly this shape. +fn release_view_tree(view: &UIView) { + let subviews = view.subviews(); + for i in 0..subviews.len() { + let sv: *const UIView = unsafe { objc2::msg_send![&subviews, objectAtIndex: i] }; + if !sv.is_null() { + release_view_tree(unsafe { &*sv }); + } + } + if let Some(handle) = handle_for_view(view as *const UIView) { + release_widget(handle); + } +} + /// Set the hidden state of a widget. pub fn set_hidden(handle: i64, hidden: bool) { if let Some(view) = get_widget(handle) { @@ -211,23 +297,7 @@ pub fn clear_children(handle: i64) { } } - // Phase 2: Collect handles for cleanup - let handles: Vec = views - .iter() - .filter_map(|sv| { - WIDGETS.with(|w| { - let widgets = w.borrow(); - for (idx, widget) in widgets.iter().enumerate() { - if std::ptr::eq(Retained::as_ptr(widget), &**sv as *const UIView) { - return Some((idx + 1) as i64); - } - } - None - }) - }) - .collect(); - - // Phase 3: Remove views in reverse order + // Phase 2: Remove views in reverse order for sv in views.iter().rev() { unsafe { let _: () = objc2::msg_send![stack, removeArrangedSubview: &**sv]; @@ -235,15 +305,15 @@ pub fn clear_children(handle: i64) { } } - // Phase 4: Clean up HEIGHT_CONSTRAINTS for removed widgets - for h in &handles { - HEIGHT_CONSTRAINTS.with(|hc| { - if let Some(old) = hc.borrow_mut().remove(h) { - unsafe { - let _: () = objc2::msg_send![&*old, setActive: false]; - } - } - }); + // Phase 3: Release each removed subtree. This both frees the views + // (the table's strong reference was the only thing keeping them + // alive after removal) and retires their `HEIGHT_CONSTRAINTS`. + // + // Done after removal, not before: `release_view_tree` walks + // `subviews` to reach descendants, and a released view must still + // be reachable through the `views` snapshot we hold here. + for sv in views.iter() { + release_view_tree(sv); } } } @@ -616,6 +686,10 @@ pub fn remove_child(parent_handle: i64, child_handle: i64) { } } child.removeFromSuperview(); + // `widgetRemoveChild` is removal, not re-parenting — the layout API has + // a separate `widgetReorderChild` for moves — so the child and its + // subtree are done. Without this the table keeps them alive forever. + release_view_tree(&child); } } diff --git a/crates/perry/src/commands/compile/bundle_apple.rs b/crates/perry/src/commands/compile/bundle_apple.rs index 8a9169569e..013e45b321 100644 --- a/crates/perry/src/commands/compile/bundle_apple.rs +++ b/crates/perry/src/commands/compile/bundle_apple.rs @@ -111,6 +111,52 @@ pub(super) fn read_app_display_name(input: &Path, platform: &str) -> Option bool { + let Ok(mut dir) = input.canonicalize() else { + return false; + }; + for _ in 0..5 { + let Some(parent) = dir.parent() else { + return false; + }; + dir = parent.to_path_buf(); + let toml_path = dir.join("perry.toml"); + if !toml_path.exists() { + continue; + } + let Ok(text) = fs::read_to_string(&toml_path) else { + return false; + }; + let Ok(doc) = text.parse::() else { + return false; + }; + let from_table = |name: &str| { + doc.get(name) + .and_then(|v| v.as_table()) + .and_then(|t| t.get("high_refresh_rate")) + .and_then(|v| v.as_bool()) + }; + // First perry.toml on the walk is the project manifest; stop there + // whether or not it carries the key (mirrors read_app_display_name). + return from_table(platform) + .or_else(|| from_table("project")) + .unwrap_or(false); + } + false +} + /// Minimal XML escape for text interpolated into an Info.plist ``. /// Display names are user-controlled (perry.toml), so an `&` or `<` would /// otherwise produce a malformed plist that the bundle tooling rejects. @@ -823,6 +869,59 @@ mod tests { assert_eq!(build, "47"); } + /// Write a `perry.toml` with `body` and return a source path beneath it. + fn project_with_toml(dir: &std::path::Path, body: &str) -> std::path::PathBuf { + std::fs::write(dir.join("perry.toml"), body).unwrap(); + let src = dir.join("src"); + std::fs::create_dir_all(&src).unwrap(); + let input = src.join("main.ts"); + std::fs::write(&input, "console.log('x')").unwrap(); + input + } + + #[test] + fn high_refresh_rate_defaults_off_without_a_manifest() { + // The default must stay off: the key it gates uncaps the frame rate, + // which costs battery in every shipped app that never asked for it. + let dir = tempfile::tempdir().unwrap(); + let input = dir.path().join("main.ts"); + std::fs::write(&input, "console.log('x')").unwrap(); + assert!(!read_high_refresh_rate(&input, "ios")); + } + + #[test] + fn high_refresh_rate_defaults_off_when_manifest_omits_the_key() { + let dir = tempfile::tempdir().unwrap(); + let input = project_with_toml(dir.path(), "[project]\nversion = \"1.0.0\"\n"); + assert!(!read_high_refresh_rate(&input, "ios")); + } + + #[test] + fn high_refresh_rate_reads_platform_table() { + let dir = tempfile::tempdir().unwrap(); + let input = project_with_toml(dir.path(), "[ios]\nhigh_refresh_rate = true\n"); + assert!(read_high_refresh_rate(&input, "ios")); + // The platform table must not leak across platforms. + assert!(!read_high_refresh_rate(&input, "tvos")); + } + + #[test] + fn high_refresh_rate_falls_back_to_project_table() { + let dir = tempfile::tempdir().unwrap(); + let input = project_with_toml(dir.path(), "[project]\nhigh_refresh_rate = true\n"); + assert!(read_high_refresh_rate(&input, "ios")); + } + + #[test] + fn platform_table_overrides_project_table() { + let dir = tempfile::tempdir().unwrap(); + let input = project_with_toml( + dir.path(), + "[project]\nhigh_refresh_rate = true\n\n[ios]\nhigh_refresh_rate = false\n", + ); + assert!(!read_high_refresh_rate(&input, "ios")); + } + #[test] fn read_apple_app_version_defaults_when_absent() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/perry/src/commands/compile/bundle_ios.rs b/crates/perry/src/commands/compile/bundle_ios.rs index b2f48eb9e4..4cc31f02e1 100644 --- a/crates/perry/src/commands/compile/bundle_ios.rs +++ b/crates/perry/src/commands/compile/bundle_ios.rs @@ -143,6 +143,14 @@ pub(super) fn build_ios_app_bundle( }) .unwrap_or_default(); + // ProMotion opt-in. See `read_high_refresh_rate` — without this key an + // iPhone is capped at 60 Hz regardless of what CADisplayLink requests. + let promotion_block = if super::bundle_apple::read_high_refresh_rate(input, "ios") { + "CADisableMinimumFrameDurationOnPhone\n\n" + } else { + "" + }; + let encryption_exempt_plist = (|| -> Option { let mut dir = input.canonicalize().ok()?; for _ in 0..5 { @@ -252,7 +260,7 @@ pub(super) fn build_ios_app_bundle( 17.0 CFBundleSupportedPlatforms {plist_supported_platform} -{dt_block}UIRequiredDeviceCapabilities +{dt_block}{promotion_block}UIRequiredDeviceCapabilities arm64 CFBundleIcons diff --git a/crates/perry/src/commands/compile/link/build_and_run.rs b/crates/perry/src/commands/compile/link/build_and_run.rs index fa5443fd80..4cc0693a3d 100644 --- a/crates/perry/src/commands/compile/link/build_and_run.rs +++ b/crates/perry/src/commands/compile/link/build_and_run.rs @@ -1042,6 +1042,33 @@ pub(crate) fn build_and_run_link( cmd.arg(format!("/WHOLEARCHIVE:{}", ui_lib.display())); } else { cmd.arg(&ui_lib); + // ld64 scans archives left-to-right once, and the UI lib is + // added *after* the runtime and stdlib. A symbol the UI lib + // references but does not define — `std` internals it shares + // with the stdlib — becomes undefined for the first time only + // here, by which point those archives have been scanned and + // will not be revisited. + // + // This made `--target ios` unlinkable outright: + // `PerryTestExitTarget::test_exit` calls `Stdout::flush`, whose + // only definition surviving strip-dedup lives in + // libperry_stdlib.a. Reproduced with the untouched + // docs/examples/platforms/ui/ios_app.ts, so it is not specific + // to one app, and it is not specific to that one symbol either: + // any std item referenced solely by the UI lib lands the same + // way, which is why this is an ordering fix rather than a + // targeted `-u`. + // + // Repeating the archives is the standard remedy and is cheap — + // the linker extracts only members resolving a still-pending + // undefined. The Windows branch above solves the same problem + // with /WHOLEARCHIVE. The duplicate-symbol warnings this + // surfaces are pre-existing runtime/stdlib overlap (both rlibs + // bundle their own std), not new conflicts. + cmd.arg(runtime_lib); + if let Some(ref stdlib) = stdlib_lib { + cmd.arg(stdlib); + } } if is_watchos { diff --git a/crates/perry/src/commands/run/launch.rs b/crates/perry/src/commands/run/launch.rs index 127b6904d7..a25c3a7239 100644 --- a/crates/perry/src/commands/run/launch.rs +++ b/crates/perry/src/commands/run/launch.rs @@ -2,6 +2,25 @@ use super::*; +/// `PERRY_*` variables from the current environment, to forward into an app +/// launched on a simulator or device. +/// +/// A bundled app is launched by the system, not inherited from this shell, so +/// without an explicit hand-off none of Perry's runtime knobs reach it — +/// `PERRY_FRAME_STATS=1 perry run --device …` silently collected nothing. +/// +/// Scoped to the `PERRY_` prefix deliberately: forwarding the whole environment +/// would drop the developer's `PATH`, `HOME`, credentials and locale into a +/// sandboxed process that has its own. +fn perry_env_passthrough() -> Vec<(String, String)> { + let mut vars: Vec<(String, String)> = std::env::vars() + .filter(|(k, _)| k.starts_with("PERRY_")) + .collect(); + // Deterministic order so a launch command is reproducible and diffable. + vars.sort(); + vars +} + /// Launch the compiled output based on target pub fn launch( result: &CompileResult, @@ -137,8 +156,15 @@ pub fn launch_ios_simulator( println!(); } - let launch = Command::new("xcrun") - .args(["simctl", "launch", "--console-pty", udid, bundle_id]) + // `simctl` forwards a variable to the launched app when it is prefixed with + // `SIMCTL_CHILD_` in simctl's own environment. + let mut launch_cmd = Command::new("xcrun"); + launch_cmd.args(["simctl", "launch", "--console-pty", udid, bundle_id]); + for (key, value) in perry_env_passthrough() { + launch_cmd.env(format!("SIMCTL_CHILD_{key}"), value); + } + + let launch = launch_cmd .status() .map_err(|e| anyhow!("Failed to run xcrun simctl launch: {}", e))?; @@ -175,17 +201,33 @@ pub fn launch_ios_device( println!(); } - let launch = Command::new("xcrun") - .args([ - "devicectl", - "device", - "process", - "launch", - "--console", - "--device", - udid, - bundle_id, - ]) + let mut launch_cmd = Command::new("xcrun"); + launch_cmd.args([ + "devicectl", + "device", + "process", + "launch", + "--console", + "--device", + udid, + ]); + + // `devicectl` takes the app environment as a single JSON object. Built with + // serde_json rather than string concatenation so a value containing a quote + // or backslash cannot produce a malformed argument. + let env_vars = perry_env_passthrough(); + if !env_vars.is_empty() { + let map: serde_json::Map = env_vars + .into_iter() + .map(|(k, v)| (k, serde_json::Value::String(v))) + .collect(); + launch_cmd.arg("--environment-variables"); + launch_cmd.arg(serde_json::Value::Object(map).to_string()); + } + + launch_cmd.arg(bundle_id); + + let launch = launch_cmd .status() .map_err(|e| anyhow!("Failed to run xcrun devicectl launch: {}", e))?; diff --git a/docs/src/ui/on-frame.md b/docs/src/ui/on-frame.md index 29960d1610..48b91c8db9 100644 --- a/docs/src/ui/on-frame.md +++ b/docs/src/ui/on-frame.md @@ -33,18 +33,70 @@ cancelFrame(id); bookkeeping anything. - **Order.** Subscribers fire in registration order each frame. - **Pause when invisible.** The web backend uses `requestAnimationFrame`, - which is paused automatically when the tab is hidden. The native - backends drive frames from their main-loop pump; treat that as a soft - guarantee for now and a real per-platform display-link driver is a - follow-up. + which is paused automatically when the tab is hidden. iOS drives a real + `CADisplayLink`, which the system stops while the app is suspended. The + remaining native backends drive frames from their main-loop pump; treat + that as a soft guarantee for now, with per-platform display-link drivers + as a follow-up. ## Platform mapping | Platform | Driver | |---|---| | Web (WASM) | `requestAnimationFrame` | +| iOS | **`CADisplayLink`** (vsync-aligned, `NSRunLoopCommonModes`) | | macOS | Main-thread pump (CADisplayLink wiring TBD) | -| iOS / tvOS / visionOS | Main-thread pump (CADisplayLink wiring TBD) | +| tvOS / visionOS | Main-thread pump (CADisplayLink wiring TBD) | | Android | Main-thread pump (Choreographer wiring TBD) | | GTK4 (Linux) | Main-loop pump (`gtk_widget_add_tick_callback` TBD) | | Windows | WM_TIMER pump (DwmFlush vsync wiring TBD) | + +On iOS, `timestampMs` comes from the display link and carries sub-microsecond +resolution. On the platforms still on the main-thread pump it comes from +`js_timer_now()`, which is truncated to whole milliseconds — fine for driving +an animation, too coarse to measure one. + +## Frame-time metrics + +The display-link driver also feeds a frame-time recorder, so frame pacing can +be measured on-device rather than inferred. It is off by default; set +`PERRY_FRAME_STATS=1` to collect. + +Collection reports **p50/p95/p99 frame times, the longest frame, and a +dropped-frame count** over a rolling window of the most recent 8192 frames. +Dropped frames are derived from the display's own nominal frame duration, so +the same numbers are correct at 60 Hz and 120 Hz without configuration. + +A frame is counted as dropped when the interval to the previous vsync spans +more than one nominal frame — jitter within half a frame of nominal is not a +drop, and a doubled interval is exactly one. + +``` +[frame-stats] frames=1840 budget=8.33ms p50=8.34ms p95=9.10ms p99=16.71ms max=41.20ms dropped=12 +``` + +Because collection keeps the display link awake, an app with no `onFrame` +subscribers — a plain scrolling list, say — still produces a full frame trace. + +### Measuring at 120 Hz + +When metrics are enabled the link requests the top of the ProMotion range +instead of the adaptive default, so a benchmark measures the framework rather +than ProMotion throttling a static screen. + +**On iPhone this is capped at 60 Hz unless the app's `Info.plist` contains +`CADisableMinimumFrameDurationOnPhone = YES`.** Perry's generated plist does +not currently emit that key, so 120 Hz measurement on iPhone needs it added +first; iPad ProMotion has no such opt-in. + +### Interpreting a run + +`js_frame_metrics_report()` returns the number of samples it summarized. +Zero means the display link never ticked — distinguishing "every frame was +fast" from "nothing was measured", which otherwise look identical in a +summary line. + +Deliberate pauses (backgrounding, an explicit stop/start) are excluded from +the distribution rather than recorded as one enormous frame. An app suspended +*without* the driver noticing will still show a single long interval, so treat +a lone multi-second `max` as a lifecycle artifact rather than a stutter.