diff --git a/.claude/launch.json b/.claude/launch.json deleted file mode 100644 index 8b83e453f..000000000 --- a/.claude/launch.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "version": "0.0.1", - "configurations": [ - { - "name": "preview-lab", - "runtimeExecutable": "python3", - "runtimeArgs": [ - "-m", - "http.server", - "2861", - "--bind", - "127.0.0.1", - "-d", - "target/dx/lpa-studio-web/release/web/public" - ], - "port": 2861 - } - ] -} diff --git a/docs/adr/2026-07-16-preview-host.md b/docs/adr/2026-07-16-preview-host.md new file mode 100644 index 000000000..28d88eb2d --- /dev/null +++ b/docs/adr/2026-07-16-preview-host.md @@ -0,0 +1,119 @@ +# ADR: PreviewHost — leased, pooled, budgeted project previews + +- **Status:** Accepted +- **Date:** 2026-07-16 +- **Deciders:** Photomancer +- **Supersedes:** None +- **Superseded by:** None + +## Context + +lp-gfx M3 (browser integration) landed the full GPU preview machine: +each fw-browser worker requests one WebGPU device at boot; runtimes are +created with an explicit tier (`CreateRuntime { label, tier }` → +`RuntimeCreated { tier, tier_reason }`); a card's `OffscreenCanvas` is +transferred into the worker and the runtime's visual product renders +straight to it (`attach_preview_surface` / `present_bus_texture`, zero +readback); selection and failure states are surfaced, never silent +(`docs/adr/2026-07-09-preview-fidelity-tiers.md`). + +Its only consumer was the dev-only preview-lab page, which also proved +the economics: 88–91% page CPU savings GPU vs CPU at gallery-like card +counts, GPU-bound with essentially no per-pixel host scaling (earlier +POC-A measurement). Meanwhile every product surface that wants "a +project, rendered live, in a box" — home-gallery thumbnails today; +visual-module previews and preview-mode authoring next — would each +need the same worker boot, runtime lifecycle, per-runtime deploy, +present scheduling, visibility handling, and failure containment. + +Two hard facts shape the design. First, runtimes could be created but +never destroyed — fine for a lab page torn down wholesale, wrong for a +long-lived gallery where cards scroll in and out. Second, a hostile +shader can hang a GPU device unrecoverably (established during the M6 +wgpu filetest work: a hung submission poisons the device and wgpu-core +panics in cleanup) — so "how many previews share one device" is a +failure-containment question, not a throughput question. + +## Decision + +One service, `PreviewHost` (module +`lp-app/lpa-studio-core/src/app/preview_host/`), owns live project +previews end to end. Consumers ask it to load content and render to a +canvas; they receive a **slot lease** and never touch workers, +runtimes, or envelopes. + +### Leases over a worker pool + +`PreviewHost` boots a small pool of preview workers (default **2**; +config) separate from the Studio session worker. A +`lease(PreviewSlotRequest)` picks the least-loaded worker, creates a +tiered runtime, deploys the requested content (an example's deploy +files, or a library project materialized by uid), attaches the +consumer's transferred canvas on the GPU tier, and returns a +`PreviewSlotHandle` exposing observable status — `Deploying`, +`Live { tier, tier_reason }`, `Suspended`, `Error { reason }` — plus +`set_visible(bool)` and release. Releasing (or LRU-evicting past the +live-slot cap) destroys the runtime via the new same-bundle +`DestroyRuntime` envelope; no wire version bump because the worker JS +and wasm ship as one bundle. + +The pool size of two is an **isolation** choice: device loss is +per-worker, so one hostile project takes down at most half the +previews, and the host recycles that worker (respawn, re-lease the +still-visible slots) while the other keeps presenting. CPU parallelism +is not the motivation — the measured path is GPU-bound. + +### One scheduler, explicit budgets + +A single host-side deadline scheduler drives every slot across the +pool: per-slot fps (thumbnails default ~12), per-slot in-flight +backpressure, suspend/resume from consumer visibility signals, and a +global live-slot cap. Card runtimes are explicit-tick by construction +(worker self-tick drives only the boot runtime), so all pacing policy +lives host-side in one place. The scheduler paces off a worker-backed +sleeper, keeping previews honest under hidden-tab timer throttling — +the preview-lab lesson. + +### Failure stays visible + +Tier selection, CPU fallback reasons, present errors, and device loss +all surface on the slot's status and thus on the consuming UI, per the +fidelity-tiers ADR. Recycling is deliberate (respawn + re-lease), never +an automatic retry flap. + +### The PreviewProfile seam + +`PreviewSlotRequest` carries a `PreviewProfile` — today an empty, +default-only struct. It is the reserved seam for per-project preview +behavior: auto-playback of inputs (button presses), audio sources for +music-reactive programs, and eventually authoring a project "in preview +mode". Naming the seam now means those features extend the request +instead of reshaping the service contract. + +## Alternatives considered + +- **Per-consumer wiring (preview-lab pattern copied into the gallery).** + Rejected: every future preview surface re-implements lifecycle, + scheduling, and failure handling; budgets fragment per consumer. +- **Reuse the Studio session worker for card runtimes.** Rejected: its + lifecycle is tied to an open project session (absent on the home + screen), and gallery failures would share a device with the editing + session. +- **One preview worker.** Rejected on blast radius: a single hostile + project would darken every preview until recycle. +- **Worker-per-preview.** Rejected: one WebGPU device request per + worker; dozens of devices for a gallery is waste with no isolation + benefit at that granularity. + +## Consequences + +- Gallery cards (and future module previews) consume a handle, not a + transport; the host is the one place budgets and containment are + enforced. +- `DestroyRuntime` joins the worker envelope set; runtimes become + fully lifecycle-managed. +- Stories must render deterministic non-live card states (byte-compared + baselines); live canvases never appear in stories. +- A future GPU fluid solver (wanted for CPU-bound sims) will change + per-slot cost assumptions; the scheduler's budgets are config for + that reason. diff --git a/lp-app/lpa-link/src/providers/browser_worker/fw_browser_worker.js b/lp-app/lpa-link/src/providers/browser_worker/fw_browser_worker.js index 622fbe933..a769dc17d 100644 --- a/lp-app/lpa-link/src/providers/browser_worker/fw_browser_worker.js +++ b/lp-app/lpa-link/src/providers/browser_worker/fw_browser_worker.js @@ -42,6 +42,10 @@ self.onmessage = async (event) => { }); break; } + case "destroy_runtime": + requireBooted(); + destroyRuntime(message); + break; case "attach_surface": requireBooted(); attachSurface(message); @@ -160,6 +164,28 @@ function previewFrame(message) { } } +// Runtime disposal: release a preview lease so the worker can be recycled. +// Destroying the boot runtime is an error (it is the authoritative sim +// serving single-runtime consumers); destroying an unknown id is a no-op ack +// so releases are idempotent. +function destroyRuntime(message) { + const runtimeId = message.runtime_id; + try { + if (runtimeId === bootRuntimeId) { + throw new Error(`refusing to destroy the boot runtime (${runtimeId})`); + } + fwBrowser.destroy_runtime(runtimeId); + self.postMessage({ kind: "runtime_destroyed", runtime_id: runtimeId }); + } catch (error) { + self.postMessage({ + kind: "preview_error", + runtime_id: runtimeId, + frame_id: 0, + message: String(error?.stack || error), + }); + } +} + // GPU-tier surface attachment: the OffscreenCanvas arrives in the message // transfer list and moves into the wasm runtime as the card's wgpu surface. function attachSurface(message) { diff --git a/lp-app/lpa-link/src/providers/browser_worker/worker_envelope.rs b/lp-app/lpa-link/src/providers/browser_worker/worker_envelope.rs index 82dd52862..7c85f58f8 100644 --- a/lp-app/lpa-link/src/providers/browser_worker/worker_envelope.rs +++ b/lp-app/lpa-link/src/providers/browser_worker/worker_envelope.rs @@ -52,6 +52,18 @@ pub enum BrowserInputEnvelope { label: String, tier: BrowserRuntimeTier, }, + /// Destroy a runtime previously created with [`Self::CreateRuntime`], + /// releasing everything it owns (GPU-tier runtimes drop their graphics + /// backend and any attached card surface). + /// + /// The worker answers with [`BrowserOutputEnvelope::RuntimeDestroyed`]. + /// Destroying an unknown id is a no-op ack (release is idempotent); + /// destroying the boot runtime is refused with + /// [`BrowserOutputEnvelope::PreviewError`] (`frame_id` 0) — it is the + /// authoritative sim serving single-runtime consumers. + DestroyRuntime { + runtime_id: u32, + }, ProtocolIn { /// Target runtime; `None` addresses the boot runtime. #[serde(skip_serializing_if = "Option::is_none")] @@ -135,6 +147,11 @@ pub enum BrowserOutputEnvelope { #[serde(default)] tier_reason: Option, }, + /// Response to [`BrowserInputEnvelope::DestroyRuntime`]: the runtime (if + /// it existed) has been dropped and its worker memory released. + /// + /// Acked for unknown ids too — destruction is an idempotent release. + RuntimeDestroyed { runtime_id: u32 }, /// A transferred card surface was attached to a GPU-tier runtime /// (response to the worker `attach_surface` message sent by /// `BrowserWorkerHandle::attach_preview_surface`). diff --git a/lp-app/lpa-link/src/providers/browser_worker/worker_handle.rs b/lp-app/lpa-link/src/providers/browser_worker/worker_handle.rs index e717dccaf..8d17682ea 100644 --- a/lp-app/lpa-link/src/providers/browser_worker/worker_handle.rs +++ b/lp-app/lpa-link/src/providers/browser_worker/worker_handle.rs @@ -186,6 +186,18 @@ impl BrowserWorkerHandle { .map_err(|error| LinkError::other(format!("{error:?}"))) } + /// Destroy a worker runtime created via + /// [`BrowserInputEnvelope::CreateRuntime`], releasing its lease so the + /// worker can be recycled. + /// + /// The worker answers with [`BrowserOutputEnvelope::RuntimeDestroyed`] + /// (also for unknown ids — release is idempotent) or refuses with + /// [`BrowserOutputEnvelope::PreviewError`] (`frame_id` 0) when the boot + /// runtime is targeted. + pub fn destroy_runtime(&self, runtime_id: u32) -> Result<(), LinkError> { + self.post(&BrowserInputEnvelope::DestroyRuntime { runtime_id }) + } + pub fn take_outputs(&mut self) -> Vec { core::mem::take(&mut *self.outputs.borrow_mut()) } @@ -303,6 +315,9 @@ fn boot_output_summary(outputs: &[BrowserOutputEnvelope]) -> String { } => { format!("; last worker output created runtime {runtime_id} ({label})") } + BrowserOutputEnvelope::RuntimeDestroyed { runtime_id } => { + format!("; last worker output destroyed runtime {runtime_id}") + } BrowserOutputEnvelope::SurfaceAttached { runtime_id } => { format!("; last worker output attached a surface to runtime {runtime_id}") } diff --git a/lp-app/lpa-studio-core/Cargo.toml b/lp-app/lpa-studio-core/Cargo.toml index 5329543eb..537dfe28c 100644 --- a/lp-app/lpa-studio-core/Cargo.toml +++ b/lp-app/lpa-studio-core/Cargo.toml @@ -27,7 +27,21 @@ async-trait = { version = "0.1", optional = true } js-sys = { version = "0.3", optional = true } wasm-bindgen = { version = "0.2", optional = true } wasm-bindgen-futures = { version = "0.4", optional = true } -web-sys = { version = "0.3", optional = true, features = ["Window"] } +web-sys = { version = "0.3", optional = true, features = [ + "Blob", + "BlobPropertyBag", + "CanvasRenderingContext2d", + "Document", + "Element", + "HtmlCanvasElement", + "ImageData", + "MessageEvent", + "OffscreenCanvas", + "Performance", + "Url", + "Window", + "Worker", +] } [dev-dependencies] # Host-only: the studio edit end-to-end tests run against an in-process diff --git a/lp-app/lpa-studio-core/README.md b/lp-app/lpa-studio-core/README.md index cd199e2b0..f11a8af5f 100644 --- a/lp-app/lpa-studio-core/README.md +++ b/lp-app/lpa-studio-core/README.md @@ -41,7 +41,12 @@ lpa-studio-web, future CLI, future desktop, tests, and agents - `core/` contains reusable data-driven app substrate: action metadata, generic pane/stack/activity/status view data, and UX node routing primitives. - `app/` contains the actual Studio product ownership areas: `studio`, - `device`, `server`, and `project`. + `device`, `server`, `project`, and `preview_host` (leased, pooled live + project previews for gallery cards and future preview consumers — see + `docs/adr/2026-07-16-preview-host.md`; the browser-facing half is gated + to `wasm32 + browser-worker` like the server client io, while its + request/status/scheduling vocabulary stays target-neutral and + unit-tested). - A future `base/` layer can hold truly primitive app-core concepts if one emerges. It is intentionally not present until there is a clean need for it. diff --git a/lp-app/lpa-studio-core/src/app/mod.rs b/lp-app/lpa-studio-core/src/app/mod.rs index a3d343c85..31480b5b8 100644 --- a/lp-app/lpa-studio-core/src/app/mod.rs +++ b/lp-app/lpa-studio-core/src/app/mod.rs @@ -4,6 +4,7 @@ pub mod home; pub mod library; pub mod node; pub mod places; +pub mod preview_host; pub mod project; pub mod server; pub mod studio; diff --git a/lp-app/lpa-studio-core/src/app/preview_host/frame_schedule.rs b/lp-app/lpa-studio-core/src/app/preview_host/frame_schedule.rs new file mode 100644 index 000000000..f99b785c0 --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/preview_host/frame_schedule.rs @@ -0,0 +1,210 @@ +//! Pure per-slot present scheduling: deadlines, backpressure, delta clamp. +//! +//! Extracted from the preview-lab's proven loop (`lab_runner.rs`'s +//! `schedule_due_frames`) so the decisions are unit-testable without a +//! browser: time arrives as `f64` milliseconds from the caller's clock. + +/// Ceiling on a single tick delta so a stalled slot (resume after suspend, +/// hidden-tab throttling, long deploy) does not fast-forward its sim. +pub const MAX_TICK_DELTA_MS: f64 = 250.0; + +/// What to do for one slot at one scheduler poll. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum FrameDecision { + /// Not started or not due yet. + Wait, + /// Due, but the previous frame has not been answered — drop this + /// period rather than queueing further behind (backpressure). + Skip, + /// Post a frame advancing the runtime clock by `delta_ms`. + Send { + /// Clamped clock advance for the tick riding this frame. + delta_ms: u32, + }, +} + +/// Deadline state for one slot. +#[derive(Clone, Debug)] +pub struct FrameSchedule { + period_ms: f64, + /// `None` while paused (suspended / not yet live). + next_due_ms: Option, + last_tick_at_ms: Option, + in_flight: bool, +} + +impl FrameSchedule { + /// A paused schedule at `fps` (non-positive fps falls back to 1). + pub fn new(fps: f32) -> Self { + let fps = if fps > 0.0 { f64::from(fps) } else { 1.0 }; + Self { + period_ms: 1_000.0 / fps, + next_due_ms: None, + last_tick_at_ms: None, + in_flight: false, + } + } + + /// Start (or resume) presenting: the next frame is due immediately. + pub fn start(&mut self, now_ms: f64) { + self.next_due_ms = Some(now_ms); + } + + /// Stop presenting (suspend). An in-flight frame may still complete. + pub fn pause(&mut self) { + self.next_due_ms = None; + } + + /// Whether a posted frame is awaiting its completion. + pub fn in_flight(&self) -> bool { + self.in_flight + } + + /// Decide for `now_ms`, updating deadlines. On [`FrameDecision::Send`] + /// the frame counts as posted (in flight) until + /// [`Self::frame_completed`] / [`Self::frame_failed`]. + pub fn poll(&mut self, now_ms: f64) -> FrameDecision { + let Some(due) = self.next_due_ms else { + return FrameDecision::Wait; + }; + if now_ms < due { + return FrameDecision::Wait; + } + if self.in_flight { + self.next_due_ms = Some(due + self.period_ms); + return FrameDecision::Skip; + } + let delta = self + .last_tick_at_ms + .map(|last| (now_ms - last).clamp(1.0, MAX_TICK_DELTA_MS)) + .unwrap_or(self.period_ms.min(MAX_TICK_DELTA_MS)); + self.in_flight = true; + self.last_tick_at_ms = Some(now_ms); + // Keep phase but avoid runaway catch-up bursts after stalls. + let mut next = due + self.period_ms; + if next < now_ms { + next = now_ms + self.period_ms; + } + self.next_due_ms = Some(next); + FrameDecision::Send { + delta_ms: delta.round().max(1.0) as u32, + } + } + + /// The posted frame completed (present ack or pixel frame arrived). + pub fn frame_completed(&mut self) { + self.in_flight = false; + } + + /// The posted frame failed, or the worker holding it is gone. + pub fn frame_failed(&mut self) { + self.in_flight = false; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn paused_schedule_never_sends() { + let mut schedule = FrameSchedule::new(12.0); + assert_eq!(schedule.poll(0.0), FrameDecision::Wait); + assert_eq!(schedule.poll(10_000.0), FrameDecision::Wait); + } + + #[test] + fn first_frame_after_start_is_due_immediately_with_period_delta() { + let mut schedule = FrameSchedule::new(10.0); // 100 ms period + schedule.start(1_000.0); + assert_eq!( + schedule.poll(1_000.0), + FrameDecision::Send { delta_ms: 100 }, + "no prior tick: the delta is one period" + ); + assert!(schedule.in_flight()); + } + + #[test] + fn next_frame_waits_out_the_period_and_carries_the_real_delta() { + let mut schedule = FrameSchedule::new(10.0); + schedule.start(0.0); + assert!(matches!(schedule.poll(0.0), FrameDecision::Send { .. })); + schedule.frame_completed(); + assert_eq!(schedule.poll(50.0), FrameDecision::Wait, "mid-period"); + assert_eq!( + schedule.poll(120.0), + FrameDecision::Send { delta_ms: 120 }, + "real measured delta since the last tick" + ); + } + + #[test] + fn in_flight_frame_backpressures_to_a_skip_and_keeps_phase() { + let mut schedule = FrameSchedule::new(10.0); + schedule.start(0.0); + assert!(matches!(schedule.poll(0.0), FrameDecision::Send { .. })); + // Never completed: the next due poll drops the period. + assert_eq!(schedule.poll(100.0), FrameDecision::Skip); + assert!(schedule.in_flight(), "skip does not clear the flight"); + assert_eq!(schedule.poll(150.0), FrameDecision::Wait, "next period"); + schedule.frame_completed(); + assert!(matches!(schedule.poll(200.0), FrameDecision::Send { .. })); + } + + #[test] + fn delta_is_clamped_after_a_stall() { + let mut schedule = FrameSchedule::new(10.0); + schedule.start(0.0); + assert!(matches!(schedule.poll(0.0), FrameDecision::Send { .. })); + schedule.frame_completed(); + // 10 s stall (hidden tab, long deploy elsewhere): no fast-forward. + assert_eq!( + schedule.poll(10_000.0), + FrameDecision::Send { + delta_ms: MAX_TICK_DELTA_MS as u32 + } + ); + } + + #[test] + fn deadlines_do_not_burst_after_a_stall() { + let mut schedule = FrameSchedule::new(10.0); + schedule.start(0.0); + assert!(matches!(schedule.poll(0.0), FrameDecision::Send { .. })); + schedule.frame_completed(); + assert!(matches!(schedule.poll(5_000.0), FrameDecision::Send { .. })); + schedule.frame_completed(); + // The next due is re-anchored past the stall, not 4.9 s in the past. + assert_eq!(schedule.poll(5_001.0), FrameDecision::Wait); + assert!(matches!(schedule.poll(5_100.0), FrameDecision::Send { .. })); + } + + #[test] + fn pause_stops_sending_and_resume_reanchors() { + let mut schedule = FrameSchedule::new(10.0); + schedule.start(0.0); + assert!(matches!(schedule.poll(0.0), FrameDecision::Send { .. })); + schedule.frame_completed(); + schedule.pause(); + assert_eq!(schedule.poll(1_000.0), FrameDecision::Wait); + schedule.start(2_000.0); + // Resume delta is clamped: the sim does not replay the 2 s gap. + assert_eq!( + schedule.poll(2_000.0), + FrameDecision::Send { + delta_ms: MAX_TICK_DELTA_MS as u32 + } + ); + } + + #[test] + fn non_positive_fps_falls_back_to_one_fps() { + let mut schedule = FrameSchedule::new(0.0); + schedule.start(0.0); + assert_eq!(schedule.poll(0.0), FrameDecision::Send { delta_ms: 250 }); + schedule.frame_completed(); + assert_eq!(schedule.poll(500.0), FrameDecision::Wait); + assert!(matches!(schedule.poll(1_000.0), FrameDecision::Send { .. })); + } +} diff --git a/lp-app/lpa-studio-core/src/app/preview_host/mod.rs b/lp-app/lpa-studio-core/src/app/preview_host/mod.rs new file mode 100644 index 000000000..f69710676 --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/preview_host/mod.rs @@ -0,0 +1,46 @@ +//! `PreviewHost`: leased, pooled, budgeted live project previews. +//! +//! One service owns "a project, rendered live, in a box" end to end +//! (`docs/adr/2026-07-16-preview-host.md`): it boots a small pool of +//! explicit-tick browser workers separate from the Studio session worker, +//! hands out **slot leases** (`lease(PreviewSlotRequest)` → +//! [`PreviewSlotHandle`]), deploys the requested content into a per-slot +//! tiered runtime, attaches the consumer's canvas on the GPU tier, and +//! drives every slot from one host-side deadline scheduler (per-slot fps, +//! in-flight backpressure, visibility suspend, global live-slot cap with +//! LRU eviction). Failure stays visible: tier fallback reasons, present +//! errors, and device loss all surface on the slot's observable status, +//! and a poisoned worker is recycled deliberately (respawn + re-lease of +//! still-visible slots), never retried in a flap. +//! +//! The browser-facing half ([`PreviewHost`] itself, its worker pool, and +//! the per-runtime deploy transport) only exists on +//! `wasm32 + feature = "browser-worker"`, mirroring how +//! `crate::app::server`'s browser worker client io is gated. The request, +//! status, scheduling, and content-materialization vocabulary below is +//! target-neutral so hosts, tests, and native tooling share one model. + +pub mod frame_schedule; +mod preview_content; +mod preview_types; +pub mod slot_policy; + +#[cfg(all(feature = "browser-worker", target_arch = "wasm32"))] +mod preview_client_io; +#[cfg(all(feature = "browser-worker", target_arch = "wasm32"))] +mod preview_host_impl; +#[cfg(all(feature = "browser-worker", target_arch = "wasm32"))] +mod preview_sleep; +#[cfg(all(feature = "browser-worker", target_arch = "wasm32"))] +mod preview_worker; + +pub use frame_schedule::{FrameDecision, FrameSchedule, MAX_TICK_DELTA_MS}; +pub use preview_content::{catalog_deploy_files, example_deploy_files}; +pub use preview_types::{ + PreviewHostConfig, PreviewProfile, PreviewSlotRequest, PreviewSlotStatus, PreviewSource, + PreviewTier, +}; +pub use slot_policy::{EvictionCandidate, choose_eviction, choose_worker}; + +#[cfg(all(feature = "browser-worker", target_arch = "wasm32"))] +pub use preview_host_impl::{PreviewHost, PreviewSlotHandle}; diff --git a/lp-app/lpa-studio-core/src/app/preview_host/preview_client_io.rs b/lp-app/lpa-studio-core/src/app/preview_host/preview_client_io.rs new file mode 100644 index 000000000..1bb2d5867 --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/preview_host/preview_client_io.rs @@ -0,0 +1,87 @@ +//! Minimal `ClientIo` over one preview slot runtime, used only for deploys. +//! +//! Sends protocol frames tagged with the slot's `runtime_id` and, because +//! preview workers run in explicit tick mode, posts a tick per receive +//! poll so the runtime's server loop actually processes queued requests +//! (the preview-lab client-io pattern, productized). + +use std::cell::RefCell; +use std::rc::Rc; + +use async_trait::async_trait; +use lpa_client::ClientIo; +use lpa_link::providers::browser_worker::BrowserInputEnvelope; +use lpc_wire::{ClientMessage, TransportError, WireServerMessage, json}; + +use super::preview_sleep::PreviewSleeper; +use super::preview_worker::PreviewWorker; + +/// ~4 s ceiling (poll every 4 ms) — in-browser shader compiles during +/// project load are the slow step this needs to ride out. +const RECEIVE_POLL_LIMIT: usize = 1_000; +const DEPLOY_TICK_DELTA_MS: u32 = 16; + +pub(super) struct PreviewClientIo { + worker: Rc>, + runtime_id: u32, + sleeper: Rc, +} + +impl PreviewClientIo { + pub(super) fn new( + worker: Rc>, + runtime_id: u32, + sleeper: Rc, + ) -> Self { + Self { + worker, + runtime_id, + sleeper, + } + } +} + +#[async_trait(?Send)] +impl ClientIo for PreviewClientIo { + async fn send(&mut self, msg: ClientMessage) -> Result<(), TransportError> { + let frame = json::to_string(&msg) + .map_err(|error| TransportError::Serialization(error.to_string()))?; + self.worker + .borrow() + .post(&BrowserInputEnvelope::ProtocolIn { + runtime_id: Some(self.runtime_id), + frame, + }) + .map_err(TransportError::Other) + } + + async fn receive(&mut self) -> Result { + for _ in 0..RECEIVE_POLL_LIMIT { + { + let mut worker = self.worker.borrow_mut(); + worker.drain_outputs(); + if let Some(frame) = worker.pop_protocol_frame(self.runtime_id) { + return json::from_str(&frame) + .map_err(|error| TransportError::Deserialization(error.to_string())); + } + // Explicit tick mode: advance the runtime so it services + // the queued request. + worker + .post(&BrowserInputEnvelope::Tick { + runtime_id: Some(self.runtime_id), + delta_ms: Some(DEPLOY_TICK_DELTA_MS), + }) + .map_err(TransportError::Other)?; + } + self.sleeper.sleep_ms(4).await; + } + Err(TransportError::Other(format!( + "timed out waiting for preview runtime {} protocol output", + self.runtime_id + ))) + } + + async fn close(&mut self) -> Result<(), TransportError> { + Ok(()) + } +} diff --git a/lp-app/lpa-studio-core/src/app/preview_host/preview_content.rs b/lp-app/lpa-studio-core/src/app/preview_host/preview_content.rs new file mode 100644 index 000000000..0f84144ea --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/preview_host/preview_content.rs @@ -0,0 +1,114 @@ +//! Materializing a [`super::PreviewSource`] into deployable project files. +//! +//! Both paths produce the `Vec` shape +//! `LpClient::deploy_project_files` consumes: embedded examples from the +//! compiled-in package set, library projects from a read-only catalog +//! snapshot (the same store access the home gallery hydrates from). + +use std::cell::RefCell; +use std::rc::Rc; + +use lpa_client::ProjectDeployFile; +use lpfs::LpFs; + +use crate::app::home::embedded_example::embedded_example; +use crate::app::library::LibraryStore; + +/// Deploy files for a compiled-in example package +/// ([`crate::UiExampleCard`] ids, e.g. `examples/fyeah-sign`). +pub fn example_deploy_files(id: &str) -> Result, String> { + let example = embedded_example(id).ok_or_else(|| format!("unknown example {id:?}"))?; + Ok(example + .files() + .into_iter() + .map(|(relative_path, bytes)| ProjectDeployFile::new(relative_path, bytes)) + .collect()) +} + +/// Deploy files for a library package, from a **read-only catalog +/// snapshot** fs (`LibraryHost::catalog_snapshot`). `key` is a `prj_…` +/// uid or a slug. The payload matches the device-push path: every package +/// file, byte for byte. +pub fn catalog_deploy_files( + fs: Rc>, + key: &str, +) -> Result, String> { + let store = LibraryStore::read_only(fs); + let uid = store + .resolve_key(key) + .map_err(|error| format!("library: {error}"))?; + let handle = store + .open(uid) + .map_err(|error| format!("library: {error}"))?; + Ok(handle + .read_all_files() + .map_err(|error| format!("library: {error}"))? + .into_iter() + .map(|(relative_path, bytes)| ProjectDeployFile::new(relative_path, bytes)) + .collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::library::PackageProvenance; + use lpfs::LpFsMemory; + + #[test] + fn example_files_materialize_for_a_known_id() { + let files = example_deploy_files(crate::STUDIO_DEMO_PROJECT_ID).unwrap(); + assert!( + files + .iter() + .any(|file| file.relative_path() == "project.json") + ); + } + + #[test] + fn unknown_example_id_is_an_error() { + let error = example_deploy_files("examples/unknown").unwrap_err(); + assert!(error.contains("examples/unknown"), "{error}"); + } + + #[test] + fn catalog_files_materialize_by_uid_and_by_slug() { + let fs: Rc> = Rc::new(RefCell::new(LpFsMemory::new())); + let store = LibraryStore::new( + Rc::clone(&fs), + Rc::new(|| [7u8; 16]), + Rc::new(|| "2026-07-16-1200".to_string()), + ); + let summary = store + .install_package( + "demo", + &[ + ( + "project.json".to_string(), + br#"{"kind":"Project","name":"demo"}"#.to_vec(), + ), + ("shader.glsl".to_string(), b"void main() {}".to_vec()), + ], + PackageProvenance::Created, + 1.0, + ) + .unwrap(); + + for key in [summary.uid.to_string(), summary.slug.clone()] { + let files = catalog_deploy_files(Rc::clone(&fs), &key).unwrap(); + assert!( + files + .iter() + .any(|file| file.relative_path() == "shader.glsl" + && file.bytes() == b"void main() {}"), + "materialized files carry package bytes (key {key})" + ); + } + } + + #[test] + fn missing_catalog_package_is_an_error() { + let fs: Rc> = Rc::new(RefCell::new(LpFsMemory::new())); + let error = catalog_deploy_files(fs, "no-such-slug").unwrap_err(); + assert!(error.contains("library"), "{error}"); + } +} diff --git a/lp-app/lpa-studio-core/src/app/preview_host/preview_host_impl.rs b/lp-app/lpa-studio-core/src/app/preview_host/preview_host_impl.rs new file mode 100644 index 000000000..f8b3fc0eb --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/preview_host/preview_host_impl.rs @@ -0,0 +1,1241 @@ +//! The browser-side `PreviewHost` service: worker pool, slot leases, and +//! the single deadline scheduler driving every preview slot. +//! +//! Architecture (preview-host ADR): consumers `lease()` a slot and observe +//! it through [`PreviewSlotHandle`] — they never touch workers, runtimes, +//! or envelopes. One `run()` future owns all IO: it boots the pool, +//! executes lease pipelines (create runtime → deploy → attach surface), +//! schedules `present_frame`/`preview_frame` posts per slot fps with +//! in-flight backpressure, enforces the global live-slot cap with LRU +//! eviction, and recycles a poisoned worker (respawn + re-lease of +//! still-visible slots) on device loss or present errors. +//! +//! Concurrency model: everything is single-threaded. `run()` keeps a list +//! of cooperative sub-tasks (worker boots, lease pipelines) and polls them +//! once per scheduler tick with a no-op waker — their awaits are timer- or +//! message-backed, so re-polling every few milliseconds drives them to +//! completion without an executor, and the scheduler keeps presenting +//! while deploys are in flight. The tick itself paces off a worker-backed +//! sleeper so hidden-tab timer throttling cannot stall previews. + +use std::cell::RefCell; +use std::future::Future; +use std::pin::Pin; +use std::rc::Rc; +use std::task::{Context, Poll, Waker}; + +use lpa_client::{LpClient, ProjectDeployFile}; +use lpa_link::providers::browser_worker::{BrowserInputEnvelope, BrowserRuntimeTier}; +use wasm_bindgen::JsCast; + +use crate::app::library::LibraryHost; + +use super::frame_schedule::{FrameDecision, FrameSchedule}; +use super::preview_client_io::PreviewClientIo; +use super::preview_content::{catalog_deploy_files, example_deploy_files}; +use super::preview_sleep::PreviewSleeper; +use super::preview_types::{ + PreviewHostConfig, PreviewSlotRequest, PreviewSlotStatus, PreviewSource, PreviewTier, +}; +use super::preview_worker::PreviewWorker; +use super::slot_policy::{EvictionCandidate, choose_eviction, choose_worker}; + +/// Scheduler/polling loop period while any slot or sub-task is active. +const LOOP_SLEEP_MS: u32 = 4; +/// Loop period while the host is idle (no slots, no sub-tasks). +const IDLE_SLEEP_MS: u32 = 100; +/// How long to wait for `runtime_created` after `create_runtime` +/// (4 ms polls). +const CREATE_RUNTIME_POLL_LIMIT: usize = 500; +/// How long to wait for the consumer canvas to mount and the worker to +/// ack `attach_surface`, in 4 ms polls. +const ATTACH_SURFACE_POLL_LIMIT: usize = 500; +/// Bus channel carrying the previewed visual product. +const PREVIEW_CHANNEL: &str = "visual.out"; +/// Project id preview deploys load under on the slot runtime. +const PREVIEW_PROJECT_ID: &str = "preview"; +/// A slot whose re-lease (after causing a worker recycle) fails again is +/// parked in `Error` instead of recycling the worker forever. +const MAX_RECYCLE_STRIKES: u8 = 2; + +type LocalTask = Pin>>; + +/// Leased, pooled, budgeted live project previews (see the module and +/// crate ADR docs). Construct once per page, `lease()` slots freely, and +/// drive [`PreviewHost::run`] from the platform edge. +pub struct PreviewHost { + shared: Rc>, +} + +/// One leased preview slot, released on drop. +/// +/// Status is observed by polling ([`Self::status`] / +/// [`Self::status_revision`]), matching how the rest of lpa-studio-core +/// exposes async state (no UI-framework signals in core). +pub struct PreviewSlotHandle { + slot: Rc>, +} + +struct SharedState { + config: PreviewHostConfig, + library: Option>, + workers: Vec, + slots: Vec>>, + next_slot_id: u64, + next_frame_id: u32, + shutdown: bool, + running: bool, +} + +struct WorkerEntry { + /// Bumped on every respawn; pipelines abandon when their snapshot goes + /// stale so a recycled worker never receives a zombie pipeline's posts. + generation: u32, + state: WorkerState, +} + +enum WorkerState { + /// Spawn/boot in flight (initial boot or post-recycle respawn). + Booting, + Ready(Rc>), + /// Boot failed; the pool member stays down (recycling is deliberate, + /// never a retry flap). + Dead(String), +} + +struct Slot { + id: u64, + source: PreviewSource, + canvas_id: String, + visible: bool, + released: bool, + /// Waiting for the run loop to start (or restart) its lease pipeline. + pending: bool, + /// A lease pipeline is in flight. + deploying: bool, + /// Visibility returned while the slot had no runtime: re-lease it. + resume_requested: bool, + /// No automatic recovery will be attempted; the consumer must lease a + /// fresh slot (usually after remounting its canvas). + terminal: bool, + /// Worker recycles this slot has caused (flap guard). + strikes: u8, + worker_index: Option, + worker_generation: u32, + runtime_id: Option, + tier: Option, + tier_reason: Option, + /// Deploy + (GPU) surface attach completed; presents may be scheduled. + presentable: bool, + /// Attach failure routed from the worker (`PreviewError` frame 0). + attach_error: Option, + schedule: FrameSchedule, + last_active_ms: f64, + presented_frames: u64, + status: PreviewSlotStatus, + status_revision: u64, + /// CPU-tier render size (the mounted canvas's pixel size). + cpu_size: (u32, u32), + /// CPU-tier blit context cache (re-resolved if the canvas remounts). + context: Option, +} + +impl Slot { + fn set_status(&mut self, status: PreviewSlotStatus) { + if self.status != status { + self.status = status; + self.status_revision += 1; + } + } + + fn live_status(&self) -> PreviewSlotStatus { + PreviewSlotStatus::Live { + tier: self.tier.unwrap_or(PreviewTier::Cpu), + tier_reason: self.tier_reason.clone(), + } + } + + /// Holding (or acquiring) a runtime — what the live-slot cap counts. + fn counts_as_live(&self) -> bool { + self.runtime_id.is_some() || self.deploying + } +} + +impl PreviewHost { + /// Build a host over `config`. `library` backs + /// [`PreviewSource::ProjectUid`] leases (catalog snapshots); without + /// it those leases fail with a clear status while example leases + /// still work. Nothing boots until [`Self::run`] is driven. + pub fn new(config: PreviewHostConfig, library: Option>) -> Self { + let pool_size = config.pool_size.max(1); + let workers = (0..pool_size) + .map(|_| WorkerEntry { + generation: 0, + state: WorkerState::Booting, + }) + .collect(); + Self { + shared: Rc::new(RefCell::new(SharedState { + config, + library, + workers, + slots: Vec::new(), + next_slot_id: 1, + next_frame_id: 1, + shutdown: false, + running: false, + })), + } + } + + /// Lease a preview slot. Returns immediately; the run loop deploys the + /// content and flips the handle's status from + /// [`PreviewSlotStatus::Deploying`] as the pipeline progresses. + /// Dropping the handle releases the slot (its runtime is destroyed). + pub fn lease(&self, request: PreviewSlotRequest) -> PreviewSlotHandle { + let mut shared = self.shared.borrow_mut(); + let id = shared.next_slot_id; + shared.next_slot_id += 1; + let fps = request.fps.unwrap_or(shared.config.default_fps); + let shut_down = shared.shutdown; + let slot = Rc::new(RefCell::new(Slot { + id, + source: request.source, + canvas_id: request.canvas_id, + visible: true, + released: false, + pending: !shut_down, + deploying: false, + resume_requested: false, + terminal: shut_down, + strikes: 0, + worker_index: None, + worker_generation: 0, + runtime_id: None, + tier: None, + tier_reason: None, + presentable: false, + attach_error: None, + schedule: FrameSchedule::new(fps), + last_active_ms: now_ms(), + presented_frames: 0, + status: if shut_down { + PreviewSlotStatus::Error { + reason: "preview host is shut down".to_string(), + } + } else { + PreviewSlotStatus::Deploying + }, + status_revision: 0, + cpu_size: (128, 128), + context: None, + })); + shared.slots.push(Rc::clone(&slot)); + PreviewSlotHandle { slot } + } + + /// Stop the host: the run loop terminates every pool worker and + /// returns. Live slots freeze as [`PreviewSlotStatus::Suspended`]; + /// further leases fail immediately. + pub fn shutdown(&self) { + self.shared.borrow_mut().shutdown = true; + } + + /// Drive the host until [`Self::shutdown`]. Call exactly once and + /// spawn it on the platform edge (the core owns no executor); a + /// second call returns immediately. + pub async fn run(&self) { + { + let mut shared = self.shared.borrow_mut(); + if shared.running { + log::warn!("preview host run() called twice; ignoring the second call"); + return; + } + shared.running = true; + } + let mut tasks: Vec = Vec::new(); + { + let shared = self.shared.borrow(); + for index in 0..shared.workers.len() { + tasks.push(boot_task(Rc::clone(&self.shared), index, 0)); + } + } + let sleeper = PreviewSleeper::new(); + loop { + if self.shared.borrow().shutdown { + self.finish_shutdown(); + break; + } + self.reap_released_slots(); + self.apply_resume_requests(); + self.start_pending_leases(&mut tasks); + let mut recycles = Vec::new(); + self.schedule_due_frames(&mut recycles); + self.collect_worker_outputs(&mut recycles); + self.apply_recycles(recycles, &mut tasks); + poll_tasks(&mut tasks); + let idle = tasks.is_empty() && self.shared.borrow().slots.is_empty(); + sleeper + .sleep_ms(if idle { IDLE_SLEEP_MS } else { LOOP_SLEEP_MS }) + .await; + } + } + + fn finish_shutdown(&self) { + let (workers, slots) = { + let mut shared = self.shared.borrow_mut(); + let workers: Vec<_> = shared + .workers + .iter_mut() + .filter_map(|entry| { + match core::mem::replace( + &mut entry.state, + WorkerState::Dead("preview host shut down".to_string()), + ) { + WorkerState::Ready(worker) => Some(worker), + WorkerState::Booting | WorkerState::Dead(_) => None, + } + }) + .collect(); + (workers, shared.slots.clone()) + }; + for worker in workers { + worker.borrow().terminate(); + } + for slot in slots { + let mut slot = slot.borrow_mut(); + slot.schedule.pause(); + slot.presentable = false; + slot.runtime_id = None; + slot.pending = false; + slot.deploying = false; + if !slot.terminal { + slot.set_status(PreviewSlotStatus::Suspended); + } + } + } + + /// Destroy runtimes of dropped handles and forget their slots. Slots + /// mid-pipeline are reaped once the pipeline notices the release. + fn reap_released_slots(&self) { + let slots = self.shared.borrow().slots.clone(); + for slot_rc in &slots { + let (release_now, runtime, worker_index, generation) = { + let slot = slot_rc.borrow(); + ( + slot.released && !slot.deploying, + slot.runtime_id, + slot.worker_index, + slot.worker_generation, + ) + }; + if !release_now { + continue; + } + if let Some(runtime_id) = runtime { + self.destroy_slot_runtime(worker_index, generation, runtime_id); + slot_rc.borrow_mut().runtime_id = None; + } + } + self.shared.borrow_mut().slots.retain(|slot| { + let slot = slot.borrow(); + !(slot.released && !slot.deploying) + }); + } + + /// Fire-and-forget `DestroyRuntime` toward the worker that granted the + /// runtime, if it is still the same booted instance. + fn destroy_slot_runtime( + &self, + worker_index: Option, + worker_generation: u32, + runtime_id: u32, + ) { + let Some(index) = worker_index else { + return; + }; + let worker = { + let shared = self.shared.borrow(); + let Some(entry) = shared.workers.get(index) else { + return; + }; + if entry.generation != worker_generation { + return; // the granting worker is gone; nothing to release + } + match &entry.state { + WorkerState::Ready(worker) => Rc::clone(worker), + WorkerState::Booting | WorkerState::Dead(_) => return, + } + }; + let mut worker = worker.borrow_mut(); + if let Err(error) = worker.destroy_runtime(runtime_id) { + log::warn!("preview host: destroy runtime {runtime_id}: {error}"); + } + worker.forget_runtime(runtime_id); + } + + /// Turn visibility-return edges on runtime-less slots into re-leases. + fn apply_resume_requests(&self) { + let slots = self.shared.borrow().slots.clone(); + for slot_rc in slots { + let mut slot = slot_rc.borrow_mut(); + if !slot.resume_requested { + continue; + } + slot.resume_requested = false; + if slot.released || slot.terminal || slot.deploying || slot.pending { + continue; + } + if slot.runtime_id.is_none() { + slot.pending = true; + slot.set_status(PreviewSlotStatus::Deploying); + } + } + } + + /// Start lease pipelines for pending slots: pick the least-loaded + /// ready worker, enforce the live-slot cap (LRU eviction), and spawn + /// the pipeline sub-task. + fn start_pending_leases(&self, tasks: &mut Vec) { + let slots = self.shared.borrow().slots.clone(); + for slot_rc in &slots { + { + let slot = slot_rc.borrow(); + if !slot.pending || slot.deploying || slot.released || slot.terminal { + continue; + } + } + let Some((worker_index, worker, generation)) = self.pick_worker(slot_rc) else { + continue; // stays pending (booting) or was parked (all dead) + }; + if !self.ensure_live_capacity(slot_rc) { + continue; // stays pending until a slot frees up + } + { + let mut slot = slot_rc.borrow_mut(); + slot.pending = false; + slot.deploying = true; + slot.presentable = false; + slot.attach_error = None; + slot.worker_index = Some(worker_index); + slot.worker_generation = generation; + slot.last_active_ms = now_ms(); + slot.set_status(PreviewSlotStatus::Deploying); + } + tasks.push(lease_pipeline( + Rc::clone(&self.shared), + Rc::clone(slot_rc), + worker, + worker_index, + generation, + )); + } + } + + /// Least-loaded ready worker for a pending slot. `None` keeps the slot + /// pending (a worker is booting) or parks it in `Error` (all dead). + fn pick_worker( + &self, + slot_rc: &Rc>, + ) -> Option<(usize, Rc>, u32)> { + let shared = self.shared.borrow(); + let loads: Vec> = shared + .workers + .iter() + .enumerate() + .map(|(index, entry)| match &entry.state { + WorkerState::Ready(_) => Some( + shared + .slots + .iter() + .filter(|slot| { + let slot = slot.borrow(); + slot.worker_index == Some(index) + && !slot.released + && slot.counts_as_live() + }) + .count(), + ), + WorkerState::Booting | WorkerState::Dead(_) => None, + }) + .collect(); + match choose_worker(&loads) { + Some(index) => { + let entry = &shared.workers[index]; + let WorkerState::Ready(worker) = &entry.state else { + return None; + }; + Some((index, Rc::clone(worker), entry.generation)) + } + None => { + let all_dead = shared + .workers + .iter() + .all(|entry| matches!(entry.state, WorkerState::Dead(_))); + if all_dead { + let reason = shared + .workers + .iter() + .find_map(|entry| match &entry.state { + WorkerState::Dead(reason) => Some(reason.clone()), + _ => None, + }) + .unwrap_or_else(|| "no preview workers".to_string()); + drop(shared); + let mut slot = slot_rc.borrow_mut(); + slot.pending = false; + slot.terminal = true; + slot.set_status(PreviewSlotStatus::Error { + reason: format!("preview workers unavailable: {reason}"), + }); + } + None + } + } + } + + /// Enforce the global live-slot cap before a new lease acquires a + /// runtime, LRU-evicting (invisible first). `false` leaves the lease + /// pending — nothing was evictable this tick. + fn ensure_live_capacity(&self, for_slot: &Rc>) -> bool { + let (live, candidates, cap) = { + let shared = self.shared.borrow(); + let for_id = for_slot.borrow().id; + let live = shared + .slots + .iter() + .filter(|slot| { + let slot = slot.borrow(); + !slot.released && slot.counts_as_live() + }) + .count(); + let candidates: Vec = shared + .slots + .iter() + .filter_map(|slot| { + let slot = slot.borrow(); + (slot.id != for_id + && !slot.released + && !slot.deploying + && slot.runtime_id.is_some()) + .then_some(EvictionCandidate { + slot_id: slot.id, + visible: slot.visible, + last_active_ms: slot.last_active_ms, + }) + }) + .collect(); + (live, candidates, shared.config.max_live_slots.max(1)) + }; + if live < cap { + return true; + } + let Some(evict_id) = choose_eviction(&candidates) else { + return false; + }; + let evicted = { + let shared = self.shared.borrow(); + shared + .slots + .iter() + .find(|slot| slot.borrow().id == evict_id) + .cloned() + }; + if let Some(evicted) = evicted { + self.evict_slot(&evicted); + } + true + } + + /// Destroy an LRU-evicted slot's runtime and park it `Suspended` (the + /// canvas keeps its last frame). It re-leases on the next visibility + /// edge — GPU slots then need a consumer-remounted canvas, since the + /// old one was consumed by its transfer. + fn evict_slot(&self, slot_rc: &Rc>) { + let (runtime, worker_index, generation) = { + let slot = slot_rc.borrow(); + (slot.runtime_id, slot.worker_index, slot.worker_generation) + }; + if let Some(runtime_id) = runtime { + self.destroy_slot_runtime(worker_index, generation, runtime_id); + } + let mut slot = slot_rc.borrow_mut(); + slot.runtime_id = None; + slot.presentable = false; + slot.schedule.pause(); + slot.schedule.frame_failed(); + if !slot.terminal { + slot.set_status(PreviewSlotStatus::Suspended); + } + log::info!("preview host: evicted slot {} (live-slot cap)", slot.id); + } + + /// Post due `present_frame` / `preview_frame` requests for live, + /// visible slots (per-slot fps, in-flight backpressure, clamped tick + /// deltas — the frame-schedule contract). + fn schedule_due_frames(&self, recycles: &mut Vec) { + let now = now_ms(); + let slots = self.shared.borrow().slots.clone(); + for slot_rc in &slots { + let (envelope, worker_index, generation) = { + let mut slot = slot_rc.borrow_mut(); + let eligible = !slot.released + && slot.visible + && slot.presentable + && slot.runtime_id.is_some() + && matches!(slot.status, PreviewSlotStatus::Live { .. }); + if !eligible { + continue; + } + match slot.schedule.poll(now) { + FrameDecision::Wait | FrameDecision::Skip => continue, + FrameDecision::Send { delta_ms } => { + let runtime_id = slot.runtime_id.expect("eligible slot has a runtime"); + let frame_id = { + let mut shared = self.shared.borrow_mut(); + let frame_id = shared.next_frame_id; + // 0 is reserved for attach/lifecycle errors. + shared.next_frame_id = shared.next_frame_id.wrapping_add(1).max(1); + frame_id + }; + let envelope = if slot.tier == Some(PreviewTier::Gpu) { + BrowserInputEnvelope::PresentFrame { + runtime_id, + delta_ms: Some(delta_ms), + channel: PREVIEW_CHANNEL.to_string(), + frame_id, + } + } else { + BrowserInputEnvelope::PreviewFrame { + runtime_id, + delta_ms: Some(delta_ms), + channel: PREVIEW_CHANNEL.to_string(), + width: slot.cpu_size.0, + height: slot.cpu_size.1, + frame_id, + } + }; + ( + envelope, + slot.worker_index.expect("live slot has a worker"), + slot.worker_generation, + ) + } + } + }; + let worker = { + let shared = self.shared.borrow(); + match shared.workers.get(worker_index) { + Some(entry) if entry.generation == generation => match &entry.state { + WorkerState::Ready(worker) => Some(Rc::clone(worker)), + _ => None, + }, + _ => None, + } + }; + match worker { + Some(worker) => { + if let Err(error) = worker.borrow().post(&envelope) { + recycles.push(RecycleRequest { + worker_index, + cause_runtime: None, + reason: format!("post preview frame: {error}"), + }); + } + } + None => slot_rc.borrow_mut().schedule.frame_failed(), + } + } + } + + /// Drain every ready worker: complete presents, blit CPU pixel + /// frames, route attach errors to their pipelines, and collect + /// worker-poisoning failures as recycle requests. + fn collect_worker_outputs(&self, recycles: &mut Vec) { + let now = now_ms(); + let workers: Vec<(usize, Rc>)> = { + let shared = self.shared.borrow(); + shared + .workers + .iter() + .enumerate() + .filter_map(|(index, entry)| match &entry.state { + WorkerState::Ready(worker) => Some((index, Rc::clone(worker))), + _ => None, + }) + .collect() + }; + for (worker_index, worker) in workers { + let (pixel_frames, presented, errors, worker_errors) = { + let mut worker = worker.borrow_mut(); + worker.drain_outputs(); + ( + worker.take_preview_frames(), + worker.take_presented_frames(), + worker.take_preview_errors(), + worker.take_worker_errors(), + ) + }; + for reason in worker_errors { + recycles.push(RecycleRequest { + worker_index, + cause_runtime: None, + reason: format!("worker error: {reason}"), + }); + } + for error in errors { + if error.frame_id == 0 { + // Attach/lifecycle failure: slot-local, consumed by the + // waiting lease pipeline. + if let Some(slot) = self.slot_by_runtime(worker_index, error.runtime_id) { + slot.borrow_mut().attach_error = Some(error.message); + } + } else { + // Present/preview failure: treat the worker as + // poisoned (device loss surfaces here) and recycle. + recycles.push(RecycleRequest { + worker_index, + cause_runtime: Some(error.runtime_id), + reason: error.message, + }); + } + } + for done in presented { + if let Some(slot) = self.slot_by_runtime(worker_index, done.runtime_id) { + let mut slot = slot.borrow_mut(); + slot.schedule.frame_completed(); + slot.presented_frames += 1; + slot.last_active_ms = now; + } + } + for frame in pixel_frames { + let Some(slot) = self.slot_by_runtime(worker_index, frame.runtime_id) else { + continue; + }; + let mut slot = slot.borrow_mut(); + slot.schedule.frame_completed(); + slot.last_active_ms = now; + match blit_pixel_frame(&mut slot, &frame) { + Ok(()) => slot.presented_frames += 1, + Err(reason) => { + slot.schedule.pause(); + slot.terminal = true; + slot.set_status(PreviewSlotStatus::Error { reason }); + } + } + } + } + } + + fn slot_by_runtime(&self, worker_index: usize, runtime_id: u32) -> Option>> { + let shared = self.shared.borrow(); + shared + .slots + .iter() + .find(|slot| { + let slot = slot.borrow(); + slot.worker_index == Some(worker_index) && slot.runtime_id == Some(runtime_id) + }) + .cloned() + } + + /// Recycle poisoned workers: terminate + respawn each condemned + /// worker, then re-lease its still-visible slots. The slot that caused + /// the recycle accrues a strike; at [`MAX_RECYCLE_STRIKES`] it parks + /// in `Error` instead of condemning workers forever. + fn apply_recycles(&self, recycles: Vec, tasks: &mut Vec) { + let mut recycled: Vec = Vec::new(); + for request in recycles { + let cause_slot_id = request.cause_runtime.and_then(|runtime_id| { + self.slot_by_runtime(request.worker_index, runtime_id) + .map(|slot| slot.borrow().id) + }); + if recycled.contains(&request.worker_index) { + // The worker is already condemned this tick; still charge + // the causing slot its strike. + self.mark_recycled_slots(request.worker_index, cause_slot_id, &request.reason); + continue; + } + let respawn = { + let mut shared = self.shared.borrow_mut(); + let Some(entry) = shared.workers.get_mut(request.worker_index) else { + continue; + }; + match core::mem::replace(&mut entry.state, WorkerState::Booting) { + WorkerState::Ready(worker) => { + worker.borrow().terminate(); + entry.generation += 1; + Some(entry.generation) + } + // Not ready: put the previous state back untouched. + other => { + entry.state = other; + None + } + } + }; + let Some(generation) = respawn else { + continue; + }; + log::warn!( + "preview host: recycling worker {} ({})", + request.worker_index, + request.reason + ); + recycled.push(request.worker_index); + self.mark_recycled_slots(request.worker_index, cause_slot_id, &request.reason); + tasks.push(boot_task( + Rc::clone(&self.shared), + request.worker_index, + generation, + )); + } + } + + /// Detach every slot of a recycled worker and decide its future: + /// still-visible slots re-lease (status `Deploying`), invisible ones + /// park `Suspended` until their next visibility edge, and the causing + /// slot parks in `Error` once it exhausts its strikes. + fn mark_recycled_slots(&self, worker_index: usize, cause_slot_id: Option, reason: &str) { + let slots = self.shared.borrow().slots.clone(); + for slot_rc in slots { + let mut slot = slot_rc.borrow_mut(); + if slot.worker_index != Some(worker_index) { + continue; + } + let had_runtime = slot.runtime_id.is_some() || slot.deploying; + if !had_runtime { + continue; + } + slot.runtime_id = None; + slot.presentable = false; + slot.schedule.pause(); + slot.schedule.frame_failed(); + if Some(slot.id) == cause_slot_id { + slot.strikes = slot.strikes.saturating_add(1); + } + if slot.released || slot.terminal { + continue; + } + if Some(slot.id) == cause_slot_id && slot.strikes >= MAX_RECYCLE_STRIKES { + slot.terminal = true; + slot.set_status(PreviewSlotStatus::Error { + reason: format!("preview failed repeatedly: {reason}"), + }); + } else if slot.visible { + // Re-lease once the pipeline (if any) unwinds. A GPU + // slot's canvas was consumed by its transfer, so the + // re-lease surfaces a clear canvas error unless the + // consumer remounted; that is the consumer's remount + // discipline (P4). + slot.resume_requested = true; + slot.set_status(PreviewSlotStatus::Deploying); + } else { + slot.set_status(PreviewSlotStatus::Suspended); + } + } + } +} + +impl PreviewSlotHandle { + /// Current observable slot state (poll-based, like the rest of the + /// app core's async state). + pub fn status(&self) -> PreviewSlotStatus { + self.slot.borrow().status.clone() + } + + /// Bumped on every status change — poll this to re-read cheaply. + pub fn status_revision(&self) -> u64 { + self.slot.borrow().status_revision + } + + /// Frames known to have reached the slot's canvas (GPU present acks + + /// CPU blits). The consumer's "swap the placeholder after the first + /// present" signal. + pub fn presented_frames(&self) -> u64 { + self.slot.borrow().presented_frames + } + + /// Suspend (`false`) or resume (`true`) presenting. Hiding pauses the + /// scheduler and freezes the canvas on its last frame; showing + /// resumes a held runtime immediately, or re-leases the slot when its + /// runtime was evicted or lost with its worker. + pub fn set_visible(&self, visible: bool) { + let mut slot = self.slot.borrow_mut(); + if slot.visible == visible { + return; + } + slot.visible = visible; + if slot.released || slot.terminal { + return; + } + if !visible { + if matches!(slot.status, PreviewSlotStatus::Live { .. }) { + slot.schedule.pause(); + slot.set_status(PreviewSlotStatus::Suspended); + } + return; + } + slot.last_active_ms = now_ms(); + if slot.runtime_id.is_some() && slot.presentable { + if matches!(slot.status, PreviewSlotStatus::Suspended) { + let start_at = slot.last_active_ms; + slot.schedule.start(start_at); + let live = slot.live_status(); + slot.set_status(live); + } + } else if slot.runtime_id.is_none() && !slot.deploying && !slot.pending { + slot.resume_requested = true; + } + } +} + +impl Drop for PreviewSlotHandle { + fn drop(&mut self) { + self.slot.borrow_mut().released = true; + } +} + +struct RecycleRequest { + worker_index: usize, + /// Runtime whose present failure condemned the worker (`None` for + /// worker-level failures), for strike accounting. + cause_runtime: Option, + reason: String, +} + +/// Boot (or respawn) pool worker `index` at `generation`. +fn boot_task(shared: Rc>, index: usize, generation: u32) -> LocalTask { + Box::pin(async move { + let label = format!("preview-host-worker-{index}-g{generation}"); + let result = PreviewWorker::boot(&label).await; + let mut shared = shared.borrow_mut(); + let superseded = shared.shutdown + || shared + .workers + .get(index) + .is_none_or(|entry| entry.generation != generation); + if superseded { + if let Ok(worker) = result { + worker.terminate(); + } + return; + } + shared.workers[index].state = match result { + Ok(worker) => WorkerState::Ready(Rc::new(RefCell::new(worker))), + Err(reason) => { + log::warn!("preview host: worker {index} boot failed: {reason}"); + WorkerState::Dead(reason) + } + }; + }) +} + +/// Why a lease pipeline stopped early. +enum LeaseEnd { + /// The slot was released or its worker was recycled/shut down while + /// the pipeline ran; unwind silently (the run loop already decided + /// the slot's future). + Stale, + /// The lease failed; park the slot in `Error`. + Fail(String), +} + +/// One slot's lease pipeline: materialize content, create the tiered +/// runtime, deploy, attach the surface (GPU) or size the canvas (CPU), +/// then go live. +fn lease_pipeline( + shared: Rc>, + slot: Rc>, + worker: Rc>, + worker_index: usize, + generation: u32, +) -> LocalTask { + Box::pin(async move { + let sleeper = Rc::new(PreviewSleeper::new()); + let result = run_lease(&shared, &slot, &worker, worker_index, generation, &sleeper).await; + slot.borrow_mut().deploying = false; + match result { + Ok(()) => {} + Err(LeaseEnd::Stale) => {} + Err(LeaseEnd::Fail(reason)) => { + // A failure raced the worker's recycle (or shutdown): the + // run loop already decided the slot's future (re-lease or + // park), so do not overwrite it with a stale error. + let superseded = { + let shared = shared.borrow(); + shared.shutdown + || shared + .workers + .get(worker_index) + .is_none_or(|entry| entry.generation != generation) + }; + if superseded { + return; + } + // Deliberate failure surface: destroy anything acquired + // and park; the consumer recovers by leasing again + // (remounting its canvas first on the GPU tier). + let runtime = slot.borrow_mut().runtime_id.take(); + if let Some(runtime_id) = runtime { + let mut worker = worker.borrow_mut(); + if let Err(error) = worker.destroy_runtime(runtime_id) { + log::warn!("preview host: destroy runtime {runtime_id}: {error}"); + } + worker.forget_runtime(runtime_id); + } + let mut slot = slot.borrow_mut(); + slot.presentable = false; + slot.terminal = true; + slot.set_status(PreviewSlotStatus::Error { reason }); + } + } + }) +} + +async fn run_lease( + shared: &Rc>, + slot: &Rc>, + worker: &Rc>, + worker_index: usize, + generation: u32, + sleeper: &Rc, +) -> Result<(), LeaseEnd> { + let stale = || -> bool { + let shared = shared.borrow(); + shared.shutdown + || slot.borrow().released + || shared + .workers + .get(worker_index) + .is_none_or(|entry| entry.generation != generation) + }; + let check = || -> Result<(), LeaseEnd> { + if stale() { + Err(LeaseEnd::Stale) + } else { + Ok(()) + } + }; + + // 1. Materialize the source into deploy files. + let (source, canvas_id, slot_id) = { + let slot = slot.borrow(); + (slot.source.clone(), slot.canvas_id.clone(), slot.id) + }; + let files: Vec = match &source { + PreviewSource::Example(id) => example_deploy_files(id).map_err(LeaseEnd::Fail)?, + PreviewSource::ProjectUid(uid) => { + let library = shared.borrow().library.clone().ok_or_else(|| { + LeaseEnd::Fail("no library attached; project previews unavailable".to_string()) + })?; + let fs = library + .catalog_snapshot() + .await + .map_err(|error| LeaseEnd::Fail(format!("library: {error}")))?; + check()?; + catalog_deploy_files(fs, uid).map_err(LeaseEnd::Fail)? + } + }; + check()?; + + // 2. Create the tiered runtime (always request GPU; the granted tier + // and any fallback reason come back on `runtime_created`). + let label = format!("preview-slot-{slot_id}-g{generation}"); + worker + .borrow() + .post(&BrowserInputEnvelope::CreateRuntime { + label: label.clone(), + tier: BrowserRuntimeTier::Gpu, + }) + .map_err(LeaseEnd::Fail)?; + let mut created = None; + for _ in 0..CREATE_RUNTIME_POLL_LIMIT { + { + let mut worker = worker.borrow_mut(); + worker.drain_outputs(); + created = worker.take_created_runtime(&label); + } + if created.is_some() { + break; + } + check()?; + sleeper.sleep_ms(4).await; + } + let created = + created.ok_or_else(|| LeaseEnd::Fail("timed out creating preview runtime".to_string()))?; + let tier = match created.tier { + BrowserRuntimeTier::Gpu => PreviewTier::Gpu, + BrowserRuntimeTier::Cpu => PreviewTier::Cpu, + }; + { + let mut slot = slot.borrow_mut(); + slot.runtime_id = Some(created.runtime_id); + slot.tier = Some(tier); + slot.tier_reason = created.tier_reason.clone(); + } + check()?; + + // 3. Deploy the project into the runtime (per-runtime protocol frames + // with tick-per-poll; explicit tick mode). + let mut client = LpClient::new(PreviewClientIo::new( + Rc::clone(worker), + created.runtime_id, + Rc::clone(sleeper), + )); + client + .deploy_project_files(PREVIEW_PROJECT_ID, files) + .await + .map_err(|error| LeaseEnd::Fail(format!("deploy: {error}")))?; + check()?; + + // 4. Wire the consumer's canvas: GPU transfers it into the worker as + // the present surface; CPU reads its size for pixel frames. + let canvas = wait_for_canvas(&canvas_id, sleeper, &stale).await?; + if tier == PreviewTier::Gpu { + let offscreen = canvas.transfer_control_to_offscreen().map_err(|_| { + LeaseEnd::Fail(format!( + "canvas #{canvas_id} was already transferred — remount a fresh canvas and lease \ + again" + )) + })?; + worker + .borrow() + .attach_preview_surface(created.runtime_id, offscreen) + .map_err(LeaseEnd::Fail)?; + let mut attached = false; + for _ in 0..ATTACH_SURFACE_POLL_LIMIT { + { + let mut worker = worker.borrow_mut(); + worker.drain_outputs(); + attached = worker.take_surface_attached(created.runtime_id); + } + if attached { + break; + } + if let Some(reason) = slot.borrow_mut().attach_error.take() { + return Err(LeaseEnd::Fail(format!("attach surface: {reason}"))); + } + check()?; + sleeper.sleep_ms(4).await; + } + if !attached { + return Err(LeaseEnd::Fail( + "timed out waiting for the preview surface to attach".to_string(), + )); + } + } else { + let width = canvas.width().clamp(16, 1_024); + let height = canvas.height().clamp(16, 1_024); + slot.borrow_mut().cpu_size = (width, height); + } + + // 5. Live (or parked, when the consumer hid the card mid-deploy). + let mut slot = slot.borrow_mut(); + slot.presentable = true; + slot.last_active_ms = now_ms(); + if slot.visible { + let start_at = slot.last_active_ms; + slot.schedule.start(start_at); + let live = slot.live_status(); + slot.set_status(live); + } else { + slot.set_status(PreviewSlotStatus::Suspended); + } + Ok(()) +} + +/// Poll the DOM for the consumer's mounted canvas element. +async fn wait_for_canvas( + canvas_id: &str, + sleeper: &Rc, + stale: &dyn Fn() -> bool, +) -> Result { + for _ in 0..ATTACH_SURFACE_POLL_LIMIT { + let canvas = web_sys::window() + .and_then(|window| window.document()) + .and_then(|document| document.get_element_by_id(canvas_id)) + .and_then(|element| element.dyn_into::().ok()); + if let Some(canvas) = canvas { + return Ok(canvas); + } + if stale() { + return Err(LeaseEnd::Stale); + } + sleeper.sleep_ms(4).await; + } + Err(LeaseEnd::Fail(format!( + "canvas #{canvas_id} not mounted for the preview" + ))) +} + +/// Blit one CPU-tier pixel frame onto the slot's canvas via +/// `putImageData` (the pixels arrived as a transferable `ArrayBuffer`, +/// never through the JSON path). +fn blit_pixel_frame( + slot: &mut Slot, + frame: &lpa_link::providers::browser_worker::PreviewPixelFrame, +) -> Result<(), String> { + let context = slot_blit_context(slot)?; + let canvas = context + .canvas() + .ok_or_else(|| "canvas context has no canvas".to_string())?; + if canvas.width() != frame.width || canvas.height() != frame.height { + canvas.set_width(frame.width); + canvas.set_height(frame.height); + } + let pixels = js_sys::Uint8ClampedArray::new(&frame.pixels); + let image = + web_sys::ImageData::new_with_js_u8_clamped_array_and_sh(&pixels, frame.width, frame.height) + .map_err(|error| format!("build ImageData: {error:?}"))?; + context + .put_image_data(&image, 0.0, 0.0) + .map_err(|error| format!("putImageData: {error:?}"))?; + Ok(()) +} + +fn slot_blit_context(slot: &mut Slot) -> Result { + if let Some(context) = &slot.context { + let connected = context + .canvas() + .map(|canvas| canvas.is_connected()) + .unwrap_or(false); + if connected { + return Ok(context.clone()); + } + slot.context = None; + } + let id = &slot.canvas_id; + let document = web_sys::window() + .and_then(|window| window.document()) + .ok_or_else(|| "missing document".to_string())?; + let canvas = document + .get_element_by_id(id) + .ok_or_else(|| format!("canvas #{id} not mounted"))? + .dyn_into::() + .map_err(|_| format!("#{id} is not a canvas"))?; + let context = canvas + .get_context("2d") + .map_err(|error| format!("get 2d context: {error:?}"))? + .ok_or_else(|| "2d context unavailable".to_string())? + .dyn_into::() + .map_err(|_| "2d context has unexpected type".to_string())?; + slot.context = Some(context.clone()); + Ok(context) +} + +/// Advance every cooperative sub-task one poll, dropping the finished +/// ones. The tasks' awaits are timer/message-backed, so re-polling each +/// scheduler tick drives them without an executor (their wakes are +/// no-ops by construction). +fn poll_tasks(tasks: &mut Vec) { + let mut context = Context::from_waker(Waker::noop()); + tasks.retain_mut(|task| task.as_mut().poll(&mut context) == Poll::Pending); +} + +fn now_ms() -> f64 { + web_sys::window() + .and_then(|window| window.performance()) + .map(|performance| performance.now()) + .unwrap_or_else(js_sys::Date::now) +} diff --git a/lp-app/lpa-studio-core/src/app/preview_host/preview_sleep.rs b/lp-app/lpa-studio-core/src/app/preview_host/preview_sleep.rs new file mode 100644 index 000000000..b8113d84f --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/preview_host/preview_sleep.rs @@ -0,0 +1,103 @@ +//! Visibility-throttle-immune sleep for the preview host. +//! +//! Hidden tabs clamp main-thread `setTimeout` to ≥1 s (background timer +//! throttling), which would collapse the host's few-ms scheduling loop to +//! one iteration per second the moment the tab is hidden. Worker timers +//! are exempt, so each sleeper paces itself off a tiny inline Worker: +//! post the delay, the worker `setTimeout`s unthrottled, and its reply +//! message (also unthrottled) resolves the sleep. This is the productized +//! preview-lab sleeper (its measured lesson; see the preview-host ADR). +//! +//! One sleeper supports one await at a time (each sleep re-arms the +//! worker's single `onmessage` slot), so every concurrently running +//! pipeline owns its own sleeper. Dropping a sleeper terminates its timer +//! worker. + +use wasm_bindgen::prelude::*; +use wasm_bindgen_futures::JsFuture; +use web_sys::{Blob, BlobPropertyBag, MessageEvent, Url, Worker}; + +const TIMER_WORKER_JS: &str = + "self.onmessage = (e) => { setTimeout(() => self.postMessage(0), e.data); };"; + +/// Sleeper backed by one timer worker; falls back to plain window +/// `setTimeout` (throttled when hidden) if worker creation fails. +pub(super) struct PreviewSleeper { + worker: Option, +} + +impl PreviewSleeper { + /// Spawn the timer worker (logging and degrading on failure). + pub(super) fn new() -> Self { + let worker = match spawn_timer_worker() { + Ok(worker) => Some(worker), + Err(error) => { + log::warn!( + "preview host timer worker unavailable ({error:?}); falling back to \ + setTimeout (throttled in hidden tabs)" + ); + None + } + }; + Self { worker } + } + + /// Sleep for `ms` milliseconds, immune to hidden-tab throttling when + /// the timer worker is available. + pub(super) async fn sleep_ms(&self, ms: u32) { + match &self.worker { + Some(worker) => { + if sleep_via_worker(worker, ms).await.is_err() { + sleep_via_window(ms).await; + } + } + None => sleep_via_window(ms).await, + } + } +} + +impl Drop for PreviewSleeper { + fn drop(&mut self) { + if let Some(worker) = &self.worker { + worker.terminate(); + } + } +} + +fn spawn_timer_worker() -> Result { + let parts = js_sys::Array::of1(&JsValue::from_str(TIMER_WORKER_JS)); + let options = BlobPropertyBag::new(); + options.set_type("application/javascript"); + let blob = Blob::new_with_str_sequence_and_options(&parts, &options)?; + let url = Url::create_object_url_with_blob(&blob)?; + let worker = Worker::new(&url); + Url::revoke_object_url(&url)?; + worker +} + +async fn sleep_via_worker(worker: &Worker, ms: u32) -> Result<(), JsValue> { + let promise = js_sys::Promise::new(&mut |resolve, _reject| { + // One-shot handler: freed automatically after its single invocation. + let handler = Closure::once_into_js(move |_event: MessageEvent| { + let _ = resolve.call0(&JsValue::NULL); + }); + worker.set_onmessage(Some(handler.unchecked_ref())); + }); + worker.post_message(&JsValue::from_f64(f64::from(ms)))?; + JsFuture::from(promise).await.map(|_| ()) +} + +async fn sleep_via_window(ms: u32) { + let promise = js_sys::Promise::new(&mut |resolve, reject| { + let Some(window) = web_sys::window() else { + let _ = reject.call1(&JsValue::NULL, &JsValue::from_str("missing window")); + return; + }; + if let Err(error) = + window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms as i32) + { + let _ = reject.call1(&JsValue::NULL, &error); + } + }); + let _ = JsFuture::from(promise).await; +} diff --git a/lp-app/lpa-studio-core/src/app/preview_host/preview_types.rs b/lp-app/lpa-studio-core/src/app/preview_host/preview_types.rs new file mode 100644 index 000000000..7442ee2a8 --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/preview_host/preview_types.rs @@ -0,0 +1,123 @@ +//! Request/status vocabulary for [`super::PreviewHost`] slot leases. +//! +//! Target-neutral: consumers (and native tests) can build requests and +//! reason about statuses without the browser-worker machinery compiled in. + +/// Budgets and pool shape for one [`super::PreviewHost`]. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PreviewHostConfig { + /// Preview workers to boot. Two by default — an **isolation** choice + /// (device loss is per-worker, so one hostile project takes down at + /// most half the previews), not a CPU-parallelism one (the measured + /// path is GPU-bound; see the preview-host ADR). + pub pool_size: usize, + /// Global cap on slots holding a live runtime (deploying counts). + /// Leasing past the cap evicts the least-recently-used live slot, + /// preferring invisible ones. + pub max_live_slots: usize, + /// Present cadence for slots whose request carries no fps override. + pub default_fps: f32, +} + +impl Default for PreviewHostConfig { + fn default() -> Self { + Self { + pool_size: 2, + max_live_slots: 12, + default_fps: 12.0, + } + } +} + +/// What a preview slot renders. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum PreviewSource { + /// A compiled-in example package by id (e.g. `examples/fyeah-sign` — + /// the id [`crate::UiExampleCard`] carries); materialized via + /// [`super::example_deploy_files`]. + Example(String), + /// A library package by `prj_…` uid (or slug), materialized from a + /// library catalog snapshot via [`super::catalog_deploy_files`]. + ProjectUid(String), +} + +/// Reserved seam for per-project preview behavior (preview-host ADR): +/// auto input playback (button presses), audio sources for music-reactive +/// programs, and eventually preview-mode authoring. Empty today; those +/// features land by adding fields here instead of reshaping the lease API. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct PreviewProfile {} + +/// One slot lease request. +#[derive(Clone, Debug, PartialEq)] +pub struct PreviewSlotRequest { + /// Content to deploy into the slot's runtime. + pub source: PreviewSource, + /// Element id of the consumer-owned `` the preview renders to. + /// The consumer owns mounting (and remounting: a GPU-tier canvas is + /// permanently consumed by `transferControlToOffscreen`, so recovery + /// after eviction or worker recycle needs a fresh element). The host + /// fails the lease with a clear [`PreviewSlotStatus::Error`] when the + /// canvas never mounts or was already transferred. + pub canvas_id: String, + /// Present cadence override; `None` uses + /// [`PreviewHostConfig::default_fps`]. + pub fps: Option, + /// Per-project preview behavior (reserved seam, empty today). + pub profile: PreviewProfile, +} + +/// Shader-execution tier a slot's runtime was granted. +/// +/// Mirrors `lpa-link`'s browser-worker tier vocabulary without pulling the +/// wasm-only provider into native builds; the host maps the granted wire +/// tier onto this at runtime creation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PreviewTier { + /// Q32 on `lpvm-wasm` (authoritative tier; pixel frames blitted by the + /// host via `putImageData`). + Cpu, + /// f32 on WebGPU, presenting straight to the transferred canvas + /// surface (zero readback). + Gpu, +} + +/// Observable per-slot state (fidelity-tiers ADR: tier selection and every +/// failure surface here, never silently). +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum PreviewSlotStatus { + /// Queued or in the lease pipeline (create → deploy → attach). + Deploying, + /// Presenting at the slot's cadence. + Live { + /// Granted tier. + tier: PreviewTier, + /// Why a GPU request resolved to the CPU tier (`None` when the + /// request was granted as asked). + tier_reason: Option, + }, + /// Not presenting: hidden (`set_visible(false)`), LRU-evicted past the + /// live-slot cap, or parked after a worker recycle while invisible. + /// The canvas keeps its last frame. + Suspended, + /// The lease failed or the slot's runtime died; `reason` is the + /// user-facing explanation. + Error { + /// What went wrong (deploy failure, canvas never mounted or + /// already transferred, present error, worker loss, …). + reason: String, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn config_defaults_match_the_adr() { + let config = PreviewHostConfig::default(); + assert_eq!(config.pool_size, 2); + assert_eq!(config.max_live_slots, 12); + assert_eq!(config.default_fps, 12.0); + } +} diff --git a/lp-app/lpa-studio-core/src/app/preview_host/preview_worker.rs b/lp-app/lpa-studio-core/src/app/preview_host/preview_worker.rs new file mode 100644 index 000000000..cd6df4c4f --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/preview_host/preview_worker.rs @@ -0,0 +1,217 @@ +//! Per-worker demultiplexer over one `lpa-link` browser-worker handle. +//! +//! One `PreviewWorker` owns one explicit-tick Web Worker hosting several +//! preview runtimes. It routes JSON envelopes (protocol frames by +//! `runtime_id`, lifecycle events, preview errors) and hands binary pixel +//! frames straight through — pixels never touch the JSON path. This is the +//! productized shape of the preview-lab's rig, owned by the host so no +//! product code imports exploration modules. + +use std::collections::{HashMap, VecDeque}; + +use lpa_link::providers::browser_worker::{ + BrowserInputEnvelope, BrowserOutputEnvelope, BrowserRuntimeTier, BrowserTickMode, + BrowserWorkerHandle, BrowserWorkerOptions, PreviewPixelFrame, +}; + +/// One failed `preview_frame` / `present_frame` / `attach_surface` +/// request (`frame_id` 0 marks attach/lifecycle failures). +pub(super) struct SlotPreviewError { + pub(super) runtime_id: u32, + pub(super) frame_id: u32, + pub(super) message: String, +} + +/// One `runtime_created` answer, carrying the granted tier. +pub(super) struct CreatedRuntime { + pub(super) runtime_id: u32, + pub(super) tier: BrowserRuntimeTier, + pub(super) tier_reason: Option, +} + +/// One completed GPU-tier present (timing header; the frame is already on +/// the slot's surface). +pub(super) struct PresentedFrame { + pub(super) runtime_id: u32, +} + +pub(super) struct PreviewWorker { + handle: BrowserWorkerHandle, + protocol: HashMap>, + created: HashMap, + surfaces_attached: Vec, + presented: Vec, + preview_errors: Vec, + /// Worker-fatal errors (crash, uncaught script error). One entry is + /// enough to condemn the worker to a recycle. + worker_errors: Vec, +} + +impl PreviewWorker { + /// Spawn and boot one explicit-tick worker. The boot runtime idles + /// (never ticked); preview runtimes are created per lease. + pub(super) async fn boot(label: &str) -> Result { + let options = BrowserWorkerOptions::default().with_tick_mode(BrowserTickMode::Explicit); + let mut handle = BrowserWorkerHandle::new(&options.worker_script_path()) + .map_err(|error| format!("spawn worker: {error}"))?; + handle + .boot(label, &options) + .await + .map_err(|error| format!("boot worker: {error}"))?; + Ok(Self { + handle, + protocol: HashMap::new(), + created: HashMap::new(), + surfaces_attached: Vec::new(), + presented: Vec::new(), + preview_errors: Vec::new(), + worker_errors: Vec::new(), + }) + } + + pub(super) fn post(&self, envelope: &BrowserInputEnvelope) -> Result<(), String> { + self.handle + .post(envelope) + .map_err(|error| format!("{error}")) + } + + /// Transfer a slot's `OffscreenCanvas` into the worker as its + /// runtime's presentation surface (GPU tier). + pub(super) fn attach_preview_surface( + &self, + runtime_id: u32, + canvas: web_sys::OffscreenCanvas, + ) -> Result<(), String> { + self.handle + .attach_preview_surface(runtime_id, canvas) + .map_err(|error| format!("{error}")) + } + + /// Release a slot runtime (fire-and-forget; the ack is idempotent). + pub(super) fn destroy_runtime(&self, runtime_id: u32) -> Result<(), String> { + self.handle + .destroy_runtime(runtime_id) + .map_err(|error| format!("{error}")) + } + + /// Drain and route pending JSON envelopes from the worker. + pub(super) fn drain_outputs(&mut self) { + for output in self.handle.take_outputs() { + match output { + BrowserOutputEnvelope::ProtocolOut { runtime_id, frame } => { + self.protocol + .entry(runtime_id) + .or_default() + .push_back(frame); + } + BrowserOutputEnvelope::RuntimeCreated { + runtime_id, + label, + tier, + tier_reason, + } => { + self.created.insert( + label, + CreatedRuntime { + runtime_id, + tier, + tier_reason, + }, + ); + } + // Destroy is fire-and-forget (idempotent ack; P2 contract). + BrowserOutputEnvelope::RuntimeDestroyed { .. } => {} + BrowserOutputEnvelope::SurfaceAttached { runtime_id } => { + self.surfaces_attached.push(runtime_id); + } + BrowserOutputEnvelope::PreviewPresented { runtime_id, .. } => { + self.presented.push(PresentedFrame { runtime_id }); + } + BrowserOutputEnvelope::PreviewError { + runtime_id, + frame_id, + message, + } => { + self.preview_errors.push(SlotPreviewError { + runtime_id, + frame_id, + message, + }); + } + BrowserOutputEnvelope::Status { + status, message, .. + } if status == "error" => { + self.worker_errors.push(message.unwrap_or_else(|| { + "worker reported error status without detail".to_string() + })); + } + BrowserOutputEnvelope::Log { level, message, .. } + if level == "error" || level == "warn" => + { + log::debug!("preview worker {level}: {message}"); + } + BrowserOutputEnvelope::Status { .. } | BrowserOutputEnvelope::Log { .. } => {} + } + } + } + + /// Take binary CPU-tier pixel frames received since the last call. + pub(super) fn take_preview_frames(&mut self) -> Vec { + self.handle.take_preview_frames() + } + + /// Take preview request failures received since the last call. + pub(super) fn take_preview_errors(&mut self) -> Vec { + core::mem::take(&mut self.preview_errors) + } + + /// Take GPU-tier present completions received since the last call. + pub(super) fn take_presented_frames(&mut self) -> Vec { + core::mem::take(&mut self.presented) + } + + /// Take worker-fatal error notes received since the last call. + pub(super) fn take_worker_errors(&mut self) -> Vec { + core::mem::take(&mut self.worker_errors) + } + + /// Consume a `surface_attached` ack for a runtime. + pub(super) fn take_surface_attached(&mut self, runtime_id: u32) -> bool { + let index = self + .surfaces_attached + .iter() + .position(|attached| *attached == runtime_id); + match index { + Some(index) => { + self.surfaces_attached.remove(index); + true + } + None => false, + } + } + + /// Pop the next protocol frame queued for `runtime_id`. + pub(super) fn pop_protocol_frame(&mut self, runtime_id: u32) -> Option { + self.protocol.get_mut(&runtime_id)?.pop_front() + } + + /// Consume a `runtime_created` event by creation label. + pub(super) fn take_created_runtime(&mut self, label: &str) -> Option { + self.created.remove(label) + } + + /// Drop any state queued for a released runtime so stale frames never + /// route to a recycled slot id. + pub(super) fn forget_runtime(&mut self, runtime_id: u32) { + self.protocol.remove(&runtime_id); + self.surfaces_attached.retain(|id| *id != runtime_id); + self.presented + .retain(|frame| frame.runtime_id != runtime_id); + self.preview_errors + .retain(|error| error.runtime_id != runtime_id); + } + + pub(super) fn terminate(&self) { + self.handle.terminate(); + } +} diff --git a/lp-app/lpa-studio-core/src/app/preview_host/slot_policy.rs b/lp-app/lpa-studio-core/src/app/preview_host/slot_policy.rs new file mode 100644 index 000000000..45ff587a6 --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/preview_host/slot_policy.rs @@ -0,0 +1,94 @@ +//! Pure pool policies: least-loaded worker choice and LRU eviction. +//! +//! Kept browser-free so the decisions the host makes under budget pressure +//! are unit-testable natively. + +/// One live slot as the eviction policy sees it. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct EvictionCandidate { + /// Caller-side identifier echoed back by [`choose_eviction`]. + pub slot_id: u64, + /// Whether the consumer currently reports the slot visible. + pub visible: bool, + /// Last activity stamp (lease, visibility, present completion), in the + /// caller's clock — smaller is older. + pub last_active_ms: f64, +} + +/// Pick the slot to evict when the live-slot cap is hit: the +/// least-recently-active candidate, preferring invisible slots over +/// visible ones (a visible slot is only evicted when every live slot is +/// visible). `None` when there is nothing to evict. +pub fn choose_eviction(candidates: &[EvictionCandidate]) -> Option { + let pick = |visible: bool| { + candidates + .iter() + .filter(|candidate| candidate.visible == visible) + .min_by(|a, b| a.last_active_ms.total_cmp(&b.last_active_ms)) + }; + pick(false).or_else(|| pick(true)).map(|c| c.slot_id) +} + +/// Pick the least-loaded available worker. `loads[i]` is `Some(assigned +/// slot count)` for a usable worker and `None` for one that is dead or +/// still booting. Ties resolve to the lowest index. +pub fn choose_worker(loads: &[Option]) -> Option { + loads + .iter() + .enumerate() + .filter_map(|(index, load)| load.map(|load| (index, load))) + .min_by_key(|&(index, load)| (load, index)) + .map(|(index, _)| index) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn candidate(slot_id: u64, visible: bool, last_active_ms: f64) -> EvictionCandidate { + EvictionCandidate { + slot_id, + visible, + last_active_ms, + } + } + + #[test] + fn eviction_prefers_the_oldest_invisible_slot() { + let candidates = [ + candidate(1, true, 10.0), + candidate(2, false, 500.0), + candidate(3, false, 200.0), + candidate(4, true, 5.0), + ]; + assert_eq!(choose_eviction(&candidates), Some(3)); + } + + #[test] + fn eviction_falls_back_to_the_oldest_visible_slot() { + let candidates = [ + candidate(1, true, 300.0), + candidate(2, true, 100.0), + candidate(3, true, 200.0), + ]; + assert_eq!(choose_eviction(&candidates), Some(2)); + } + + #[test] + fn eviction_with_no_candidates_is_none() { + assert_eq!(choose_eviction(&[]), None); + } + + #[test] + fn worker_choice_takes_the_least_loaded_available_worker() { + assert_eq!(choose_worker(&[Some(3), Some(1)]), Some(1)); + assert_eq!(choose_worker(&[None, Some(4)]), Some(1)); + assert_eq!(choose_worker(&[None, None]), None); + assert_eq!(choose_worker(&[]), None); + } + + #[test] + fn worker_choice_breaks_ties_toward_the_lowest_index() { + assert_eq!(choose_worker(&[Some(2), Some(2)]), Some(0)); + } +} diff --git a/lp-app/lpa-studio-core/src/app/server/browser_worker_client_io.rs b/lp-app/lpa-studio-core/src/app/server/browser_worker_client_io.rs index 3a751f1b6..bacf1342e 100644 --- a/lp-app/lpa-studio-core/src/app/server/browser_worker_client_io.rs +++ b/lp-app/lpa-studio-core/src/app/server/browser_worker_client_io.rs @@ -156,6 +156,7 @@ fn worker_output_to_log(output: BrowserOutputEnvelope) -> Option { } BrowserOutputEnvelope::ProtocolOut { .. } | BrowserOutputEnvelope::RuntimeCreated { .. } + | BrowserOutputEnvelope::RuntimeDestroyed { .. } | BrowserOutputEnvelope::SurfaceAttached { .. } | BrowserOutputEnvelope::PreviewPresented { .. } => None, } diff --git a/lp-app/lpa-studio-core/src/lib.rs b/lp-app/lpa-studio-core/src/lib.rs index e3ad1b6e6..fe29b8143 100644 --- a/lp-app/lpa-studio-core/src/lib.rs +++ b/lp-app/lpa-studio-core/src/lib.rs @@ -47,6 +47,12 @@ pub use app::node::{ UiSlotRecord, UiSlotShape, UiSlotShapeField, UiSlotSourceState, UiSlotUnit, UiSlotValue, UiSlotValueKind, }; +#[cfg(all(feature = "browser-worker", target_arch = "wasm32"))] +pub use app::preview_host::{PreviewHost, PreviewSlotHandle}; +pub use app::preview_host::{ + PreviewHostConfig, PreviewProfile, PreviewSlotRequest, PreviewSlotStatus, PreviewSource, + PreviewTier, +}; pub use app::project::{ AssetContentFetchOp, AssetEditOp, DirtySummary, LoadedProjectChoice, MAX_ASSET_BODY_BYTES, NodeController, NodeControllerState, NodeRevertOp, PendingAssetEdit, PendingEdit, diff --git a/lp-app/lpa-studio-web/Cargo.toml b/lp-app/lpa-studio-web/Cargo.toml index c60c75d07..0401b9310 100644 --- a/lp-app/lpa-studio-web/Cargo.toml +++ b/lp-app/lpa-studio-web/Cargo.toml @@ -44,6 +44,8 @@ web-sys = { version = "0.3", features = [ "HtmlCanvasElement", "HtmlElement", "ImageData", + "IntersectionObserver", + "IntersectionObserverEntry", "Location", "MediaQueryList", "MessageEvent", diff --git a/lp-app/lpa-studio-web/assets/tailwind.css b/lp-app/lpa-studio-web/assets/tailwind.css index 2990a9a85..0f4212e92 100644 --- a/lp-app/lpa-studio-web/assets/tailwind.css +++ b/lp-app/lpa-studio-web/assets/tailwind.css @@ -116,6 +116,9 @@ .tw\:top-0 { top: calc(var(--tw-spacing) * 0); } + .tw\:top-1\.5 { + top: calc(var(--tw-spacing) * 1.5); + } .tw\:top-1\/2 { top: calc(1/2 * 100%); } @@ -437,6 +440,9 @@ .tw\:w-\[128px\] { width: 128px; } + .tw\:w-\[720px\] { + width: 720px; + } .tw\:w-\[min\(320px\,calc\(100vw-24px\)\)\] { width: min(320px, calc(100vw - 24px)); } @@ -929,6 +935,12 @@ .tw\:bg-accent { background-color: var(--tw-color-accent); } + .tw\:bg-background\/70 { + background-color: var(--tw-color-background); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--tw-color-background) 70%, transparent); + } + } .tw\:bg-background\/80 { background-color: var(--tw-color-background); @supports (color: color-mix(in lab, red, red)) { @@ -1091,6 +1103,9 @@ .tw\:bg-\[linear-gradient\(270deg\,var\(--studio-status-working-bg\)_0\%\,var\(--studio-status-working-bg\)_34\%\,transparent_100\%\)\] { background-image: linear-gradient(270deg,var(--studio-status-working-bg) 0%,var(--studio-status-working-bg) 34%,transparent 100%); } + .tw\:object-cover { + object-fit: cover; + } .tw\:p-0 { padding: calc(var(--tw-spacing) * 0); } @@ -1310,6 +1325,10 @@ .tw\:text-\[11px\] { font-size: 11px; } + .tw\:leading-4 { + --tw-leading: calc(var(--tw-spacing) * 4); + line-height: calc(var(--tw-spacing) * 4); + } .tw\:leading-none { --tw-leading: 1; line-height: 1; @@ -1511,6 +1530,10 @@ --tw-duration: 150ms; transition-duration: 150ms; } + .tw\:duration-200 { + --tw-duration: 200ms; + transition-duration: 200ms; + } .tw\:duration-300 { --tw-duration: 300ms; transition-duration: 300ms; diff --git a/lp-app/lpa-studio-web/src/app/home/card_thumb.rs b/lp-app/lpa-studio-web/src/app/home/card_thumb.rs index 36988bd0c..88763d236 100644 --- a/lp-app/lpa-studio-web/src/app/home/card_thumb.rs +++ b/lp-app/lpa-studio-web/src/app/home/card_thumb.rs @@ -1,11 +1,27 @@ -//! Placeholder card thumbnails. +//! Card thumbnails: a layered stack under the live GPU gallery. //! -//! The thumbnail source is swappable by design (roadmap M4): today a -//! deterministic gradient seeded by the package identity with the name's -//! initial; a cached rendered frame (and later a live GPU gallery) replaces -//! this component's body without touching the cards. +//! Layers, top to bottom (gpu-live-gallery P4 + the M6 primary-visual +//! coordination): +//! +//! 1. **Live canvas** — mounted when the card has a preview source, +//! revealed once the `PreviewHost` slot presents its first frame. The +//! presented channel is bus `visual.out`, which IS the M6 "primary +//! visual" contract (the engine resolves the highest-priority +//! provider), so cards never re-derive which product is a project's +//! face. +//! 2. **Snapshot seam** — a structurally present `` for M6's +//! save-time capture; sourceless (and hidden) until that lands. +//! 3. **Gradient base** — the deterministic identity gradient with the +//! name's initial: the placeholder before the first present, the +//! stories' whole face, and the fallback when previews fail. +//! +//! A corner badge surfaces the granted tier and failures (fidelity-tiers +//! ADR: never silent). use dioxus::prelude::*; +use lpa_studio_core::PreviewSource; + +use crate::app::home::gallery_preview::{ThumbPreviewBadge, use_thumb_preview}; #[component] #[allow(non_snake_case, reason = "Dioxus components use PascalCase")] @@ -13,7 +29,17 @@ pub(crate) fn CardThumb( seed: String, label: String, #[props(default = false)] muted: bool, + /// Live preview content for this thumb. `None` (stories, device-less + /// contexts) renders the static gradient stack — no host, no canvas. + #[props(default)] + source: Option, + /// Story/test injection: render this badge statically, without any + /// PreviewHost. Overrides the live badge when both exist. + #[props(default)] + static_badge: Option, ) -> Element { + let preview = use_thumb_preview(source); + let badge = static_badge.or(preview.badge); let (hue_a, hue_b) = thumb_hues(&seed); let (saturation, lightness) = if muted { (12, 16) } else { (42, 22) }; let style = format!( @@ -37,13 +63,87 @@ pub(crate) fn CardThumb( rsx! { div { - class: "tw:flex tw:h-24 tw:w-full tw:items-center tw:justify-center tw:rounded-t-md", - style: "{style}", - span { class: initial_class, "{initial}" } + id: "{preview.frame_id}", + class: "tw:relative tw:h-24 tw:w-full tw:overflow-hidden tw:rounded-t-md", + // base layer: identity gradient + initial + div { + class: "tw:absolute tw:inset-0 tw:flex tw:items-center tw:justify-center", + style: "{style}", + span { class: initial_class, "{initial}" } + } + // Snapshot seam (M6 coordination): the save-time capture layer. + // Structurally present so M6 only has to hand it a source + // (LibraryStore package metadata, never the deploy-synced file + // tree); sourceless and hidden until that capture lands. + img { + class: "tw:absolute tw:inset-0 tw:hidden tw:h-full tw:w-full tw:object-cover", + alt: "", + } + // live layer: the PreviewHost canvas, revealed after the first + // presented frame; keyed so a bumped generation mounts a FRESH + // element (a GPU-tier canvas is consumed by its transfer) + if let Some(canvas) = preview.canvas { + canvas { + key: "{canvas.id}", + id: "{canvas.id}", + width: "256", + height: "96", + class: thumb_canvas_class(canvas.revealed), + } + } + if let Some(badge) = badge { + span { + class: "tw:absolute tw:right-1.5 tw:top-1.5 tw:rounded-sm tw:border tw:bg-background/70 tw:px-1 tw:text-[0.6rem] tw:font-bold tw:uppercase tw:leading-4 {thumb_badge_class(&badge)}", + title: thumb_badge_title(&badge), + {thumb_badge_text(&badge)} + } + } } } } +/// The live canvas layer: hidden (gradient shows) until the first frame +/// reaches it, then revealed with a short fade. +fn thumb_canvas_class(revealed: bool) -> &'static str { + if revealed { + "tw:absolute tw:inset-0 tw:h-full tw:w-full tw:opacity-100 tw:transition-opacity tw:duration-200" + } else { + "tw:absolute tw:inset-0 tw:h-full tw:w-full tw:opacity-0" + } +} + +/// Badge chip styling per state — preview-lab's tier vocabulary (GPU wears +/// the accent border, CPU the muted one) in gallery-sized clothes; errors +/// read as errors. +fn thumb_badge_class(badge: &ThumbPreviewBadge) -> &'static str { + match badge { + ThumbPreviewBadge::Gpu => "tw:border-accent-border tw:text-strong-foreground", + ThumbPreviewBadge::Cpu { .. } => "tw:border-border-strong tw:text-muted-foreground", + ThumbPreviewBadge::Error { .. } => "tw:border-border-strong tw:text-error-foreground", + } +} + +/// Badge chip text (compact: the tier name, or `!` for failures). +fn thumb_badge_text(badge: &ThumbPreviewBadge) -> &'static str { + match badge { + ThumbPreviewBadge::Gpu => "GPU", + ThumbPreviewBadge::Cpu { .. } => "CPU", + ThumbPreviewBadge::Error { .. } => "!", + } +} + +/// Badge tooltip: the fallback / failure reason when there is one. +fn thumb_badge_title(badge: &ThumbPreviewBadge) -> String { + match badge { + ThumbPreviewBadge::Gpu => "Live preview on the GPU tier".to_string(), + ThumbPreviewBadge::Cpu { reason: None } => "Live preview on the CPU tier".to_string(), + ThumbPreviewBadge::Cpu { + reason: Some(reason), + } => format!("CPU tier (GPU unavailable: {reason})"), + ThumbPreviewBadge::Error { reason } => format!("Preview failed: {reason}"), + } +} + /// Two stable hues from the seed (uid or name): FNV-1a, split into two /// angles far enough apart to read as a gradient. fn thumb_hues(seed: &str) -> (u16, u16) { diff --git a/lp-app/lpa-studio-web/src/app/home/example_card.rs b/lp-app/lpa-studio-web/src/app/home/example_card.rs index 2342839a3..049a5c758 100644 --- a/lp-app/lpa-studio-web/src/app/home/example_card.rs +++ b/lp-app/lpa-studio-web/src/app/home/example_card.rs @@ -1,7 +1,7 @@ //! Example cards: the window-shopper path. use dioxus::prelude::*; -use lpa_studio_core::{HomeOp, UiAction, UiExampleCard}; +use lpa_studio_core::{HomeOp, PreviewSource, UiAction, UiExampleCard}; use crate::app::home::card_thumb::CardThumb; use crate::app::home::package_card::home_action; @@ -32,7 +32,11 @@ pub(crate) fn ExampleCard( })); } }, - CardThumb { seed: card.id.clone(), label: card.name.clone() } + CardThumb { + seed: card.id.clone(), + label: card.name.clone(), + source: Some(PreviewSource::Example(card.id.clone())), + } div { class: "tw:grid tw:gap-0.5 tw:p-3", p { class: "tw:m-0 tw:truncate tw:text-sm tw:font-semibold tw:text-strong-foreground", "{card.name}" diff --git a/lp-app/lpa-studio-web/src/app/home/gallery_preview.rs b/lp-app/lpa-studio-web/src/app/home/gallery_preview.rs new file mode 100644 index 000000000..7d7ec25b3 --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/home/gallery_preview.rs @@ -0,0 +1,438 @@ +//! Live gallery thumbnails: the web-side seam between home cards and the +//! core `PreviewHost` (`docs/adr/2026-07-16-preview-host.md`). +//! +//! The core actor's home state stays metadata-only (`UiPackageCard` / +//! `UiExampleCard` carry the uid / example id, which IS the preview +//! source); everything DOM-shaped — canvas mounting, generation remounts, +//! IntersectionObserver visibility, status polling — lives here, next to +//! the cards that consume it. [`use_thumb_preview`] is the whole consumer +//! surface: `CardThumb` calls it and renders the returned snapshot. +//! +//! # Host construction and lifetime +//! +//! One [`PreviewHost`] per page, constructed **lazily** the first time a +//! live thumb becomes visible (so story builds, which never pass a +//! source, never boot preview workers) and kept for the **app lifetime**: +//! +//! - the host's `run()` contract is construct-once / drive-once, and its +//! pool workers each hold a WebGPU device request — re-booting the pool +//! on every home visit would pay that cost repeatedly; +//! - with no leased slots (project open, gallery unmounted) the host idles +//! at a ~100 ms poll with zero runtimes — there is nothing worth tearing +//! down; +//! - future preview consumers (editor visual-module previews, preview-mode +//! authoring) share the same host, which is the point of the service. +//! +//! Construction waits briefly for the app's local-store probe so +//! [`PreviewSource::ProjectUid`] leases get the library seam; if the store +//! never settles (OPFS unavailable) the host starts without it and project +//! leases fail-soft with a visible reason — exactly the state in which the +//! gallery renders no project cards anyway. + +use std::sync::atomic::{AtomicU64, Ordering}; + +use dioxus::prelude::*; +use lpa_studio_core::PreviewSource; + +/// What the thumb's corner badge shows about its preview slot (fidelity- +/// tiers ADR: the granted tier and every failure are user-visible, never +/// silent). Stories inject these statically; live cards derive them from +/// the slot status. +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr( + not(any(target_arch = "wasm32", feature = "stories")), + allow( + dead_code, + reason = "constructed by the wasm live-preview path and the stories feature; host builds only run the unit tests" + ) +)] +pub(crate) enum ThumbPreviewBadge { + /// Live on the GPU tier (presenting straight to the transferred + /// canvas, zero readback). + Gpu, + /// Live on the CPU tier; `reason` says why the GPU request fell back + /// (host-blitted `putImageData` frames — the UI never touches pixels). + Cpu { + /// The surfaced `tier_reason` (`None` when CPU was granted as + /// asked — not the case today; gallery leases always request GPU). + reason: Option, + }, + /// The preview gave up; the gradient stays and the chip carries the + /// reason as its tooltip. + Error { + /// User-facing explanation from the slot status. + reason: String, + }, +} + +/// Per-render snapshot of one card thumb's live-preview state, produced by +/// [`use_thumb_preview`]. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct ThumbPreview { + /// Stable per-mount element id for the thumb frame — the + /// IntersectionObserver target. + pub frame_id: String, + /// The live canvas layer to mount, when this thumb has a preview + /// source (wasm builds only; `None` renders the static stack). + pub canvas: Option, + /// Badge derived from the live slot status (`None` until the slot + /// goes live or fails). + pub badge: Option, +} + +/// The mounted live `` layer of a [`ThumbPreview`]. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct ThumbCanvas { + /// Generation-suffixed canvas element id: `transferControlToOffscreen` + /// permanently consumes a canvas, so every recovery mounts a fresh + /// element (preview-lab's generation discipline). + pub id: String, + /// A frame has reached this canvas — reveal it over the gradient. + pub revealed: bool, +} + +/// Monotonic thumb-frame ids (one per mounted `CardThumb`). +static NEXT_THUMB_ID: AtomicU64 = AtomicU64::new(0); + +/// Drive one card thumb's live preview and snapshot it for rendering. +/// +/// `source: None` (stories, non-wasm builds, cards without previews) is +/// fully inert: no host, no canvas, no observer. With a source, the thumb +/// leases a `PreviewHost` slot when it first scrolls into view, follows +/// visibility edges with `set_visible`, reveals the canvas after the first +/// presented frame, and recovers from errors by remounting a fresh canvas +/// generation and leasing again (bounded; then parks on an error badge). +pub(crate) fn use_thumb_preview(source: Option) -> ThumbPreview { + let frame_id = use_hook(|| { + let id = NEXT_THUMB_ID.fetch_add(1, Ordering::Relaxed); + format!("gallery-thumb-{id}") + }); + #[cfg(target_arch = "wasm32")] + { + wasm::use_live_thumb(frame_id, source) + } + #[cfg(not(target_arch = "wasm32"))] + { + // Host builds of this crate run unit tests only and never mount a + // live preview; the static stack still renders. + let _ = source; + ThumbPreview { + frame_id, + canvas: None, + badge: None, + } + } +} + +#[cfg(target_arch = "wasm32")] +mod wasm { + use std::cell::RefCell; + use std::rc::Rc; + + use dioxus::prelude::*; + use gloo_timers::future::TimeoutFuture; + use lpa_studio_core::{ + PreviewHost, PreviewHostConfig, PreviewProfile, PreviewSlotHandle, PreviewSlotRequest, + PreviewSlotStatus, PreviewSource, PreviewTier, + }; + use wasm_bindgen::JsCast; + use wasm_bindgen::prelude::Closure; + + use super::{ThumbCanvas, ThumbPreview, ThumbPreviewBadge}; + + /// Status/visibility poll cadence per thumb. Change detection rides + /// `status_revision()`, so each tick is a couple of cheap reads. + const THUMB_POLL_MS: u32 = 200; + /// Substantive lease failures (deploy errors, worker loss, …) allowed + /// before a thumb parks on an error badge instead of leasing again. + const THUMB_ERROR_LIMIT: u8 = 2; + /// Canvas-generation remounts allowed for the *expected* GPU staleness + /// error (eviction/recycle consumed the transferred canvas) before the + /// thumb parks. Bounds a hostile project's recycle flap. + const THUMB_REMOUNT_LIMIT: u8 = 5; + /// How long the host constructor waits for the app's local-store probe + /// before starting without the library seam, in 100 ms polls. + const LIBRARY_WAIT_POLLS: u32 = 50; + + /// The page-wide preview host (see the module docs for the lifetime + /// decision). + enum HostState { + /// No live thumb has needed the host yet. + Idle, + /// The async constructor (library wait + boot) is in flight. + Starting, + /// Constructed; `run()` is being driven on a spawned task. + Ready(Rc), + } + + thread_local! { + static HOST: RefCell = const { RefCell::new(HostState::Idle) }; + } + + /// The shared preview host, kicking off its lazy construction on first + /// call. `None` while construction is still in flight — callers poll. + fn preview_host() -> Option> { + HOST.with(|cell| { + let mut state = cell.borrow_mut(); + match &*state { + HostState::Ready(host) => Some(Rc::clone(host)), + HostState::Starting => None, + HostState::Idle => { + *state = HostState::Starting; + wasm_bindgen_futures::spawn_local(async { + // Give the app's startup probe (web_app.rs) time to + // attach the library; constructing with `None` + // would fail-soft every ProjectUid lease for the + // rest of the page. + let mut library = crate::local_store::library_host(); + let mut polls = 0; + while library.is_none() && polls < LIBRARY_WAIT_POLLS { + TimeoutFuture::new(100).await; + library = crate::local_store::library_host(); + polls += 1; + } + if library.is_none() { + log::warn!( + "gallery preview host: starting without a library \ + (project previews will surface an error)" + ); + } + let host = Rc::new(PreviewHost::new(PreviewHostConfig::default(), library)); + HOST.with(|cell| { + *cell.borrow_mut() = HostState::Ready(Rc::clone(&host)); + }); + // Drive-once contract: the host owns no executor. + // App-lifetime host — never shut down; page unload + // terminates the pool workers with the page. + host.run().await; + }); + None + } + } + }) + } + + /// Everything one live thumb owns outside the render path: the slot + /// lease, the visibility observer, and recovery accounting. Shared + /// (`Rc>`) between the poll loop, the observer callback, + /// and the drop hook. + #[derive(Default)] + struct LiveThumbState { + /// The leased slot; dropping it releases (DestroyRuntime). + handle: Option, + observer: Option, + /// Keeps the observer's JS callback alive for the thumb's life. + observer_closure: Option>, + /// Observer construction failed; treat the thumb as always + /// visible instead of retrying every tick. + observer_broken: bool, + /// Latest observer edge (`None` until the first callback fires — + /// leasing waits for it, so offscreen cards never deploy). + visible: Option, + /// Last consumed `status_revision` (cheap change detection). + last_revision: Option, + /// Substantive failures so far (parks at [`THUMB_ERROR_LIMIT`]). + errors: u8, + /// Canvas-generation remounts so far (parks at + /// [`THUMB_REMOUNT_LIMIT`]). + remounts: u8, + } + + /// The wasm arm of [`super::use_thumb_preview`]. + pub(super) fn use_live_thumb(frame_id: String, source: Option) -> ThumbPreview { + // Canvas element generation: bumped on every recovery so a fresh + // element mounts (a GPU-tier canvas is consumed by its transfer). + let generation = use_signal(|| 0_u32); + let badge = use_signal(|| None::); + let revealed = use_signal(|| false); + let state = use_hook(|| Rc::new(RefCell::new(LiveThumbState::default()))); + + let tick_state = Rc::clone(&state); + let tick_frame = frame_id.clone(); + let tick_source = source.clone(); + use_future(move || { + let state = Rc::clone(&tick_state); + let frame_id = tick_frame.clone(); + let source = tick_source.clone(); + async move { + let Some(source) = source else { + return; // static thumb: nothing to drive + }; + loop { + drive_thumb(&state, &frame_id, &source, generation, badge, revealed); + TimeoutFuture::new(THUMB_POLL_MS).await; + } + } + }); + + let drop_state = Rc::clone(&state); + use_drop(move || { + let mut state = drop_state.borrow_mut(); + if let Some(observer) = state.observer.take() { + observer.disconnect(); + } + state.observer_closure = None; + // Dropping the handle releases the slot (DestroyRuntime). + state.handle = None; + }); + + ThumbPreview { + canvas: source.as_ref().map(|_| ThumbCanvas { + id: thumb_canvas_id(&frame_id, generation()), + revealed: revealed(), + }), + badge: badge(), + frame_id, + } + } + + /// One poll tick: attach the observer once the frame exists, lease + /// when first visible, and fold slot status into the render signals. + fn drive_thumb( + state_rc: &Rc>, + frame_id: &str, + source: &PreviewSource, + mut generation: Signal, + mut badge: Signal>, + mut revealed: Signal, + ) { + let mut state = state_rc.borrow_mut(); + + if state.observer.is_none() && !state.observer_broken { + attach_observer(&mut state, state_rc, frame_id); + } + + // Lease when the card is (or is assumed) visible and recovery + // budget remains. The canvas for the current generation is already + // mounted — the host's lease pipeline finds it by id. + let assumed_visible = state.visible == Some(true) || state.observer_broken; + if state.handle.is_none() + && assumed_visible + && state.errors < THUMB_ERROR_LIMIT + && state.remounts < THUMB_REMOUNT_LIMIT + { + if let Some(host) = preview_host() { + let handle = host.lease(PreviewSlotRequest { + source: source.clone(), + canvas_id: thumb_canvas_id(frame_id, *generation.peek()), + fps: None, + profile: PreviewProfile::default(), + }); + state.last_revision = None; + state.handle = Some(handle); + } + } + + // Snapshot the handle's observables before mutating the state. + let (presented, revision, status) = { + let Some(handle) = &state.handle else { + return; + }; + ( + handle.presented_frames() > 0, + handle.status_revision(), + handle.status(), + ) + }; + if *revealed.peek() != presented { + revealed.set(presented); + } + if state.last_revision == Some(revision) { + return; + } + state.last_revision = Some(revision); + match status { + // Deploying keeps whatever the thumb showed; Suspended freezes + // the canvas on its last frame (scroll-away) — both are + // non-events for the overlay. + PreviewSlotStatus::Deploying | PreviewSlotStatus::Suspended => {} + PreviewSlotStatus::Live { tier, tier_reason } => { + let next = match tier { + PreviewTier::Gpu => ThumbPreviewBadge::Gpu, + PreviewTier::Cpu => ThumbPreviewBadge::Cpu { + reason: tier_reason, + }, + }; + if badge.peek().as_ref() != Some(&next) { + badge.set(Some(next)); + } + } + PreviewSlotStatus::Error { reason } => { + // Release first: never reuse a canvas that a GPU-tier slot + // transferred. The host parks errored slots; recovery is + // ours (remount a fresh generation, lease again). + state.handle = None; + state.last_revision = None; + revealed.set(false); + // The transferred-canvas error is the EXPECTED recovery + // path after LRU eviction or a worker recycle (the host + // words it "already transferred — remount"), so it spends + // the remount budget, not the error budget. Matching on + // the message is a P5 candidate for a typed status flag. + let expected_remount = reason.contains("already transferred"); + if expected_remount { + state.remounts = state.remounts.saturating_add(1); + } else { + state.errors = state.errors.saturating_add(1); + } + if state.errors >= THUMB_ERROR_LIMIT || state.remounts >= THUMB_REMOUNT_LIMIT { + log::warn!("gallery preview #{frame_id} gave up: {reason}"); + badge.set(Some(ThumbPreviewBadge::Error { reason })); + } else { + generation += 1; // fresh canvas element; re-lease next tick + if badge.peek().is_some() { + badge.set(None); + } + } + } + } + } + + /// Observe the thumb frame for visibility edges. The callback pushes + /// edges straight onto the slot handle (`set_visible`) and records + /// them for the lease gate. Retried each tick until the frame mounts; + /// an unavailable IntersectionObserver degrades to always-visible. + fn attach_observer( + state: &mut LiveThumbState, + state_rc: &Rc>, + frame_id: &str, + ) { + let Some(element) = web_sys::window() + .and_then(|window| window.document()) + .and_then(|document| document.get_element_by_id(frame_id)) + else { + return; // not mounted yet; retry next tick + }; + let callback_state = Rc::clone(state_rc); + let closure = Closure::::new(move |entries: js_sys::Array| { + let mut state = callback_state.borrow_mut(); + for entry in entries.iter() { + let Ok(entry) = entry.dyn_into::() else { + continue; + }; + let visible = entry.is_intersecting(); + state.visible = Some(visible); + if let Some(handle) = &state.handle { + handle.set_visible(visible); + } + } + }); + match web_sys::IntersectionObserver::new(closure.as_ref().unchecked_ref()) { + Ok(observer) => { + observer.observe(&element); + state.observer = Some(observer); + state.observer_closure = Some(closure); + } + Err(error) => { + log::warn!("gallery preview: IntersectionObserver unavailable: {error:?}"); + state.observer_broken = true; + } + } + } + + /// The generation-suffixed canvas element id (preview-lab's pattern: + /// `transferControlToOffscreen` is one-shot, so ids are per mount). + fn thumb_canvas_id(frame_id: &str, generation: u32) -> String { + format!("{frame_id}-canvas-g{generation}") + } +} diff --git a/lp-app/lpa-studio-web/src/app/home/home_gallery_stories.rs b/lp-app/lpa-studio-web/src/app/home/home_gallery_stories.rs index 90d30f337..786108a4f 100644 --- a/lp-app/lpa-studio-web/src/app/home/home_gallery_stories.rs +++ b/lp-app/lpa-studio-web/src/app/home/home_gallery_stories.rs @@ -8,6 +8,8 @@ use lpa_studio_core::{ }; use crate::app::home::HomeGallery; +use crate::app::home::card_thumb::CardThumb; +use crate::app::home::gallery_preview::ThumbPreviewBadge; /// A fixed "now" so relative times in baselines never drift. const STORY_NOW: f64 = 1_800_000_000.0; @@ -213,6 +215,54 @@ fn opening_a_project() -> Element { } } +#[story] +fn live_thumb_states() -> Element { + // The live-thumb overlay states, injected statically (story mode has + // no PreviewHost and mounts no canvas): placeholder gradient, GPU + // tier, CPU fallback with a surfaced reason, and a failed preview. + // Live cards derive the same badges from their slot status. + rsx! { + section { class: "tw:grid tw:w-[720px] tw:grid-cols-4 tw:gap-3.5 tw:p-4", + article { class: "tw:overflow-hidden tw:rounded-md tw:border tw:border-border tw:bg-card", + CardThumb { seed: "prj_3fKq8Zr21bTxYw0AhVmDpe".to_string(), label: "placeholder".to_string() } + p { class: thumb_state_caption_class(), "Placeholder" } + } + article { class: "tw:overflow-hidden tw:rounded-md tw:border tw:border-border tw:bg-card", + CardThumb { + seed: "prj_9sLm2Xc44dQnUv7BgWkEyt".to_string(), + label: "gpu".to_string(), + static_badge: Some(ThumbPreviewBadge::Gpu), + } + p { class: thumb_state_caption_class(), "GPU tier" } + } + article { class: "tw:overflow-hidden tw:rounded-md tw:border tw:border-border tw:bg-card", + CardThumb { + seed: "prj_1aBc3De56fGhIj8KlMnOpq".to_string(), + label: "cpu".to_string(), + static_badge: Some(ThumbPreviewBadge::Cpu { + reason: Some("WebGPU unavailable".to_string()), + }), + } + p { class: thumb_state_caption_class(), "CPU fallback" } + } + article { class: "tw:overflow-hidden tw:rounded-md tw:border tw:border-border tw:bg-card", + CardThumb { + seed: "examples/basic".to_string(), + label: "failed".to_string(), + static_badge: Some(ThumbPreviewBadge::Error { + reason: "deploy: shader compile failed".to_string(), + }), + } + p { class: thumb_state_caption_class(), "Failed" } + } + } + } +} + +fn thumb_state_caption_class() -> &'static str { + "tw:m-0 tw:p-3 tw:text-xs tw:text-muted-foreground" +} + #[story] fn store_unavailable_with_issue() -> Element { let home = UiHomeView { diff --git a/lp-app/lpa-studio-web/src/app/home/mod.rs b/lp-app/lpa-studio-web/src/app/home/mod.rs index 0baeebe63..cb86b0f08 100644 --- a/lp-app/lpa-studio-web/src/app/home/mod.rs +++ b/lp-app/lpa-studio-web/src/app/home/mod.rs @@ -3,6 +3,7 @@ pub(crate) mod card_thumb; pub(crate) mod device_card; pub(crate) mod example_card; +pub(crate) mod gallery_preview; pub mod home_gallery; #[cfg(feature = "stories")] pub(crate) mod home_gallery_stories; diff --git a/lp-app/lpa-studio-web/src/app/home/package_card.rs b/lp-app/lpa-studio-web/src/app/home/package_card.rs index ccbcc3f73..737297a45 100644 --- a/lp-app/lpa-studio-web/src/app/home/package_card.rs +++ b/lp-app/lpa-studio-web/src/app/home/package_card.rs @@ -4,8 +4,8 @@ use std::cell::RefCell; use dioxus::prelude::*; use lpa_studio_core::{ - ActionConfirmation, ControllerId, DEPLOY_NODE_ID, DeployOp, HOME_NODE_ID, HomeOp, SyncRelation, - UiAction, UiPackageCard, + ActionConfirmation, ControllerId, DEPLOY_NODE_ID, DeployOp, HOME_NODE_ID, HomeOp, + PreviewSource, SyncRelation, UiAction, UiPackageCard, }; use crate::app::home::card_thumb::CardThumb; @@ -61,7 +61,11 @@ pub(crate) fn PackageCard( } }, } - CardThumb { seed: card.uid.clone(), label: card.slug.clone() } + CardThumb { + seed: card.uid.clone(), + label: card.slug.clone(), + source: Some(PreviewSource::ProjectUid(card.uid.clone())), + } div { class: "tw:flex tw:items-start tw:justify-between tw:gap-2 tw:p-3", div { class: "tw:grid tw:min-w-0 tw:gap-0.5", p { class: "tw:m-0 tw:truncate tw:text-sm tw:font-semibold tw:text-strong-foreground", diff --git a/lp-app/lpa-studio-web/src/exploration/preview_lab/worker_rig.rs b/lp-app/lpa-studio-web/src/exploration/preview_lab/worker_rig.rs index 1856a8f5c..cd1f03f1d 100644 --- a/lp-app/lpa-studio-web/src/exploration/preview_lab/worker_rig.rs +++ b/lp-app/lpa-studio-web/src/exploration/preview_lab/worker_rig.rs @@ -111,6 +111,9 @@ impl WorkerRig { tier_reason, }); } + BrowserOutputEnvelope::RuntimeDestroyed { runtime_id } => { + self.note(format!("runtime {runtime_id} destroyed")); + } BrowserOutputEnvelope::SurfaceAttached { runtime_id } => { self.surfaces_attached.push(runtime_id); } diff --git a/lp-app/lpa-studio-web/story-images/studio__home__home-gallery__live-thumb-states__lg.png b/lp-app/lpa-studio-web/story-images/studio__home__home-gallery__live-thumb-states__lg.png new file mode 100644 index 000000000..f7bc24511 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__home__home-gallery__live-thumb-states__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__home__home-gallery__live-thumb-states__md.png b/lp-app/lpa-studio-web/story-images/studio__home__home-gallery__live-thumb-states__md.png new file mode 100644 index 000000000..476a4a56a Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__home__home-gallery__live-thumb-states__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__home__home-gallery__live-thumb-states__sm.png b/lp-app/lpa-studio-web/story-images/studio__home__home-gallery__live-thumb-states__sm.png new file mode 100644 index 000000000..2c400e58e Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__home__home-gallery__live-thumb-states__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__home__home-gallery__overview__lg.png b/lp-app/lpa-studio-web/story-images/studio__home__home-gallery__overview__lg.png index 4760a26d4..3a52c2dd0 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__home__home-gallery__overview__lg.png and b/lp-app/lpa-studio-web/story-images/studio__home__home-gallery__overview__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__home__home-gallery__overview__md.png b/lp-app/lpa-studio-web/story-images/studio__home__home-gallery__overview__md.png index 1b9b8847c..2e51844e1 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__home__home-gallery__overview__md.png and b/lp-app/lpa-studio-web/story-images/studio__home__home-gallery__overview__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__home__home-gallery__overview__sm.png b/lp-app/lpa-studio-web/story-images/studio__home__home-gallery__overview__sm.png index cabbd3785..4fd193b24 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__home__home-gallery__overview__sm.png and b/lp-app/lpa-studio-web/story-images/studio__home__home-gallery__overview__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__overview__sm.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__overview__sm.png index 9fb77f088..c073a7d75 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__overview__sm.png and b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__overview__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__version-badge__loaded__md.png b/lp-app/lpa-studio-web/story-images/studio__layout__version-badge__loaded__md.png index 2890b8c69..55307174c 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__layout__version-badge__loaded__md.png and b/lp-app/lpa-studio-web/story-images/studio__layout__version-badge__loaded__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__version-badge__overview__md.png b/lp-app/lpa-studio-web/story-images/studio__layout__version-badge__overview__md.png index 67743dc02..f2792272d 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__layout__version-badge__overview__md.png and b/lp-app/lpa-studio-web/story-images/studio__layout__version-badge__overview__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__version-badge__overview__sm.png b/lp-app/lpa-studio-web/story-images/studio__layout__version-badge__overview__sm.png index 8788d59a3..7be398841 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__layout__version-badge__overview__sm.png and b/lp-app/lpa-studio-web/story-images/studio__layout__version-badge__overview__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__editable-vector-grids__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__editable-vector-grids__lg.png index e8016d166..eabf0bd59 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__editable-vector-grids__lg.png and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__editable-vector-grids__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__enum-variant-switched__md.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__enum-variant-switched__md.png index bc9906093..bdf56b7da 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__enum-variant-switched__md.png and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__enum-variant-switched__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__map-add-immediate__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__map-add-immediate__lg.png index 1a8f5e8f3..c01b02d60 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__map-add-immediate__lg.png and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__map-add-immediate__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__map-add-string-key__md.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__map-add-string-key__md.png index 32bbb1ae2..03de4f72b 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__map-add-string-key__md.png and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__map-add-string-key__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__map-added-entry-dirty__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__map-added-entry-dirty__lg.png index 3e0fce613..569e9b899 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__map-added-entry-dirty__lg.png and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__map-added-entry-dirty__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__map-added-entry-dirty__md.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__map-added-entry-dirty__md.png index 60f65f2be..b892a2757 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__map-added-entry-dirty__md.png and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__map-added-entry-dirty__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__map-entry-key-edit-open__md.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__map-entry-key-edit-open__md.png index 943afefd5..6514ce558 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__map-entry-key-edit-open__md.png and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__map-entry-key-edit-open__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__map-move-rejected-occupied__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__map-move-rejected-occupied__lg.png index f150e8338..9e0ffa30b 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__map-move-rejected-occupied__lg.png and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__map-move-rejected-occupied__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__map-move-rejected-occupied__md.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__map-move-rejected-occupied__md.png index 1d55dc0e7..665957399 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__map-move-rejected-occupied__md.png and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__map-move-rejected-occupied__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__map-removed-entry-parent-dirty__md.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__map-removed-entry-parent-dirty__md.png index 3191a97a7..976b78e53 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__map-removed-entry-parent-dirty__md.png and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__map-removed-entry-parent-dirty__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__option-set-and-clear__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__option-set-and-clear__lg.png index 9f5671213..3bc701dc7 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__option-set-and-clear__lg.png and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__option-set-and-clear__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__overview__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__overview__lg.png index 3f0be7801..a61368f50 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__overview__lg.png and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__overview__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-option-presence__checkbox-square__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-option-presence__checkbox-square__lg.png index 3b3e629c4..450802032 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__slot-option-presence__checkbox-square__lg.png and b/lp-app/lpa-studio-web/story-images/studio__node__slot-option-presence__checkbox-square__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-option-presence__overview__md.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-option-presence__overview__md.png index 60cec5d1e..5ffd8e7bc 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__slot-option-presence__overview__md.png and b/lp-app/lpa-studio-web/story-images/studio__node__slot-option-presence__overview__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-option-presence__presence-in-row__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-option-presence__presence-in-row__lg.png index 37dc6db51..c9aacdfb7 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__slot-option-presence__presence-in-row__lg.png and b/lp-app/lpa-studio-web/story-images/studio__node__slot-option-presence__presence-in-row__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__overview__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__overview__lg.png index 980099d04..397fe8dae 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__overview__lg.png and b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__overview__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__overview__md.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__overview__md.png index e380bbe02..e4d7c6ca4 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__overview__md.png and b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__overview__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__xy-raw-input-popup__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__xy-raw-input-popup__lg.png index 9ac3269e9..633d81079 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__xy-raw-input-popup__lg.png and b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__xy-raw-input-popup__lg.png differ diff --git a/lp-fw/fw-browser/src/runtime.rs b/lp-fw/fw-browser/src/runtime.rs index e3dd2f959..7fbefcace 100644 --- a/lp-fw/fw-browser/src/runtime.rs +++ b/lp-fw/fw-browser/src/runtime.rs @@ -77,6 +77,13 @@ pub(crate) struct BrowserFirmwareRuntime { /// GPU-tier pieces owned by one runtime: the worker's shared device, the /// runtime's `GpuGraphics` backend, and (once attached) its card surface. +/// +/// Dropping this (runtime destruction) is clean even while a canvas is +/// attached: wgpu's WebGPU backend `Surface` drop is an explicit no-op that +/// just releases its JS refs (`GPUCanvasContext` + canvas), and the +/// `OffscreenCanvas` was transferred into the worker, so the surface holds +/// the only reference and JS GC reclaims both. The worker's shared device +/// outlives the runtime via `worker_gpu`. struct GpuRuntimeState { worker_gpu: Rc, graphics: Arc, diff --git a/lp-fw/fw-browser/src/runtime_registry.rs b/lp-fw/fw-browser/src/runtime_registry.rs index 1baa584d8..3632e3d12 100644 --- a/lp-fw/fw-browser/src/runtime_registry.rs +++ b/lp-fw/fw-browser/src/runtime_registry.rs @@ -3,13 +3,17 @@ //! The wasm boundary uses numeric runtime ids so one page can create multiple //! browser firmware instances without exposing Rust references to JavaScript. -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use crate::runtime::BrowserFirmwareRuntime; use crate::tier::{RuntimeTier, TierSelection}; thread_local! { static RUNTIMES: RefCell> = const { RefCell::new(Vec::new()) }; + // Monotonic so a destroyed runtime's id is never reissued: `len() + 1` + // would collide with a live runtime once destruction punches holes in + // the id sequence. + static NEXT_RUNTIME_ID: Cell = const { Cell::new(1) }; } /// Create a runtime on the requested tier; returns its stable id plus the @@ -21,7 +25,11 @@ pub(crate) fn create_runtime( ) -> Result<(u32, TierSelection), String> { RUNTIMES.with(|runtimes| { let mut runtimes = runtimes.borrow_mut(); - let id = runtimes.len() as u32 + 1; + let id = NEXT_RUNTIME_ID.with(|next| { + let id = next.get(); + next.set(id + 1); + id + }); let runtime = BrowserFirmwareRuntime::new(id, label, requested)?; let selection = runtime.tier().clone(); runtimes.push(runtime); @@ -29,6 +37,20 @@ pub(crate) fn create_runtime( }) } +/// Drop the runtime with `runtime_id`, releasing everything it owns (server, +/// filesystem, and on the GPU tier the graphics backend plus any attached +/// preview surface). +/// +/// Returns `false` when no such runtime exists — release is idempotent. +pub(crate) fn destroy_runtime(runtime_id: u32) -> bool { + RUNTIMES.with(|runtimes| { + let mut runtimes = runtimes.borrow_mut(); + let before = runtimes.len(); + runtimes.retain(|runtime| runtime.id() != runtime_id); + runtimes.len() != before + }) +} + /// Return the number of runtimes currently held by this wasm instance. pub(crate) fn runtime_count() -> u32 { RUNTIMES.with(|runtimes| runtimes.borrow().len() as u32) diff --git a/lp-fw/fw-browser/src/wasm_exports.rs b/lp-fw/fw-browser/src/wasm_exports.rs index 65881257b..b7dc37d02 100644 --- a/lp-fw/fw-browser/src/wasm_exports.rs +++ b/lp-fw/fw-browser/src/wasm_exports.rs @@ -62,6 +62,20 @@ pub fn create_runtime(label: &str, tier: &str) -> Result { .map_err(|error| format!("serialize runtime creation result: {error}")) } +/// Destroy a runtime created by [`create_runtime`], dropping its firmware +/// state (on the GPU tier this includes the runtime's graphics backend and +/// any attached card surface — a clean drop even with a live canvas, see +/// `runtime::GpuRuntimeState`). +/// +/// Returns `true` when a runtime was destroyed, `false` when the id was +/// unknown — destruction is an idempotent release, so double-destroy is not +/// an error. The boot-runtime guard lives in the worker script, which owns +/// the boot runtime id. +#[wasm_bindgen] +pub fn destroy_runtime(runtime_id: u32) -> bool { + runtime_registry::destroy_runtime(runtime_id) +} + /// Number of live browser firmware runtimes. #[wasm_bindgen] pub fn runtime_count() -> u32 {