;; This Source Code Form is subject to the terms of the Mozilla Public ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; ;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns benches.render-wasm.protocol "Shared render-completion protocol for the renderer benchmarks. The WASM renderer finishes progressively in tile budgets: one `_render` call returns a frame type and the caller keeps calling until `FRAME_TYPE_FULL`. This namespace implements a driver loop and camera sessions over an injected `hooks` map. Callers use the same timing and continuation rules for rendering and interaction: - `drain` drives `_render` to `FRAME_TYPE_FULL`, collecting one slice per call. - `restore` puts the camera back to the case's initial view, then drains. Callers run it outside timing. - `start-camera!`, `preview-view!`, `animate-view!`, `sleep!` and `finish-camera!` form one Promise-aware camera session. `interact` is a thin adapter for callers with a predeclared frame vector. `hooks` supplies clock, scheduling and renderer operations. The browser supplies real capabilities; tests supply fakes. - `:now` zero-arg clock, ms. - `:frame` zero-arg promise of the next rAF timestamp. - `:sleep` `(fn [ms] ...)` promise. - `:check` zero-arg guard. Throws on cancellation, wall deadline, context loss or superseded ownership. The protocol checks before work, after awaited frames and sleeps, and around render calls. It never retries; synchronous stalls cannot be interrupted in-page, so Node stays the outer backstop. - `:render` `(fn [timestamp flags] ...)` returning a frame type. - `:render-from-cache` zero-arg cached-preview call. The protocol times the call itself and discards its return value. - `:set-view` `(fn [view] ...)` with `view` a `{:scale :x :y}` map. - `:set-view-start` / `:set-view-end` zero-arg camera calls. Guard and renderer errors reject the returned promises. The browser's `:check` guard throws when work is cancelled, exceeds its deadline, loses its rendering context or is no longer the active task. Frame types come from `app.common.render-wasm.wasm`: FRAME_TYPE_PARTIAL and FRAME_TYPE_VIEWPORT_READY continue, FRAME_TYPE_FULL completes, FRAME_TYPE_NONE or anything else fails the attempt. The previous returned frame type becomes the next call's flags. `restore` and the final drain of `interact` start with the SyncTiles flag immediately (no rAF); a fresh drain can start with different flags and wait for a frame. ## Metrics reported - `:slices` one map per `_render` call: - `:timestamp`: the rAF stamp the call ran under, or the clock value for an immediate call without a frame - `:flags`: flags passed in - `:frame-type`: frame type returned - `:duration-ms`: wall time of the call itself Raw slices are retained as-is, including zero durations. - `:cached-slices` one map per gesture frame: - `:timestamp` rAF stamp of the input - `:duration-ms`: the timed `_render-from-cache` call. - `:view-and-preview-ms`: the view update and cached-preview call span. Cached previews have their own shape: no flags or frame type. - `:viewport-ready-ms` first Full-or-ViewportReady completion measured from the drain origin. An immediate Full supplies both boundaries, so they are equal there. - `:full-ms` Full completion measured from the drain origin. - `:interact-ms` from before `_set_view_start` through final Full. - `:active-ms` from before `_set_view_start` to after the last cached preview and its guard check, or an authored sleep after that preview. - `:authored-sleeps` requested and actual durations of optional pauses. - `:settling-requested-ms` the declared settle wait passed in. - `:settling-actual-ms` from the end of the active span to finalization start, recorded separately from the declared value. - `:set-view-end-ms` wall time of the `_set_view_end` call itself. Finalization starts before `_set_view_end`, so the final drain times include it; the call time is still reported on its own. - `:time-to-viewport-ready-ms` / `:time-to-full-ms` the drain's boundaries, renamed at the interaction level. - `:last-input-to-full-ms` from the clock read before the last view update to the clock read after the final drain promise resolves." (:require [app.common.render-wasm.wasm :as wasm])) (defn- next-render-timestamp [{:keys [now frame]} immediate?] (if immediate? (js/Promise.resolve (now)) (frame))) (defn- render-slice "Times one render call and adds its slice to the drain state." [{:keys [now check render]} {:keys [flags] :as state} timestamp] (check) (let [started-ms (now) frame-type (render timestamp flags) ended-ms (now) slice {:timestamp timestamp :flags flags :frame-type frame-type :duration-ms (- ended-ms started-ms)}] (check) {:state (update state :slices conj slice) :frame-type frame-type :ended-ms ended-ms})) (defn- valid-frame-type? [frame-type] (or (= frame-type wasm/FRAME_TYPE_PARTIAL) (= frame-type wasm/FRAME_TYPE_VIEWPORT_READY) (= frame-type wasm/FRAME_TYPE_FULL))) (defn- viewport-ready-time [current-ready-ms frame-type ended-ms origin-ms] (or current-ready-ms (when (or (= frame-type wasm/FRAME_TYPE_VIEWPORT_READY) (= frame-type wasm/FRAME_TYPE_FULL)) (- ended-ms origin-ms)))) (defn- advance-drain-state [{:keys [viewport-ready-ms] :as state} frame-type ended-ms origin-ms] (when-not (valid-frame-type? frame-type) (throw (ex-info (str "unexpected frame type: " (pr-str frame-type)) {:type ::unexpected-frame-type :frame-type frame-type}))) (assoc state :flags frame-type :viewport-ready-ms (viewport-ready-time viewport-ready-ms frame-type ended-ms origin-ms))) (defn- drain-complete? [frame-type] (= frame-type wasm/FRAME_TYPE_FULL)) (defn- drain-result [{:keys [slices viewport-ready-ms]} ended-ms origin-ms] {:slices slices :viewport-ready-ms viewport-ready-ms :full-ms (- ended-ms origin-ms)}) (defn drain "Drives `_render` to `FRAME_TYPE_FULL` and resolves to `{:slices [...] :viewport-ready-ms ... :full-ms ...}`. The flow is: drain └─ step ├─ next-render-timestamp ├─ render-slice ├─ advance-drain-state └─ either ├─ drain-result └─ step `opts` contains: - `:flags` (first call's flags) - `:origin` (required starting moment in ms) - `:immediate` (run the first `_render` in the same task instead of waiting for a frame; later frames always wait). Partial and ViewportReady continue with the returned frame type as the next flags. FRAME_TYPE_FULL resolves. Anything else rejects with `::unexpected-frame-type`. A failure of the hook `:check` rejects without retrying." [{:keys [check] :as hooks} {:keys [flags origin immediate]}] (try (check) (letfn [(step [state immediate?] (-> (next-render-timestamp hooks immediate?) (.then ;; recurse or drain (fn [timestamp] (let [{:keys [frame-type ended-ms] :as rendered} (render-slice hooks state timestamp) next-state (advance-drain-state (:state rendered) frame-type ended-ms origin)] (if (drain-complete? frame-type) (drain-result next-state ended-ms origin) (step next-state false)))))))] (step {:flags flags :slices [] :viewport-ready-ms nil} immediate)) (catch :default cause (js/Promise.reject cause)))) (defn restore "Puts the camera back to `view` (`{:scale :x :y}`) and drains with the SyncTiles flag immediately. Resolves what `drain` resolves. Callers run this outside timing: the reset itself is never scored." [{:keys [now check set-view set-view-start set-view-end] :as hooks} view] (try (check) (set-view-start) (set-view view) (set-view-end) (drain hooks {:flags wasm/RENDER_FLAG_SYNC_TILES :origin (now) :immediate true}) (catch :default cause (js/Promise.reject cause)))) (defn start-camera! "Starts a measured camera session after the caller's untimed preparation. `rtx` supplies `:hooks` and the current full `:view`." [{:keys [hooks view]} {:keys [settle-ms]}] (try (when-not (and (number? settle-ms) (js/Number.isFinite settle-ms) (<= 0 settle-ms)) (throw (ex-info "settle-ms must be a nonnegative finite number" {:type ::invalid-settle-ms :settle-ms settle-ms}))) (let [{:keys [now check set-view-start]} hooks] (check) (let [started-ms (now)] (set-view-start) (js/Promise.resolve {:hooks hooks :view view :settle-ms settle-ms :started-ms started-ms :last-input-ms started-ms :active-end-ms (now) :cached-slices [] :authored-sleeps []}))) (catch :default cause (js/Promise.reject cause)))) (defn preview-view! "Awaits a session or session promise, then submits one cached preview after exactly one rAF. The call span includes `_set_view` and cache rendering." [session-p view] (-> (js/Promise.resolve session-p) (.then (fn [{:keys [hooks] :as session}] (let [{:keys [now frame check render-from-cache set-view]} hooks] (check) (-> (frame) (.then (fn [timestamp] (check) (let [input-ms (now) call-start (now)] (set-view view) (let [cache-start (now)] (render-from-cache) (let [cache-end (now)] (check) (assoc session :view view :last-input-ms input-ms :active-end-ms (now) :cached-slices (conj (:cached-slices session) {:timestamp timestamp :duration-ms (- cache-end cache-start) :view-and-preview-ms (- cache-end call-start)}))))))))))))) (defn animate-view! "Submits exactly `steps` serial, linearly interpolated full views." [session-p {:keys [to steps]}] (-> (js/Promise.resolve session-p) (.then (fn [{from :view :as session}] (when-not (and (integer? steps) (pos? steps)) (throw (ex-info "camera steps must be a positive integer" {:type ::invalid-steps :steps steps}))) (reduce (fn [session-p i] (let [t (/ i steps)] (preview-view! session-p {:scale (+ (:scale from) (* t (- (:scale to) (:scale from)))) :x (+ (:x from) (* t (- (:x to) (:x from)))) :y (+ (:y from) (* t (- (:y to) (:y from))))}))) (js/Promise.resolve session) (range 1 (inc steps))))))) (defn sleep! "Awaits an authored pause inside the active span and records its actual time." [session-p requested-ms] (-> (js/Promise.resolve session-p) (.then (fn [{:keys [hooks] :as session}] (let [{:keys [now sleep check]} hooks] (when-not (and (number? requested-ms) (js/Number.isFinite requested-ms) (<= 0 requested-ms)) (throw (ex-info "sleep duration must be a nonnegative finite number" {:type ::invalid-sleep :requested-ms requested-ms}))) (check) (let [started-ms (now)] (-> (sleep requested-ms) (.then (fn [_] (check) (let [ended-ms (now)] (-> session (assoc :active-end-ms ended-ms) (update :authored-sleeps conj {:requested-ms requested-ms :actual-ms (- ended-ms started-ms)})))))))))))) (defn- finish-interaction [{:keys [now check set-view-end] :as hooks} {:keys [settle-ms started-ms cached-slices last-input-ms active-end-ms authored-sleeps]}] (check) (let [finalization-start (now) t0 (now)] (set-view-end) (let [set-view-end-ms (- (now) t0)] (-> (drain hooks {:flags wasm/RENDER_FLAG_SYNC_TILES :origin finalization-start :immediate true}) (.then (fn [{:keys [slices viewport-ready-ms full-ms]}] {:cached-slices cached-slices :slices slices :interact-ms (- (now) started-ms) :active-ms (- active-end-ms started-ms) :authored-sleeps authored-sleeps :settling-requested-ms settle-ms :settling-actual-ms (- finalization-start active-end-ms) :set-view-end-ms set-view-end-ms :time-to-viewport-ready-ms viewport-ready-ms :time-to-full-ms full-ms :last-input-to-full-ms (- (now) last-input-ms)})))))) (defn finish-camera! "Awaits a session, settles, ends the camera and drains through Full." [session-p] (-> (js/Promise.resolve session-p) (.then (fn [{:keys [hooks settle-ms] :as session}] ((:check hooks)) (-> ((:sleep hooks) settle-ms) (.then (fn [_] (finish-interaction hooks session)))))))) (defn interact "Replays `frames` (a vector of `{:scale :x :y}` views) as one gesture and resolves the interaction metrics (see the namespace docstring). The flow is: interact ├─ start-camera! ├─ preview-view! × N └─ finish-camera! ├─ settle ├─ set-view-end └─ drain Each frame waits for rAF, records the input time, sets the view and times one cached preview into `:cached-slices`. Afterwards the declared `settle-ms` elapses, `_set_view_end` is timed on its own, and a SyncTiles immediate drain runs with its origin at finalization start, so the final times include `_set_view_end` while reporting it separately." [hooks {:keys [frames settle-ms]}] (-> (reduce preview-view! (start-camera! {:hooks hooks :view (first frames)} {:settle-ms settle-ms}) frames) (finish-camera!)))