2026-10-28 19:17:39 +01:00

402 lines
18 KiB
Clojure

;; 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.runtime.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.
`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 camera-session drain 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- partial-error
"Attaches available trace evidence to a rejection without adding clock reads.
Deeper failure evidence takes precedence over a caller's earlier state."
[cause evidence]
(ex-info (or (ex-message cause) "renderer protocol failed")
(assoc (ex-data cause) :partial (merge evidence (:partial (ex-data cause))))
(if (some? (ex-data cause)) (ex-cause cause) cause)))
(defn- next-render-timestamp
"Gets an immediate clock timestamp or awaits the next frame 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)}]
(try
(check)
(catch :default cause
(throw (partial-error cause {:slices (conj (:slices state) slice)}))))
{:state (update state :slices conj slice)
:frame-type frame-type
:ended-ms ended-ms}))
(defn- valid-frame-type?
"Recognizes the progressive and final renderer frame types."
[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
"Keeps the first submission boundary that exposes the requested viewport."
[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
"Updates progressive completion or rejects with the available raw slices."
[{: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
:partial (select-keys state [:slices :viewport-ready-ms])})))
(assoc state
:flags frame-type
:viewport-ready-ms
(viewport-ready-time viewport-ready-ms
frame-type
ended-ms
origin-ms)))
(defn- drain-complete?
"Tests for the renderer's Full submission boundary."
[frame-type]
(= frame-type wasm/FRAME_TYPE_FULL))
(defn- drain-result
"Returns raw slices and completion times from the drain origin."
[{: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)))))
(.catch (fn [cause]
(throw (partial-error cause (select-keys state [:slices :viewport-ready-ms])))))))]
(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 and submits one cached preview after exactly one rAF.
The call span includes view update and cache rendering. Failures retain
completed previews and authored pauses without adding clock reads."
[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
evidence (select-keys session [:cached-slices :authored-sleeps])]
(try
(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)
slices (conj (:cached-slices session)
{:timestamp timestamp
:duration-ms (- cache-end cache-start)
:view-and-preview-ms (- cache-end call-start)})]
(try
(check)
(catch :default cause
(throw (partial-error cause (assoc evidence :cached-slices slices)))))
(assoc session :view view :last-input-ms input-ms
:active-end-ms (now) :cached-slices slices))))))
(.catch (fn [cause] (throw (partial-error cause evidence)))))
(catch :default cause
(js/Promise.reject (partial-error cause evidence)))))))))
(defn animate-view!
"Submits exactly `steps` serial, linearly interpolated full views.
Invalid steps retain the session's completed previews and pauses."
[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
:partial (select-keys session [:cached-slices :authored-sleeps])})))
(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.
Rejections retain earlier completed previews and pauses without new timers."
[session-p requested-ms]
(-> (js/Promise.resolve session-p)
(.then (fn [{:keys [hooks] :as session}]
(let [{:keys [now sleep check]} hooks
evidence (select-keys session [:cached-slices :authored-sleeps])]
(try
(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)})))))
(.catch (fn [cause] (throw (partial-error cause evidence))))))
(catch :default cause
(js/Promise.reject (partial-error cause evidence)))))))))
(defn- finish-interaction
"Ends the camera and returns final metrics, retaining trace evidence on failure."
[{: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)}))
(.catch (fn [cause]
(throw (partial-error cause {:cached-slices cached-slices
:authored-sleeps authored-sleeps}))))))))
(defn finish-camera!
"Awaits a session, settles, ends the camera and drains through Full.
Failures keep completed cached previews and authored pauses."
[session-p]
(-> (js/Promise.resolve session-p)
(.then (fn [{:keys [hooks settle-ms] :as session}]
(let [evidence (select-keys session [:cached-slices :authored-sleeps])]
(try
((:check hooks))
(-> ((:sleep hooks) settle-ms)
(.then (fn [_] (finish-interaction hooks session)))
(.catch (fn [cause] (throw (partial-error cause evidence)))))
(catch :default cause
(js/Promise.reject (partial-error cause evidence)))))))))