diff --git a/apps/effectcraft-web/js/host.js b/apps/effectcraft-web/js/host.js index 2482521c..793900be 100644 --- a/apps/effectcraft-web/js/host.js +++ b/apps/effectcraft-web/js/host.js @@ -639,3 +639,48 @@ export function browsePickFiles(directory) { input.click(); }); } + +/// The page's WebGPU device was lost (#225): the canvas can't show anything new while the project +/// stays open and editable. Say so over the stale picture, and offer to save the project (a +/// download) and to reload; the reload first writes pending changes to browser storage, from +/// which the session comes back when it is `kept` there. +export function showDeviceLost(message, kept) { + if (document.getElementById("device-lost")) return; + const box = document.createElement("div"); + box.id = "device-lost"; + box.setAttribute("role", "alertdialog"); + const title = document.createElement("strong"); + title.textContent = "The graphics device was lost"; + const text = document.createElement("p"); + text.textContent = + "EffectCraft can't update the display any more. Your project is still open. " + + (kept ? "Reload to continue: the project and its unsaved changes come back." : "This session isn't kept in browser storage: save the project before you reload."); + const detail = document.createElement("small"); + detail.textContent = message; + const save = document.createElement("button"); + save.textContent = "Save Project"; + save.onclick = () => + self.effectcraft + .execute("file.save", {}) + .catch(() => self.effectcraft.execute("file.saveAs", { path: "/Untitled Project.ecproj" })) + .catch((e) => (text.textContent = `Saving failed: ${(e && e.message) || e}`)); + const reload = document.createElement("button"); + reload.textContent = "Reload"; + reload.onclick = async () => { + reload.disabled = true; + try { + await self.effectcraft.flush(); + } catch (e) { + text.textContent = `Writing to browser storage failed (${(e && e.message) || e}). Save the project, or reload anyway and lose the changes.`; + reload.textContent = "Reload Anyway"; + reload.disabled = false; + reload.onclick = () => location.reload(); + return; + } + location.reload(); + }; + const buttons = document.createElement("div"); + buttons.append(save, reload); + box.append(title, text, detail, buttons); + document.body.append(box); +} diff --git a/apps/effectcraft-web/src/app.rs b/apps/effectcraft-web/src/app.rs index ded2c56c..48f296c9 100644 --- a/apps/effectcraft-web/src/app.rs +++ b/apps/effectcraft-web/src/app.rs @@ -11,6 +11,12 @@ use effectcraft_ui_egui::EffectcraftApp; use serde_json::json; use wasm_bindgen::prelude::*; +#[wasm_bindgen(module = "/js/host.js")] +extern "C" { + #[wasm_bindgen(js_name = showDeviceLost)] + fn show_device_lost(message: &str, kept: bool); +} + /// Work for the UI thread with access to the app (posted by async tasks: picked files…). type Action = Box; @@ -215,9 +221,14 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> { log::error!("{message}"); } })); + // The canvas can't present any more (#225): the page says so over it and + // offers a reload, which brings the session back when browser storage keeps it. let lost = gpu_failures.clone(); + let kept = !empty && persist::backend() != "memory"; rs.device.set_device_lost_callback(move |reason, message| { - lost.report(&format!("GPU device lost ({reason:?}): {message}"), true); + let message = format!("GPU device lost ({reason:?}): {message}"); + lost.report(&message, true); + show_device_lost(&message, kept); }); } let mut session = session(); diff --git a/apps/effectcraft-web/tests/page.mjs b/apps/effectcraft-web/tests/page.mjs index 2b8cc9bf..06301523 100644 --- a/apps/effectcraft-web/tests/page.mjs +++ b/apps/effectcraft-web/tests/page.mjs @@ -5,12 +5,16 @@ // // - Add Files… / Upload Folder…: a picked file that can't be read is listed with its error // instead of failing the whole pick silently, and cancelling picks nothing (#226). +// - A lost WebGPU device: the page says so over the canvas and offers Save Project and Reload; +// the reload writes pending changes to browser storage first, and a failed write is reported +// instead of reloading (#225). // Exits non-zero on failure. import assert from "node:assert/strict"; import { dirname, join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; const here = dirname(fileURLToPath(import.meta.url)); +const tick = () => new Promise((r) => setTimeout(r, 0)); globalThis.self = globalThis; class FakeElement { @@ -26,6 +30,9 @@ class FakeElement { append(...c) { this.children.push(...c); } + click() { + this.onclick?.(); + } *all() { yield this; for (const c of this.children) yield* c.all(); @@ -46,6 +53,8 @@ globalThis.document = { return [...this.body.all()].find((e) => e.id === id) ?? null; }, }; +let reloads = 0; +globalThis.location = { reload: () => reloads++ }; const host = await import(pathToFileURL(join(here, "../js/host.js"))); @@ -67,4 +76,54 @@ const cancelled = host.browsePickFiles(false); picker.oncancel(); assert.deepEqual(await cancelled, [], "cancelling picks nothing"); +// #225: the device-lost notice. +const calls = []; +let flushFails = false; +self.effectcraft = { + flush: async () => { + calls.push("flush"); + if (flushFails) throw new Error("quota exceeded"); + }, + execute: async (command, params) => { + calls.push([command, params]); + if (command === "file.save") throw new Error("no path: use file.saveAs {path}"); + }, +}; +const notice = () => document.getElementById("device-lost"); +const buttons = () => [...notice().all()].filter((e) => e.tag === "button"); +const text = () => [...notice().all()].find((e) => e.tag === "p").textContent; + +host.showDeviceLost("GPU device lost (Destroyed): destroyed", true); +host.showDeviceLost("again", true); +assert.equal(document.body.children.length, 1, "one notice"); +assert.equal([...notice().all()].find((e) => e.tag === "small").textContent, "GPU device lost (Destroyed): destroyed"); +assert.match(text(), /Reload to continue/); +assert.deepEqual( + buttons().map((b) => b.textContent), + ["Save Project", "Reload"], +); +// Save Project: Save, or Save As for an untitled project (a download). +await buttons()[0].onclick(); +assert.deepEqual(calls.splice(0), [ + ["file.save", {}], + ["file.saveAs", { path: "/Untitled Project.ecproj" }], +]); +// Reload writes pending changes first. +await buttons()[1].onclick(); +assert.deepEqual(calls.splice(0), ["flush"]); +assert.equal(reloads, 1); + +// A failed write is reported instead of reloading; Reload Anyway reloads. +document.body.children = []; +flushFails = true; +host.showDeviceLost("GPU device lost (Unknown): gone", false); +assert.match(text(), /isn't kept in browser storage/); +await buttons()[1].onclick(); +await tick(); +assert.equal(reloads, 1, "no reload after a failed write"); +assert.match(text(), /quota exceeded/); +assert.equal(buttons()[1].textContent, "Reload Anyway"); +buttons()[1].onclick(); +assert.equal(reloads, 2); + console.log("page: ok"); diff --git a/apps/effectcraft-web/web/index.html b/apps/effectcraft-web/web/index.html index 578e6159..70ab93ff 100644 --- a/apps/effectcraft-web/web/index.html +++ b/apps/effectcraft-web/web/index.html @@ -20,6 +20,23 @@ } #loading img { width: 56px; height: 56px; } #loading .err { color: #ff8a7a; white-space: pre-wrap; max-width: 70ch; pointer-events: auto; } + #device-lost { + position: absolute; inset: 0; display: flex; flex-direction: column; gap: 10px; + align-items: center; justify-content: center; padding: 16px; text-align: center; + background: rgb(18 18 18 / 0.85); color: #c8c8c8; font: 14px/1.4 system-ui, sans-serif; + } + #device-lost strong { color: #e3e3e3; font-size: 16px; } + #device-lost p { margin: 0; max-width: 60ch; } + #device-lost small { color: #8c8c8c; font-size: 12px; } + #device-lost div { display: flex; gap: 8px; } + #device-lost button { + padding: 6px 14px; border: 1px solid #3a3a3a; border-radius: 4px; + background: #232323; color: #e3e3e3; font: inherit; cursor: pointer; + } + #device-lost button:hover { background: #303030; } + #device-lost button:last-child { background: #2d8ceb; border-color: #2d8ceb; } + #device-lost button:last-child:hover { background: #4aa0f5; } + #device-lost button:disabled { opacity: 0.6; cursor: default; } diff --git a/docs/web.md b/docs/web.md index 1b5e0fa7..8c198970 100644 --- a/docs/web.md +++ b/docs/web.md @@ -263,6 +263,13 @@ eyedroppers read GPU frames back asynchronously (`Gpu::read_display_async`: the frame later). On WebGL2, or without a usable adapter, everything renders on the CPU. `effectcraft.info().gpu` reports `{compositor, viewerOnGpu}`. +If the page's WebGPU device is lost (a driver reset, the browser reclaiming the GPU), the canvas +can't show anything new, while the project stays open and commands keep working. The page then +covers the canvas with a notice (`showDeviceLost` in `js/host.js`) offering **Save Project** (a +download) and **Reload**, which first writes pending changes to browser storage +(`effectcraft.flush()`) so the session comes back after the reload; when the write fails it says +so and offers **Reload Anyway**. + **GPU effects run in the frame workers.** A browser never lets JavaScript (or wasm) wait for a GPU readback, not even in a worker: a buffer maps only once the thread returns to its event loop. The CPU renderer, which runs GPU effect chains and reads their results back mid-frame, therefore @@ -420,7 +427,8 @@ reused worker again even at the same size, and a worker that fails to start fail terminated (`node apps/effectcraft-web/tests/workers.mjs`). `apps/effectcraft-web/tests/page.mjs` checks page-side helpers of `js/host.js` the same way with -a fake DOM: a picked file that can't be read is reported while the others are added +a fake DOM: a picked file that can't be read is reported while the others are added, and the +device-lost notice saves, flushes before reloading and reports a failed flush (`node apps/effectcraft-web/tests/page.mjs`). It also checks the M13.10 paths: viewer frames rendered in frame workers while scrubbing with the