From 0bd3fcd679656542d3064f7b7c4467a65937bcca Mon Sep 17 00:00:00 2001 From: Miguel de Benito Delgado Date: Tue, 3 Nov 2026 18:49:29 +0000 Subject: [PATCH] :sparkles: Integrate renderer benchmark case phases - Run preparation, main and reset through the shared command executor. - Refactor preparation into named operations and pure data projections. - Fully document the browser bridge. AI-assisted-by: gpt-6.1-sol --- .../performance/benches/render_wasm/README.md | 139 ++- .../benches/render_wasm/browser/bridge.cljs | 923 ++++++++++++++---- .../benches/render_wasm/declarations.cljc | 30 +- .../benches/render_wasm/runtime/camera.cljs | 4 +- .../render_wasm/contracts_test.cljs | 11 +- .../render_wasm/lifecycle_test.cljs | 411 +++++--- 6 files changed, 1136 insertions(+), 382 deletions(-) diff --git a/frontend/performance/benches/render_wasm/README.md b/frontend/performance/benches/render_wasm/README.md index 50862a0eeb..590bdca0fd 100644 --- a/frontend/performance/benches/render_wasm/README.md +++ b/frontend/performance/benches/render_wasm/README.md @@ -61,8 +61,8 @@ The suite has three parts: policy, completion rule, optional operation settings, and a run function. A case is the unit that is measured. - **Operation**: the function that performs a case's work during one attempt. - It receives a runtime context with the case data, scene snapshot, renderer - module, view, and hooks. Standard operations load, pan, and zoom. + It receives a command context with the case data, scene snapshot, current + view, and hooks. Standard operations load, pan, and zoom. - **View**: the camera's zoom `:scale` and offsets `:x` and `:y`. Optional `:viewport` settings give the canvas width, height, and device pixel ratio, the number of drawing-buffer pixels per CSS pixel along each dimension. @@ -90,9 +90,9 @@ The suite has three parts: context, snapshot, and upload serve all attempts of the case. - **Preparation**: setup for one graphics context. It covers module instantiation, WebGL2 context creation, scene build, snapshot validation, - fingerprinting, upload-order derivation, and upload. A warm preparation also - restores the starting view and drains to `Full`. One page owns one - preparation. + fingerprinting, upload-order derivation, and upload. It then runs the optional + preparation callback and drains to `Full` unless the case declares + `:preparation {:render :none}`. One page owns one preparation. - **Attempt**: one execution of a case against a preparation. An attempt records one outcome, one set of metrics, and a raw trace. A failed attempt still consumes its allocated slot. @@ -234,7 +234,7 @@ The browser bridge owns everything inside the page: - it validates a request before assigning the page to that work; - it imports the renderer module and creates the canvas and WebGL2 context; - it builds, validates, fingerprints, orders, and uploads the scene; -- it restores the starting view outside timing for warm cases; +- it carries each phase's returned context into the next phase or attempt; - it runs the case operation and records the requested measurements; - it returns plain data and disposes the context when asked. @@ -264,7 +264,7 @@ sequenceDiagram B->>B: build, validate, fingerprint the snapshot B->>W: upload shapes in tree order B->>W: render until Full - B-->>N: metrics, slices, and preparation evidence + B-->>N: preparation evidence and raw phase blocks N->>N: persist the run record ``` @@ -378,10 +378,10 @@ derives the scene from that keyword namespace and attaches the run function. {:params default-params :view {:scale 1 :x 0 :y 0} :context :reuse - :completion :render-full - :operation {:steps 20 :dx 200 :dy 0 :settle-ms 100}} - [rtx] - (camera/pan! rtx)) + :reset (fn [rtx] + (camera/pan rtx {:steps 20 :dx -200 :dy 0 :settle-ms 100}))} + [rtx] + (camera/pan* rtx {:steps 20 :dx 200 :dy 0 :settle-ms 100})) ``` The case declares: @@ -393,14 +393,15 @@ The case declares: - `:context`: `:fresh` creates a page, module, and context per attempt. `:reuse` keeps one page, module, context, snapshot, and upload for all attempts of the case. -- `:completion`: the declared completion rule, `:call`, `:await`, or - `:render-full`. The standard load body drains to `Full`; the standard pan and - zoom bodies finish a camera session with a final `Full` drain. -- `:operation`: settings read by the operation body. -- `:batch-size` and `:preparation`: execution settings recorded in the - descriptor and checked by comparisons. A batch size groups inner calls inside - one attempt; a repetition is a separate attempt. Standard load, pan, and zoom -cases run one call per attempt. +- `:preparation`: an optional `:callback` function runs after upload and returns + the next context or its Promise. Preparation then renders Full by default. + `:render :none` completes preparation after the callback for cold-render cases. +- `:reset`: an optional function that receives the context after successful + main execution and returns the next context or its Promise. Reset runs after + every successful main, including warmups and the final attempt. + +Commands receive workload arguments from the case body. The callback, main body, +and reset share the command context and can each call measured commands. ### Case collection and seeds @@ -410,6 +411,10 @@ the case's data fields, and checks that the descriptor survives a serialization round trip. Shape construction, browser startup, and renderer initialization take place during execution. +The local registry keeps the main, reset, and preparation callback functions. +The descriptor carries the declared preparation data. The browser resolves the +functions from its registry using the case id. + The scene seed depends only on the master seed, the scene id, and the canonical parameters. These inputs give load, pan, and zoom cases the same starting content across case bodies, collection orders, attempt counts, and feature @@ -441,11 +446,9 @@ shape. | `plus` | 1000 plus signs made from two open line subpaths | load, pan, zoom | | `masks` | 24 masked groups, each with a circle mask and 8 striped rectangles | load, pan, zoom | -A load case renders a fresh scene and times module start, graphics start, -upload, and the first render to `Full`. A pan or zoom case prepares the scene -once, restores the starting view outside timing for each attempt, then times a -camera session. The standard camera sessions use 20 view updates and a 100 ms -settling wait. Pan and zoom parameters vary by scene. +Scene recipes supply canonical geometry for rendering and camera workloads. +Camera sessions submit view updates and wait for settling before their final +render. Each case declares its sequence, arguments, and reset behavior in code. ## Renderer commands @@ -629,6 +632,39 @@ the individual values that call produced. The command execution owns this history; its guard authorizes work for that execution. Pass the resulting context to `commands/execute` to start another command execution. +### Case phases + +The browser executes preparation, main, and reset through the command executor. +Preparation builds and uploads the scene, runs its optional callback once, and +renders Full unless the case declares `:preparation {:render :none}`. A fresh +context prepares once for its attempt; a reused context prepares once for its +case. Each phase passes its returned context to the next phase. + +A successful preparation response contains its raw block in +`[:preparation :phases]`. A failed preparation returns its available blocks in +top-level `:phases`. An attempt response contains main and any declared reset +in `:phases`: + +```clojure +{:phase :main + :preparation-id "preparation-1" + :outcome :completed + :events [{:command :benches.render-wasm.runtime.camera/render-sync + :metrics {:command/elapsed [7]}}]} +``` + +Each block retains ordered command events, raw numeric vectors, and any +completion diagnostics. A phase can complete with an empty event vector. +Initialization and upload durations stay in preparation `:diagnostics`. +Automatic initial rendering retains its raw trace under +`[:diagnostics :setup-render]`. Measured commands in the callback supply the +preparation block's selected events. + +Reset runs after successful main. Its returned context supplies the next reused +attempt. A phase failure releases its owned context and returns the failed +block with earlier events and available traces. Completed preparation retains +its own evidence when main or reset fails. + ### Runtime code [`runtime/commands.clj`](runtime/commands.clj) declares commands; @@ -643,7 +679,8 @@ declarations and functions during catalogue collection. Renderer operations run through the supplied hooks when the functions execute in the browser. [`browser/bridge.cljs`](browser/bridge.cljs) constructs the runtime context for case operations and prepares graphics contexts, snapshots, and uploads. Reused -cases restore their starting view outside interaction timing before each attempt. +cases carry their returned context into the next attempt; their reset function +controls restoration. ## Measurement @@ -659,16 +696,18 @@ Work around those intervals includes: - request and result serialization; - validation, logging, and screenshots; - module script import; -- camera restore and the drain that prepares a warm case. +- the automatic initial Full render during preparation. -The timed work includes: +Preparation diagnostics time: - module instantiation; - graphics context creation and initial size synchronization; - shape batch upload, including the loading lifecycle and first view setup; -- the render drain to `Full`; -- for warm cases, the camera session: view updates, cached previews, settling, - ending the interaction, and the final drain. +- the automatic render drain to `Full`. + +Measured commands record their own intervals during preparation, main, or reset. +For a camera command, this includes view updates, cached previews, settling, +ending the interaction, and the final drain. ### Lifecycle of a fresh load @@ -679,22 +718,25 @@ flowchart TD C --> D[Build and validate snapshot - untimed] D --> E[Fingerprint and derive upload order - untimed] E --> F[Time upload and first view setup] - F --> G[Time render drain to Full] - G --> H[Record result] - H --> I[Dispose page and context] + F --> G[Run preparation callback] + G --> H[Render initial Full unless render is none] + H --> I[Run main] + I --> J[Run reset after successful main] + J --> K[Return raw phase records] + K --> L[Dispose page and context] ``` The module script import is untimed because the browser loads it once per page. Module instantiation starts a new renderer state. Graphics start creates the WebGL2 context and synchronizes the drawing buffer. Upload covers the loading -lifecycle, batch serialization, and tile preparation. The first render clock -starts after upload ends. +lifecycle, batch serialization, and tile preparation. Each measured command +starts its clock immediately before its work. ### Warm interactions -A warm case owns one prepared page. Before each attempt, the browser restores -the case's starting view and drains to `Full` outside timing. The operation then -runs a camera session: +A warm case owns one prepared page. Its main body starts from the context +returned by preparation or the preceding attempt's reset. A camera command runs +this session: 1. start the camera interaction; 2. for each view update, wait for the next animation frame, apply the view, and @@ -711,19 +753,18 @@ the run also records that call's duration. ### Slices and metrics -The browser returns raw evidence for each attempt. A fresh attempt also reports -`module-init-ms`, `graphics-init-ms`, and `upload-ms` from preparation. +The browser returns ordered events within each phase. Preparation diagnostics +retain `module-init-ms`, `graphics-init-ms`, and `upload-ms` separately. -A fresh attempt reports one drain: +A render drain retains these trace fields: - `slices`: one record per renderer call with `timestamp`, `flags`, `frame-type`, and `duration-ms`; - `viewport-ready-ms`: the first `ViewportReady` or `Full` completion from the drain origin; -- `full-ms`: the `Full` completion from the drain origin; -- `first-render-ms`: the single first-render metric for a fresh case. +- `full-ms`: the `Full` completion from the drain origin. -A warm attempt reports the camera session and the final drain: +A camera command's trace contains the camera session and final drain: - `cached-slices`: one record per gesture frame with `timestamp`, `duration-ms` for the cached preview call, and `view-and-preview-ms` for the @@ -746,13 +787,9 @@ Both drains and camera sessions return `end-ms`, the clock reading immediately after the final renderer call returns `Full`. Completion durations end at that reading. Promise callbacks can run later as the browser schedules queued work. -Both kinds of attempt report `renderer-call-total-ms`, the sum of the renderer -call durations, computed after timing. - -A saved run metric declares its unit, semantics, basis, and zero policy. A batch -average records its call count; one attempt supplies one observation. Raw slices -keep zero durations and non-finite values. The metric entry classifies the value -for analysis and records a reason when it is invalid. +Measured command events keep each metric's raw values in a vector. Repeated +commands produce separate events, including when they name the same metric. +Raw values preserve zero, negative, and non-finite measurements for analysis. ### Renderer completion diff --git a/frontend/performance/benches/render_wasm/browser/bridge.cljs b/frontend/performance/benches/render_wasm/browser/bridge.cljs index b46df52fce..764958d870 100644 --- a/frontend/performance/benches/render_wasm/browser/bridge.cljs +++ b/frontend/performance/benches/render_wasm/browser/bridge.cljs @@ -5,19 +5,59 @@ ;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns benches.render-wasm.browser.bridge - "The browser bridge prepares scenes and executes renderer benchmark attempts. + "Prepares renderer scenes and executes benchmark case phases in the browser. - A preparation owns one module, graphics context, scene snapshot and upload. - 'Warm' attempts are those that reuse that preparation. 'Fresh' preparations - execute once. + A scene is a recipe that builds a snapshot. A snapshot contains the concrete + shapes in `:objects` and label references in `:refs`. A case pairs a scene + with parameters, a camera view and execution settings. A collected case + descriptor reaches the browser through Transit; this namespace resolves the + matching local registration, which supplies the `run!` function and optional + preparation callback and reset function. - Setup follows the editor initialization and synchronous object upload paths. - Node calls the exported bridge through `page.evaluate`. + A preparation owns one renderer lifetime: the instantiated module, canvas, + WebGL2 context, uploaded snapshot and command context. It loads the module, + creates graphics, builds and uploads the scene, runs the preparation callback + once, and then renders an initial Full frame unless the case declares + `:preparation {:render :none}`. Fresh cases prepare once per attempt. Reused + cases prepare once and carry the returned view and command context into later + attempts. - Scene, measurement and reports return Transit. Ping and disposal return status - objects. + Execution has up to three phases: preparation, main and reset. Main runs the + case's `run!` function. Reset follows each successful main execution. A + phase's raw block contains `:phase`, `:preparation-id`, `:outcome`, + selected command `:events` and, when present, `:diagnostics`. A successful + preparation exposes its blocks at `[:preparation :phases]`. A failed + preparation places its available blocks at the top level. An attempt places + main and optional reset blocks at the top level and keeps the preparation + evidence captured for that attempt. - Failures retain their phase, cause and available partial evidence." + A command context has exactly four fields: `:case`, the collected case + descriptor; `:scene`, the snapshot; `:view`, the camera scale and offsets; + and `:hooks`, the functions for time, scheduling, checks and renderer + operations. Every command receives the full hook map with its context. The + context, module, graphics handles, canvas and snapshot stay in the browser. + Only projected evidence, such as preparation data, phase blocks, identity + data and summary or comparison data, crosses Transit. + + An owner is the current page lifetime. Lifecycle guards compare a captured + owner epoch with the current epoch; a mismatch throws a top-level + `::errors/stale` marker. A newer load, disposal or WebGL context loss + increments the epoch and supersedes the old owner. Only a top-level + `::errors/stale` marker yields a stale result. A superseded operation that + rejects for another reason returns failed evidence for that operation and + leaves the newer owner in place; a failed attempt keeps its own preparation + and phase blocks. An ordinary failure in the current owner ends that owner + and returns its phase, cause and available partial evidence. + + Renderer-call measurements end when each call returns. Setup diagnostics + retain separate intervals for module initialization, graphics setup, upload + and initial rendering. GPU execution and display proceed asynchronously + after renderer submission. + + The exported `bridge` object maps the names used by the runner to this public + interface; the runner calls it through `page.evaluate`. Report formatting + entries return text; the other entries return Transit text or, for `ping` and + `dispose`, status objects." (:require-macros [app.common.exceptions :as ex]) (:require @@ -38,11 +78,11 @@ [benches.render-wasm.errors :as errors :refer [attempt failure?]] [benches.render-wasm.fingerprint :as fingerprint] [benches.render-wasm.identity :as identity] - [benches.render-wasm.measurement :as measurement] [benches.render-wasm.report.compare :as compare] [benches.render-wasm.report.format :as format] [benches.render-wasm.report.summarize :as summary] [benches.render-wasm.result :as result] + [benches.render-wasm.runtime.commands :as commands] [benches.render-wasm.runtime.protocol :as protocol] [benches.render-wasm.snapshot :as snapshot])) @@ -50,7 +90,13 @@ (declare now guard-current! read-graphics) (def schema:load-scene-args - "Decoded Transit request for `load-scene`." + "Decoded form of the Transit request accepted by `load-scene` and + `prepare-scene-request`. + + `:case` is a collected case descriptor. `:preparation-id` labels the + preparation and defaults to the owner epoch. `:timeout-ms` bounds setup and, + through the stored preparation, later attempts that omit their own timeout. + `:module-url` and `:wasm-url` override the renderer artifacts." [:map {:closed true} [:case decl/schema:collected-case] [:preparation-id {:optional true} some?] @@ -59,16 +105,23 @@ [:wasm-url {:optional true} string?]]) (def ^:const ^:private attempt-ms-budget - "Default wall deadline for setup or one attempt, checked by browser-hooks. - Requests can supply a timeout. Node's outer timeout covers blocked pages." + "Default elapsed-time deadline for setup or one attempt, checked by + `browser-hooks`. Requests can supply a timeout. The outer process timeout + covers blocked pages." 30000) (defn- browser-hooks - "Browser hooks for one owner epoch and deadline. + "Returns the command hooks for one owner epoch and deadline. - This somewhat awkward decoupling keeps scene declarations safe to load in - Node, where we don't have a canvas nor a graphics context. It also lets tests - control time and renderer responses." + Args: + epoch: owner epoch that `:check` compares with the current epoch. + deadline-ms: deadline in milliseconds on the `now` clock. + + Returns: + A map with `:now`, `:frame`, `:sleep`, `:check`, `:render`, + `:render-sync`, `:render-from-cache`, `:set-view`, `:set-view-start` and + `:set-view-end`. `:check` rejects work after the owner is superseded, the + WebGL context is lost or the deadline passes." [epoch deadline-ms] {:now now ;; rAF scheduling @@ -76,7 +129,7 @@ (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 + ;; lost, and the deadline hasn't passed :check (fn [] (when @wasm/context-lost? (throw (ex-info "WebGL context lost" @@ -96,8 +149,8 @@ :set-view-end #(h/call wasm/internal-module "_set_view_end")}) -;; Monotonic owner generation. Every async continuation compares its -;; captured epoch and drops stale results silently; dispose/load bump it. +;; Monotonic owner generation. Async continuations capture the current epoch +;; and compare it before acting; a mismatch marks their work stale. (defonce ^:private owner-epoch* (atom 0)) @@ -110,6 +163,8 @@ (defonce ^:private canvas* (atom nil)) +;; Current completed preparation for `run-prepared`: owner, case, reusable +;; command context and browser-local graphics. (defonce ^:private prepared* (atom nil)) @@ -121,16 +176,25 @@ (set! wasm/context-initialized? true)) (defn- now - "Reads the browser's monotonic clock in milliseconds." + "Returns the browser's monotonic clock in milliseconds." [] (.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." + "Imports the Emscripten glue and instantiates it with the prepared `.wasm` + bytes. + + The import is cached per page and untimed: script import precedes + module-init timing. `locateFile` points at `wasm-url`. + + Args: + module-url: URL of the Emscripten glue module. + wasm-url: URL of the `.wasm` file passed to `locateFile`. + + Returns: + A Promise of `{:module instance :factory-ms ms}`, where `:factory-ms` is + the instantiation duration in milliseconds. A failed import or factory + call rejects the Promise." [module-url wasm-url] (when (nil? @module-promise*) (reset! module-promise* @@ -145,6 +209,16 @@ @module-promise*) (defn- graphics-string + "Reads a WebGL string parameter. + + Args: + context: WebGL context to query. + parameter: property name on `WEBGL_debug_renderer_info`, such as + `\"UNMASKED_RENDERER_WEBGL\"`. + + Returns: + The parameter's string value, or `\"unknown\"` when the extension is + unavailable or the read throws." [context parameter] (or (ex/ignoring (when-let [ext (.getExtension ^js context "WEBGL_debug_renderer_info")] @@ -152,18 +226,40 @@ "unknown")) (defn- renderer-string - "Reads the unmasked WebGL renderer identity." + "Reads the unmasked WebGL renderer identity. + + Args: + context: WebGL context to query. + + Returns: + The unmasked renderer text, or `\"unknown\"` when the debug extension is + unavailable or the read throws." [context] (graphics-string context "UNMASKED_RENDERER_WEBGL")) (defn- software-renderer? - "Recognizes common software graphics implementations from their renderer text." + "Recognizes common software graphics implementations from their renderer text. + + Args: + s: renderer text to inspect; nil is treated as empty. + + Returns: + `true` when the text names SwiftShader, llvmpipe, generic software or a + basic render, ignoring case; `false` otherwise." [s] (boolean (re-find #"(?i)swiftshader|llvmpipe|software|basic render" (or s "")))) (defn- teardown-canvas! "Releases the context after successful initialization, then removes its - canvas. The initializer owns cleanup until it returns a context." + canvas. + + The initializer owns cleanup until it returns a context. + + Args: + canvas: canvas to remove; nil skips both steps. + + Returns: + nil after the cleanup." [canvas] (when (some? canvas) (when-let [context wasm/gl-context] @@ -171,9 +267,11 @@ (ex/ignoring (.remove ^js canvas)))) (defn- release-owner-resources! - "Drops the current owner's canvas, GL handles and WASM module binding - without touching the owner epoch. Callers own the epoch: `dispose!` bumps - first, superseding loads already bumped for the new owner." + "Releases the current owner's canvas, GL handles, module binding and + stored preparation. + + Returns: + nil after releasing the resources." [] (teardown-canvas! @canvas*) (wasm/reset-context-state!) @@ -185,10 +283,15 @@ (reset! prepared* nil)) (defn dispose! - "Closes the current owner: bumps the epoch (running continuations go - stale), releases renderer resources and drops the cached module promise so - a later load re-imports cleanly. Idempotent. Stale callers must not clean - up a newer owner; use `dispose-if-current!` from async continuations." + "Closes the current owner and returns a JavaScript status object. + + It bumps the epoch, so running continuations go stale, releases renderer + resources and drops the cached module promise so a later load re-imports + cleanly. Idempotent. Stale callers must not clean up a newer owner; use + `dispose-if-current!` from async continuations. + + Returns: + A JavaScript object with status `\"disposed\"`." [] (swap! owner-epoch* inc) (release-owner-resources!) @@ -196,8 +299,14 @@ #js {"status" "disposed"}) (defn- dispose-if-current! - "Disposes only when `epoch` is still current. Stale work returns false - without modifying the newer owner's canvas, GL context or module cache." + "Disposes only when `epoch` is still current. + + Args: + epoch: owner epoch captured by the caller. + + Returns: + `true` after disposing, or nil for stale work. Stale work leaves the newer + owner's canvas, GL context and module cache unchanged." [epoch] (when (= epoch @owner-epoch*) (dispose!) @@ -205,12 +314,27 @@ (defn- read-graphics - "Records effective graphics settings: requested CSS size and DPR - separately from the observed drawing-buffer size and the context's actual - attributes. Requested defaults live in `webgl/default-context-options`; - the drawing-buffer size is the context's actual `drawingBufferWidth/Height`, - not the canvas attributes. What the browser granted comes from - `getContextAttributes`." + "Records the effective graphics settings for a live WebGL context. + + The result separates the requested CSS size and device pixel ratio from the + observed drawing-buffer size and the context's actual attributes. Requested + defaults live in `webgl/default-context-options`; the drawing-buffer size is + the context's actual `drawingBufferWidth` and `drawingBufferHeight`, not the + canvas attributes. Granted attributes come from `getContextAttributes`. + + Args: + ctx: live WebGL context to inspect. + width: requested CSS width in pixels. + height: requested CSS height in pixels. + dpr: requested device pixel ratio. + renderer: unmasked renderer text to record. + + Returns: + A map with the requested `:css-width`, `:css-height` and `:dpr`; the + observed `:drawing-buffer-width` and `:drawing-buffer-height`; `:webgl2` + set to `true`; the granted `:antialias`, `:depth`, `:stencil`, `:alpha` + and `:preserve-drawing-buffer` attributes; and the `:renderer`, `:vendor` + and `:software` identity fields." [ctx width height dpr renderer] (let [attrs (ex/ignoring (.getContextAttributes ^js ctx)) attr (fn [k] @@ -233,11 +357,19 @@ :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 protocol - loop only observes staleness afterwards, so a cleanup by `render-ok` / - `warm-ok` there would always miss." + "Wires context lost/restored handling for `canvas`, guarded by the epoch. + + A stale canvas leaves the live owner untouched. On loss the current owner + releases its canvas and GL resources, clears the module promise, marks the + context lost and supersedes its epoch immediately. A restore event + supersedes the epoch when its owner still matches. + + Args: + canvas: canvas that receives the listeners. + epoch: owner epoch captured when the listeners are installed. + + Returns: + nil after installing both listeners." [canvas epoch] (.addEventListener canvas "webglcontextlost" @@ -261,33 +393,69 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (defn- terminal-failure - "Maps a chain rejection to the bridge value. Stale work resolves to the - stale marker without touching the newer owner; any other failure releases - the owner when it still owns the realm and resolves to a failure map, so - the bridge promise never rejects. The top message stays phase-labeled for - accounting; the original failure lives under `:cause` as plain data. Only - the top-level cause decides staleness." + "Returns the bridge result for a rejected asynchronous step. + + Only a top-level `::errors/stale` marker decides staleness. A stale cause + returns `{:status \"stale\"}` and leaves the newer owner in place. Any other + cause disposes the owner when `epoch` is still current and returns failed + evidence. The evidence uses the phase from the cause, or `fallback-phase` + when absent, and keeps the original failure as plain data under `:cause`. + The function attaches stored preparation evidence when that preparation + belongs to `epoch`. + + Args: + epoch: owner epoch that the failed step belongs to. + cause: rejected value from the step. + fallback-phase: phase name used when the cause has no `:phase`. + + Returns: + `{:status \"stale\"}` for a top-level stale cause; otherwise failed + evidence with status `\"failed\"`, `:phase`, `:message`, `:cause`, optional + `:partial` data and optional `:preparation` evidence." [epoch cause fallback-phase] (if (errors/stale? cause) {:status "stale"} (let [phase (or (ex/ignoring (:phase (ex-data cause))) fallback-phase "aborted") - preparation (get-in @prepared* [:graphics :preparation])] + prepared @prepared* + preparation (when (= epoch (get-in prepared [:owner :epoch])) + (get-in prepared [:graphics :preparation]))] (dispose-if-current! epoch) (cond-> (errors/fail-data (errors/evidence phase cause)) preparation (assoc :preparation preparation))))) (defn- guard-current! - "Throws the stale marker when `epoch` no longer owns the realm. Every - async step calls it first with its own phase so the terminal handler can - tell superseded work from genuine failures. The marker lives in `ex-data`, - never in the message: message text is not a protocol." + "Throws the stale marker when `epoch` no longer owns the page. + + Callers pass their phase so the terminal handler can tell superseded work + from genuine failures. The marker is `::errors/stale` in `ex-data`; message + text is for humans only. + + Args: + epoch: owner epoch captured by the caller. + phase: phase name recorded in the stale marker. + + Returns: + nil when `epoch` is current. + + Throws: + An exception when `epoch` is stale, with `::errors/stale true` and + `:phase` in its `ex-data`." [epoch phase] (when-not (= epoch @owner-epoch*) (throw (ex-info "superseded" {::errors/stale true :phase phase})))) (defn- build-canvas! "Creates the bench canvas with a `width*dpr` backing buffer and an explicit - CSS size, so the drawn scene fills exactly the reported viewport." + CSS size, so the drawn scene fills exactly the reported viewport. + + Args: + width: viewport width in CSS pixels. + height: viewport height in CSS pixels. + dpr: device pixel ratio used for the backing buffer. + + Returns: + An unattached canvas element whose backing buffer is `width*dpr` by + `height*dpr` and whose CSS size is `width` by `height` pixels." [width height dpr] (let [canvas (.createElement js/document "canvas")] (aset canvas "width" (* width dpr)) @@ -296,60 +464,92 @@ (aset (.-style ^js canvas) "height" (str height "px")) canvas)) -(defn- case-ok - "Adds lifecycle and graphics data to one compiled body's measurement." - [epoch case-desc seed graphics measurement] - (guard-current! epoch "render") - (if-not (wasm/live?) - (do - (dispose-if-current! epoch) - (errors/fail-data {:phase (if (= :reuse (:context case-desc)) - "interact" - "first-render") - :message "disposed or context lost during case"})) - (let [{:keys [module-ms graphics-ms snapshot upload-ms preparation]} graphics - setup {:module-init-ms module-ms :graphics-init-ms graphics-ms :upload-ms upload-ms}] - (merge measurement - {:status "ok" - :preparation preparation - :metrics (measurement/metrics measurement (= :fresh (:context case-desc)) setup) - :trace (dissoc measurement :metrics) - :scene {:shapes (count (:objects snapshot)) - :seed seed} - :effective-graphics (:effective-graphics preparation)})))) +(defn- ^:async run-phase + "Executes one lifecycle phase through the command executor. -(defn- run-case - "Awaits untimed restore for reuse cases, then calls the compiled body once. - Its synchronous return is assimilated into the same Promise result path." - [{:keys [epoch hooks]} case-desc local-case graphics] - (let [reuse? (= :reuse (:context case-desc)) - prep (if reuse? - (-> (protocol/restore hooks (:view graphics)) - (.catch (fn [cause] - (if (errors/stale? cause) - (throw cause) - (throw (errors/with-context cause "restore failed" - {:phase "restore"})))))) - (js/Promise.resolve nil)) - rtx {:case case-desc - :scene (:snapshot graphics) - :module wasm/internal-module - :view (:view graphics) - :hooks hooks}] - (-> prep - (.then (fn [_] - (guard-current! epoch "render") - (js/Promise.resolve ((:run! local-case) rtx)))) - (.then (fn [measurement] - (case-ok epoch case-desc (:scene-seed case-desc) - graphics measurement))) - (.catch (fn [cause] - (assoc (terminal-failure epoch cause (if reuse? "interact" "first-render")) - :preparation (:preparation graphics))))))) + `run` receives a command context and returns the next context or a Promise + of it. The raw block keeps `:phase`, `:preparation-id`, `:outcome`, + selected command `:events` and optional `:diagnostics`, and is appended to + `phases`. + + Args: + phase: lifecycle phase name, such as `:preparation`, `:main` or `:reset`. + preparation-id: identifier recorded in the raw block. + rtx: command context supplied to the command executor. + run: function from a command context to the next context or a Promise. + phases: volatile that receives each raw phase block. + + Returns: + A Promise of the next command context. A failed command outcome rejects + the Promise with a phase-labeled error after the block is recorded." + [phase preparation-id rtx run phases] + (let [outcome (await (commands/execute rtx run)) + block (assoc (select-keys outcome [:outcome :events :diagnostics]) + :phase phase :preparation-id preparation-id)] + (vswap! phases conj block) + (when (= :failed (:outcome outcome)) + (let [cause (:error outcome)] + (throw (errors/with-context cause (errors/message-of cause "phase failed") + {:phase (or (:phase (ex-data cause)) (name phase))})))) + (:context outcome))) + +(defn- ^:async run-case + "Runs main and the optional reset against one prepared command context. + + Main executes the local case's `run!` function. Reset executes only after a + successful main. The context returned by main, or by reset when present, is + stored for the next reused attempt, so camera changes carry over. + + Args: + owner: map with `:epoch`, the owner epoch, and `:hooks`, the hooks used by + main and reset. + case-desc: collected case descriptor. + local-case: local case registration with `run!` and optional `:reset`. + graphics: browser-local graphics map containing the preparation evidence + and snapshot. + context: prepared command context for main. + + Returns: + A Promise of status `\"ok\"` with `:preparation` evidence, `:phases` for + main and any reset, `:scene` with the shape count in `:shapes` and the + seed in `:seed`, and `:effective-graphics`. A non-stale failure resolves + to failed evidence with this attempt's preparation and blocks, even when + a newer owner exists. A top-level `::errors/stale` marker resolves to the + stale result." + [{:keys [epoch hooks]} case-desc local-case graphics context] + (let [phases (volatile! []) + preparation (:preparation graphics) + preparation-id (:preparation-id preparation)] + (try + (let [rtx (assoc context :hooks hooks) + rtx (await (run-phase :main preparation-id rtx (:run! local-case) phases)) + rtx (if-some [reset (:reset local-case)] + (await (run-phase :reset preparation-id rtx reset phases)) + rtx)] + (guard-current! epoch "main") + (when-not (wasm/live?) + (throw (ex-info "disposed or context lost during case" {:phase "main"}))) + (swap! prepared* assoc :context rtx) + {:status "ok" + :preparation preparation + :phases @phases + :scene {:shapes (count (:objects (:snapshot graphics))) + :seed (:scene-seed case-desc)} + :effective-graphics (:effective-graphics preparation)}) + (catch :default cause + (cond-> (terminal-failure epoch cause "main") + (not (errors/stale? cause)) (assoc :preparation preparation :phases @phases)))))) (defn- parse-load-request - "Decodes and validates the request before claiming the page. Failure - leaves the current owner untouched." + "Decodes and validates a Transit scene request before claiming the page. + + Args: + request: Transit string containing the fields in + `schema:load-scene-args`. + + Returns: + A map with `:params`, `:case-desc`, `:local-case` and `:scene`, or the + validation Error. Failure leaves the current owner in place." [request] (attempt (let [params (t/decode-str request)] @@ -367,14 +567,26 @@ (= (:scene local) (:scene case-desc))) (throw (ex-info (str "missing case body for " (:id case-desc)) {:phase "invalid-args"}))) + (decl/check-registered-case! local) {:params params :case-desc case-desc :local-case local :scene scene})))) (defn- begin-load! - "Claims the page, releases the prior owner and fixes the viewport. - Module instantiation gets a fresh cache. Preparation supplies the deadline." + "Claims the page, releases the prior owner and resolves the viewport. + + It increments the owner epoch, releases the prior owner's canvas, graphics + resources and preparation, and clears the module promise so the next load + imports fresh. The caller supplies hooks and a deadline. + + Args: + case-desc: collected case descriptor whose `:view` supplies viewport + overrides. + + Returns: + A map with `:epoch`, the new owner epoch, and `:dims`, the resolved + viewport with `:width`, `:height` and `:dpr`." [case-desc] (let [epoch (swap! owner-epoch* inc)] (release-owner-resources!) @@ -384,8 +596,17 @@ :dims viewport}))) (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." + "Loads and instantiates the renderer. + + Args: + epoch: owner epoch checked after the module resolves. + params: decoded request map; `:module-url` and `:wasm-url` override the + renderer artifact URLs. + + Returns: + A Promise of the module map with `:module` and `:factory-ms`. A failed + import or factory call rejects with the module-init phase and its cause + for bridge accounting; a superseded owner rejects with the stale marker." [epoch params] (-> (ensure-module! (or (:module-url params) "./render-wasm.js") (or (:wasm-url params) "./render-wasm.wasm")) @@ -399,8 +620,23 @@ 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." + "Creates the canvas and WebGL context and binds them to the renderer. + + Args: + epoch: owner epoch used by the context-loss listeners. + viewport: map with `:width`, `:height` and `:dpr`. + installed: module map with `:module` and `:factory-ms`. + + Returns: + A map with `:module-ms`, the factory instantiation duration in + milliseconds; `:graphics-ms`, the context creation, renderer binding, + viewbox resize and listener installation duration in milliseconds; + `:renderer`, the unmasked renderer text; and `:ctx`, the graphics + resources kept in the browser. + + Throws: + The stale marker when `epoch` is no longer current. Context creation or + viewbox resize failures throw an exception with phase `graphics-init`." [epoch {:keys [width height dpr]} {:keys [module factory-ms]}] (guard-current! epoch "graphics-init") (set! wasm/internal-module module) @@ -443,8 +679,25 @@ :ctx ctx}))) (defn- prepare-scene - "Builds the registered scene, then fingerprints and orders its shapes before - the upload timer begins. Scene builders return validated snapshots." + "Builds the registered scene, then fingerprints and orders its shapes. + + The scene builder returns a validated snapshot. Fingerprinting and upload + order derivation finish before the upload timer begins. + + Args: + epoch: owner epoch checked before the scene is built. + scene: registered scene declaration with a `:build` function. + case-desc: collected case descriptor whose `:params` are passed to the + builder. + seed: scene seed supplied as `:seed` to the builder. + graphics: graphics map to extend. + + Returns: + `graphics` with `:snapshot`, `:fingerprint` and `:ordered`. + + Throws: + The stale marker when `epoch` is no longer current. Other scene + preparation failures throw an exception with phase `scene-build`." [epoch scene case-desc seed graphics] (guard-current! epoch "scene-build") (try @@ -461,8 +714,24 @@ cause)))))) (defn- upload-scene! - "Sets the initial view and uploads prepared shapes inside the upload - timer. Ends the loading lifecycle even when serialization fails." + "Sets the initial view and uploads prepared shapes inside the upload timer. + + The timer covers view setup, pool initialization, the loading lifecycle and + batch serialization. The loading lifecycle ends even when serialization + fails. + + Args: + epoch: owner epoch checked before uploading. + case-desc: collected case descriptor whose `:view` sets the camera. + graphics: prepared graphics map with `:snapshot` and `:ordered`. + + Returns: + `graphics` with `:upload-ms`, the upload duration in milliseconds, and + `:view`, the camera view that was set. + + Throws: + The stale marker when `epoch` is no longer current. Other upload failures + throw an exception with phase `upload`." [epoch case-desc graphics] (guard-current! epoch "upload") (try @@ -491,70 +760,270 @@ cause)))))) (defn- preparation-data - "Projects setup facts without renderer handles or snapshot contents." - [id case-desc dims graphics] - (let [{:keys [module-ms graphics-ms upload-ms renderer ctx snapshot fingerprint setup-render]} graphics - {:keys [width height dpr]} dims] + "Projects completed setup into preparation evidence. + + Args: + id: preparation identifier. + case-desc: collected case descriptor. + effective-graphics: graphics facts read from the live context. + graphics: browser-local graphics map with timings, snapshot, fingerprint + and optional `:setup-render`. + phases: raw preparation phase blocks. + + Returns: + Portable identity data, graphics facts, raw phase blocks and setup + diagnostics." + [id case-desc effective-graphics graphics phases] + (let [{:keys [module-ms graphics-ms upload-ms snapshot fingerprint setup-render]} graphics] {:preparation-id id :case-id (:id case-desc) + :phases phases :fingerprint fingerprint - :effective-graphics (read-graphics (:context ctx) width height dpr renderer) + :effective-graphics effective-graphics :diagnostics (cond-> {:module-init-ms module-ms :graphics-init-ms graphics-ms :upload-ms upload-ms :scene {:shapes (count (:objects snapshot)) :seed (:scene-seed case-desc)}} setup-render (assoc :setup-render setup-render))})) +(defn- command-context + "Returns the four-field command context for one preparation. + + Args: + case-desc: collected case descriptor. + hooks: browser hooks for the current owner and deadline. + graphics: prepared graphics map containing `:snapshot` and `:view`. + + Returns: + The command context with the uploaded snapshot in `:scene` and the + current camera view in `:view`." + [case-desc hooks graphics] + {:case case-desc + :scene (:snapshot graphics) + :view (:view graphics) + :hooks hooks}) + +(defn- ^:async prepare-initial-view + "Runs the preparation callback, then renders the initial Full frame. + + Args: + callback: function from a command context to the next context or a + Promise. + render-mode: `:none` skips the initial render; every other value renders. + setup-render: volatile that receives the drain trace when rendering runs. + rtx: command context passed to the callback. + + Returns: + A Promise of the callback's resulting context. The drain uses that + context's hooks, starts with flags 0 and records its traces and durations + in `setup-render`. A callback or drain failure rejects the Promise." + [callback render-mode setup-render rtx] + (let [rtx (await (callback rtx))] + (when-not (= :none render-mode) + (let [hooks (:hooks rtx)] + (vreset! setup-render + (await (protocol/drain hooks {:flags 0 + :origin ((:now hooks)) + :immediate false}))))) + rtx)) + +(defn- ^:async prepare-context + "Runs the preparation phase and attaches its context to `graphics`. + + The local case supplies the preparation callback; `identity` is used when + none is registered. + + Args: + preparation: map with `:owner`, `:case-desc`, `:local-case` and + `:preparation-id`. + phases: volatile that receives the raw phase block. + graphics: prepared graphics map with the uploaded snapshot and view. + + Returns: + A Promise of `graphics` with `:context`, the context returned by the + callback and initial render, and `:setup-render`, the automatic render + trace or nil. A failed preparation phase rejects the Promise." + [{:keys [owner case-desc local-case preparation-id]} phases graphics] + (let [setup-render (volatile! nil) + callback (get-in local-case [:preparation :callback] identity) + render-mode (get-in case-desc [:preparation :render]) + context (command-context case-desc (:hooks owner) graphics) + context (await (run-phase :preparation preparation-id context + (partial prepare-initial-view callback render-mode setup-render) + phases))] + (assoc graphics :context context :setup-render @setup-render))) + +(defn- prepared-state + "Collects the reusable context, graphics resources and preparation evidence. + + Args: + preparation: case definitions, owner and timeout settings. + graphics: prepared graphics and the completed command context. + evidence: portable preparation evidence. + + Returns: + State ready for its first execution, with the context stored separately + from graphics." + [preparation graphics evidence] + (assoc (select-keys preparation [:owner :case-desc :local-case :timeout-ms]) + :busy? false + :used? false + :context (:context graphics) + :graphics (assoc (dissoc graphics :context) :preparation evidence))) + +(defn- complete-preparation + "Reads effective graphics, records the completed preparation and returns it. + + Args: + preparation: map with `:owner`, `:case-desc` and `:preparation-id`. + phases: volatile of raw preparation blocks. + graphics: prepared graphics map with the live context and setup facts. + + Returns: + `{:status \"ok\" :preparation evidence}` after storing the preparation for + later attempts. + + Throws: + A top-level stale marker when the owner changed during preparation." + [{:keys [owner case-desc preparation-id] :as preparation} phases graphics] + (guard-current! (:epoch owner) "prepare") + (let [{:keys [width height dpr]} (:dims owner) + {:keys [ctx renderer]} graphics + effective-graphics (read-graphics (:context ctx) width height dpr renderer) + evidence (preparation-data preparation-id case-desc effective-graphics graphics @phases)] + (reset! prepared* (prepared-state preparation graphics evidence)) + {:status "ok" :preparation evidence})) + +(defn- record-setup + "Records setup facts for partial failure evidence and returns `graphics`. + + The stored keys are `:module-ms`, `:graphics-ms`, `:fingerprint` and + `:upload-ms` when present. + + Args: + available: volatile map that receives the available setup facts. + graphics: graphics map to read the facts from. + + Returns: + `graphics`, unchanged." + [available graphics] + (vreset! available (select-keys graphics [:module-ms :graphics-ms :fingerprint :upload-ms])) + graphics) + +(defn- preparation-failure + "Returns failure evidence for a preparation that did not complete. + + A top-level stale cause returns the stale result. + + Args: + epoch: owner epoch that the preparation belongs to. + available: volatile of setup facts recorded before the failure. + phases: volatile of raw preparation blocks. + cause: failure that ended preparation. + + Returns: + `{:status \"stale\"}` for a top-level stale cause; otherwise failed + evidence that keeps the recorded setup facts under `:partial :setup`, any + partial render data from the cause, and the raw preparation blocks at the + top level under `:phases`. It releases the owner when `epoch` is still + current." + [epoch available phases cause] + (if (errors/stale? cause) + (terminal-failure epoch cause "aborted") + (assoc (terminal-failure epoch + (errors/with-context cause (errors/message-of cause "setup failed") + {:partial {:setup @available}}) + "aborted") + :phases @phases))) + +(defn- ^:async prepare-owned-scene + "Prepares a claimed scene from a supplied module installation Promise. + + Args: + preparation: map with `:owner`, `:case-desc`, `:scene`, `:local-case` and + `:preparation-id`. + module-promise: Promise of the installed module map; the function awaits it + before graphics initialization and scene preparation. + + Returns: + A Promise of `{:status \"ok\" :preparation evidence}` on success. + Ordinary failure returns failed evidence with the failed phase, any + recorded setup facts under `:partial :setup`, and the raw preparation + blocks at the top level under `:phases`. A top-level stale marker produces + the stale result." + [{:keys [owner case-desc scene] :as preparation} module-promise] + (let [{:keys [epoch dims]} owner + available (volatile! {}) + phases (volatile! [])] + (try + (let [module (await module-promise) + graphics (->> module + (initialize-graphics! epoch dims) + (record-setup available)) + graphics (->> graphics + (prepare-scene epoch scene case-desc (:scene-seed case-desc)) + (record-setup available)) + graphics (->> graphics + (upload-scene! epoch case-desc) + (record-setup available)) + graphics (await (prepare-context preparation phases graphics))] + (complete-preparation preparation phases graphics)) + (catch :default cause + (preparation-failure epoch available phases cause))))) + (defn- prepare-request - "Builds, fingerprints and uploads one owned scene. - Warm initial rendering stays in diagnostics. No attempt slot runs here." + "Validates a Transit scene request, claims the page and prepares the scene. + + Validation runs before the owner changes. A valid request releases the prior + owner, starts a new owner epoch, builds browser hooks with a deadline and + prepares the scene. `:preparation-id` defaults to the new epoch and + `:timeout-ms` defaults to `attempt-ms-budget`. + + Args: + request: Transit string matching `schema:load-scene-args`. + + Returns: + A Promise of `{:status \"ok\" :preparation evidence}`, failed evidence with + a phase, message, cause and optional partial data, or the stale result. A + preparation failure also carries its available blocks under top-level + `:phases`. Invalid arguments resolve as failed `invalid-args` evidence and + leave the current owner untouched." [request] (try (let [parsed (parse-load-request request)] (if (failure? parsed) (js/Promise.resolve (errors/fail-data (errors/evidence "invalid-args" parsed))) - (let [{:keys [params case-desc local-case scene]} parsed - {:keys [epoch dims] :as owner} (begin-load! case-desc) - timeout-ms (or (:timeout-ms params) attempt-ms-budget) - owner (assoc owner :hooks (browser-hooks epoch (+ (now) timeout-ms))) - id (or (:preparation-id params) epoch) - available* (volatile! {})] - (-> (load-module epoch params) - (.then (fn [module] (initialize-graphics! epoch dims module))) - (.then (fn [graphics] - (vreset! available* (select-keys graphics [:module-ms :graphics-ms])) - (let [graphics (prepare-scene epoch scene case-desc (:scene-seed case-desc) graphics)] - (vswap! available* assoc :fingerprint (:fingerprint graphics)) - graphics))) - (.then (fn [graphics] (upload-scene! epoch case-desc graphics))) - (.then (fn [graphics] - (vswap! available* assoc :upload-ms (:upload-ms graphics)) - (if (= :reuse (:context case-desc)) - (-> (protocol/restore (:hooks owner) (:view graphics)) - (.then (fn [trace] (assoc graphics :setup-render trace)))) - graphics))) - (.then (fn [graphics] - (guard-current! epoch "prepare") - (let [preparation (preparation-data id case-desc dims graphics)] - (reset! prepared* {:owner owner :case-desc case-desc :local-case local-case - :timeout-ms timeout-ms :busy? false :used? false - :graphics (assoc graphics :preparation preparation)}) - {:status "ok" :preparation preparation}))) - (.catch (fn [cause] - (if (errors/stale? cause) - (terminal-failure epoch cause "aborted") - (terminal-failure epoch - (errors/with-context cause (errors/message-of cause "setup failed") - {:partial {:setup @available*}}) - "aborted")))))))) + (let [{:keys [params case-desc]} parsed + {:keys [epoch] :as owner} (begin-load! case-desc) + timeout-ms (or (:timeout-ms params) attempt-ms-budget) + owner (assoc owner :hooks (browser-hooks epoch (+ (now) timeout-ms)))] + (prepare-owned-scene (assoc parsed + :owner owner + :timeout-ms timeout-ms + :preparation-id (or (:preparation-id params) epoch)) + (load-module epoch params))))) (catch :default cause (js/Promise.resolve (errors/fail-data (errors/evidence "setup" cause)))))) (defn- execute-prepared - "Runs one body against the owned preparation with a fresh attempt deadline. - Fresh preparations run once; warm preparations restore and drain before each - body without rebuilding, uploading or copying setup metrics into samples." + "Runs main and optional reset against the stored preparation. + + Args: + preparation-id: identifier of the prepared scene to use. + timeout-ms: optional deadline for this attempt; defaults to the + preparation's timeout. + + Returns: + A Promise of status ok with `:preparation` evidence, top-level `:phases` + for main and any reset, `:scene` shape count and seed, and + `:effective-graphics`; or failed evidence. A top-level stale marker + produces the stale result. + + A missing, busy or mismatched preparation, or a fresh preparation already + used, resolves as failed `invalid-args` evidence, leaving the owner + unchanged. A non-stale phase failure releases that preparation, so later + attempts find it unavailable." [{:keys [preparation-id timeout-ms]}] - (let [{:keys [owner case-desc local-case graphics busy? used?] :as prepared} @prepared* + (let [{:keys [owner case-desc local-case graphics context busy? used?] :as prepared} @prepared* id (get-in graphics [:preparation :preparation-id])] (if (or (nil? prepared) (not= id preparation-id) busy? (and used? (= :fresh (:context case-desc)))) @@ -564,7 +1033,7 @@ timeout-ms (or timeout-ms (:timeout-ms prepared)) owner (assoc owner :hooks (browser-hooks epoch (+ (now) timeout-ms)))] (swap! prepared* assoc :busy? true :used? true) - (-> (run-case owner case-desc local-case graphics) + (-> (run-case owner case-desc local-case graphics context) (.then (fn [evidence] (when (= epoch @owner-epoch*) (swap! prepared* assoc :busy? false)) @@ -572,7 +1041,18 @@ (.catch (fn [cause] (terminal-failure epoch cause "attempt")))))))) (defn- encode-evidence - "Encodes bridge evidence after all timing; reports codec errors as failures." + "Encodes bridge evidence after timing and resolves failures as bridge + failures. + + Encoding runs after all measured work. If `promise` rejects or encoding + fails, the owner is disposed when the captured epoch is still current. + + Args: + promise: Promise of bridge evidence to encode. + + Returns: + A Promise of Transit text for the supplied evidence, or for failed bridge + evidence when a failure occurs." [promise] (let [epoch @owner-epoch*] (-> promise @@ -583,14 +1063,34 @@ :message (errors/message-of cause "result encoding failed")}))))))) (defn prepare-scene-request - "Accepts a Transit load request and returns a Transit preparation. - Repeated warm attempts use runPrepared with this preparation's exact ID." + "Prepares a scene from a Transit request and returns Transit evidence. + + Args: + request: Transit string accepted by `schema:load-scene-args`. + + Returns: + A Promise of Transit text. Success contains status ok and `:preparation` + evidence. Preparation failures contain status failed, phase, message and + available cause, partial evidence and blocks under top-level `:phases`. + Encoding failures report the bridge phase with status, phase and message. + A failure with a top-level stale marker returns status stale." [request] (encode-evidence (prepare-request request))) (defn run-prepared - "Accepts Transit {:preparation-id id :timeout-ms optional-positive-ms}. - Invalid requests leave the owner untouched. Evidence resolves as Transit." + "Runs one attempt against a prepared scene. + + Args: + request: Transit `{:preparation-id id :timeout-ms optional-positive-ms}`. + + Returns: + A Promise of Transit evidence. Success contains status ok, the preparation + evidence, top-level main and optional reset phase blocks, scene shape count + and seed, and effective graphics. Failures contain status failed, phase and + message. Phase failures also include their cause, preparation evidence, + recorded blocks and available partial data. Invalid requests report the + invalid-args phase; encoding failures report the bridge phase. A failure + with a top-level stale marker returns the stale result." [request] (encode-evidence (try @@ -606,9 +1106,18 @@ :message (errors/message-of cause "invalid Transit request")})))))) (defn load-scene - "Prepares a collected case and runs one attempt, returning Transit evidence. - Fresh render timing starts after upload. Warm setup stays diagnostic; - prepareScene/runPrepared let a serial runner reuse the owned warm scene." + "Prepares a collected case and runs one attempt. + + Args: + request: Transit string accepted by `prepare-scene-request`. + + Returns: + A Promise of Transit evidence. On success, `:preparation` holds the + preparation evidence and top-level `:phases` holds main and any reset. A + failed preparation places its available blocks at the top level. An + attempt failure retains the preparation evidence and recorded blocks + captured for that attempt. A failure with a top-level stale marker returns + the stale result." [request] (encode-evidence (-> (prepare-request request) @@ -618,36 +1127,86 @@ evidence)))))) (defn ping - "Bridge placeholder that checks the Node/browser boundary." + "Reports that the bridge is callable. + + Returns: + A JavaScript object with status `\"ok\"`." [] #js {"status" "ok"}) (defn summarize-record - "Returns a Transit summary of a raw Transit record." + "Returns a Transit summary of a saved run record. + + Args: + record-text: Transit text for a supported run record. + analysis-text: optional Transit map of analysis settings. + + Returns: + Transit text for the summary. + + Throws: + When the run or settings cannot be decoded, validated, summarized or + encoded." ([record-text] (summarize-record record-text nil)) ([record-text analysis-text] (codec/encode-str (summary/summarize (result/decode record-text) (when analysis-text (t/decode-str analysis-text)))))) (defn compare-records - "Returns schema:comparison Transit for two saved runs and Transit options." + "Compares two saved runs and returns Transit comparison data. + + Args: + baseline-text: Transit text for the baseline run. + candidate-text: Transit text for the candidate run. + options-text: Transit map of comparison options. + + Returns: + Transit text for the comparison. + + Throws: + When a run or the options cannot be decoded, validated, compared or + encoded." [baseline-text candidate-text options-text] (result/encode (compare/compare-runs (result/decode baseline-text) (result/decode candidate-text) (t/decode-str options-text)))) (defn format-run - "Returns text for a summary computed from a raw Transit run." + "Returns human-readable text for a saved run record. + + Args: + record-text: Transit text for a supported run record. + + Returns: + Text for the run summary. + + Throws: + When the run cannot be decoded, validated, summarized or formatted." [record-text] (format/format-run (summary/summarize (result/decode record-text)))) (defn format-comparison - "Returns text for a schema:comparison Transit record." + "Returns human-readable text for a saved comparison record. + + Args: + record-text: Transit text for a supported comparison record. + + Returns: + Text for the comparison report. + + Throws: + When the comparison cannot be decoded, validated or formatted." [record-text] (format/format-comparison (result/decode record-text))) (def bridge - "Browser-side bridge exported from the compiled module." + "Browser-side functions exported from the compiled module. + + Keys use the camelCase names used by the runner: `ping`, + `buildIdentity`, `loadScene`, `prepareScene`, `runPrepared`, `summarize`, + `compare`, `formatRun`, `formatComparison` and `dispose`. Each entry returns + Transit text, report text, or, for `ping` and `dispose`, a JavaScript status + object." #js {"ping" ping "buildIdentity" #(t/encode-str (identity/data)) "loadScene" load-scene diff --git a/frontend/performance/benches/render_wasm/declarations.cljc b/frontend/performance/benches/render_wasm/declarations.cljc index 43189cd50b..c0134531e6 100644 --- a/frontend/performance/benches/render_wasm/declarations.cljc +++ b/frontend/performance/benches/render_wasm/declarations.cljc @@ -9,7 +9,8 @@ A scene declares an id, description, parameters schema and build function. A case declares an id namespaced by its scene, parameters, view, context - policy, completion mode and optional operation identity and run function. + policy and a run function. Optional preparation and reset functions transform + the context before and after its main execution. Ordinary :require loads the declaration macros and runtime helpers. Collection validates declarations and produces plain descriptors." @@ -83,11 +84,15 @@ The scene comes from the namespace part of the :scene/case id. An optional argument vector and body supply the run function, which accepts - one execution context. Collection produces plain case descriptors. + one command context and returns its next context or a Promise of it. + `[:preparation :callback]` runs once after upload. Preparation renders + Full by default; `:render :none` selects a cold first render. `:reset` + runs after each successful main execution. Collection produces plain + case descriptors. Example: - (defcase :rects/pan options [context] (pan! context))" + (defcase :rects/pan options [context] (pan* context pan-options))" ([id opts] (case-registration-form id (scene-from-id id) opts nil)) ([id opts argv & body] @@ -97,12 +102,11 @@ #?(:cljs (do ;; Case execution context: - ;; :case - collected descriptor, including operation and completion + ;; :case - collected descriptor ;; :scene - scene snapshot {:objects ... :refs ...} - ;; :module - WASM module handle - ;; :view - prepared camera view + ;; :view - current camera view ;; :hooks - clock, scheduling, guard and renderer operations - ;; Case bodies return a measurement or a Promise of one. + ;; Case bodies return the next context or a Promise of it. ;; Declarations and their transitive imports load in Node and the browser. ;; Browser capabilities enter through the execution context; resource @@ -152,7 +156,11 @@ [:completion {:optional true} [:enum :call :await :render-full]] [:batch-size {:optional true} [:int {:min 1}]] [:operation {:optional true} [:map]] - [:preparation {:optional true} [:map]] + [:preparation {:optional true} + [:map + [:render {:optional true} [:enum :full :none]] + [:callback {:optional true} fn?]]] + [:reset {:optional true} fn?] [:run! {:optional true} fn?]]) (def schema:collected-case @@ -166,7 +174,7 @@ [:context [:enum :fresh :reuse]] [:completion [:enum :call :await :render-full]] [:batch-size [:int {:min 1}]] - [:preparation [:map]] + [:preparation [:map [:render {:optional true} [:enum :full :none]]]] [:operation {:optional true} [:map]]]) (defonce registry @@ -348,7 +356,7 @@ (= encoded (-> encoded t/decode-str t/encode-str)))))) (defn project-case - "Returns the case's descriptor fields with defaults for completion, + "Returns the case's data descriptor with defaults for completion, batch size and preparation." [bench-case] (-> bench-case @@ -356,7 +364,7 @@ :operation :preparation]) (update :completion #(or % :render-full)) (update :batch-size #(or % 1)) - (update :preparation #(or % {})))) + (update :preparation #(dissoc (or % {}) :callback)))) (defn check-collected-case "Validates a collected case descriptor and its Transit representation, diff --git a/frontend/performance/benches/render_wasm/runtime/camera.cljs b/frontend/performance/benches/render_wasm/runtime/camera.cljs index 99aeb63aa1..24974b22ba 100644 --- a/frontend/performance/benches/render_wasm/runtime/camera.cljs +++ b/frontend/performance/benches/render_wasm/runtime/camera.cljs @@ -23,9 +23,7 @@ - `:view`: the current view. - `:hooks`: the clock, scheduling, renderer and execution guard functions. - The runtime context for a case operation also includes `:module`, the - instantiated WebAssembly renderer. `pan!` and `zoom!` read movement settings - from `[:case :operation]`. + `pan!` and `zoom!` read movement settings from `[:case :operation]`. ## Rendering and camera sessions diff --git a/frontend/test/performance_tests/render_wasm/contracts_test.cljs b/frontend/test/performance_tests/render_wasm/contracts_test.cljs index 5d4d11272f..133ce83dcc 100644 --- a/frontend/test/performance_tests/render_wasm/contracts_test.cljs +++ b/frontend/test/performance_tests/render_wasm/contracts_test.cljs @@ -107,15 +107,20 @@ (t/deftest declared-preparation-survives-collection-and-wire (with-synthetic-scene :contracts-prepared {:count 3} (fn [] - (let [preparation {:settle-ms 25} + (let [preparation {:render :none :settle-ms 25} registered (decl/registered-case :contracts-prepared/case)] - (decl/register-case! (assoc registered :preparation preparation)) + (decl/register-case! (assoc registered + :preparation (assoc preparation :callback identity) + :reset identity)) (let [collected (cases/collect-cases {:master-seed 42 :filter "contracts-prepared/"}) decoded (transit/decode-str (transit/encode-str collected))] (t/is (= [:contracts-prepared/case] (mapv :id decoded))) (t/is (= collected decoded)) (t/is (= preparation (:preparation (first decoded)))) - (t/is (= (first decoded) (decl/check-collected-case (first decoded))))))))) + (t/is (= (first decoded) (decl/check-collected-case (first decoded))))) + (decl/register-case! (assoc registered :preparation {:asset {:loader identity}})) + (t/is (= ::cases/invalid-case + (:type (failure-data #(cases/collect-cases {:master-seed 42 :filter "contracts-prepared/"}))))))))) (t/deftest rects-cases-carry-declared-view-context-and-completion (let [collected (rect-cases)] diff --git a/frontend/test/performance_tests/render_wasm/lifecycle_test.cljs b/frontend/test/performance_tests/render_wasm/lifecycle_test.cljs index ae86c9acec..3a30248e7a 100644 --- a/frontend/test/performance_tests/render_wasm/lifecycle_test.cljs +++ b/frontend/test/performance_tests/render_wasm/lifecycle_test.cljs @@ -17,11 +17,7 @@ [benches.render-wasm.cases :as cases] [benches.render-wasm.declarations :as decl] [benches.render-wasm.errors :as errors] - [benches.render-wasm.measurement :as measurement] - [benches.render-wasm.report.compare :as compare] - [benches.render-wasm.report.format :as format] - [benches.render-wasm.report.summarize :as summary] - [benches.render-wasm.result :as result] + [benches.render-wasm.runtime.camera :as camera] [cljs.test :as t :include-macros true])) ;; Forward declaration: entry-test helpers below build args against the @@ -32,10 +28,25 @@ ;; `frontend-tests.render-wasm.webgl-test`, which also pins the success, ;; nil-context and registration-failure cases. +(defn- register-phase-case + [options run] + (let [params (:params (decl/registered-case :rects/load))] + (decl/defcase :rects/lifecycle + (-> (merge {:params params + :view {:scale 1 :x 0 :y 0} + :context :fresh} + options) + (update :params #(merge params %))) + [rtx] + (run rtx)))) + (defn- load-case - "Collected `:rects/load` descriptor for entry tests." + "Returns the collected rectangle case for entry tests." [] - (first (cases/collect-cases {:master-seed 42 :filter "rects/load"}))) + (first (cases/collect-cases {:master-seed 42 + :filter (if (decl/registered-case :rects/lifecycle) + "rects/lifecycle" + "rects/load")}))) (defn- scene-args "Transit request for `browser/load-scene`. `overrides` change the @@ -424,9 +435,11 @@ (try (browser/dispose!) (finally + (decl/unregister-case! :rects/lifecycle) (restore-dom!))))] (reset-bench-globals!) (try + (register-phase-case {} camera/render-sync*) (f cleanup) (catch :default cause (cleanup) @@ -458,7 +471,9 @@ (load-result (scene-args 7 {}) (fn [m] (t/is (= "ok" (:status m))) - (t/is (= 1 (count (get-in m [:trace :slices])))) + (t/is (= 1 (count (get-in m [:preparation :diagnostics :setup-render :slices])))) + (t/is (= [::camera/render-sync] + (mapv :command (get-in m [:phases 0 :events])))) (t/is (= 1001 (:shapes (:scene m))) "the canonical scene uploads whole") (let [graphics (:effective-graphics m)] (t/is (= 1234 (:drawing-buffer-width graphics)) @@ -475,35 +490,7 @@ (cleanup) (done))))))) -(t/deftest successful-load-produces-portable-reports - (t/async done - (with-entry-env - (fn [cleanup] - (load-result - (scene-args 7 {}) - (fn [evidence] - (let [case (load-case) - preparation (:preparation evidence) - run (-> (result/create-run {:run-id "load-report" :started-at "start" - :plan {:cases [case] :warmups 0 :repetitions 1} - :metadata {:git {:sha nil :dirty nil} - :build {:functional {:mode :test}} - :environment {} :scored? false}}) - (result/record-preparation preparation) - (result/record-attempt (measurement/attempt (:id case) (:preparation-id preparation) false evidence)) - (result/finish-run "end" {:reason :completed})) - text (result/encode run) - report (summary/summarize (result/decode text)) - comparison (compare/compare-runs run run) - comparison-text (.compare browser/bridge text text (transit/encode-str {}))] - (t/is (= report (transit/decode-str (.summarize browser/bridge text)))) - (t/is (= (format/format-run report) (.formatRun browser/bridge text))) - (t/is (= comparison (result/decode comparison-text))) - (t/is (= (format/format-comparison comparison) (.formatComparison browser/bridge comparison-text)))) - (cleanup) - (done))))))) - -(t/deftest exported-load-scene-retains-progressive-frame-evidence +(t/deftest preparation-retains-progressive-frame-evidence (t/async done (with-entry-env (fn [cleanup] @@ -515,9 +502,10 @@ (-> (.loadScene browser/bridge (scene-args 7 {})) (.then (fn [text] (t/is (string? text) "the exported loadScene returns Transit") - (let [{:keys [status slices viewport-ready-ms full-ms metrics trace scene]} - (transit/decode-str text) - retained (:slices trace) + (let [{:keys [status preparation scene]} (transit/decode-str text) + {:keys [slices viewport-ready-ms full-ms]} + (get-in preparation [:diagnostics :setup-render]) + retained slices render-calls (filterv #(= "render" (first %)) (read-calls))] (t/is (= "ok" status)) (t/is (= {:shapes 1001 :seed 7} scene) @@ -538,7 +526,6 @@ ["render" timestamp flags]) retained)) "decoded slices retain every WASM call's timestamp and flags in order") - (t/is (= slices retained) "raw and trace slices survive Transit together") (t/is (= 37 viewport-ready-ms (+ (:timestamp (nth retained 1)) (:duration-ms (nth retained 1)))) @@ -548,12 +535,10 @@ (:duration-ms (nth retained 2)))) "Full completion ends with the final slice") (t/is (< viewport-ready-ms full-ms)) - (t/is (= full-ms (get-in metrics [:first-render-ms :value]))) - (t/is (= viewport-ready-ms (get-in metrics [:viewport-ready-ms :value]))) - (t/is (= 10 - (reduce + 0 (map :duration-ms retained)) - (get-in metrics [:renderer-call-total-ms :value])) - "renderer-call total sums the retained calls without frame waits")) + (t/is (= [{:phase :preparation + :preparation-id (:preparation-id preparation) + :outcome :completed :events []}] + (:phases preparation)))) (cleanup) (done))) (.catch (fn [cause] @@ -579,108 +564,264 @@ (cleanup) (done)))))))))) -(t/deftest warm-pan-restores-and-reaches-full +(t/deftest compiled-pan-finishes-at-its-final-render (t/async done (with-entry-env (fn [cleanup] - (load-result (scene-args 7 {:id :rects/pan :context :reuse}) + (register-phase-case {:context :reuse} + #(camera/pan* % {:dx 10 :dy 5 :steps 2 :settle-ms 0})) + (load-result (scene-args 7 {}) (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 (= 3 (effect-count (read-calls) "render")) - "setup, restore and finalization each drain once") + (let [phase (first (:phases m)) + event (first (:events phase)) + trace (:trace event)] + (t/is (= "ok" (:status m))) + (t/is (= :main (:phase phase))) + (t/is (= ::camera/pan (:command event))) + (t/is (= 2 (count (:cached-slices trace)))) + (t/is (= 0 (:settling-requested-ms trace))) + (t/is (= [4] (mapv :flags (:slices trace)))) + (t/is (= [2] (mapv :frame-type (:slices trace)))) + (t/is (= 2 (effect-count (read-calls) "render")) + "preparation and camera finalization each render 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 ^:async reused-context-prepares-once-and-resets-each-main + (await + (with-entry-env + (^:async fn [cleanup] + (try + (let [views (atom []) + options {:dy 0 :steps 1 :settle-ms 0} + pan (fn [phase dx rtx] + (swap! views conj [phase (get-in rtx [:view :x])]) + (camera/pan* rtx (assoc options :dx dx)))] + (register-phase-case {:context :reuse + :preparation {:callback #(pan :preparation 5 %)} + :reset #(pan :reset -10 %)} + #(pan :main 10 %)) + (let [prepared (transit/decode-str (await (browser/prepare-scene-request (scene-args 7 {})))) + prep (:preparation prepared) + request (transit/encode-str {:preparation-id (:preparation-id prep)}) + first (transit/decode-str (await (browser/run-prepared request))) + second (transit/decode-str (await (browser/run-prepared request)))] + (t/is (= ["ok" "ok" "ok"] (mapv :status [prepared first second]))) + (t/is (= [[:preparation 0] [:main 5] [:reset 15] [:main 5] [:reset 15]] @views)) + (t/is (= [:preparation] (mapv :phase (:phases prep)))) + (t/is (= [::camera/pan] (mapv :command (get-in prep [:phases 0 :events])))) + (t/is (= prep (:preparation first) (:preparation second))) + (doseq [execution [first second]] + (t/is (= [:main :reset] (mapv :phase (:phases execution)))) + (t/is (= [:completed :completed] (mapv :outcome (:phases execution)))) + (t/is (= [[::camera/pan] [::camera/pan]] + (mapv #(mapv :command (:events %)) (:phases execution))))) + (t/is (= 1 (effect-count (read-calls) "create"))) + (t/is (= 6 (effect-count (read-calls) "render")) + "the callback, initial Full, and four main/reset gestures render") + (t/is (= 1 (count (filter #(= ["call" "_init_shapes_pool"] %) (read-calls))))))) + (finally + (cleanup))))))) -(t/deftest prepared-warm-scene-runs-twice-with-one-context-and-upload - (t/async done - (with-entry-env - (fn [cleanup] - (-> (browser/prepare-scene-request - (scene-args 7 {:id :rects/pan :context :reuse - :operation {:steps 2 :dx 10 :dy 5 :settle-ms 0}})) - (.then (fn [text] - (let [prepared (transit/decode-str text) - prep (:preparation prepared) - request (transit/encode-str {:preparation-id (:preparation-id prep)})] - (t/is (= "ok" (:status prepared))) - (t/is (seq (get-in prep [:diagnostics :setup-render :slices]))) - (-> (browser/run-prepared request) - (.then (fn [first-text] - (let [first-result (transit/decode-str first-text)] - (t/is (= "ok" (:status first-result))) - (t/is (not (contains? (:metrics first-result) :upload-ms))) - (t/is (= prep (:preparation first-result))) - (browser/run-prepared request)))) - (.then (fn [second-text] - (let [second-result (transit/decode-str second-text) - calls (read-calls)] - (t/is (= "ok" (:status second-result))) - (t/is (= prep (:preparation second-result))) - (t/is (= 1 (effect-count calls "create"))) - (t/is (= 5 (effect-count calls "render"))) - (t/is (= 1 (count (filter #(= ["call" "_init_shapes_pool"] %) calls)))) - (t/is (= 2 (count (:cached-slices second-result)))) - (cleanup) - (done)))))))) - (.catch (fn [cause] - (t/is false (str "prepared reuse failed: " cause)) - (cleanup) - (done)))))))) +(t/deftest ^:async fresh-contexts-prepare-and-reset-once + (await + (with-entry-env + (^:async fn [cleanup] + (try + (let [visits (atom []) + work (fn [phase rtx] + (swap! visits conj phase) + (camera/render-sync* rtx))] + (register-phase-case {:preparation {:callback #(work :preparation %)} + :reset #(work :reset %)} + #(work :main %)) + (dotimes [_ 2] + (let [prepared (transit/decode-str (await (browser/prepare-scene-request (scene-args 7 {})))) + prep (:preparation prepared) + request (transit/encode-str {:preparation-id (:preparation-id prep)}) + evidence (transit/decode-str (await (browser/run-prepared request))) + repeated (transit/decode-str (await (browser/run-prepared request)))] + (t/is (= "ok" (:status evidence))) + (t/is (= [:main :reset] (mapv :phase (:phases evidence)))) + (t/is (= [::camera/render-sync] (mapv :command (get-in prep [:phases 0 :events])))) + (t/is (= "invalid-args" (:phase repeated))))) + (t/is (= [:preparation :main :reset :preparation :main :reset] @visits)) + (t/is (= 2 (effect-count (read-calls) "create"))) + (t/is (= 2 (effect-count (read-calls) "render")))) + (finally + (cleanup))))))) -(t/deftest fresh-preparation-runs-once-and-saves-structured-metrics - (t/async done - (with-entry-env - (fn [cleanup] - (-> (browser/prepare-scene-request (scene-args 7 {})) - (.then (fn [text] - (let [request (transit/encode-str - {:preparation-id (get-in (transit/decode-str text) - [:preparation :preparation-id])})] - (t/is (zero? (effect-count (read-calls) "render"))) - (-> (browser/run-prepared request) - (.then (fn [text] - (let [evidence (transit/decode-str text)] - (t/is (= "ok" (:status evidence))) - (t/is (= (:full-ms evidence) (get-in evidence [:metrics :first-render-ms :value]))) - (t/is (contains? (:metrics evidence) :upload-ms)) - (t/is (not (contains? (:metrics evidence) :full-ms))) - (t/is (not (contains? evidence :first-render-ms))) - (t/is (not (contains? evidence :render-frames))) - (browser/run-prepared request)))) - (.then (fn [text] - (t/is (= "invalid-args" (:phase (transit/decode-str text)))) - (cleanup) - (done))))))) - (.catch (fn [cause] - (t/is false (str "fresh preparation failed: " cause)) - (cleanup) - (done)))))))) +(t/deftest ^:async preparation-policy-keeps-selected-events-and-empty-main + (doseq [context [:fresh :reuse] + render [nil :none]] + (await + (with-entry-env + (^:async fn [cleanup] + (try + (unchecked-set js/globalThis "__benchNow" 0) + (register-phase-case + {:params {:count 2} + :context context + :preparation (cond-> {:callback #(-> % (camera/render-sync*) (camera/render-sync*))} + render (assoc :render render))} + identity) + (let [evidence (transit/decode-str (await (browser/load-scene (scene-args 7 {})))) + prep (:preparation evidence) + block (first (:phases prep))] + (t/is (= "ok" (:status evidence))) + (t/is (= :completed (:outcome block))) + (t/is (= [{:command ::camera/render-sync :metrics {:command/elapsed [0]}} + {:command ::camera/render-sync :metrics {:command/elapsed [0]}}] + (:events block))) + (t/is (= [{:phase :main :preparation-id (:preparation-id prep) + :outcome :completed :events []}] + (:phases evidence))) + (t/is (= (if (= render :none) 0 1) (effect-count (read-calls) "render")))) + (finally + (cleanup)))))))) + +(t/deftest ^:async reused-context-carries-view-without-a-reset + (await + (with-entry-env + (^:async fn [cleanup] + (try + (let [views (atom [])] + (register-phase-case {:params {:count 2} :context :reuse} + (fn [rtx] + (swap! views conj (:view rtx)) + (camera/pan rtx {:dx 10 :dy 5 :steps 1 :settle-ms 0}))) + (let [prepared (transit/decode-str (await (browser/prepare-scene-request (scene-args 7 {})))) + request (transit/encode-str {:preparation-id (get-in prepared [:preparation :preparation-id])}) + first (transit/decode-str (await (browser/run-prepared request))) + second (transit/decode-str (await (browser/run-prepared request)))] + (t/is (= ["ok" "ok"] (mapv :status [first second]))) + (t/is (= [{:scale 1 :x 0 :y 0} {:scale 1 :x 10 :y 5}] @views)) + (t/is (= [[] []] (mapv #(get-in % [:phases 0 :events]) [first second]))))) + (finally + (cleanup))))))) + +(t/deftest ^:async phase-failure-keeps-raw-evidence-and-stops-the-context + (doseq [context [:fresh :reuse] + failed-phase [:preparation :main :reset]] + (await + (with-entry-env + (^:async fn [cleanup] + (try + (unchecked-set js/globalThis "__benchNow" 0) + (let [visits (atom []) + work (fn [phase rtx] + (swap! visits conj phase) + (-> (camera/render-sync* rtx) + (.then (fn [next-rtx] + (when (= phase failed-phase) + (throw (js/Error. "phase fixture failed"))) + next-rtx))))] + (register-phase-case {:params {:count 2} :context context + :preparation {:callback #(work :preparation %)} + :reset #(work :reset %)} + #(work :main %)) + (let [request (-> (transit/decode-str (scene-args 7 {})) + (assoc :preparation-id "phase-failure") + (transit/encode-str)) + prepared (transit/decode-str (await (browser/prepare-scene-request request))) + run (transit/encode-str {:preparation-id "phase-failure"}) + evidence (if (= failed-phase :preparation) + prepared + (transit/decode-str (await (browser/run-prepared run)))) + stopped (transit/decode-str (await (browser/run-prepared run))) + blocks (:phases evidence)] + (t/is (= "failed" (:status evidence))) + (t/is (= (name failed-phase) (:phase evidence))) + (t/is (= "phase fixture failed" (:message evidence))) + (t/is (= (case failed-phase + :preparation [:preparation] + :main [:preparation :main] + :reset [:preparation :main :reset]) + @visits)) + (t/is (= (if (= failed-phase :reset) [:main :reset] [failed-phase]) + (mapv :phase blocks))) + (t/is (= :failed (:outcome (last blocks)))) + (t/is (= (repeat (count blocks) + [{:command ::camera/render-sync :metrics {:command/elapsed [0]}}]) + (map :events blocks))) + (when (= failed-phase :preparation) + (let [setup (get-in evidence [:partial :setup])] + (t/is (number? (:module-ms setup))) + (t/is (number? (:graphics-ms setup))) + (t/is (number? (:upload-ms setup))) + (t/is (string? (get-in setup [:fingerprint :digest]))))) + (when-not (= failed-phase :preparation) + (t/is (= (:preparation prepared) (:preparation evidence))) + (t/is (= :completed (get-in evidence [:preparation :phases 0 :outcome])))) + (t/is (= "invalid-args" (:phase stopped))) + (t/is (= 1 (effect-count (read-calls) "remove")))) + (register-phase-case {:params {:count 2}} camera/render-sync*) + (let [next-case (transit/decode-str (await (browser/load-scene (scene-args 8 {}))))] + (t/is (= "ok" (:status next-case))) + (t/is (= [::camera/render-sync] + (mapv :command (get-in next-case [:phases 0 :events])))))) + (finally + (cleanup)))))))) + +(t/deftest ^:async superseded-phase-failure-keeps-its-own-preparation + (doseq [failed-phase [:main :reset]] + (await + (with-entry-env + (^:async fn [cleanup] + (try + (let [entered (volatile! nil) + reject (volatile! nil) + started (js/Promise. (fn [resolve _reject] (vreset! entered resolve))) + work (fn [phase rtx] + (-> (camera/render-sync* rtx) + (.then (fn [next-rtx] + (if (= phase failed-phase) + (js/Promise. (fn [_resolve fail] + (vreset! reject fail) + (@entered nil))) + next-rtx)))))] + (register-phase-case {:params {:count 2} :context :reuse + :reset #(work :reset %)} + #(work :main %)) + (let [request (-> (transit/decode-str (scene-args 7 {})) + (assoc :preparation-id "first") + (transit/encode-str)) + prepared (transit/decode-str (await (browser/prepare-scene-request request))) + first-run (browser/run-prepared (transit/encode-str {:preparation-id "first"}))] + (await started) + (register-phase-case {:params {:count 2}} camera/render-sync*) + (let [next-request (-> (transit/decode-str (scene-args 8 {})) + (assoc :preparation-id "second") + (transit/encode-str)) + next-prepared (transit/decode-str (await (browser/prepare-scene-request next-request)))] + (@reject (js/Error. "late phase failure")) + (let [failed (transit/decode-str (await first-run)) + next-run (transit/decode-str + (await (browser/run-prepared (transit/encode-str {:preparation-id "second"}))))] + (t/is (= "failed" (:status failed))) + (t/is (= (name failed-phase) (:phase failed))) + (t/is (= (:preparation prepared) (:preparation failed))) + (t/is (= (if (= failed-phase :main) [:main] [:main :reset]) + (mapv :phase (:phases failed)))) + (t/is (= :failed (:outcome (last (:phases failed))))) + (t/is (= (repeat (count (:phases failed)) "first") + (map :preparation-id (:phases failed)))) + (t/is (= ["ok" "ok"] (mapv :status [next-prepared next-run]))) + (t/is (= "second" (get-in next-run [:preparation :preparation-id]))))))) + (finally + (cleanup)))))))) (t/deftest rejected-camera-body-releases-owner (t/async done (with-entry-env (fn [cleanup] (unchecked-set js/globalThis "__benchThrowIn" "_render_from_cache") - (load-result (scene-args 7 {:id :rects/pan :context :reuse}) + (register-phase-case {:context :reuse} + #(camera/pan* % {:dx 10 :dy 5 :steps 2 :settle-ms 0})) + (load-result (scene-args 7 {}) (fn [m] (t/is (= "failed" (:status m))) - (t/is (= "interact" (:phase m))) + (t/is (= "main" (:phase m))) (t/is (= 1 (effect-count (read-calls) "remove"))) (t/is (nil? @@#'browser/canvas*)) (cleanup) @@ -698,6 +839,7 @@ (fn [m] (t/is (= "failed" (:status m))) (t/is (= "module-init" (:phase m))) + (t/is (= {} (get-in m [:partial :setup]))) (cleanup) (done))))))) @@ -727,6 +869,7 @@ (fn [m] (t/is (= "failed" (:status m))) (t/is (= "graphics-init" (:phase m))) + (t/is (= {} (get-in m [:partial :setup]))) (t/is (= "graphics init failed" (:message m)) "top message stays phase-labeled") (assert-wasm-cause (:cause m) @@ -756,6 +899,10 @@ (assert-wasm-cause (:cause m) "_set_view" #"fake _set_view failure") + (let [setup (get-in m [:partial :setup])] + (t/is (number? (:module-ms setup))) + (t/is (number? (:graphics-ms setup))) + (t/is (string? (get-in setup [:fingerprint :digest])))) (cleanup) (done)))))))