Miguel de Benito Delgado d12df5058b 🚧 WIP Step 13, Track failure causes
AI-assisted-by: muse-spark, GLM-5.3
2026-11-05 11:18:13 +01:00

611 lines
25 KiB
Clojure

;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns benches.render-wasm.browser
"Browser bridge for the benchmarks.
Lifecycle here mirrors the editor cold-load path
(`app.render-wasm.api/initialize-viewport` + `set-objects` sync).
This ns exports `bridge`, which is what the Node 'pilot' uses inside the page.
We only ever pass data through `page.evaluate`.
All `bridge` entries resolve, that is they never return an unhandled rejected
promise.
Failures are returned as `{\"status\" \"failed\" ...}` maps."
(:require
[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- teardown-canvas!
"Releases the context after successful initialization, then removes its
canvas. The initializer owns cleanup until it returns a context."
[canvas]
(when (some? canvas)
(when-let [context wasm/gl-context]
(webgl/release-context! wasm/internal-module context wasm/gl-context-handle))
(try
(.remove ^js canvas)
(catch :default _))))
(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."
[]
(teardown-canvas! @canvas*)
(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! canvas* 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."
[]
(swap! owner-epoch* inc)
(release-owner-resources!)
(reset! module-promise* nil)
#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."
[epoch]
(when (= epoch @owner-epoch*)
(dispose!)
true))
(defn- poll-full
"TODO(mem:render-wasm/performance/cljs-rewrite/06-render-and-interaction-protocol):
interim inline drain. Ticket 06 replaces this with `protocol.cljs`; do not
extend it. Resolves `{:ms [...] :frames n}` at Full, `{:full false ...}` on
budget exhaustion, and checks the owner epoch every frame. A synchronous
throw inside a frame rejects: rAF callbacks run outside the executor, so
without this the bridge promise would hang."
[epoch started-frames started-ms]
(js/Promise.
(fn [resolve reject]
(letfn [(step [frames]
(if (not= epoch @owner-epoch*)
(resolve #js {"stale" true})
(let [frame-type (try
(h/call wasm/internal-module "_render" (now) 0)
(catch :default cause
(reject cause)
::thrown))]
(when-not (= frame-type ::thrown)
(cond
(= frame-type frame-full)
(resolve #js {"full" true
"frames" (- frames started-frames)
"ms" (- (now) started-ms)})
(or (>= frames (+ started-frames poll-frame-budget))
(>= (- (now) started-ms) poll-ms-budget))
(resolve #js {"full" false
"frames" (- frames started-frames)
"ms" (- (now) started-ms)
"frameType" frame-type})
:else
(.requestAnimationFrame
js/window
(fn [] (step (inc frames)))))))))]
(.requestAnimationFrame js/window (fn [] (step (inc started-frames))))))))
(defn- 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`;
what the browser granted comes from `getContextAttributes`."
[canvas ctx width height dpr renderer]
(let [attrs (try
(.getContextAttributes ^js ctx)
(catch :default _ nil))
attr (fn [k]
(when (some? attrs)
(unchecked-get attrs k)))]
{"cssWidth" width
"cssHeight" height
"drawingBufferWidth" (.-width ^js canvas)
"drawingBufferHeight" (.-height ^js canvas)
"dpr" dpr
"webgl2" true
"antialias" (attr "antialias")
"depth" (attr "depth")
"stencil" (attr "stencil")
"alpha" (attr "alpha")
"preserveDrawingBuffer" (attr "preserveDrawingBuffer")
"renderer" renderer
"software" (software-renderer? renderer)}))
(defn- render-ok
"Maps one poll result to the bridge value. Success keeps the owner live
for explicit disposal. Every failure path releases the owner when it still
owns the realm, so budget exhaustion and context loss never leave a live
canvas behind. Stale results never dispose: a newer owner is live."
[epoch module-ms graphics-ms upload-ms alive? poll snapshot seed canvas ctx width height dpr renderer]
(let [pm (js->clj poll :keywordize-keys true)]
(cond
(:stale pm)
;; dispose! resets loss state, so stale-plus-lost means genuine
;; context loss, not disposal. The loss listener already released the
;; canvas; the dispose call below only matters when loss was recorded
;; without an epoch bump.
(if @wasm/context-lost?
(do
(dispose-if-current! epoch)
(fail-data {:phase "context-lost"
:message "WebGL context lost during render"}))
#js {"status" "stale"})
;; Safety net: every loss writer bumps the epoch, so staleness
;; normally fires first.
(not alive?)
(do
(dispose-if-current! epoch)
(fail-data {:phase "first-render"
:message "disposed or context lost during render"}))
(:full pm)
(clj->js
{"status" "ok"
"moduleInitMs" module-ms
"graphicsInitMs" graphics-ms
"uploadMs" upload-ms
"firstRenderMs" (:ms pm)
"renderFrames" (:frames pm)
"scene" {"shapes" (count (:objects snapshot))
"seed" seed}
"effectiveGraphics" (read-graphics canvas ctx width height dpr renderer)})
:else
(do
(dispose-if-current! epoch)
(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. On loss the
current owner releases its canvas and GL resources itself: the poll loop
only observes staleness afterwards, so a `render-ok` cleanup there would
always miss."
[canvas epoch]
(.addEventListener
canvas "webglcontextlost"
(let [installed epoch]
(fn [e]
(.preventDefault ^js e)
(when (= installed @owner-epoch*)
(release-owner-resources!)
(reset! module-promise* nil)
(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- stale?
"True when `cause` is the superseded-owner marker from `guard-current!`."
[cause]
(true? (::stale (ex-data cause))))
(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."
[epoch phase]
(when-not (= epoch @owner-epoch*)
(throw (ex-info "superseded" {::stale true :phase phase}))))
(def ^:private max-cause-depth
"How many cause levels the failure cause keeps: the phase wrapper, the
WASM-error mapping and the original failure."
3)
(def ^:private max-cause-chars
"Hard cap for any string in the failure cause, so a hostile value never
bloats the bridge payload."
500)
(def ^:private unrenderable-value
"Fallback for a cause value `pr-str` cannot render."
"unrenderable value")
(def ^:private unrenderable-cause-text
"Fallback message when a cause level cannot be rendered."
"unrenderable cause")
(defn- truncate-cause-str
"Truncates `s` to `max-cause-chars` without splitting a surrogate pair.
Never throws."
[s]
(try
(let [text (str s)]
(if (<= (count text) max-cause-chars)
text
(let [cut (subs text 0 max-cause-chars)
last (.charCodeAt cut (dec max-cause-chars))]
(if (and (>= last 0xD800)
(<= last 0xDBFF)
(let [nxt (.charCodeAt text max-cause-chars)]
(and (>= nxt 0xDC00) (<= nxt 0xDFFF))))
(subs cut 0 (dec max-cause-chars))
cut))))
(catch :default _
unrenderable-value)))
(defn- finite-cause-number?
"True for numbers the bridge can carry. Mirrors `serializable?`: NaN and
infinities travel as truncated strings, never as numbers."
[value]
(and (number? value)
(js/isFinite value)))
(defn- sanitize-cause-value
"Keeps plain-data leaves as-is, stringifies anything else with `pr-str`
truncation. Never throws."
[value]
(try
(cond
(or (nil? value) (boolean? value) (keyword? value))
value
(string? value)
(truncate-cause-str value)
(finite-cause-number? value)
value
:else
(truncate-cause-str (pr-str value)))
(catch :default _
unrenderable-value)))
(defn- describe-cause-level
"Plain-data projection of one `ex-cause` link. Keeps `:message` plus the
allowlisted WASM details, sanitized. Never throws."
[cause]
(try
(let [message (try (ex-message cause) (catch :default _ nil))
data (try (ex-data cause) (catch :default _ nil))
text (cond
(string? message) message
(string? cause) cause
(nil? cause) "unknown failure"
:else (pr-str cause))
out {:message (sanitize-cause-value text)}]
(if (map? data)
(reduce (fn [m k]
(if (contains? data k)
(assoc m k (sanitize-cause-value (get data k)))
m))
out
[:fn :code :type :hint])
out))
(catch :default _
{:message unrenderable-cause-text})))
(defn- describe-cause
"Walks at most `max-cause-depth` cause levels, projecting each with
`describe-cause-level`. Always returns plain data, never throws."
[cause]
(try
(loop [current cause
depth 0
acc []]
(if (or (nil? current) (>= depth max-cause-depth))
(if (seq acc)
acc
[{:message "unknown failure"}])
(let [level (describe-cause-level current)
next (try (ex-cause current) (catch :default _ nil))]
(recur next (inc depth) (conj acc level)))))
(catch :default _
[{:message unrenderable-cause-text}])))
(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."
[epoch cause fallback-phase]
(if (stale? cause)
#js {"status" "stale"}
(let [phase (try (or (:phase (ex-data cause))
fallback-phase
"aborted")
(catch :default _
(or fallback-phase "aborted")))
message (try (or (ex-message cause) "unknown failure")
(catch :default _
"unknown failure"))
detail (try (describe-cause cause)
(catch :default _
[{:message unrenderable-cause-text}]))]
(dispose-if-current! epoch)
(fail-data {:phase phase
:message message
:cause detail}))))
(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."
[width height dpr]
(let [canvas (.createElement js/document "canvas")]
(aset canvas "width" (* width dpr))
(aset canvas "height" (* height dpr))
(aset (.-style ^js canvas) "width" (str width "px"))
(aset (.-style ^js canvas) "height" (str height "px"))
canvas))
;; TODO: remove in Ticket 06
(defn load-rects
"Loads the `:rects/load` scene and renders it to Full. Takes plain data:
`{\"seed\" int, \"view\" {scale x y viewport?}?, \"module-url\"?, \"wasm-url\"?}`.
Viewport defaults to 1920x1080@2. Always resolves (failure maps included).
The chain is flat: one `.then` per phase (module, graphics, scene, upload,
drain) plus a terminal `.catch`, so every rejection carries its phase to a
single ownership-aware handler."
[args]
(try
(let [params (js->clj (or args #js {}) :keywordize-keys true)
view (merge default-view (:view params))
seed (:seed params)]
(if-not (sm/validate schema:load-args
(cond-> {:seed seed}
(:view params) (assoc :view (:view params))
(:module-url params) (assoc :module-url (:module-url params))
(:wasm-url params) (assoc :wasm-url (:wasm-url params))))
(js/Promise.resolve
(fail-data {:phase "invalid-args"
:message "load-rects needs {seed int, view?}"}))
(let [epoch (swap! owner-epoch* inc)]
;; Supersede: the epoch bump above already retired the previous
;; owner's continuations. Release its canvas and GL resources now
;; with the production teardown path, and drop the cached module
;; promise so this owner measures a fresh instantiation.
(release-owner-resources!)
(reset! module-promise* nil)
(let [viewport (core/resolve-viewport view)
width (:width viewport)
height (:height viewport)
dpr (:dpr viewport)
live? (fn [] (and (= epoch @owner-epoch*) (wasm/live?)))]
(-> (ensure-module! (or (:module-url params) "./render-wasm.js")
(or (:wasm-url params) "./render-wasm.wasm"))
(.catch (fn [cause]
(throw (ex-info (str "module init failed: "
(or (ex-message cause) cause))
{:phase "module-init"}
cause))))
(.then (fn [installed]
(guard-current! epoch "module-init")
installed))
(.then (fn [{:keys [module factory-ms]}]
(guard-current! epoch "graphics-init")
(set! wasm/internal-module module)
(let [canvas (build-canvas! width height dpr)]
(.appendChild (.-body js/document) canvas)
(reset! canvas* canvas)
(let [g0 (now)
ctx (try
(webgl/init-context!
canvas
{:module module
:context-id "webgl2"
:css-width width
:css-height height
:dpr dpr
:flags 0
:browser (sr/translate-browser :chrome)
:params {}})
(catch :default cause
(throw (ex-info "graphics init failed"
{:phase "graphics-init"}
cause))))]
(if (nil? ctx)
(throw (ex-info "WebGL2 context unavailable"
{:phase "graphics-init"}))
(do
(set! wasm/gl-context-handle (:handle ctx))
(set! wasm/gl-context (:context ctx))
(set! wasm/canvas canvas)
(mark-live!)
(try
(h/call wasm/internal-module "_resize_viewbox" width height)
(catch :default cause
(throw (ex-info "resize viewbox failed"
{:phase "graphics-init"}
cause))))
(install-listeners! canvas epoch)
{:module-ms factory-ms
:graphics-ms (- (now) g0)
:renderer (renderer-string (:context ctx))
:canvas canvas
:ctx ctx}))))))
(.then (fn [g]
(guard-current! epoch "scene-build")
(try
(let [snapshot (-> (rects/build {:seed seed})
(scenes/validate!))]
(assoc g
:snapshot snapshot
:ordered (upload/prepare-scene snapshot)))
(catch :default cause
(throw (ex-info "scene build failed"
{:phase "scene-build"}
cause))))))
(.then (fn [g]
(guard-current! epoch "upload")
(try
(let [u0 (now)]
(h/call wasm/internal-module "_set_view"
(:scale view) (- (:x view)) (- (:y view)))
(h/call wasm/internal-module "_init_shapes_pool"
(count (:objects (:snapshot g))))
(h/call wasm/internal-module "_begin_loading")
(try
(serialize-shape/serialize-shapes-batch!
(:ordered g)
{:include-layout? false
:include-fills-strokes? true})
(finally
(h/call wasm/internal-module "_end_loading")))
(h/call wasm/internal-module "_set_view_end")
(assoc g :upload-ms (- (now) u0)))
(catch :default cause
(if (stale? cause)
(throw cause)
(throw (ex-info "upload failed"
{:phase "upload"}
cause)))))))
(.then (fn [g]
(guard-current! epoch "first-render")
(let [{:keys [module-ms graphics-ms renderer canvas ctx snapshot upload-ms]} g
r0 (now)]
(-> (poll-full epoch 0 r0)
(.then (fn [poll]
(render-ok epoch module-ms graphics-ms upload-ms
(live?) poll snapshot seed
canvas (:context ctx)
width height dpr renderer)))
(.catch (fn [cause]
(terminal-failure epoch cause "first-render")))))))
(.catch (fn [cause]
(terminal-failure epoch cause "aborted"))))))))
(catch :default cause
(js/Promise.resolve
(fail-data {:phase "setup" :message (ex-message cause)})))))
(defn ping
"Bridge placeholder that checks the Node/browser boundary."
[]
#js {"status" "ok"})
(def bridge
"Browser-side bridge exported from the compiled module."
#js {"ping" ping
"loadRects" load-rects
"dispose" dispose!})