mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
Web: a lost graphics device says so and offers a reload (#225)
When the page's WebGPU device was lost, the canvas kept showing its last frame: edits still went through and rendered frames were right, but the editor never showed them and nothing said why (the status message about it is drawn in the canvas that can't present any more). The page now covers the canvas with a notice that offers Save Project (a download) and Reload. Reload first writes pending changes to browser storage, so the session snapshot brings the project and its unsaved changes back; if that write fails it says so and offers Reload Anyway. Without a kept session (?empty, memory storage) the notice says to save first. Test: apps/effectcraft-web/tests/page.mjs (Node, fake DOM). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
82a1862fa1
commit
db3b34b134
5 files changed
+142
-2
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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<dyn FnOnce(&mut EffectcraftApp)>;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
@@ -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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+9
-1
@@ -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
|
||||
|
||||
Reference in new issue
Block a user