diff --git a/frontend/test/benches/render_wasm/browser.cljs b/frontend/test/benches/render_wasm/browser.cljs index 4eccbcc2d4..35b12f6f72 100644 --- a/frontend/test/benches/render_wasm/browser.cljs +++ b/frontend/test/benches/render_wasm/browser.cljs @@ -5,29 +5,379 @@ ;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns benches.render-wasm.browser - "Browser entry for the CLJS renderer benchmark suite. + "Browser bridge for the benchmarks. - Requiring `app.render-wasm.api.enums` binds the enum table from the - prepared renderer build's `shared.js` at namespace load time. A missing - `shared.js` fails the compile; a stale or mismatched one throws at load. + Lifecycle here mirrors the editor cold-load path + (`app.render-wasm.api/initialize-viewport` + `set-objects` sync). - The exported `bridge` is the only handle the Node runner uses inside the - page. Keep the boundary plain data: never pass a compiled CLJS closure - through `page.evaluate`." + This ns exports `bridge`, which is what the Node 'pilot' uses inside the page. + We only ever pass data through `page.evaluate`. + + All `bridge` entries resolve, that is they never return an unhandled rejected + promise. + + Failures are returned as `{\"status\" \"failed\" ...}` maps." (:require - [app.render-wasm.api.enums])) + [app.common.render-wasm.helpers :as h] + [app.common.render-wasm.serialize-shape :as serialize-shape] + [app.common.render-wasm.serializers :as sr] + [app.common.render-wasm.wasm :as wasm] + [app.common.schema :as sm] + ;; 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.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) + +(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." + [:map {:closed true} + [:seed [:int {:min 0 :max 4294967295}]] + [:view {:optional true} core/schema:view] + [:module-url {:optional true} string?] + [:wasm-url {:optional true} string?]]) + +;; Monotonic owner generation. Every async continuation compares its +;; captured epoch and drops stale results silently; dispose/load bump it. +(defonce ^:private owner-epoch* + (atom 0)) + +;; Cached Emscripten import. Import precedes module-init timing (untimed). +(defonce ^:private module-promise* + (atom nil)) + +;; Page-scoped canvas owned by the current load. Fresh page per attempt in +;; the future runner; the pilot loads once. +(defonce ^:private canvas* + (atom nil)) + +(defn- fail-data + [m] + (clj->js (assoc m "status" "failed"))) + +(defn- mark-live! + [] + (reset! wasm/context-lost? false) + (set! wasm/context-initialized? true)) + +(defn- now + [] + (.now js/performance)) + +(defn- ensure-module! + "Imports the Emscripten glue (cached per page, untimed: script import + precedes module-init timing) and instantiates it with `locateFile` + pointing at the prepared `.wasm` bytes. Resolves + `{:module instance :factory-ms ms}` where factory-ms covers the + instantiate call only." + [module-url wasm-url] + (when (nil? @module-promise*) + (reset! module-promise* + (-> (js/dynamicImport module-url) + (.then (fn [module] + (let [t0 (now) + default-fn (unchecked-get module "default")] + (-> (default-fn #js {:locateFile (constantly wasm-url)}) + (.then (fn [instance] + {:module instance + :factory-ms (- (now) t0)}))))))))) + @module-promise*) + +(defn- renderer-string + "Reads the unmasked renderer (SwiftShader/llvmpipe detection for the + software-renderer warning). The init helper forces the debug extension." + [context] + (try + (let [ext (.getExtension ^js context "WEBGL_debug_renderer_info")] + (if (some? ext) + (str (.getParameter ^js context (unchecked-get ext "UNMASKED_RENDERER_WEBGL"))) + "unknown")) + (catch :default _ "unknown"))) + +(defn- software-renderer? + [s] + (boolean (re-find #"(?i)swiftshader|llvmpipe|software|basic render" (or s "")))) + +(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- read-graphics + "Records effective graphics settings from the declared helper authority + plus the observed renderer string." + [width height dpr renderer] + (let [opts webgl/default-context-options] + {"width" width + "height" height + "dpr" dpr + "webgl2" true + "antialias" (unchecked-get opts "antialias") + "depth" (unchecked-get opts "depth") + "stencil" (unchecked-get opts "stencil") + "alpha" (unchecked-get opts "alpha") + "preserveDrawingBuffer" (unchecked-get opts "preserveDrawingBuffer") + "renderer" renderer + "software" (software-renderer? renderer)})) + +(defn- render-ok + [module-ms graphics-ms upload-ms alive? poll snapshot seed 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. + (if @wasm/context-lost? + (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?) + (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 width height dpr renderer)}) + + :else + (fail-data {:phase "first-render" + :message "Full not reached in budget" + :detail pm})))) + +(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." + [canvas epoch] + (.addEventListener + canvas "webglcontextlost" + (let [installed epoch] + (fn [e] + (.preventDefault ^js e) + (when (= installed @owner-epoch*) + (reset! wasm/context-lost? true) + (swap! owner-epoch* inc))))) + (.addEventListener + canvas "webglcontextrestored" + (let [installed epoch] + (fn [_] + (when (= installed @owner-epoch*) + (swap! owner-epoch* inc)))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Public interface +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn dispose! + "Closes the current owner: bumps the epoch (in-flight continuations go + stale), tears down WASM context state and drops the module handle so a + later load re-imports cleanly. Idempotent." + [] + (swap! owner-epoch* inc) + (wasm/reset-context-state!) + ;; reset-context-state! deliberately preserves loss state for the editor + ;; reload path; bench disposal owns it explicitly. + (reset! wasm/context-lost? false) + (set! wasm/internal-module nil) + (reset! module-promise* nil) + (when-let [canvas @canvas*] + (.remove ^js canvas) + (reset! canvas* nil)) + #js {"status" "disposed"}) + +;; 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)." + [args] + (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: drop the previous owner's canvas now; its + ;; continuations go stale through the epoch bump above. + ;; The old GL context frees with the removed canvas. + (when-let [old @canvas*] + (.remove ^js old)) + (let [viewport (core/resolve-viewport view) + width (:width viewport) + height (:height viewport) + dpr (:dpr viewport) + ;; Epoch-only until mark-live!: liveness requires an + ;; initialized context, which does not exist yet. + current? (fn [] (= epoch @owner-epoch*)) + live? (fn [] (and (= epoch @owner-epoch*) (wasm/live?)))] + (-> (ensure-module! (or (:module-url params) "./render-wasm.js") + (or (:wasm-url params) "./render-wasm.wasm")) + (.then + (fn [{:keys [module factory-ms]}] + (when-not (current?) + (throw (ex-info "stale" {}))) + (set! wasm/internal-module module) + (let [module-ms factory-ms + canvas (doto (.createElement js/document "canvas") + (aset "width" (* width dpr)) + (aset "height" (* height dpr))) + _ (.appendChild (.-body js/document) canvas) + _ (reset! canvas* canvas) + g0 (now) + ctx (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 {}})] + (if (nil? ctx) + (do + (dispose!) + (fail-data {:phase "graphics-init" + :message "WebGL2 context unavailable"})) + (do + (set! wasm/gl-context-handle (:handle ctx)) + (set! wasm/gl-context (:context ctx)) + (set! wasm/canvas canvas) + (mark-live!) + ;; Mirror the editor DOM sizing choice. + (h/call wasm/internal-module "_resize_viewbox" width height) + (install-listeners! canvas epoch) + (let [graphics-ms (- (now) g0) + renderer (renderer-string (:context ctx)) + ;; Generation, validation and order derivation + ;; stay outside timers. rects/build fills + ;; workload defaults; only the seed varies per + ;; attempt. + snapshot (-> (rects/build {:seed seed}) + (scenes/validate!)) + ordered (upload/prepare-scene snapshot)] + (when-not (current?) + (throw (ex-info "stale" {}))) + ;; Editor order: view before pool. The timed window + ;; covers view, pool, loading lifecycle, batch and + ;; tile preparation. + (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))) + (h/call wasm/internal-module "_begin_loading") + (try + (serialize-shape/serialize-shapes-batch! + ordered + {:include-layout? false + :include-fills-strokes? true}) + (finally + (h/call wasm/internal-module "_end_loading"))) + (h/call wasm/internal-module "_set_view_end") + (let [upload-ms (- (now) u0) + r0 (now)] + (-> (poll-full epoch 0 r0) + (.then + (fn [poll] + (render-ok module-ms graphics-ms upload-ms + (live?) poll snapshot seed + width height dpr renderer)) + (fn [_] + (dispose!) + (fail-data {:phase "first-render" + :message "render threw"}))))))))))) + (fn [result] + result) + (fn [cause] + (if (= "stale" (ex-message cause)) + #js {"status" "stale"} + (do + (dispose!) + (fail-data {:phase "aborted" + :message (ex-message cause)})))))))))) + (catch :default cause + (js/Promise.resolve + (fail-data {:phase "setup" :message (ex-message cause)}))))) (defn ping - "Bridge placeholder that checks the Node/browser boundary. - - TODO(mem:render-wasm/performance/cljs-rewrite/05-browser-load-pilot): - replace with the renderer load and measurement protocol." + "Bridge placeholder that checks the Node/browser boundary." [] #js {"status" "ok"}) (def bridge - "Browser-side bridge exported from the compiled module. - - TODO(mem:render-wasm/performance/cljs-rewrite/06-render-and-interaction-protocol): - add `runAttempt`, `dispose` and the render completion protocol." - #js {"ping" ping}) + "Browser-side bridge exported from the compiled module." + #js {"ping" ping + "loadRects" load-rects + "dispose" dispose!}) diff --git a/frontend/test/benches/render_wasm/scenes/core.cljs b/frontend/test/benches/render_wasm/scenes/core.cljs index 0f0027248e..1de13c664f 100644 --- a/frontend/test/benches/render_wasm/scenes/core.cljs +++ b/frontend/test/benches/render_wasm/scenes/core.cljs @@ -51,11 +51,30 @@ ;; Schemas ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(def default-viewport + "Viewport used when a case declares no `:viewport`: 1920x1080 at DPR 2. + Per-case config; run-level CLI overrides belong to ticket 11." + {:width 1920 :height 1080 :dpr 2}) + +(def schema:viewport + [:map {:closed true} + [:width {:optional true} ::sm/positive-safe-number] + [:height {:optional true} ::sm/positive-safe-number] + [:dpr {:optional true} ::sm/positive-safe-number]]) + (def schema:view [:map {:closed true} [:scale [:and number? pos?]] [:x number?] - [:y number?]]) + [:y number?] + [:viewport {:optional true} schema:viewport]]) + +(defn resolve-viewport + "Merges `default-viewport` under a case view's declared `:viewport`. + Collected descriptors keep the declared view; callers resolve at use so + existing cases need no edits." + [view] + (merge default-viewport (:viewport view))) (def schema:scene [:map {:closed true} diff --git a/frontend/test/benches/render_wasm/upload.cljs b/frontend/test/benches/render_wasm/upload.cljs index 03aa43f4a4..815fda9db1 100644 --- a/frontend/test/benches/render_wasm/upload.cljs +++ b/frontend/test/benches/render_wasm/upload.cljs @@ -20,6 +20,13 @@ [app.common.render-wasm.serialize-shape :as serialize-shape] [benches.render-wasm.scenes.common :as common])) +(defn prepare-scene + "Derives the parent-before-child shape vector from a validated `snapshot`. + Call it outside measured regions, then pass the result to + `serialize-shapes-batch!` inside." + [snapshot] + (common/upload-order snapshot)) + (defn upload-scene! "Uploads validated `snapshot` with `opts`. @@ -31,5 +38,4 @@ Returns the prepared shape vector." [snapshot opts] - (let [ordered (common/upload-order snapshot)] - (serialize-shape/serialize-shapes-batch! ordered opts))) + (serialize-shape/serialize-shapes-batch! (prepare-scene snapshot) opts)) diff --git a/frontend/test/frontend_tests/benches/lifecycle_test.cljs b/frontend/test/frontend_tests/benches/lifecycle_test.cljs new file mode 100644 index 0000000000..11cfc8de46 --- /dev/null +++ b/frontend/test/frontend_tests/benches/lifecycle_test.cljs @@ -0,0 +1,92 @@ +;; 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.lifecycle-test + "Boundary-fake coverage for the ticket-05 browser lifecycle. + + Full load/render runs headless in the pilot (real WASM + DOM). Here every + DOM/FFI boundary is faked or avoided: bridge arg validation (seeds, views, + viewports), viewport resolution, disposal state reset, epoch bumps, + context-loss flags and bridge shape. No canvas, rAF or module import is + exercised: partial-init rendering, live context loss and stale-callback + races run headless in the pilot; exhaustive stale-callback unit coverage + belongs to ticket 06's protocol-test with its injected clock." + (:require + [app.common.render-wasm.wasm :as wasm] + [benches.render-wasm.browser :as browser] + [benches.render-wasm.scenes.core :as core] + [cljs.test :as t :include-macros true])) + +(defn- expect-failed-args + "Calls `load-rects` 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) + (fn [result] + (let [m (js->clj result :keywordize-keys true)] + (t/is (= "failed" (:status m))) + (when check-phase? + (t/is (= "invalid-args" (:phase m)))) + (done)))) + (.catch (fn [cause] + (t/is false (str "must resolve, threw: " cause)) + (done))))) + +(t/deftest invalid-seed-fails-before-timers + (t/async done + (expect-failed-args #js {"seed" -1} true done))) + +(t/deftest missing-seed-fails-before-timers + (t/async done + (expect-failed-args #js {} 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}} + 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}}} + true + done))) + +(t/deftest viewport-defaults-apply-at-use + (t/is (= {:width 1920 :height 1080 :dpr 2} + (core/resolve-viewport {:scale 1 :x 0 :y 0}))) + (t/is (= {:width 800 :height 600 :dpr 2} + (core/resolve-viewport {:scale 1 :x 0 :y 0 + :viewport {:width 800 :height 600}})))) + +(t/deftest dispose-resets-lifecycle-state + (set! wasm/internal-module #js {}) + (set! wasm/context-initialized? true) + (reset! wasm/context-lost? true) + (let [result (browser/dispose!)] + (t/is (= "disposed" (unchecked-get result "status"))) + (t/is (nil? wasm/internal-module)) + (t/is (false? wasm/context-initialized?)) + (t/is (false? @wasm/context-lost?))) + (t/testing "double dispose is idempotent" + (let [result (browser/dispose!)] + (t/is (= "disposed" (unchecked-get result "status"))) + (t/is (nil? wasm/internal-module)))) + (t/testing "dispose bumps the owner epoch" + (let [before @@#'browser/owner-epoch*] + (browser/dispose!) + (t/is (< before @@#'browser/owner-epoch*))))) + +(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 #{"dispose"} keys)))) diff --git a/frontend/test/frontend_tests/benches/upload_test.cljs b/frontend/test/frontend_tests/benches/upload_test.cljs index f24b84e5bd..60d75150a8 100644 --- a/frontend/test/frontend_tests/benches/upload_test.cljs +++ b/frontend/test/frontend_tests/benches/upload_test.cljs @@ -37,6 +37,11 @@ {:objects {uuid/zero root, r1-id r1, r2-id r2} :refs {:r1 r1-id :r2 r2-id}})) +(t/deftest prepare-scene-derives-upload-order + (let [snapshot (common/validate! (sample-snapshot))] + (t/is (= (common/upload-order snapshot) + (bench-upload/prepare-scene snapshot))))) + (t/deftest upload-scene-uses-snapshot-order-and-caller-opts (let [snapshot (common/validate! (sample-snapshot)) opts {:include-layout? false :include-fills-strokes? true}