🚧 WIP Step 16 Introduce hooks for the browser and load-scene

This commit is contained in:
Miguel de Benito Delgado 2026-09-30 12:11:14 +02:00
parent b01cdbe880
commit 6cec9d7be4
5 changed files with 1070 additions and 380 deletions

View File

@ -10,11 +10,9 @@
Lifecycle here mirrors the editor cold-load path
(`app.render-wasm.api/initialize-viewport` + `set-objects` sync).
This ns exports `bridge`, which is what the Node 'pilot' uses inside the page.
We only ever pass data through `page.evaluate`.
The browser exports `bridge`; Node passes data through `page.evaluate`.
All `bridge` entries resolve, that is they never return an unhandled rejected
promise.
`loadScene` resolves to Transit. `ping` and `dispose` return plain data.
Failures are returned as `{\"status\" \"failed\" ...}` maps."
(:require
@ -23,41 +21,98 @@
[app.common.render-wasm.serializers :as sr]
[app.common.render-wasm.wasm :as wasm]
[app.common.schema :as sm]
[app.common.transit :as t]
;; bind the enum table from the prepared renderer build's `shared.js` so
;; a missing `shared.js` fails the compile. A stale or mismatched one throws
;; at load
[app.render-wasm.api.enums]
[app.render-wasm.api.webgl :as webgl]
[benches.render-wasm.cases]
[benches.render-wasm.protocol :as protocol]
[benches.render-wasm.scenes.common :as scenes]
[benches.render-wasm.scenes.core :as core]
[benches.render-wasm.scenes.rects :as rects]
[benches.render-wasm.upload :as upload]))
;; TODO(mem:render-wasm/performance/cljs-rewrite/06-render-and-interaction-protocol):
;; move these to the shared WASM layer and delete them here. They mirror
;; `app.render-wasm.api/FRAME_TYPE_*` until ticket 06 owns the protocol.
(def ^:private frame-full 2)
;; Forward decls
(declare now guard-current! read-graphics)
(def ^:private default-view
{:scale 1 :x 0 :y 0})
(def ^:private poll-frame-budget
"Own deadline for the interim Full poll: at most this many rAF frames."
600)
(def ^:private poll-ms-budget
"Own deadline for the interim Full poll: at most this many wall ms."
15000)
(def schema:load-args
"Bridge-boundary schema for `load-rects`. Invalid args produce a failure
map before any timer starts, never inside timed bodies."
(def schema:load-scene-args
"Decoded Transit request for `load-scene`."
[:map {:closed true}
[:seed [:int {:min 0 :max 4294967295}]]
[:view {:optional true} core/schema:view]
[:seed [:int {:min 0 :max 4294967295}]] ;; derived in `collect-cases`
[:case map?]
[:module-url {:optional true} string?]
[:wasm-url {:optional true} string?]])
(def ^:const ^:private attempt-ms-budget
"Own wall deadline for one bridge attempt, checked by `browser-hooks`.
Ticket 11 accepts a per-attempt timeout; until then this fixed value
bounds runaway drains. The Node outer timeout stays the backstop for
synchronously blocked pages."
30000)
(def ^:const ^:private gesture-settle-ms
"Declared settle wait for warm gestures. The actual wait is recorded
separately by `protocol/interact`."
100)
(defn- browser-hooks
"Browser hooks for one owner epoch and deadline."
[epoch deadline-ms]
{:now now
;; rAF scheduling
:frame #(js/Promise.
(fn [resolve] (.requestAnimationFrame js/window resolve)))
:sleep #(js/Promise. (fn [resolve] (js/setTimeout resolve %)))
;; checks that this attempt still owns the page, WebGL context hasn't been
;; lost, and the deadline hasn't passed
:check (fn []
(when @wasm/context-lost?
(throw (ex-info "WebGL context lost"
{:phase "context-lost"})))
(guard-current! epoch "protocol")
(when (> (now) deadline-ms)
(throw (ex-info "attempt deadline exceeded"
{:phase "deadline"}))))
;; WASM calls
:render (fn [timestamp flags]
(h/call wasm/internal-module "_render" timestamp flags))
:render-from-cache #(h/call wasm/internal-module "_render_from_cache" 0)
:set-view #(h/call wasm/internal-module "_set_view"
(:scale %) (- (:x %)) (- (:y %)))
:set-view-start #(h/call wasm/internal-module "_set_view_start")
:set-view-end #(h/call wasm/internal-module "_set_view_end")})
;; TODO(tickets 07/08): Move gestures into scene cases. These rectangle ID
;; checks reject every new warm case; the bridge should run its gesture.
(defn- linear-frames
"Linear interpolation of `{:scale :x :y}` views from `from` to `to` over
`n` frames."
[from to n]
(mapv (fn [i]
(let [t (/ (inc i) n)]
{:scale (+ (:scale from) (* t (- (:scale to) (:scale from))))
:x (+ (:x from) (* t (- (:x to) (:x from))))
:y (+ (:y from) (* t (- (:y to) (:y from))))}))
(range n)))
(defn- gesture-frames
"Builds rectangle pan or zoom frames from the case view. Other case ids
throw because the bridge has no gesture for them."
[case view]
(cond
(= (:id case) :rects/pan)
(linear-frames view (update view :x + 200) 20)
(= (:id case) :rects/zoom)
(linear-frames view (update view :scale * 1.5) 20)
:else
(throw (ex-info (str "no gesture declared for case " (:id case))
{:phase "gesture"
:case (:id case)}))))
;; Monotonic owner generation. Every async continuation compares its
;; captured epoch and drops stale results silently; dispose/load bump it.
(defonce ^:private owner-epoch*
@ -74,7 +129,7 @@
(defn- fail-data
[m]
(clj->js (assoc m "status" "failed")))
(assoc m :status "failed"))
(defn- mark-live!
[]
@ -162,43 +217,27 @@
(dispose!)
true))
(defn- poll-full
"TODO(mem:render-wasm/performance/cljs-rewrite/06-render-and-interaction-protocol):
interim inline drain. Ticket 06 replaces this with `protocol.cljs`; do not
extend it. Resolves `{:ms [...] :frames n}` at Full, `{:full false ...}` on
budget exhaustion, and checks the owner epoch every frame. A synchronous
throw inside a frame rejects: rAF callbacks run outside the executor, so
without this the bridge promise would hang."
[epoch started-frames started-ms]
(js/Promise.
(fn [resolve reject]
(letfn [(step [frames]
(if (not= epoch @owner-epoch*)
(resolve #js {"stale" true})
(let [frame-type (try
(h/call wasm/internal-module "_render" (now) 0)
(catch :default cause
(reject cause)
::thrown))]
(when-not (= frame-type ::thrown)
(cond
(= frame-type frame-full)
(resolve #js {"full" true
"frames" (- frames started-frames)
"ms" (- (now) started-ms)})
(or (>= frames (+ started-frames poll-frame-budget))
(>= (- (now) started-ms) poll-ms-budget))
(resolve #js {"full" false
"frames" (- frames started-frames)
"ms" (- (now) started-ms)
"frameType" frame-type})
:else
(.requestAnimationFrame
js/window
(fn [] (step (inc frames)))))))))]
(.requestAnimationFrame js/window (fn [] (step (inc started-frames))))))))
(defn- render-ok
"Maps one drain result to the bridge value. Success keeps the owner live
for explicit disposal. Failures release the owner only if it still is current
(i.e. it owns the page resources) so a stale failure cannot dispose of
resources belonging to a newer owner."
[epoch module-ms graphics-ms upload-ms alive? drain-result snapshot seed ctx width height dpr renderer]
(if-not alive?
(do
(dispose-if-current! epoch)
(fail-data {:phase "first-render"
:message "disposed or context lost during render"}))
(assoc drain-result
:status "ok"
:module-init-ms module-ms
:graphics-init-ms graphics-ms
:upload-ms upload-ms
:first-render-ms (:full-ms drain-result)
:render-frames (count (:slices drain-result))
:scene {:shapes (count (:objects snapshot))
:seed seed}
:effective-graphics (read-graphics ctx width height dpr renderer))))
(defn- read-graphics
"Records effective graphics settings: requested CSS size and DPR
@ -214,73 +253,26 @@
attr (fn [k]
(when (some? attrs)
(unchecked-get attrs k)))]
{"cssWidth" width
"cssHeight" height
"drawingBufferWidth" (.-drawingBufferWidth ^js ctx)
"drawingBufferHeight" (.-drawingBufferHeight ^js ctx)
"dpr" dpr
"webgl2" true
"antialias" (attr "antialias")
"depth" (attr "depth")
"stencil" (attr "stencil")
"alpha" (attr "alpha")
"preserveDrawingBuffer" (attr "preserveDrawingBuffer")
"renderer" renderer
"software" (software-renderer? renderer)}))
(defn- render-ok
"Maps one poll result to the bridge value. Success keeps the owner live
for explicit disposal. Every failure path releases the owner when it still
owns the realm, so budget exhaustion and context loss never leave a live
canvas behind. Stale results never dispose: a newer owner is live."
[epoch module-ms graphics-ms upload-ms alive? poll snapshot seed ctx width height dpr renderer]
(let [pm (js->clj poll :keywordize-keys true)]
(cond
(:stale pm)
;; dispose! resets loss state, so stale-plus-lost means genuine
;; context loss, not disposal. The loss listener already released the
;; canvas; the dispose call below only matters when loss was recorded
;; without an epoch bump.
(if @wasm/context-lost?
(do
(dispose-if-current! epoch)
(fail-data {:phase "context-lost"
:message "WebGL context lost during render"}))
#js {"status" "stale"})
;; Safety net: every loss writer bumps the epoch, so staleness
;; normally fires first.
(not alive?)
(do
(dispose-if-current! epoch)
(fail-data {:phase "first-render"
:message "disposed or context lost during render"}))
(:full pm)
(clj->js
{"status" "ok"
"moduleInitMs" module-ms
"graphicsInitMs" graphics-ms
"uploadMs" upload-ms
"firstRenderMs" (:ms pm)
"renderFrames" (:frames pm)
"scene" {"shapes" (count (:objects snapshot))
"seed" seed}
"effectiveGraphics" (read-graphics ctx width height dpr renderer)})
:else
(do
(dispose-if-current! epoch)
(fail-data {:phase "first-render"
:message "Full not reached in budget"
:detail pm})))))
{:css-width width
:css-height height
:drawing-buffer-width (.-drawingBufferWidth ^js ctx)
:drawing-buffer-height (.-drawingBufferHeight ^js ctx)
:dpr dpr
:webgl2 true
:antialias (attr "antialias")
:depth (attr "depth")
:stencil (attr "stencil")
:alpha (attr "alpha")
:preserve-drawing-buffer (attr "preserveDrawingBuffer")
:renderer renderer
:software (software-renderer? renderer)}))
(defn- install-listeners!
"Wires context lost/restored handling for `canvas`, epoch-guarded: a stale
canvas (removed but not yet GC'd) must not kill the live owner. On loss the
current owner releases its canvas and GL resources itself: the poll loop
only observes staleness afterwards, so a `render-ok` cleanup there would
always miss."
current owner releases its canvas and GL resources itself: the protocol
loop only observes staleness afterwards, so a cleanup by `render-ok` /
`warm-ok` there would always miss."
[canvas epoch]
(.addEventListener
canvas "webglcontextlost"
@ -432,7 +424,7 @@
the top-level cause decides staleness."
[epoch cause fallback-phase]
(if (stale? cause)
#js {"status" "stale"}
{:status "stale"}
(let [phase (try (or (:phase (ex-data cause))
fallback-phase
"aborted")
@ -460,144 +452,254 @@
(aset (.-style ^js canvas) "height" (str height "px"))
canvas))
;; TODO: remove in Ticket 06
(defn load-rects
"Loads the `:rects/load` scene and renders it to Full. Takes plain data:
`{\"seed\" int, \"view\" {scale x y viewport?}?, \"module-url\"?, \"wasm-url\"?}`.
Viewport defaults to 1920x1080@2. Always resolves (failure maps included).
(defn- warm-ok
"Maps an interaction to the bridge result. Success keeps the owner live;
failure releases it if this attempt still owns the page."
[epoch module-ms graphics-ms upload-ms interact-ms alive? interaction snapshot seed ctx width height dpr renderer]
(if-not alive?
(do
(dispose-if-current! epoch)
(fail-data {:phase "interact"
:message "disposed or context lost during interaction"}))
(merge interaction
{:status "ok"
:module-init-ms module-ms
:graphics-init-ms graphics-ms
:upload-ms upload-ms
:interact-ms interact-ms
:scene {:shapes (count (:objects snapshot))
:seed seed}
:effective-graphics (read-graphics ctx width height dpr renderer)})))
The chain is flat: one `.then` per phase (module, graphics, scene, upload,
drain) plus a terminal `.catch`, so every rejection carries its phase to a
single ownership-aware handler."
[args]
(defn- run-fresh
"Times one drain for a fresh-context case. Resolves the bridge value
through `render-ok`; drain failures resolve through `terminal-failure`."
[epoch hooks seed dims g live?]
(let [{:keys [module-ms graphics-ms renderer ctx snapshot upload-ms]} g
{:keys [width height dpr]} dims]
(-> (protocol/drain hooks {:flags 0 :origin ((:now hooks)) :immediate false})
(.then (fn [drain-result]
(render-ok epoch module-ms graphics-ms upload-ms
(live?) drain-result snapshot seed
(:context ctx) width height dpr renderer)))
(.catch (fn [cause]
(terminal-failure epoch cause "first-render"))))))
(defn- run-warm
"Restores the initial view and drains outside timing, then times one
gesture for a reuse-context case. Resolves the bridge value through
`warm-ok`; gesture failures resolve through `terminal-failure`."
[epoch hooks case-desc seed dims g live?]
(let [{:keys [module-ms graphics-ms renderer ctx snapshot upload-ms view]} g
{:keys [width height dpr]} dims]
(-> (protocol/restore hooks view)
(.then (fn [_]
(guard-current! epoch "interact")
(let [t0 ((:now hooks))]
(-> (protocol/interact
hooks
{:frames (gesture-frames case-desc view)
:settle-ms gesture-settle-ms})
(.then (fn [interaction]
(warm-ok epoch module-ms graphics-ms upload-ms
(- ((:now hooks)) t0) (live?) interaction
snapshot seed (:context ctx)
width height dpr renderer)))
(.catch (fn [cause]
(terminal-failure epoch cause "interact")))))))
(.catch (fn [cause]
(terminal-failure epoch cause "restore"))))))
(defn- parse-load-request
"Decodes and validates the request before claiming the page. Failure
leaves the current owner untouched."
[request]
(try
(let [params (js->clj (or args #js {}) :keywordize-keys true)
view (merge default-view (:view params))
seed (:seed params)]
(if-not (sm/validate schema:load-args
(cond-> {:seed seed}
(:view params) (assoc :view (:view params))
(:module-url params) (assoc :module-url (:module-url params))
(:wasm-url params) (assoc :wasm-url (:wasm-url params))))
(js/Promise.resolve
(fail-data {:phase "invalid-args"
:message "load-rects needs {seed int, view?}"}))
(let [epoch (swap! owner-epoch* inc)]
;; Supersede: the epoch bump above already retired the previous
;; owner's continuations. Release its canvas and GL resources now
;; with the production teardown path, and drop the cached module
;; promise so this owner measures a fresh instantiation.
(release-owner-resources!)
(reset! module-promise* nil)
(let [viewport (core/resolve-viewport view)
width (:width viewport)
height (:height viewport)
dpr (:dpr viewport)
live? (fn [] (and (= epoch @owner-epoch*) (wasm/live?)))]
(-> (ensure-module! (or (:module-url params) "./render-wasm.js")
(or (:wasm-url params) "./render-wasm.wasm"))
(.catch (fn [cause]
(throw (ex-info (str "module init failed: "
(or (ex-message cause) cause))
{:phase "module-init"}
cause))))
(.then (fn [installed]
(guard-current! epoch "module-init")
installed))
(.then (fn [{:keys [module factory-ms]}]
(guard-current! epoch "graphics-init")
(set! wasm/internal-module module)
(let [canvas (build-canvas! width height dpr)]
(.appendChild (.-body js/document) canvas)
(reset! canvas* canvas)
(let [g0 (now)
ctx (try
(webgl/init-context!
canvas
{:module module
:context-id "webgl2"
:css-width width
:css-height height
:dpr dpr
:flags 0
:browser (sr/translate-browser :chrome)
:params {}})
(catch :default cause
(throw (ex-info "graphics init failed"
{:phase "graphics-init"}
cause))))]
(if (nil? ctx)
(throw (ex-info "WebGL2 context unavailable"
{:phase "graphics-init"}))
(do
(set! wasm/gl-context-handle (:handle ctx))
(set! wasm/gl-context (:context ctx))
(set! wasm/canvas canvas)
(mark-live!)
(try
(h/call wasm/internal-module "_resize_viewbox" width height)
(catch :default cause
(throw (ex-info "resize viewbox failed"
{:phase "graphics-init"}
cause))))
(install-listeners! canvas epoch)
{:module-ms factory-ms
:graphics-ms (- (now) g0)
:renderer (renderer-string (:context ctx))
:ctx ctx}))))))
(.then (fn [g]
(guard-current! epoch "scene-build")
(try
(let [snapshot (-> (rects/build {:seed seed})
(scenes/validate!))]
(assoc g
:snapshot snapshot
:ordered (upload/prepare-scene snapshot)))
(catch :default cause
(throw (ex-info "scene build failed"
{:phase "scene-build"}
cause))))))
(.then (fn [g]
(guard-current! epoch "upload")
(try
(let [u0 (now)]
(h/call wasm/internal-module "_set_view"
(:scale view) (- (:x view)) (- (:y view)))
(h/call wasm/internal-module "_init_shapes_pool"
(count (:objects (:snapshot g))))
(h/call wasm/internal-module "_begin_loading")
(try
(serialize-shape/serialize-shapes-batch!
(:ordered g)
{:include-layout? false
:include-fills-strokes? true})
(finally
(h/call wasm/internal-module "_end_loading")))
(h/call wasm/internal-module "_set_view_end")
(assoc g :upload-ms (- (now) u0)))
(catch :default cause
(if (stale? cause)
(throw cause)
(throw (ex-info "upload failed"
{:phase "upload"}
cause)))))))
(.then (fn [g]
(guard-current! epoch "first-render")
(let [{:keys [module-ms graphics-ms renderer ctx snapshot upload-ms]} g
r0 (now)]
(-> (poll-full epoch 0 r0)
(.then (fn [poll]
(render-ok epoch module-ms graphics-ms upload-ms
(live?) poll snapshot seed
(:context ctx)
width height dpr renderer)))
(.catch (fn [cause]
(terminal-failure epoch cause "first-render")))))))
(.catch (fn [cause]
(terminal-failure epoch cause "aborted"))))))))
(let [params (t/decode-str request)]
(if-not (sm/validate schema:load-scene-args params)
{:error (fail-data {:phase "invalid-args"
:message "load-scene needs a Transit request with seed and case"})}
(let [case-desc (:case params)
scene (core/registered-scene (:scene case-desc))]
(when (nil? scene)
(throw (ex-info (str "unknown scene: " (:scene case-desc))
{:phase "invalid-args"})))
{:params params
:case-desc (core/check-collected-case case-desc)
:scene scene})))
(catch :default cause
(js/Promise.resolve
(fail-data {:phase "setup" :message (ex-message cause)})))))
{:error (fail-data {:phase "invalid-args"
:message (or (ex-message cause)
"invalid Transit request")})})))
(defn- begin-load!
"Claims the page, releases the prior owner, then fixes this attempt's
viewport, hooks and deadline. Module instantiation gets a fresh cache."
[case-desc]
(let [epoch (swap! owner-epoch* inc)]
(release-owner-resources!)
(reset! module-promise* nil)
(let [viewport (core/resolve-viewport (:view case-desc))]
{:epoch epoch
:dims viewport
:hooks (browser-hooks epoch (+ (now) attempt-ms-budget))
:live? (fn [] (and (= epoch @owner-epoch*) (wasm/live?)))})))
(defn- load-module
"Imports and instantiates the renderer. A failed import or factory call
keeps the module-init phase and its cause for bridge accounting."
[epoch params]
(-> (ensure-module! (or (:module-url params) "./render-wasm.js")
(or (:wasm-url params) "./render-wasm.wasm"))
(.catch (fn [cause]
(throw (ex-info (str "module init failed: "
(or (ex-message cause) cause))
{:phase "module-init"}
cause))))
(.then (fn [installed]
(guard-current! epoch "module-init")
installed))))
(defn- initialize-graphics!
"Creates the canvas and WebGL context, binds them to the renderer and
returns the module and graphics timings for later phases."
[epoch {:keys [width height dpr]} {:keys [module factory-ms]}]
(guard-current! epoch "graphics-init")
(set! wasm/internal-module module)
(let [canvas (build-canvas! width height dpr)]
(.appendChild (.-body js/document) canvas)
(reset! canvas* canvas)
(let [g0 (now)
ctx (try
(webgl/init-context
canvas
{:module module
:context-id "webgl2"
:css-width width
:css-height height
:dpr dpr
:flags 0
:browser (sr/translate-browser :chrome)
:params {}})
(catch :default cause
(throw (ex-info "graphics init failed"
{:phase "graphics-init"}
cause))))]
(when (nil? ctx)
(throw (ex-info "WebGL2 context unavailable"
{:phase "graphics-init"})))
(set! wasm/gl-context-handle (:handle ctx))
(set! wasm/gl-context (:context ctx))
(set! wasm/canvas canvas)
(mark-live!)
(try
(h/call wasm/internal-module "_resize_viewbox" width height)
(catch :default cause
(throw (ex-info "resize viewbox failed"
{:phase "graphics-init"}
cause))))
(install-listeners! canvas epoch)
{:module-ms factory-ms
:graphics-ms (- (now) g0)
:renderer (renderer-string (:context ctx))
:ctx ctx})))
(defn- prepare-scene
"Builds and validates the registered scene, then orders its shapes before
the upload timer begins."
[epoch scene case-desc seed graphics]
(guard-current! epoch "scene-build")
(try
(let [snapshot (-> ((:build scene) (assoc (:params case-desc) :seed seed))
(scenes/validate!))]
(assoc graphics
:snapshot snapshot
:ordered (upload/prepare-scene snapshot)))
(catch :default cause
(if (stale? cause)
(throw cause)
(throw (ex-info "scene build failed"
{:phase "scene-build"}
cause))))))
(defn- upload-scene!
"Sets the initial view and uploads prepared shapes inside the upload
timer. Ends the loading lifecycle even when serialization fails."
[epoch case-desc graphics]
(guard-current! epoch "upload")
(try
(let [u0 (now)
view (:view case-desc)]
(h/call wasm/internal-module "_set_view"
(:scale view) (- (:x view)) (- (:y view)))
(h/call wasm/internal-module "_init_shapes_pool"
(count (:objects (:snapshot graphics))))
(h/call wasm/internal-module "_begin_loading")
(try
;; Rects carry no layout; per-scene upload options arrive with 07/08.
(serialize-shape/serialize-shapes-batch
(:ordered graphics)
{:include-layout? false
:include-fills-strokes? true})
(finally
(h/call wasm/internal-module "_end_loading")))
(h/call wasm/internal-module "_set_view_end")
(assoc graphics :upload-ms (- (now) u0) :view view))
(catch :default cause
(if (stale? cause)
(throw cause)
(throw (ex-info "upload failed"
{:phase "upload"}
cause))))))
;; TODO(ticket 14): Run the declared case body here. Context dispatch skips
;; `:run!`, so custom cases cannot execute through this bridge.
(defn- render-scene
"Runs the fresh or reuse completion path after upload."
[{:keys [epoch dims hooks live?]} case-desc seed graphics]
(guard-current! epoch "render")
(if (= (:context case-desc) :fresh)
(run-fresh epoch hooks seed dims graphics live?)
(run-warm epoch hooks case-desc seed dims graphics live?)))
(defn load-scene
"Loads one collected case to Full. Takes a Transit request with `:seed`,
`:case` and optional module/WASM URLs; resolves to a Transit result.
Validation precedes ownership and all timers. Fresh cases time upload
and one drain; reuse cases restore and drain outside interaction timing."
[request]
(let [epoch* (volatile! nil)
result (try
(let [{:keys [error params case-desc scene]}
(parse-load-request request)
seed (:seed params)]
(if error
(js/Promise.resolve error)
(let [{:keys [epoch dims] :as attempt} (begin-load! case-desc)]
(vreset! epoch* epoch)
(-> (load-module epoch params)
(.then (fn [module]
(initialize-graphics! epoch dims module)))
(.then (fn [graphics]
(prepare-scene epoch scene case-desc seed graphics)))
(.then (fn [graphics]
(upload-scene! epoch case-desc graphics)))
(.then (fn [graphics]
(render-scene attempt case-desc seed graphics)))
(.catch (fn [cause]
(terminal-failure epoch cause "aborted")))))))
(catch :default cause
(js/Promise.resolve
(fail-data {:phase "setup" :message (ex-message cause)}))))]
(-> result
(.then t/encode-str)
(.catch (fn [cause]
(when-some [epoch @epoch*]
(dispose-if-current! epoch))
(t/encode-str
(fail-data {:phase "bridge"
:message (or (ex-message cause)
"result encoding failed")})))))))
(defn ping
"Bridge placeholder that checks the Node/browser boundary."
@ -607,5 +709,5 @@
(def bridge
"Browser-side bridge exported from the compiled module."
#js {"ping" ping
"loadRects" load-rects
"loadScene" load-scene
"dispose" dispose!})

View File

@ -0,0 +1,344 @@
;; 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 as three functions over an injected
`hooks` map. Callers can 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.
- `interact` replays a deterministic gesture of small camera moves with
cheap cached previews, waits out the declared settle, then drains to
`FRAME_TYPE_FULL`. The caller's interaction timer includes the camera
start, gesture, settle wait, camera end and final drain.
`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.
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.
- `:active-ms` from before `_set_view_start` to after the last cached
preview and its guard check.
- `:settling-requested-ms` the declared settle wait passed in.
- `:settling-actual-ms` from the end of the last cached preview 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- replay-frame
[{:keys [now frame check render-from-cache set-view]}
state
view
last-frame?]
(-> (frame)
(.then
(fn [timestamp]
(check)
(let [input-ms (now)]
(set-view view)
(let [t0 (now)
_ (render-from-cache)
duration-ms (- (now) t0)]
(check)
(cond-> (-> state
(assoc :last-input-ms input-ms)
(update :cached-slices conj
{:timestamp timestamp
:duration-ms duration-ms}))
last-frame?
(assoc :active-end-ms (now)))))))))
(defn- replay-gesture
[{:keys [now] :as hooks} frames started-ms]
(if (empty? frames)
(js/Promise.resolve
{:cached-slices []
:last-input-ms started-ms
:active-end-ms (now)})
(let [last-index (dec (count frames))]
(reduce-kv
(fn [state-p index view]
(.then state-p
#(replay-frame hooks
%
view
(= index last-index))))
(js/Promise.resolve
{:cached-slices []
:last-input-ms started-ms})
frames))))
(defn- finish-interaction
[{:keys [now check set-view-end] :as hooks}
settle-ms
started-ms
{:keys [cached-slices last-input-ms active-end-ms]}]
(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
:active-ms (- active-end-ms started-ms)
: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- settle-and-finish
[{:keys [sleep] :as hooks}
settle-ms
started-ms
gesture-state]
(-> (sleep settle-ms)
(.then #(finish-interaction hooks
settle-ms
started-ms
gesture-state))))
(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
├─ set-view-start
├─ replay-gesture
│ └─ replay-frame × N
├─ settle
└─ finish-interaction
├─ 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."
[{:keys [now check set-view-start] :as hooks}
{:keys [frames settle-ms]}]
(try
(let [started-ms (do (check) (now))]
(set-view-start)
(-> (replay-gesture hooks (vec frames) started-ms)
(.then #(settle-and-finish hooks
settle-ms
started-ms
%))))
(catch :default cause
(js/Promise.reject cause))))

View File

@ -28,12 +28,11 @@
:params-schema [:map {:closed true}]}
(fn [params] params))
(core/defcase :contracts-scene/case
:contracts-scene
(core/defcase :contracts-scene/case :contracts-scene
{:params {}
:view {:scale 1 :x 0 :y 0}
:context :fresh}
(-> rtx (touch!)))
(fn [ctx] (touch! ctx)))
(defn- failure-data
[f]
@ -191,7 +190,7 @@
(assoc good :operation {:point (->Point 1 2)})
(assoc-in good [:params :count] js/NaN)]]
(t/is (= ::core/non-serializable-case
(:type (failure-data #(core/check-collected-case! bad))))
(:type (failure-data #(core/check-collected-case bad))))
(pr-str bad)))))
(t/deftest duplicate-scene-id-from-another-namespace-is-rejected
@ -400,12 +399,11 @@
(:type (failure-data #(cases/collect-cases {:master-seed 42
:filter "missing"}))))))
(t/deftest defcase-body-is-stored-and-stripped
(t/deftest defcase-registers-function-without-sending-it
(reset! body-calls [])
(let [collected (cases/collect-cases {:master-seed 42 :filter "contracts-scene/"})]
(t/is (= [:contracts-scene/case] (mapv :id collected)))
(t/is (not (contains? (first collected) :run!)))
(t/is (not (contains? (first collected) :body-source)))
(t/is (core/transit-round-trips? (first collected))))
(t/is (empty? @body-calls))
(let [entry (core/registered-case :contracts-scene/case)]
@ -414,38 +412,9 @@
(t/is (= :runtime ((:run! entry) :runtime)))
(t/is (= [:touched] @body-calls))))
(t/deftest check-body-source-accepts-pipelines
(doseq [source ['((-> rtx (pan! x)))
'((let [a 1] (-> rtx (pan! a))))
'((do (log! 1) (-> rtx (pan!))))
'((comment "setup") (-> rtx (pan!)))
'((clojure.core/-> rtx (pan!)))]]
(t/is (= source (core/check-body-source! :case source)) (pr-str source))))
(t/deftest check-body-source-rejects-other-shapes
(doseq [source ['((pan! x))
'((-> other (pan!)))
'((->> rtx (pan!)))
'((if c (-> rtx a) (-> rtx b)))
'()
'((do))
'((let [x 1]))]]
(t/is (= ::core/invalid-case
(:type (failure-data #(core/check-body-source! :case source))))
(pr-str source))))
(t/deftest register-case-rejects-invalid-body-source
(try
(t/is (= ::core/invalid-case
(:type (failure-data
#(core/register-case! {:id :contracts-badbody/case
:scene :contracts-scene
:ns "contracts-test"
:params {}
:view {:scale 1 :x 0 :y 0}
:context :fresh
:run! (fn [rtx] rtx)
:body-source '((pan! x))})))))
(t/is (nil? (core/registered-case :contracts-badbody/case)))
(finally
(core/unregister-case! :contracts-badbody/case))))
(t/deftest defcase-rejects-run-function-in-options
(t/is (= :benches.render-wasm.scenes.core/invalid-run-function
(:type (failure-data
#(core/defcase :contracts-scene/unchecked :contracts-scene
{:run! (fn [x y] [x y])})))))
(t/is (nil? (core/registered-case :contracts-scene/unchecked))))

View File

@ -5,36 +5,49 @@
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns frontend-tests.benches.lifecycle-test
"Boundary-fake coverage for the ticket-05 browser lifecycle.
"Tests the browser bridge with fake DOM and renderer boundaries.
Full load/render runs headless in the pilot (real WASM + DOM). Here the
DOM/FFI boundary is faked: bridge arg validation (seeds, views, viewports,
non-finite numbers), viewport resolution, disposal state reset, epoch
bumps, owner-guard and terminal-failure mapping, context-loss flags, bridge
shape, and through-entry `load-rects` scenarios (happy path, rejected
import, post-registration failure, overlapping loads) against fake canvases
and a fake Emscripten factory. GL init/release unit behavior lives in
`frontend-tests.render-wasm.webgl-test`; live context loss and
blocked-page cancellation belong to ticket 15's failure verification;
exhaustive stale-callback coverage belongs to ticket 06's protocol-test
with its injected clock."
It covers bridge input, ownership, disposal and `load-scene` with fake
canvases and a fake Emscripten factory. Protocol timing tests use an
injected clock in `frontend-tests.benches.protocol-test`."
(:require
[app.common.render-wasm.wasm :as wasm]
[app.common.transit :as transit]
[benches.render-wasm.browser :as browser]
[benches.render-wasm.cases :as cases]
[benches.render-wasm.scenes.core :as core]
[cljs.test :as t :include-macros true]))
;; Partial-init unwinding of `init-context!` is covered in
;; Forward declaration: entry-test helpers below build args against the
;; fake Emscripten factory URL defined alongside the fake-DOM harness.
(declare factory-url)
;; Partial-init unwinding of `init-context` is covered in
;; `frontend-tests.render-wasm.webgl-test`, which also pins the success,
;; nil-context and registration-failure cases.
(defn- load-case
"Collected `:rects/load` descriptor for entry tests."
[]
(first (cases/collect-cases {:master-seed 42 :filter "rects/load"})))
(defn- scene-args
"Transit request for `browser/load-scene`. `overrides` change the
collected case descriptor."
[seed overrides]
(transit/encode-str
{:seed seed
:case (merge (load-case) overrides)
:module-url (factory-url)
:wasm-url "./fake.wasm"}))
(defn- expect-failed-args
"Calls `load-rects` with `args` (never touching DOM/FFI: validation runs
"Calls `load-scene` with `args` (never touching DOM/FFI: validation runs
first) and asserts a failure map. Calls `done` when finished."
[args check-phase? done]
(-> (.then (browser/load-rects args)
(-> (.then (browser/load-scene args)
(fn [result]
(let [m (js->clj result :keywordize-keys true)]
(let [m (transit/decode-str result)]
(t/is (= "failed" (:status m)))
(when check-phase?
(t/is (= "invalid-args" (:phase m))))
@ -45,41 +58,44 @@
(t/deftest invalid-seed-fails-before-timers
(t/async done
(expect-failed-args #js {"seed" -1} true done)))
(expect-failed-args (scene-args -1 {}) true done)))
(t/deftest missing-seed-fails-before-timers
(t/async done
(expect-failed-args #js {} true done)))
(expect-failed-args (transit/encode-str {:case (load-case)}) true done)))
(t/deftest missing-case-fails-before-timers
(t/async done
(expect-failed-args (transit/encode-str {:seed 42}) true done)))
(t/deftest malformed-transit-fails-before-timers
(t/async done
(expect-failed-args "not transit" true done)))
(t/deftest invalid-view-fails-before-timers
(t/async done
(expect-failed-args
#js {"seed" 42
"view" #js {"scale" -1 "x" 0 "y" 0}}
(scene-args 42 {:view {:scale -1 :x 0 :y 0}})
true
done)))
(t/deftest invalid-viewport-fails-before-timers
(t/async done
(expect-failed-args
#js {"seed" 42
"view" #js {"scale" 1 "x" 0 "y" 0
"viewport" #js {"width" -5}}}
(scene-args 42 {:view {:scale 1 :x 0 :y 0
:viewport {:width -5}}})
true
done)))
(t/deftest non-finite-view-values-fail-before-timers
(t/async done
(expect-failed-args #js {"seed" 42
"view" #js {"scale" js/Infinity "x" 0 "y" 0}}
(expect-failed-args (scene-args 42 {:view {:scale js/Infinity :x 0 :y 0}})
true
(fn []
(expect-failed-args #js {"seed" 42
"view" #js {"scale" 1 "x" js/NaN "y" 0}}
(expect-failed-args (scene-args 42 {:view {:scale 1 :x js/NaN :y 0}})
true
(fn []
(expect-failed-args #js {"seed" 42
"view" #js {"scale" 1 "x" 0 "y" js/Infinity}}
(expect-failed-args (scene-args 42 {:view {:scale 1 :x 0 :y js/Infinity}})
true
done)))))))
@ -142,37 +158,33 @@
:phase "upload"})
"upload")
after @@#'browser/owner-epoch*]
(t/is (= "stale" (unchecked-get result "status")))
(t/is (= "stale" (:status result)))
(t/is (= before after) "no epoch bump: the newer owner is untouched")))
(t/testing "terminal failure keeps its phase and disposes the current owner"
(let [current @@#'browser/owner-epoch*
result (terminate current (ex-info "upload failed" {:phase "upload"}) "aborted")]
(t/is (= "failed" (unchecked-get result "status")))
(t/is (= "upload" (unchecked-get result "phase")))
(t/is (= "failed" (:status result)))
(t/is (= "upload" (:phase result)))
(t/is (< current @@#'browser/owner-epoch*) "current owner disposed")))
(t/testing "phaseless failures use the fallback phase"
(set! wasm/internal-module nil)
(let [current @@#'browser/owner-epoch*
result (terminate current (ex-info "boom" {}) "aborted")]
(t/is (= "failed" (unchecked-get result "status")))
(t/is (= "aborted" (unchecked-get result "phase")))
(t/is (= "failed" (:status result)))
(t/is (= "aborted" (:phase result)))
(t/is (< current @@#'browser/owner-epoch*) "current owner disposed")))))
(t/deftest bridge-exposes-pilot-entries
(let [keys (js/Object.keys browser/bridge)]
(t/is (some #{"ping"} keys))
(t/is (some #{"loadRects"} keys))
(t/is (some #{"loadScene"} keys))
(t/is (some #{"dispose"} keys))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Through-entry fake harness.
;;
;; TODO(mem:render-wasm/performance/cljs-rewrite/06-render-and-interaction-protocol):
;; shared entry-test harness. Ticket 06 replaces `load-rects` with the generic
;; case-descriptor entry: keep this section (fake DOM, fake Emscripten factory,
;; call recording) and repoint the bridge calls below; only call shapes and
;; result assertions change. The DOM/module boundary the fakes cover does not
;; change with the entry.
;; The DOM/module boundary the fakes cover does not change with the entry:
;; fake DOM, fake Emscripten factory, call recording.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- record!
@ -229,7 +241,7 @@
(fn [cb]
;; Park the first frame while `__benchGateRaf`
;; is set so a second load can supersede a live,
;; polling owner; later frames run synchronously.
;; active owner; later frames run synchronously.
(if (and (unchecked-get js/globalThis "__benchGateRaf")
(nil? (unchecked-get js/globalThis "__benchRafCb")))
(unchecked-set js/globalThis "__benchRafCb" cb)
@ -315,12 +327,13 @@
(count (filter #(= effect (first %)) calls)))
(defn- load-result
"Calls `browser/load-rects` with `args` and delivers the plain-data result
to `k`. A rejected bridge (a broken always-resolve contract) arrives as a
`threw` map so the test fails with the cause attached."
"Decodes the bridge's Transit result for `k`. A rejected bridge arrives
as a `threw` map so the test fails with the cause attached."
[args k]
(-> (browser/load-rects args)
(.then (fn [result] (k (js->clj result :keywordize-keys true))))
(-> (browser/load-scene args)
(.then (fn [result]
(t/is (string? result) "loadScene returns Transit")
(k (transit/decode-str result))))
(.catch (fn [cause] (k {:status "threw" :cause (str cause)})))))
(defn- with-entry-env
@ -339,17 +352,15 @@
(t/async done
(with-entry-env
(fn [cleanup]
(load-result #js {"seed" 7
"module-url" (factory-url)
"wasm-url" "./fake.wasm"}
(load-result (scene-args 7 {})
(fn [m]
(t/is (= "ok" (:status m)))
(t/is (= 1 (:renderFrames m)))
(t/is (= 1 (:render-frames m)))
(t/is (= 1001 (:shapes (:scene m))) "the canonical scene uploads whole")
(let [graphics (:effectiveGraphics m)]
(t/is (= 1234 (:drawingBufferWidth graphics))
(let [graphics (:effective-graphics m)]
(t/is (= 1234 (:drawing-buffer-width graphics))
"drawing buffer width comes from the context")
(t/is (= 5678 (:drawingBufferHeight graphics))
(t/is (= 5678 (:drawing-buffer-height graphics))
"drawing buffer height comes from the context")
(t/is (false? (:antialias graphics))
"context attributes pass through")
@ -361,14 +372,62 @@
(cleanup)
(done)))))))
(t/deftest invalid-args-keep-the-current-owner
(t/async done
(with-entry-env
(fn [cleanup]
(load-result (scene-args 7 {})
(fn [loaded]
(t/is (= "ok" (:status loaded)))
(let [owner @@#'browser/canvas*
epoch @@#'browser/owner-epoch*]
(load-result (transit/encode-str {:seed -1})
(fn [invalid]
(t/is (= "invalid-args" (:phase invalid)))
(t/is (identical? owner @@#'browser/canvas*))
(t/is (= epoch @@#'browser/owner-epoch*))
(t/is (zero? (effect-count (read-calls) "remove")))
(cleanup)
(done))))))))))
(t/deftest warm-pan-restores-and-reaches-full
(t/async done
(with-entry-env
(fn [cleanup]
(load-result (scene-args 7 {:id :rects/pan :context :reuse})
(fn [m]
(t/is (= "ok" (:status m)))
(t/is (= 20 (count (:cached-slices m))))
(t/is (= 100 (:settling-requested-ms m)))
(t/is (= [4] (mapv :flags (:slices m))))
(t/is (= [2] (mapv :frame-type (:slices m))))
(t/is (= 2 (effect-count (read-calls) "render"))
"restore and finalization each drain once")
(cleanup)
(done)))))))
(t/deftest warm-zoom-reaches-full
(t/async done
(with-entry-env
(fn [cleanup]
(load-result (scene-args 7 {:id :rects/zoom :context :reuse})
(fn [m]
(t/is (= "ok" (:status m)))
(t/is (= 20 (count (:cached-slices m))))
(t/is (= [2] (mapv :frame-type (:slices m))))
(cleanup)
(done)))))))
(t/deftest rejected-import-resolves-module-init-failure
(t/async done
;; Needs the entry env for the import polyfill, even though the import
;; itself fails before touching the DOM.
(with-entry-env
(fn [cleanup]
(load-result #js {"seed" 7
"module-url" "data:text/javascript,this is not valid javascript((("}
(load-result (transit/encode-str
{:seed 7
:case (load-case)
:module-url "data:text/javascript,this is not valid javascript((("})
(fn [m]
(t/is (= "failed" (:status m)))
(t/is (= "module-init" (:phase m)))
@ -376,7 +435,7 @@
(done)))))))
(defn- assert-wasm-cause
"Shared cause assertions for WASM failures through `load-rects`: plain-data
"Shared cause assertions for WASM failures through `load-scene`: plain-data
vector, wire contract, failing fn name, fake message and WASM code."
[cause fn-name message-pattern]
(t/is (vector? cause) "cause is plain-data vector")
@ -397,9 +456,7 @@
(with-entry-env
(fn [cleanup]
(unchecked-set js/globalThis "__benchThrowIn" "_set_browser")
(load-result #js {"seed" 7
"module-url" (factory-url)
"wasm-url" "./fake.wasm"}
(load-result (scene-args 7 {})
(fn [m]
(t/is (= "failed" (:status m)))
(t/is (= "graphics-init" (:phase m)))
@ -423,9 +480,7 @@
(with-entry-env
(fn [cleanup]
(unchecked-set js/globalThis "__benchThrowIn" "_set_view")
(load-result #js {"seed" 7
"module-url" (factory-url)
"wasm-url" "./fake.wasm"}
(load-result (scene-args 7 {})
(fn [m]
(t/is (= "failed" (:status m)))
(t/is (= "upload" (:phase m)))
@ -447,7 +502,7 @@
:hint (apply str (repeat 1000 "x"))}
(js/Error. "inner boom"))
result (terminate current hostile "aborted")
m (js->clj result :keywordize-keys true)
m result
cause (:cause m)]
(t/is (= "failed" (:status m)))
(t/is (= "aborted" (:phase m)))
@ -478,7 +533,7 @@
current @@#'browser/owner-epoch*
hostile (ex-info "evil boom" {:fn evil} (js/Error. "inner"))
result (terminate current hostile "aborted")
m (js->clj result :keywordize-keys true)
m result
cause (:cause m)
outer (first cause)]
(t/is (= "failed" (:status m)))
@ -494,7 +549,7 @@
terminate @#'browser/terminal-failure
current @@#'browser/owner-epoch*
result (terminate current evil "aborted")
m (js->clj result :keywordize-keys true)
m result
cause (:cause m)]
(t/is (= "failed" (:status m)))
(t/is (core/transit-round-trips? cause) "throwing cause still crosses the wire")
@ -510,7 +565,7 @@
{:benches.render-wasm.browser/stale true
:phase "upload"}))
result (terminate current nested "aborted")
m (js->clj result :keywordize-keys true)]
m result]
(t/is (= "failed" (:status m)) "only the top level decides staleness")
(t/is (= "upload" (:phase m)))
(t/is (vector? (:cause m)) "cause still rendered")))
@ -520,28 +575,26 @@
(with-entry-env
(fn [cleanup]
(unchecked-set js/globalThis "__benchGateRaf" true)
(let [args (fn [seed] #js {"seed" seed
"module-url" (factory-url)
"wasm-url" "./fake.wasm"})
first-load (browser/load-rects (args 7))]
(let [args (fn [seed] (scene-args seed {}))
first-load (browser/load-scene (args 7))]
;; Microtasks drain before this macrotask: the first load parked on
;; its first poll frame with a live canvas. Macrotask ordering
;; its first frame with a live canvas. Macrotask ordering
;; (never wall timing) sequences the assertions.
(js/setTimeout
(fn []
(t/is (some? (unchecked-get js/globalThis "__benchRafCb"))
"first load is polling")
(let [second-load (browser/load-rects (args 8))]
"first load is waiting for a frame")
(let [second-load (browser/load-scene (args 8))]
(js/setTimeout
(fn []
;; The second load completed; release the parked frame so
;; the first load observes its superseded epoch.
(if-some [parked (unchecked-get js/globalThis "__benchRafCb")]
(parked 0)
(t/is false "first load never parked on a poll frame"))
(t/is false "first load never parked on a frame"))
(-> (js/Promise.all #js [first-load second-load])
(.then (fn [results]
(let [[stale ok] (js->clj results :keywordize-keys true)]
(let [[stale ok] (mapv transit/decode-str (array-seq results))]
(t/is (= "stale" (:status stale)) "superseded load resolves stale")
(t/is (= "ok" (:status ok)) "newer owner completes")
(t/is (= 8 (:seed (:scene ok))) "the completer is the second load")

View File

@ -0,0 +1,222 @@
;; 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 frontend-tests.benches.protocol-test
"Tests drain, restore and interact with an injected clock and renderer.
Browser entry tests cover load and warm pan with a fake module."
(:require
[app.common.render-wasm.wasm :as wasm]
[benches.render-wasm.protocol :as protocol]
[cljs.test :as t :include-macros true]))
(defn- fake-clock
"Controllable ms clock. `advance!` moves it; tests drive time by hand."
[start]
(let [t (atom start)]
{:now (fn [] @t)
:advance! (fn [ms] (swap! t + ms) @t)}))
(defn- drain-hooks
"Fake hooks driving `:render` through `script` (a vector of frame types
returned in order), recording observed flags in `calls`. `:frame`
advances 16 ms per rAF; `:sleep` advances the clock without yielding."
[{:keys [clock script calls check]}]
(let [remaining (atom (vec script))]
{:now (:now clock)
:frame (fn []
((:advance! clock) 16)
(js/Promise.resolve ((:now clock))))
:sleep (fn [ms]
((:advance! clock) ms)
(js/Promise.resolve nil))
:check (or check (fn [] nil))
:render (fn [_timestamp flags]
(swap! calls conj flags)
(let [next (first @remaining)]
(swap! remaining rest)
next))
:render-from-cache (fn [] 0)
:set-view (fn [_view] nil)
:set-view-start (fn [] nil)
:set-view-end (fn [] nil)}))
(t/deftest immediate-full-reports-equal-boundaries
(t/async done
(let [clock (fake-clock 1000)
calls (atom [])
hooks (drain-hooks {:clock clock :script [2] :calls calls})]
(-> (protocol/drain hooks {:flags 0 :origin 1000 :immediate true})
(.then (fn [{:keys [slices viewport-ready-ms full-ms]}]
(t/is (= 1 (count slices)))
(t/is (= {:timestamp 1000
:flags 0
:frame-type 2
:duration-ms 0}
(first slices)))
(t/is (= full-ms viewport-ready-ms)
"immediate Full supplies both boundaries")
(done)))
(.catch (fn [cause]
(t/is false (str "must resolve, threw: " cause))
(done)))))))
(t/deftest progressive-frames-propagate-flags
(t/async done
(let [clock (fake-clock 0)
calls (atom [])
hooks (drain-hooks {:clock clock :script [1 3 2] :calls calls})]
(-> (protocol/drain hooks {:flags 0 :origin 0 :immediate false})
(.then (fn [{:keys [slices viewport-ready-ms full-ms]}]
(t/is (= [0 1 3] @calls)
"the previous frame type becomes the next flags")
(t/is (= [1 3 2] (mapv :frame-type slices)))
(t/is (< viewport-ready-ms full-ms)
"ViewportReady lands before Full")
(t/is (= (:timestamp (nth slices 1)) (+ 16 16))
"later frames wait for rAF")
(done)))
(.catch (fn [cause]
(t/is false (str "must resolve, threw: " cause))
(done)))))))
(t/deftest partial-jumps-straight-to-full
(t/async done
(let [clock (fake-clock 0)
calls (atom [])
hooks (drain-hooks {:clock clock :script [1 2] :calls calls})]
(-> (protocol/drain hooks {:flags 0 :origin 0 :immediate true})
(.then (fn [{:keys [slices viewport-ready-ms full-ms]}]
(t/is (= [0 1] @calls))
(t/is (= [1 2] (mapv :frame-type slices)))
(t/is (= viewport-ready-ms full-ms)
"Full as the first completion supplies both boundaries")
(done)))
(.catch (fn [cause]
(t/is false (str "must resolve, threw: " cause))
(done)))))))
(t/deftest unknown-frame-types-reject
(t/async done
;; One async context chaining the bad values: a `t/async` inside
;; `doseq` would never run (the doseq returns nil to the runner).
(letfn [(step [remaining]
(if (empty? remaining)
(done)
(let [bad (first remaining)
clock (fake-clock 0)
calls (atom [])
hooks (drain-hooks {:clock clock :script [bad] :calls calls})]
(-> (protocol/drain hooks {:flags 0 :origin 0 :immediate true})
(.then (fn [_]
(t/is false (str "must reject for frame type " bad))
(done)))
(.catch (fn [cause]
(t/is (= :benches.render-wasm.protocol/unexpected-frame-type
(:type (ex-data cause)))
(str "frame type " bad))
(step (rest remaining))))))))]
(step [0 7 js/NaN]))))
(t/deftest cancellation-stops-further-renders
(t/async done
(let [clock (fake-clock 0)
calls (atom [])
fired (atom 0)
hooks (drain-hooks {:clock clock
:script [1 1 1 1]
:calls calls
:check (fn []
(swap! fired inc)
(when (> @fired 2)
(throw (ex-info "canceled" {:phase "test"}))))})]
(-> (protocol/drain hooks {:flags 0 :origin 0 :immediate true})
(.then (fn [_]
(t/is false "must reject once check throws")
(done)))
(.catch (fn [cause]
(t/is (= "canceled" (ex-message cause)))
(t/is (= 1 (count @calls)) "cancellation stops before the next render")
(t/is (= 3 @fired) "guard fires once before and once after the render")
(done)))))))
(t/deftest restore-drains-with-sync-tiles-immediately
(t/async done
(let [clock (fake-clock 500)
calls (atom [])
order (atom [])
hooks (assoc (drain-hooks {:clock clock :script [1 3 2] :calls calls})
:set-view-start (fn [] (swap! order conj :start) nil)
:set-view (fn [view]
(swap! order conj [:view view])
nil)
:set-view-end (fn [] (swap! order conj :end) nil))
view {:scale 1 :x 10 :y 20}]
(-> (protocol/restore hooks view)
(.then (fn [{:keys [slices]}]
(t/is (= [:start [:view view] :end] @order)
"camera resets before the drain")
(t/is (= [4 1 3] @calls)
"restore drains with SyncTiles first")
(t/is (= [1 3 2] (mapv :frame-type slices)))
(done)))
(.catch (fn [cause]
(t/is false (str "must resolve, threw: " cause))
(done)))))))
(t/deftest interact-times-gesture-settle-and-final-drain
(t/async done
(let [clock (fake-clock 0)
renders (atom [])
cached (atom [])
views (atom [])
hooks {:now (:now clock)
:frame (fn []
((:advance! clock) 16)
(js/Promise.resolve ((:now clock))))
:sleep (fn [ms]
((:advance! clock) ms)
(js/Promise.resolve nil))
:check (fn [] nil)
:render (fn [_timestamp flags]
(swap! renders conj flags)
2)
:render-from-cache (fn []
(swap! cached conj true)
((:advance! clock) 3)
nil)
:set-view (fn [view]
(swap! views conj view)
nil)
:set-view-start (fn [] nil)
:set-view-end (fn []
((:advance! clock) 5)
nil)}
frames [{:scale 1 :x 0 :y 0} {:scale 1 :x 100 :y 0}]]
(-> (protocol/interact hooks {:frames frames :settle-ms 100})
(.then (fn [m]
(t/is (= frames @views) "every gesture frame sets the view")
(t/is (= 2 (count @cached)) "one cached preview per frame")
(t/is (= 2 (count (:cached-slices m))))
(t/is (every? #(contains? % :timestamp) (:cached-slices m)))
(t/is (every? #(contains? % :duration-ms) (:cached-slices m))
"cached slices keep their own shape")
(t/is (= 100 (:settling-requested-ms m)))
(t/is (= 100 (:settling-actual-ms m))
"fake sleep advances the clock exactly")
(t/is (= 5 (:set-view-end-ms m)))
(t/is (= [4] @renders) "final drain starts with SyncTiles")
(t/is (>= (:time-to-full-ms m) (:set-view-end-ms m))
"finalization includes _set_view_end")
(t/is (= (:time-to-viewport-ready-ms m) (:time-to-full-ms m))
"immediate final drain gives equal boundaries")
(t/is (= 108 (:last-input-to-full-ms m))
"last input at 35, Full at 143: settle + set_view_end + drain")
(t/is (= [wasm/FRAME_TYPE_FULL] (mapv :frame-type (:slices m)))
"frame is full")
(done)))
(.catch (fn [cause]
(t/is false (str "must resolve, threw: " cause))
(done)))))))