Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 0 additions & 19 deletions .claude/launch.json

This file was deleted.

119 changes: 119 additions & 0 deletions docs/adr/2026-07-16-preview-host.md
Original file line number Diff line number Diff line change
@@ -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.
26 changes: 26 additions & 0 deletions lp-app/lpa-link/src/providers/browser_worker/fw_browser_worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ self.onmessage = async (event) => {
});
break;
}
case "destroy_runtime":
requireBooted();
destroyRuntime(message);
break;
case "attach_surface":
requireBooted();
attachSurface(message);
Expand Down Expand Up @@ -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) {
Expand Down
17 changes: 17 additions & 0 deletions lp-app/lpa-link/src/providers/browser_worker/worker_envelope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -135,6 +147,11 @@ pub enum BrowserOutputEnvelope {
#[serde(default)]
tier_reason: Option<String>,
},
/// 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`).
Expand Down
15 changes: 15 additions & 0 deletions lp-app/lpa-link/src/providers/browser_worker/worker_handle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<BrowserOutputEnvelope> {
core::mem::take(&mut *self.outputs.borrow_mut())
}
Expand Down Expand Up @@ -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}")
}
Expand Down
16 changes: 15 additions & 1 deletion lp-app/lpa-studio-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion lp-app/lpa-studio-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions lp-app/lpa-studio-core/src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Loading
Loading