Files
effectcraft/apps/effectcraft-web/src/diskcache.rs
T
Brandon ThomasandClaude Opus 5.5 1b726349ab M13.24: web app depth (WEB-1) — GPU effects in frame workers, OPFS disk cache, storage manager
- GPU in the browser: each frame worker opens its own WebGPU device (Gpu::request_deferred) and
  renders the GPU compositor and GPU effects with deferred readbacks (effectcraft-gpu deferred):
  readbacks keyed by content start with map_async and miss, the pass returns placeholders, the
  layer cache's gate drops inserts, and FrameServer::resume renders again once the device
  delivered. Backend Auto times whole frames per comp in the workers. Frames with effects go to
  GPU workers instead of the page. CPU fallback without WebGPU in workers (?nogpuworkers).
- Fix: a WGSL constant (3.40282347e38) that Chrome rejects invalidated every GPU kernel in Chrome
  (the page's compositor too); a test checks every float literal fits f32.
- Disk cache in the Origin Private File System: workers write sealed frame entries with sync
  access handles (temp file + move), the page keeps the DiskIndex (LRU under the settings' limit,
  at most half the quota) and reads hits instead of rendering; survives reloads; blue cache bar.
- Storage manager: Settings ▸ Disk ▸ Browser Storage (usage/quota, persistent storage, Clear
  buttons, automation ids) and storage.info / storage.persist / storage.clear (StorageHost).
- Docs: web.md (GPU workers, deferred passes, disk cache, storage manager, threading design:
  one engine instance per worker), architecture, preferences, parity (WEB-1), roadmap.
- Tests: deferred passes vs CPU on a real device, placeholder gating, frame server passes and
  Auto, GPU worker routing + disk cache across a reload, storage commands and widgets, store
  usage by kind; smoke test: GPU effect frames in workers with pixel parity, disk hits after a
  reload, storage manager.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 18:35:03 -04:00

229 lines
7.7 KiB
Rust

//! The disk cache (Settings ▸ Disk ▸ Disk Cache) in the browser: viewer frames kept in the
//! Origin Private File System between visits, with the size limit from the settings and
//! least-recently-used eviction.
//!
//! - **Writes** happen in the frame workers: a render request carries the frame's disk-cache key
//! (`effectcraft_engine::remote::FrameMsg::Render`), the worker posts the frame, then writes
//! the sealed entry (`disk_cache::frame_entry`: LZ4, checksum) with a synchronous access
//! handle into a temporary file moved into place (`js/host.js` `cacheWrite`), and reports
//! `FrameReply::Stored`.
//! - **The index** lives here, on the page ([`DiskIndex`], shared with the desktop's cache):
//! rebuilt at startup from the folder listing (oldest files first), updated by the workers'
//! `Stored` replies and by reads; eviction deletes the least recently used files.
//! - **Reads** are asynchronous: a frame whose key is indexed is read and decoded here instead
//! of rendering it (`crate::frames`); a damaged or missing file is a miss and is forgotten.
//!
//! Only frames are cached on disk in the browser (the desktop also keeps slow layer buffers):
//! a layer lookup happens in the middle of a synchronous render, where the browser's file
//! system can't be read.
use std::cell::RefCell;
use effectcraft_engine::render::disk_cache::{self, DiskIndex, Kind};
use serde_json::{Value, json};
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::JsFuture;
#[wasm_bindgen(module = "/js/host.js")]
extern "C" {
#[wasm_bindgen(js_name = cacheList)]
fn cache_list() -> js_sys::Promise;
#[wasm_bindgen(js_name = cacheRead)]
fn cache_read(name: &str) -> js_sys::Promise;
#[wasm_bindgen(js_name = cacheWrite)]
pub(crate) fn cache_write(name: &str, bytes: js_sys::Uint8Array) -> js_sys::Promise;
#[wasm_bindgen(js_name = cacheDelete)]
fn cache_delete(names: js_sys::Array) -> js_sys::Promise;
#[wasm_bindgen(js_name = cacheClear)]
fn cache_clear() -> js_sys::Promise;
}
#[derive(Default)]
struct State {
index: DiskIndex,
/// The folder was listed (lookups before then miss).
loaded: bool,
/// The Origin Private File System works here.
available: bool,
/// Settings ▸ Disk ▸ Enable Disk Cache.
enabled: bool,
max_bytes: u64,
hits: u64,
misses: u64,
writes: u64,
evictions: u64,
error: Option<String>,
}
thread_local! {
static ST: RefCell<State> = RefCell::new(State { enabled: true, max_bytes: 1 << 30, ..Default::default() });
}
fn js_err(e: JsValue) -> String {
e.as_string().or_else(|| js_sys::Reflect::get(&e, &"message".into()).ok().and_then(|m| m.as_string())).unwrap_or_else(|| format!("{e:?}"))
}
/// List the cache folder and build the index (in the background).
pub fn init() {
wasm_bindgen_futures::spawn_local(async {
match JsFuture::from(cache_list()).await {
Ok(list) => {
let mut entries: Vec<(f64, u128, u64)> = js_sys::Array::from(&list)
.iter()
.filter_map(|e| {
let get = |k: &str| js_sys::Reflect::get(&e, &k.into()).ok();
let key = disk_cache::parse_entry_name(&get("name")?.as_string()?)?;
Some((get("modified")?.as_f64().unwrap_or(0.0), key, get("size")?.as_f64().unwrap_or(0.0) as u64))
})
.collect();
entries.sort_by(|a, b| a.0.total_cmp(&b.0));
ST.with(|s| {
let mut s = s.borrow_mut();
for (_, key, bytes) in entries {
s.index.insert(Kind::Frame, key, bytes);
}
s.loaded = true;
s.available = true;
});
evict();
}
Err(e) => {
let e = js_err(e);
log::info!("disk cache unavailable: {e}");
ST.with(|s| s.borrow_mut().error = Some(e));
}
}
});
}
/// Apply the settings: on/off and the limit (`max_gb`, at most half the origin's quota when it
/// is known).
pub fn configure(enabled: bool, max_gb: u32, quota: Option<u64>) {
let mut max = (max_gb.max(1) as u64) << 30;
if let Some(q) = quota.filter(|q| *q > 0) {
max = max.min(q / 2);
}
let changed = ST.with(|s| {
let mut s = s.borrow_mut();
let changed = s.enabled != enabled || s.max_bytes != max;
s.enabled = enabled;
s.max_bytes = max;
changed
});
if changed {
evict();
}
}
/// The cache is usable: listed, available and enabled.
pub fn active() -> bool {
ST.with(|s| {
let s = s.borrow();
s.loaded && s.available && s.enabled
})
}
/// The frame of `key` is cached.
pub fn contains(key: u128) -> bool {
active() && ST.with(|s| s.borrow().index.contains(Kind::Frame, key))
}
/// A lookup missed (counted for the statistics).
pub fn note_miss() {
ST.with(|s| s.borrow_mut().misses += 1);
}
/// Read and decode a cached frame; `None` (forgotten and deleted) when the file is missing or
/// damaged.
pub async fn read(key: u128) -> Option<disk_cache::Frame8> {
let name = disk_cache::entry_name(key);
let bytes = JsFuture::from(cache_read(&name)).await.ok().and_then(|b| b.dyn_into::<js_sys::Uint8Array>().ok()).map(|b| b.to_vec());
let frame = bytes.as_deref().and_then(disk_cache::read_frame_entry);
ST.with(|s| {
let mut s = s.borrow_mut();
if frame.is_some() {
s.hits += 1;
s.index.touch(Kind::Frame, key);
} else {
s.misses += 1;
s.index.remove(Kind::Frame, key);
}
});
if frame.is_none() && bytes.is_some() {
delete(vec![name]);
}
frame
}
/// A worker stored the frame of `key` (`bytes` on disk).
pub fn stored(key: u128, bytes: u64) {
ST.with(|s| {
let mut s = s.borrow_mut();
s.index.insert(Kind::Frame, key, bytes);
s.writes += 1;
});
evict();
}
fn evict() {
let victims = ST.with(|s| {
let mut s = s.borrow_mut();
let max = if s.enabled { s.max_bytes } else { u64::MAX };
let v = s.index.evict(max);
s.evictions += v.len() as u64;
v
});
if !victims.is_empty() {
delete(victims.into_iter().map(|(_, k)| disk_cache::entry_name(k)).collect());
}
}
fn delete(names: Vec<String>) {
let arr = js_sys::Array::new();
for n in names {
arr.push(&JsValue::from_str(&n));
}
wasm_bindgen_futures::spawn_local(async move {
let _ = JsFuture::from(cache_delete(arr)).await;
});
}
/// Delete every cached frame; returns (entries, bytes) removed.
pub fn clear() -> (u64, u64) {
let r = ST.with(|s| {
let mut s = s.borrow_mut();
let r = (s.index.len() as u64, s.index.total());
s.index.clear();
r
});
wasm_bindgen_futures::spawn_local(async {
if let Err(e) = JsFuture::from(cache_clear()).await {
log::warn!("disk cache: clearing failed: {}", js_err(e));
}
});
r
}
/// `cache.diskStats` / `storage.info().diskCache`.
pub fn stats() -> Value {
ST.with(|s| {
let s = s.borrow();
json!({
"enabled": s.enabled && s.available,
"available": s.available,
"loaded": s.loaded,
"folder": "Origin Private File System: effectcraft-cache/v1/frames",
"entries": s.index.len(),
"frames": s.index.len(),
"layers": 0,
"bytes": s.index.total(),
"maxBytes": s.max_bytes,
"hits": s.hits,
"misses": s.misses,
"writes": s.writes,
"evictions": s.evictions,
"error": s.error,
})
})
}