diff --git a/frontend/test/benches/render_wasm/browser.cljs b/frontend/test/benches/render_wasm/browser.cljs index 85689699c9..e84412147d 100644 --- a/frontend/test/benches/render_wasm/browser.cljs +++ b/frontend/test/benches/render_wasm/browser.cljs @@ -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!}) diff --git a/frontend/test/benches/render_wasm/protocol.cljs b/frontend/test/benches/render_wasm/protocol.cljs new file mode 100644 index 0000000000..2aeddc36de --- /dev/null +++ b/frontend/test/benches/render_wasm/protocol.cljs @@ -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)))) diff --git a/frontend/test/frontend_tests/benches/contracts_test.cljs b/frontend/test/frontend_tests/benches/contracts_test.cljs index fadde0dadb..6bacc691cb 100644 --- a/frontend/test/frontend_tests/benches/contracts_test.cljs +++ b/frontend/test/frontend_tests/benches/contracts_test.cljs @@ -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)))) diff --git a/frontend/test/frontend_tests/benches/lifecycle_test.cljs b/frontend/test/frontend_tests/benches/lifecycle_test.cljs index a2542c2a90..8014a168f5 100644 --- a/frontend/test/frontend_tests/benches/lifecycle_test.cljs +++ b/frontend/test/frontend_tests/benches/lifecycle_test.cljs @@ -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") diff --git a/frontend/test/frontend_tests/benches/protocol_test.cljs b/frontend/test/frontend_tests/benches/protocol_test.cljs new file mode 100644 index 0000000000..7d0fc424c8 --- /dev/null +++ b/frontend/test/frontend_tests/benches/protocol_test.cljs @@ -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)))))))