From f252439d843a20449421e5e2d8ca15a97eb12678 Mon Sep 17 00:00:00 2001 From: Miguel de Benito Delgado Date: Wed, 7 Oct 2026 18:19:34 +0200 Subject: [PATCH] :recycle: Factor GL context initialization out of canvas init (#11931) * :recycle: Factor GL context initialization out of canvas init - fixes setting of gl-context-handle to be after _init so an exception does not leave invalid state behind. - api.init-canvas-context uses a new webgl.init-context! - this decouples config from init, so we can reuse it in benchmarks - some helpers move to the webgl ns AI-assisted-by: muse-spark-1.3, deepseek-flash, glm-5.3 * :bug: Release acquired GL context when renderer init fails - Properly release resources if init fails - Rethrown exceptions with the original cause - Fix vacuous check in clear-canvas AI-assisted-by: muse-spark-1.3, deepseek-flash, glm-5.3 * :sparkles: Test webgl context init and release AI-assisted-by: muse-spark, glm-5.3 * :lipstick: Address review comments * :lipstick: Drop `!` from some function names --- .../common/render_wasm/serialize_shape.cljs | 6 +- exporter/src/app/wasm/serialize.cljs | 4 +- frontend/src/app/render_wasm/api.cljs | 146 ++++++------------ frontend/src/app/render_wasm/api/webgl.cljs | 136 +++++++++++++++- .../render_wasm/serialization_test.cljs | 10 +- .../render_wasm/webgl_test.cljs | 105 +++++++++++++ frontend/test/frontend_tests/runner.cljs | 2 + 7 files changed, 300 insertions(+), 109 deletions(-) create mode 100644 frontend/test/frontend_tests/render_wasm/webgl_test.cljs diff --git a/common/src/app/common/render_wasm/serialize_shape.cljs b/common/src/app/common/render_wasm/serialize_shape.cljs index 1df4a84505..b97fc8599a 100644 --- a/common/src/app/common/render_wasm/serialize_shape.cljs +++ b/common/src/app/common/render_wasm/serialize_shape.cljs @@ -48,7 +48,7 @@ (when (and (contains? #{:path :bool} type) (some? (:content shape))) (props/set-shape-path-content (:content shape))))) -(defn serialize-shape! +(defn serialize-shape "Applies every host-independent WASM property of `shape`." [shape] (let [shape (svg-derived/apply-svg-derived shape)] @@ -59,11 +59,11 @@ shape)) -(defn serialize-shapes-batch! +(defn serialize-shapes-batch "Structural batch upload plus per-shape svg-attrs/path tail. Derives via svg-derived, uploads one `_set_shapes_batch` with `opts`, - then selects each shape needing svg-attrs/path content via `use-shape!` + then selects each shape needing svg-attrs/path content via `use-shape` and applies the tail. Host text/grid/image sequencing stays in callers. Returns the prepared vector for the downstream host-attrs loop." [shapes opts] diff --git a/exporter/src/app/wasm/serialize.cljs b/exporter/src/app/wasm/serialize.cljs index 6f316187c7..93071eaa31 100644 --- a/exporter/src/app/wasm/serialize.cljs +++ b/exporter/src/app/wasm/serialize.cljs @@ -23,12 +23,12 @@ (defn set-shape! "Serializes a single shape into the WASM design state. The host-independent properties (base props, children, blur, shadows, svg-attrs, mask, bool-type, - path geometry, grow-type) go through the shared `serialize-shape!` — the same + path geometry, grow-type) go through the shared `serialize-shape` — the same code the workspace's `set-object` uses, so the two can't drift. Only the host-specific parts are handled here: fills/strokes (image bytes are provisioned separately) and text content (fonts provisioned separately)." [shape] - (let [prepared (serialize-shape/serialize-shape! shape) + (let [prepared (serialize-shape/serialize-shape shape) type (:type prepared)] (props/write-shape-fills! (:fills prepared)) (when-not (= type :group) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 59ad963352..93f840767e 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -276,8 +276,6 @@ (def text-editor-select-paragraph text-editor/text-editor-select-paragraph) (def text-editor-sync-content text-editor/text-editor-sync-content) -(def dpr - (if use-dpr? (if (exists? js/window) js/window.devicePixelRatio 1.0) 1.0)) (defn get-dpr "Returns the current device pixel ratio. Use instead of `dpr` wherever @@ -1132,7 +1130,7 @@ (h/call wasm/internal-module "_set_shape_hidden" hidden)) (defn clear-shape-fills! - "Clear the fills of the currently-selected shape (call `use-shape!` first). + "Clear the fills of the currently-selected shape (call `use-shape` first). Equivalent to `set-shape-fills` with an empty collection." [] (when (initialized?) @@ -1590,7 +1588,7 @@ `skip-fills-strokes?` is true, fill/stroke records were already in the batch; only image fetches remain. - Always `use-shape!` first: after a multi-shape batch the WASM current shape is + Always `use-shape` first: after a multi-shape batch the WASM current shape is the last record in the chunk, not this shape." [shape skip-layout? & {:keys [skip-fills-strokes?] :or {skip-fills-strokes? false}}] (let [id (dm/get-prop shape :id) @@ -1645,7 +1643,7 @@ {:thumbnails [] :full [] :font-face-keys #{} :pending-font-face-keys #{}} (do (perf/begin-measure "set-object") - (let [prepared (serialize-shape/serialize-shape! shape) + (let [prepared (serialize-shape/serialize-shape shape) result (set-object-host-attrs prepared false)] (perf/end-measure "set-object") result)))) @@ -1813,7 +1811,7 @@ end-index (min total (+ start-index BATCH_MAX_SHAPES)) chunk (into [] (subvec (if (vector? shapes) shapes (vec shapes)) start-index end-index)) - prepared (serialize-shape/serialize-shapes-batch! + prepared (serialize-shape/serialize-shapes-batch chunk {:include-layout? true :include-fills-strokes? true})] @@ -1962,7 +1960,7 @@ (defn- set-objects-sync "Synchronously process all shapes (for small shape counts)." [shapes render-callback on-shapes-ready] - (let [prepared (serialize-shape/serialize-shapes-batch! + (let [prepared (serialize-shape/serialize-shapes-batch shapes {:include-layout? true :include-fills-strokes? true}) @@ -2336,13 +2334,6 @@ (fn [resolve _reject] (timers/raf (fn [] (resolve nil)))))) -(def ^:private default-context-options - #js {:antialias false - :depth true - :stencil true - :alpha true - "preserveDrawingBuffer" true}) - (defn resize-viewbox "Resizes the WASM viewbox. No-ops unless the GL context is live (`wasm/live?`), so callers cannot hit `_resize_viewbox` during @@ -2377,54 +2368,19 @@ (when (wasm/live?) (h/call wasm/internal-module "_set_render_options" (debug-flags) new-dpr))) -(def ^:private max-surface-size - ;; Must match `gpu_state::MAX_SURFACE_SIZE`. - 8192) - -(defn- clamp-physical-size - "Clamp physical pixel dimensions before assigning `canvas.width/height`. - Rust `resize` applies the same cap and syncs the effective DPR from the - real drawing buffer." - [w h] - (let [w (mth/max 1 w) - h (mth/max 1 h) - scale (mth/min 1 (/ max-surface-size w) (/ max-surface-size h))] - [(mth/max 1 (mth/floor (* scale w))) - (mth/max 1 (mth/floor (* scale h)))])) - (defn resize-offscreen-canvas! "Resize a persistent OffscreenCanvas to new physical-pixel dimensions and update the WASM render surfaces accordingly (via `_resize_viewbox`). The design state (shape pool) is preserved so `set-objects` is not needed again." [canvas new-physical-w new-physical-h] (when (wasm/live?) - (let [dpr (get-dpr) - [pw ph] (clamp-physical-size new-physical-w new-physical-h)] + (let [dpr (get-dpr) + [pw ph] (webgl/clamp-physical-size new-physical-w new-physical-h)] (set! (.-width canvas) pw) (set! (.-height canvas) ph) (set-render-options! dpr) (resize-viewbox (/ new-physical-w dpr) (/ new-physical-h dpr))))) -(defn- wasm-set-param-from-route-params-if-present - [param-name] - (when-let [value (wasm-get-numeric-value param-name)] - (let [setter-name (str/concat "_set_" (name param-name))] - (h/call wasm/internal-module setter-name value)))) - -(defn- canvas-css-size - "Return canvas size in CSS pixels. - - - For DOM canvases: use `clientWidth/clientHeight`. - - For OffscreenCanvas: fall back to `width/height` (physical px) converted by DPR." - [canvas dpr] - (let [cw (.-clientWidth ^js canvas) - ch (.-clientHeight ^js canvas)] - (if (and (number? cw) (pos? cw) - (number? ch) (pos? ch)) - [cw ch] - [(/ (.-width ^js canvas) dpr) - (/ (.-height ^js canvas) dpr)]))) - (defn resize-canvas! "Sizes the canvas drawing buffer, the WASM render surface and the DPR from a single source of truth (the canvas CSS client size) so the GL framebuffer @@ -2436,10 +2392,10 @@ (resize-canvas! canvas (get-dpr))) ([canvas new-dpr] (when (wasm/live?) - (let [[css-w css-h] (canvas-css-size canvas new-dpr) - css-w (mth/max 1 css-w) - css-h (mth/max 1 css-h) - [phys-w phys-h] (clamp-physical-size + (let [[css-w css-h] (webgl/canvas-css-size canvas new-dpr) + css-w (mth/max 1 css-w) + css-h (mth/max 1 css-h) + [phys-w phys-h] (webgl/clamp-physical-size (mth/floor (* css-w new-dpr)) (mth/floor (* css-h new-dpr)))] (set! (.-width ^js canvas) phys-w) @@ -2503,45 +2459,40 @@ [canvas] (if-not (wasm/module-ready?) false - (let [gl (unchecked-get wasm/internal-module "GL") - flags (debug-flags) + (let [flags (debug-flags) context-id (if (dbg/enabled? :wasm-gl-context-init-error) "fail" "webgl2") - context (.getContext ^js canvas context-id default-context-options) - context-init? (not (nil? context)) - browser (sr/translate-browser cf/browser) - dpr (get-dpr) - [css-w css-h] (canvas-css-size canvas dpr) + browser (sr/translate-browser cf/browser) + dpr (get-dpr) + [css-w css-h] (webgl/canvas-css-size canvas dpr) ;; Avoid 0×0 Skia/GL surfaces (crashes on some browsers). - css-w (mth/max 1 css-w) - css-h (mth/max 1 css-h) + css-w (mth/max 1 css-w) + css-h (mth/max 1 css-h) + params {:antialias_threshold (wasm-get-numeric-value :antialias_threshold) + :viewport_interest_area_threshold (wasm-get-numeric-value :viewport_interest_area_threshold) + :max_blocking_time_ms (wasm-get-numeric-value :max_blocking_time_ms) + :node_batch_threshold (wasm-get-numeric-value :node_batch_threshold) + :blur_downscale_threshold (wasm-get-numeric-value :blur_downscale_threshold)} + result (webgl/init-context canvas {:module wasm/internal-module + :context-id context-id + :css-width css-w + :css-height css-h + :dpr dpr + :flags flags + :browser browser + :params params}) + context-init? (some? result) can-listen? (fn? (.-addEventListener ^js canvas))] - (when-not (nil? context) - (let [handle (.registerContext ^js gl context #js {"majorVersion" 2})] - (.makeContextCurrent ^js gl handle) + (when result + (let [{:keys [context handle]} result] (set! wasm/gl-context-handle handle) (set! wasm/gl-context context) - ;; Force the WEBGL_debug_renderer_info extension as emscripten does not enable it - (.getExtension context "WEBGL_debug_renderer_info") - - ;; Initialize Wasm Render Engine - (h/call wasm/internal-module "_init" css-w css-h) - (h/call wasm/internal-module "_set_render_options" flags dpr) - - ;; Configurable parameters. - (wasm-set-param-from-route-params-if-present :antialias_threshold) - (wasm-set-param-from-route-params-if-present :viewport_interest_area_threshold) - (wasm-set-param-from-route-params-if-present :max_blocking_time_ms) - (wasm-set-param-from-route-params-if-present :node_batch_threshold) - (wasm-set-param-from-route-params-if-present :blur_downscale_threshold) - - ;; Set browser after `_init`; mark live before sizing so the + ;; `init-context` already ran `_init` through `_set_browser` in + ;; order. Publish the handles, then mark live before sizing so the ;; guarded `resize-*` helpers can run (they require `wasm/live?`, ;; which is true here even while `reloading?` still blocks app callers). - (h/call wasm/internal-module "_set_browser" browser) - - ;; Add event listeners for WebGL context lost (set! wasm/canvas canvas) + ;; Add event listeners for WebGL context lost (when can-listen? (.addEventListener canvas "webglcontextlost" webgl-context-lost-listener) (.addEventListener canvas "webglcontextrestored" webgl-context-restored-listener)) @@ -2587,19 +2538,18 @@ ;; Ensure the WebGL context is properly disposed so browsers do not keep ;; accumulating active contexts between page switches. - (when-let [gl (unchecked-get wasm/internal-module "GL")] - (when-let [handle wasm/gl-context-handle] - (try - ;; For hard teardown we can explicitly lose browser context. - ;; For reload->reinit flows we skip this because immediate context - ;; recreation may fail on some browsers/GPUs while context is lost. - (when lose-browser-context? - (when-let [ctx wasm/gl-context] - (when-let [lose-ext (.getExtension ^js ctx "WEBGL_lose_context")] - (.loseContext ^js lose-ext)))) - (.deleteContext ^js gl handle) - (catch :default dispose-error - (.error js/console dispose-error))))) + (when-let [handle wasm/gl-context-handle] + (try + ;; For hard teardown we can explicitly lose browser context. + ;; For reload->reinit flows we skip this because immediate context + ;; recreation may fail on some browsers/GPUs while context is lost. + (when lose-browser-context? + (when-let [ctx wasm/gl-context] + (when-let [lose-ext (.getExtension ^js ctx "WEBGL_lose_context")] + (.loseContext ^js lose-ext)))) + (webgl/delete-context! wasm/internal-module handle) + (catch :default dispose-error + (.error js/console dispose-error)))) (wasm-gesture/reset-after-wasm-reload!) (wasm/reset-context-state!) diff --git a/frontend/src/app/render_wasm/api/webgl.cljs b/frontend/src/app/render_wasm/api/webgl.cljs index 5fedee789f..3fe8a7b0ba 100644 --- a/frontend/src/app/render_wasm/api/webgl.cljs +++ b/frontend/src/app/render_wasm/api/webgl.cljs @@ -5,12 +5,142 @@ ;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns app.render-wasm.api.webgl - "WebGL utilities for pixel capture and rendering" + "WebGL context creation and utilities for pixel capture and rendering" (:require [app.common.logging :as log] + [app.common.math :as mth] + [app.common.render-wasm.helpers :as h] [app.common.render-wasm.wasm :as wasm] [promesa.core :as p])) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Context +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(def default-context-options + "Shared WebGL2 context attributes" + #js {:antialias false + :depth true + :stencil true + :alpha true + :preserveDrawingBuffer true}) + +(def ^:const ^:private max-surface-size + ;; Must match `gpu_state::MAX_SURFACE_SIZE`. + 8192) + +(defn canvas-css-size + "Return canvas size in CSS pixels. + + - For DOM canvases: use `clientWidth/clientHeight`. + - For OffscreenCanvas: fall back to `width/height` (physical px) converted by DPR." + [canvas dpr] + (let [cw (.-clientWidth ^js canvas) + ch (.-clientHeight ^js canvas)] + (if (and (number? cw) (pos? cw) + (number? ch) (pos? ch)) + [cw ch] + [(/ (.-width ^js canvas) dpr) + (/ (.-height ^js canvas) dpr)]))) + +(defn clamp-physical-size + "Clamp physical pixel dimensions before assigning `canvas.width/height`. + Rust `resize` applies the same cap and syncs the effective DPR from the + real drawing buffer." + [w h] + (let [w (mth/max 1 w) + h (mth/max 1 h) + scale (mth/min 1 (/ max-surface-size w) (/ max-surface-size h))] + [(mth/max 1 (mth/floor (* scale w))) + (mth/max 1 (mth/floor (* scale h)))])) + +(defn delete-context! + "Deletes an Emscripten GL context by handle.Never throws." + [module handle] + (when (some? handle) + (try + ;; We look the method up by string key bc dot access would let `:advanced` + ;; builds rename the Emscripten method + (let [gl (unchecked-get module "GL") + delete-context (unchecked-get gl "deleteContext")] + (when (fn? delete-context) + (.call ^js delete-context gl handle))) + (catch :default _)))) + +(defn release-context! + "Releases renderer state and a WebGL context owned by the caller. + `handle` may be nil if Emscripten registration failed. Each release is + attempted even when an earlier one throws; cleanup must preserve the + original initialization failure. Writes no global lifecycle state." + [module context handle] + (when (some? handle) + (try + (h/call module "_clean_up") + (catch :default _))) + (try + (when-let [ext (.getExtension ^js context "WEBGL_lose_context")] + (.loseContext ^js ext)) + (catch :default _)) + (delete-context! module handle)) + +(defn init-context + "Create a WebGL2 context on `canvas` and run the WASM renderer init sequence. + + Expects all values resolved by the caller. Writes no `wasm/` atoms and installs + no listeners. + + - `opts` has keys `module`, `context-id`, `css-width`, `css-height`, `dpr`, + `flags`, `browser`, and `params`. + - `module` is the Emscripten module object holding `GL`, `_init`, + `_set_render_options`, and `_set_browser`. `context-id` is `\"webgl2\"` + (or `\"fail\"` to force the debug failure path). + - `css-width` and `css-height` are CSS pixel sizes already probed and clamped + to at least 1. + - `params` maps tunable keywords to values (which pass to `_set_*` unchecked): + - `:antialias_threshold` + - `:viewport_interest_area_threshold` + - `:max_blocking_time_ms` + - `:node_batch_threshold` + - `:blur_downscale_threshold` + + Returns `{:context :handle}` or nil when `getContext` returns nil. + On failure, releases acquired resources before rethrowing. On success, + ownership transfers to the caller." + [canvas {:keys [module context-id css-width css-height dpr flags browser params]}] + (let [gl (unchecked-get module "GL") + context (.getContext ^js canvas context-id default-context-options)] + (when-not (nil? context) + (let [handle (volatile! nil)] + (try + (vreset! handle (.registerContext ^js gl context #js {"majorVersion" 2})) + (.makeContextCurrent ^js gl @handle) + + ;; Force the WEBGL_debug_renderer_info extension as emscripten does not enable it + (.getExtension context "WEBGL_debug_renderer_info") + + ;; Initialize Wasm Render Engine + (h/call module "_init" css-width css-height) + (h/call module "_set_render_options" flags dpr) + + ;; Configurable parameters, fixed order to match the editor path. + (when-let [value (:antialias_threshold params)] + (h/call module "_set_antialias_threshold" value)) + (when-let [value (:viewport_interest_area_threshold params)] + (h/call module "_set_viewport_interest_area_threshold" value)) + (when-let [value (:max_blocking_time_ms params)] + (h/call module "_set_max_blocking_time_ms" value)) + (when-let [value (:node_batch_threshold params)] + (h/call module "_set_node_batch_threshold" value)) + (when-let [value (:blur_downscale_threshold params)] + (h/call module "_set_blur_downscale_threshold" value)) + + (h/call module "_set_browser" browser) + {:context context + :handle @handle} + (catch :default cause + (release-context! module context @handle) + (throw cause))))))) + (defn get-webgl-context "Gets the WebGL context from the WASM module" [] @@ -23,6 +153,10 @@ (when current-ctx (.-GLctx ^js current-ctx))))))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Bitmap rendering +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + (defn create-webgl-texture-from-image "Creates a WebGL texture from an HTMLImageElement or ImageBitmap and returns the texture object" [gl image-element] diff --git a/frontend/test/frontend_tests/render_wasm/serialization_test.cljs b/frontend/test/frontend_tests/render_wasm/serialization_test.cljs index cb5438d1ce..b204bc13b6 100644 --- a/frontend/test/frontend_tests/render_wasm/serialization_test.cljs +++ b/frontend/test/frontend_tests/render_wasm/serialization_test.cljs @@ -5,7 +5,7 @@ ;; Copyright (c) KALEIDOS SUBSIDIARY SL (ns frontend-tests.render-wasm.serialization-test - "Routing tests for `serialize-shape!` and `serialize-shapes-batch!`. + "Routing tests for `serialize-shape` and `serialize-shapes-batch`. A shared `tail-cases` table states, per shape, whether the svg-attrs/path tail must fire. The pure `needs-shape-tail?` predicate is tested against @@ -23,7 +23,7 @@ (defn- with-ffi-stubs* "Stubs the single-arity FFI boundary fns (`flush-shapes-batch!`, - `use-shape!`, `set-shape-svg-attrs`, `set-shape-path-content`). + `use-shape`, `set-shape-svg-attrs`, `set-shape-path-content`). `set-shape-upload!` is deliberately left real: it is a trivial one-liner over the stubbed flush, so the single test exercises its actual delegation and default opts. A plain fn `set!` onto a multi-arity var breaks under @@ -121,7 +121,7 @@ :svg-attrs (fn [attrs] (swap! events conj [:svg-attrs @current attrs]) nil) :path (fn [content] (swap! events conj [:path @current content]) nil)} result (with-ffi-stubs* stubs - #(serialize-shape/serialize-shapes-batch! shapes opts)) + #(serialize-shape/serialize-shapes-batch shapes opts)) flushed (first @flush-calls)] (t/is (= 1 (count @flush-calls)) "exactly one flush") (t/is (= opts (:opts flushed)) "flush carries passed opts") @@ -140,7 +140,7 @@ :svg-attrs (fn [attrs] (swap! svg-applied conj attrs) nil) :path (fn [content] (swap! path-applied conj content) nil)}] (let [expected (svg-derived/apply-svg-derived shape) - result (with-ffi-stubs* stubs #(serialize-shape/serialize-shape! shape))] + result (with-ffi-stubs* stubs #(serialize-shape/serialize-shape shape))] (t/is (= [{:shapes [expected] :opts {:include-layout? false}}] @flush-calls) (str "single upload carries derived shape without layout for " (:type shape))) (t/is (= expected result) @@ -161,7 +161,7 @@ :svg-attrs (fn [_] (t/is false "no svg-attrs write on empty batch") nil) :path (fn [_] (t/is false "no path write on empty batch") nil)} result (with-ffi-stubs* stubs - #(serialize-shape/serialize-shapes-batch! [] opts))] + #(serialize-shape/serialize-shapes-batch [] opts))] (t/is (= [] result) "returns empty prepared") (t/is (empty? @flush-calls) "no flush on empty batch") (t/is (empty? @selected) "no select on empty batch"))) diff --git a/frontend/test/frontend_tests/render_wasm/webgl_test.cljs b/frontend/test/frontend_tests/render_wasm/webgl_test.cljs new file mode 100644 index 0000000000..beb60abce9 --- /dev/null +++ b/frontend/test/frontend_tests/render_wasm/webgl_test.cljs @@ -0,0 +1,105 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns frontend-tests.render-wasm.webgl-test + "Unit tests for `app.render-wasm.api.webgl` context init and release." + (:require + [app.render-wasm.api.webgl :as webgl] + [cljs.test :as t :include-macros true])) + +(defn- fake-env + "Builds `{canvas context module calls}` fakes. + - `calls` records `:register`, `:delete`, `:clean` and `:lose` events. + - Options: + - `:throw-in` makes that module fn throw (`:_init`, `:_set_browser`, ...), + - `:register-throws?` fails Emscripten registration (no handle exists), + - `:nil-context?` makes `getContext` return nil (nothing is acquired)." + [{:keys [throw-in register-throws? nil-context?]}] + (let [calls (atom []) + lose (fn [] (swap! calls conj [:lose]) nil) + context #js {:getExtension (fn [name] + (when (= name "WEBGL_lose_context") + #js {:loseContext lose}))} + gl #js {:registerContext (fn [_ _] + (swap! calls conj [:register]) + (when register-throws? + (throw (js/Error. "register failed"))) + 7) + :makeContextCurrent (fn [_] nil) + :deleteContext (fn [handle] + (swap! calls conj [:delete handle]) + nil)} + thrower (fn [name] + (fn [& _] + (when (= name throw-in) + (throw (js/Error. (str "fake " (pr-str name) " failure")))) + nil)) + module #js {:GL gl + :_init (thrower :_init) + :_set_render_options (thrower :_set_render_options) + :_set_browser (thrower :_set_browser) + :_clean_up (fn [& _] (swap! calls conj [:clean]) nil)} + canvas #js {:getContext (fn [_ _] (when-not nil-context? context))}] + {:calls calls :canvas canvas :context context :module module})) + +(defn- init-opts + [module] + {:module module + :context-id "webgl2" + :css-width 100 + :css-height 100 + :dpr 1 + :flags 0 + :browser 0 + :params {}}) + +(defn- init-failure + "Runs `init-context` against `env`, returning the thrown cause." + [{:keys [canvas module]}] + (try + (webgl/init-context canvas (init-opts module)) + nil + (catch :default cause + cause))) + +(defn- effect-count + [calls effect] + (count (filter #(= effect (first %)) calls))) + +(t/deftest init-failure-releases-the-acquired-context + (let [{:keys [canvas module calls]} (fake-env {:throw-in :_set_browser}) + failure (init-failure {:canvas canvas :module module})] + (t/is (some? failure) "the original failure propagates") + (t/is (= "_set_browser" (:fn (ex-data failure))) + "the cause keeps the failing fn") + (t/is (= 1 (effect-count @calls :clean)) "renderer state is released") + (t/is (some #{[:delete 7]} @calls) "the registered handle is deleted") + (t/is (some #{[:lose]} @calls) "the browser context is released"))) + +(t/deftest init-success-returns-context-and-handle + (let [{:keys [canvas context module calls]} (fake-env {}) + result (webgl/init-context canvas (init-opts module))] + (t/is (= 7 (:handle result))) + (t/is (identical? context (:context result))) + (t/is (zero? (effect-count @calls :clean)) "no release on success") + (t/is (zero? (effect-count @calls :delete)) "no release on success") + (t/is (zero? (effect-count @calls :lose)) "no release on success"))) + +(t/deftest nil-context-returns-nil-without-releasing + (let [{:keys [canvas module calls]} (fake-env {:nil-context? true})] + (t/is (nil? (webgl/init-context canvas (init-opts module)))) + (t/is (empty? @calls) "nothing acquired, nothing released"))) + +(t/deftest registration-failure-releases-browser-context-only + (let [{:keys [canvas module calls]} (fake-env {:register-throws? true}) + failure (init-failure {:canvas canvas :module module})] + (t/is (some? failure) "the original failure propagates") + (t/is (zero? (effect-count @calls :clean)) + "no renderer state exists without registration") + (t/is (zero? (effect-count @calls :delete)) + "no handle exists to delete") + (t/is (= 1 (effect-count @calls :lose)) + "the browser context is still released"))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index daaae968a0..df7409c045 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -90,6 +90,7 @@ [frontend-tests.render-wasm.text-editor-apply-styles-test] [frontend-tests.render-wasm.text-editor-caret-color-test] [frontend-tests.render-wasm.text-paste-test] + [frontend-tests.render-wasm.webgl-test] [frontend-tests.router-test] [frontend-tests.svg-fills-test] [frontend-tests.svg-filters-test] @@ -239,6 +240,7 @@ 'frontend-tests.render-wasm.text-editor-apply-styles-test 'frontend-tests.render-wasm.text-editor-caret-color-test 'frontend-tests.render-wasm.text-paste-test + 'frontend-tests.render-wasm.webgl-test 'frontend-tests.router-test 'frontend-tests.svg-fills-test 'frontend-tests.svg-filters-test