🚧 WIP Step 19.2 Missing browser utils

This commit is contained in:
Miguel de Benito Delgado 2026-10-02 13:11:38 +02:00
parent 8140e751b8
commit 27e4597b45
2 changed files with 77 additions and 138 deletions

View File

@ -40,7 +40,6 @@
(def schema:load-scene-args
"Decoded Transit request for `load-scene`."
[:map {:closed true}
[:seed [:int {:min 0 :max 4294967295}]] ;; derived in `collect-cases`
[:case map?]
[:module-url {:optional true} string?]
[:wasm-url {:optional true} string?]])
@ -52,11 +51,6 @@
synchronously blocked pages."
30000)
(def ^:const ^:private gesture-settle-ms
"Declared settle wait for warm gestures. The actual wait is recorded
separately by `protocol/interact`."
100)
(defn- browser-hooks
"Browser hooks for one owner epoch and deadline."
[epoch deadline-ms]
@ -85,34 +79,6 @@
:set-view-end #(h/call wasm/internal-module "_set_view_end")})
;; TODO(tickets 07/08): Move gestures into scene cases. These rectangle ID
;; checks reject every new warm case; the bridge should run its gesture.
(defn- linear-frames
"Linear interpolation of `{:scale :x :y}` views from `from` to `to` over
`n` frames."
[from to n]
(mapv (fn [i]
(let [t (/ (inc i) n)]
{:scale (+ (:scale from) (* t (- (:scale to) (:scale from))))
:x (+ (:x from) (* t (- (:x to) (:x from))))
:y (+ (:y from) (* t (- (:y to) (:y from))))}))
(range n)))
(defn- gesture-frames
"Builds rectangle pan or zoom frames from the case view. Other case ids
throw because the bridge has no gesture for them."
[case view]
(cond
(= (:id case) :rects/pan)
(linear-frames view (update view :x + 200) 20)
(= (:id case) :rects/zoom)
(linear-frames view (update view :scale * 1.5) 20)
:else
(throw (ex-info (str "no gesture declared for case " (:id case))
{:phase "gesture"
:case (:id case)}))))
;; Monotonic owner generation. Every async continuation compares its
;; captured epoch and drops stale results silently; dispose/load bump it.
@ -215,28 +181,6 @@
(dispose!)
true))
(defn- render-ok
"Maps one drain result to the bridge value.
Success keeps the owner live for explicit disposal. Failures release the owner
only if it still is current (i.e. it owns the page resources) to avoid
disposing of resources belonging to a newer owner in case of a stale failure."
[epoch module-ms graphics-ms upload-ms alive? drain-result snapshot seed ctx width height dpr renderer]
(if-not alive?
(do
(dispose-if-current! epoch)
(fail/fail-data {:phase "first-render"
:message "disposed or context lost during render"}))
(assoc drain-result
:status "ok"
:module-init-ms module-ms
:graphics-init-ms graphics-ms
:upload-ms upload-ms
:first-render-ms (:full-ms drain-result)
:render-frames (count (:slices drain-result))
:scene {:shapes (count (:objects snapshot))
:seed seed}
:effective-graphics (read-graphics ctx width height dpr renderer))))
(defn- read-graphics
"Records effective graphics settings: requested CSS size and DPR
@ -340,63 +284,57 @@
(aset (.-style ^js canvas) "height" (str height "px"))
canvas))
(defn- warm-ok
"Maps an interaction to the bridge result. Success keeps the owner live;
failure releases it if this attempt still owns the page."
[epoch module-ms graphics-ms upload-ms interact-ms alive? interaction snapshot seed ctx width height dpr renderer]
(if-not alive?
(defn- case-ok
"Adds lifecycle and graphics data to one compiled body's measurement."
[epoch live? case-desc seed dims graphics measurement]
(guard-current! epoch "render")
(if-not (live?)
(do
(dispose-if-current! epoch)
(fail/fail-data {:phase "interact"
:message "disposed or context lost during interaction"}))
(merge interaction
{:status "ok"
:module-init-ms module-ms
:graphics-init-ms graphics-ms
:upload-ms upload-ms
:interact-ms interact-ms
:scene {:shapes (count (:objects snapshot))
:seed seed}
:effective-graphics (read-graphics ctx width height dpr renderer)})))
(fail/fail-data {:phase (if (= :reuse (:context case-desc))
"interact"
"first-render")
:message "disposed or context lost during case"}))
(let [{:keys [module-ms graphics-ms renderer ctx snapshot upload-ms]} graphics
{:keys [width height dpr]} dims]
(cond-> (merge measurement
{:status "ok"
:module-init-ms module-ms
:graphics-init-ms graphics-ms
:upload-ms upload-ms
:scene {:shapes (count (:objects snapshot))
:seed seed}
:effective-graphics (read-graphics (:context ctx) width height dpr renderer)})
(= :fresh (:context case-desc))
(assoc :first-render-ms (:full-ms measurement)
:render-frames (count (:slices measurement)))))))
(defn- run-fresh
"Times one drain for a fresh-context case. Resolves the bridge value
through `render-ok`; drain failures resolve through `terminal-failure`."
[epoch hooks seed dims g live?]
(let [{:keys [module-ms graphics-ms renderer ctx snapshot upload-ms]} g
{:keys [width height dpr]} dims]
(-> (protocol/drain hooks {:flags 0 :origin ((:now hooks)) :immediate false})
(.then (fn [drain-result]
(render-ok epoch module-ms graphics-ms upload-ms
(live?) drain-result snapshot seed
(:context ctx) width height dpr renderer)))
(.catch (fn [cause]
(terminal-failure epoch cause "first-render"))))))
(defn- run-warm
"Restores the initial view and drains outside timing, then times one
gesture for a reuse-context case. Resolves the bridge value through
`warm-ok`; gesture failures resolve through `terminal-failure`."
[epoch hooks case-desc seed dims g live?]
(let [{:keys [module-ms graphics-ms renderer ctx snapshot upload-ms view]} g
{:keys [width height dpr]} dims]
(-> (protocol/restore hooks view)
(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 dims hooks live?]} case-desc local-case graphics]
(let [reuse? (= :reuse (:context case-desc))
prep (if reuse?
(-> (protocol/restore hooks (:view graphics))
(.catch (fn [cause]
(if (fail/stale? cause)
(throw cause)
(throw (ex-info "restore failed" {:phase "restore"} cause))))))
(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 "interact")
(let [t0 ((:now hooks))]
(-> (protocol/interact
hooks
{:frames (gesture-frames case-desc view)
:settle-ms gesture-settle-ms})
(.then (fn [interaction]
(warm-ok epoch module-ms graphics-ms upload-ms
(- ((:now hooks)) t0) (live?) interaction
snapshot seed (:context ctx)
width height dpr renderer)))
(.catch (fn [cause]
(terminal-failure epoch cause "interact")))))))
(guard-current! epoch "render")
(js/Promise.resolve ((:run! local-case) rtx))))
(.then (fn [measurement]
(case-ok epoch live? case-desc (:scene-seed case-desc)
dims graphics measurement)))
(.catch (fn [cause]
(terminal-failure epoch cause "restore"))))))
(terminal-failure epoch cause (if reuse? "interact" "first-render")))))))
(defn- parse-load-request
"Decodes and validates the request before claiming the page. Failure
@ -406,14 +344,22 @@
(let [params (t/decode-str request)]
(if-not (sm/validate schema:load-scene-args params)
{:error (fail/fail-data {:phase "invalid-args"
:message "load-scene needs a Transit request with seed and case"})}
:message "load-scene needs a Transit request with case"})}
(let [case-desc (:case params)
scene (core/registered-scene (:scene case-desc))]
checked (core/check-collected-case case-desc)
scene (core/registered-scene (:scene checked))
local (core/registered-case (:id checked))]
(when (nil? scene)
(throw (ex-info (str "unknown scene: " (:scene case-desc))
(throw (ex-info (str "unknown scene: " (:scene checked))
{:phase "invalid-args"})))
(when-not (and local
(fn? (:run! local))
(= (:scene local) (:scene checked)))
(throw (ex-info (str "missing case body for " (:id checked))
{:phase "invalid-args"})))
{:params params
:case-desc (core/check-collected-case case-desc)
:case-desc checked
:local-case local
:scene scene})))
(catch :default cause
{:error (fail/fail-data {:phase "invalid-args"
@ -524,7 +470,7 @@
(count (:objects (:snapshot graphics))))
(h/call wasm/internal-module "_begin_loading")
(try
;; Rects carry no layout; per-scene upload options arrive with 07/08.
;; Current procedural scenes upload without layout data.
(serialize-shape/serialize-shapes-batch
(:ordered graphics)
{:include-layout? false
@ -540,27 +486,23 @@
{:phase "upload"}
cause))))))
;; TODO(ticket 14): Run the declared case body here. Context dispatch skips
;; `:run!`, so custom cases cannot execute through this bridge.
(defn- render-scene
"Runs the fresh or reuse completion path after upload."
[{:keys [epoch dims hooks live?]} case-desc seed graphics]
"Runs the compiled case body after upload and any untimed preparation."
[{:keys [epoch] :as attempt} case-desc local-case graphics]
(guard-current! epoch "render")
(if (= (:context case-desc) :fresh)
(run-fresh epoch hooks seed dims graphics live?)
(run-warm epoch hooks case-desc seed dims graphics live?)))
(run-case attempt case-desc local-case graphics))
(defn load-scene
"Loads one collected case to Full. Takes a Transit request with `:seed`,
`:case` and optional module/WASM URLs; resolves to a Transit result.
"Loads one collected case to Full. Takes a Transit request with `:case`
and optional module/WASM URLs; resolves to a Transit result.
Validation precedes ownership and all timers. Fresh cases time upload
and one drain; reuse cases restore and drain outside interaction timing."
[request]
(let [epoch* (volatile! nil)
result (try
(let [{:keys [error params case-desc scene]}
(let [{:keys [error params case-desc local-case scene]}
(parse-load-request request)
seed (:seed params)]
seed (:scene-seed case-desc)]
(if error
(js/Promise.resolve error)
(let [{:keys [epoch dims] :as attempt} (begin-load! case-desc)]
@ -573,7 +515,7 @@
(.then (fn [graphics]
(upload-scene! epoch case-desc graphics)))
(.then (fn [graphics]
(render-scene attempt case-desc seed graphics)))
(render-scene attempt case-desc local-case graphics)))
(.catch (fn [cause]
(terminal-failure epoch cause "aborted")))))))
(catch :default cause

View File

@ -28,29 +28,24 @@
;; Runtime contract
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Ticket 14 will define rtx as the case execution context. Its proposed
;; fields are:
;; The browser executor supplies rtx to each compiled case body:
;;
;; :case the collected case descriptor (id, scene, params, view,
;; context, completion, batch-size, operation)
;; :scene the built scene snapshot {:objects ... :refs ...}
;; :module the WASM module handle, bound by the driver after init
;; :slices recorded render slices; helpers conj here
;; :metrics recorded phase timings
;; :now zero-arg clock fn
;; :frame zero-arg promise of the next rAF timestamp
;; :sleep (fn [ms] ...) promise
;; :check zero-arg guard covering cancellation, deadline, context loss
;; :view the prepared full camera view
;; :hooks injected clock, scheduling, guard and renderer calls
;;
;; Case run functions take one runtime argument. The current bridge uses case
;; metadata and does not run these functions.
;; Standard bodies return a measurement or Promise of one. Ticket 14 extends
;; this runtime for general custom operations.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Node/browser dependency contract (case execution pending in ticket 14)
;; Node/browser dependency contract
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Scene declarations and bodies load in both Node and browser. Ticket 14's
;; browser driver will execute bodies. Their transitive imports must stay safe
;; Scene declarations and bodies load in both Node and browser. The browser
;; executor calls the bodies. Their transitive imports must stay safe
;; for Node; removing :run! from descriptors does not isolate dependencies.
;;
;; If an operation needs a browser API/helper, keep that import in a browser
@ -106,6 +101,8 @@
[:params-schema vector?]
[:build fn?]])
;; Ticket 14 owns operation argument schemas and collection-time validation.
;; Keep the reserved operation map open until that contract is defined.
(def schema:registered-case
[:map {:closed true}
[:id qualified-keyword?]