diff --git a/apps/effectcraft-web/js/host.js b/apps/effectcraft-web/js/host.js index 3db6ad35..98badd5a 100644 --- a/apps/effectcraft-web/js/host.js +++ b/apps/effectcraft-web/js/host.js @@ -315,7 +315,15 @@ function spawn(base) { /// path, bytes)` receives "reply" (json), "file" (path, bytes: a rendered file to download), /// "store" (path, bytes: a file to keep in browser storage) and "error" (json = message). export function workerRun(id, json, files, base, onMessage) { - const w = idle.pop() ?? spawn(base); + let w; + try { + w = idle.pop() ?? spawn(base); + } catch (e) { + // `new Worker` throws at once when the page's origin is opaque (a sandboxed iframe): the job + // fails like one whose worker didn't start. + setTimeout(() => onMessage("error", String((e && e.message) || e), null, null)); + return; + } running.set(id, w); w.ready.then(() => { w.worker.onmessage = (e) => { @@ -380,7 +388,17 @@ const frameWorkers = []; /// GPU effects on it. export function frameWorkerStart(base, onMessage, gpu) { const index = frameWorkers.length; - const worker = new Worker(new URL("worker.js", base), { type: "module", name: `effectcraft-frames-${index}` }); + let worker; + try { + worker = new Worker(new URL("worker.js", base), { type: "module", name: `effectcraft-frames-${index}` }); + } catch (e) { + // The constructor throws at once when the page's origin is opaque (a sandboxed iframe). The + // slot stays, and the failure is reported like a worker that died, after the caller has + // recorded the index: frames then render on the page's thread (as with `?noworkers`). + frameWorkers.push(null); + setTimeout(() => onMessage(index, undefined, undefined, String((e && e.message) || e || "frame worker failed"))); + return index; + } worker.onmessage = (e) => { const m = e.data; if (m && m.type === "frameReply") onMessage(index, m.json, m.bytes ? new Uint8Array(m.bytes) : null, undefined); diff --git a/apps/effectcraft-web/tests/workers.mjs b/apps/effectcraft-web/tests/workers.mjs index 15a77f1d..58901885 100644 --- a/apps/effectcraft-web/tests/workers.mjs +++ b/apps/effectcraft-web/tests/workers.mjs @@ -7,6 +7,8 @@ // (#218), and not when it is unchanged. // - A worker whose start fails fails its job and is terminated, instead of the job waiting for // "ready" forever (#216): host.js on the worker's error, worker.js re-raising the failed start. +// - A `new Worker` that throws (a sandboxed iframe's opaque origin, #1586) fails the job, and a +// frame worker is reported dead, instead of the exception escaping into startup. // Exits non-zero on failure. import assert from "node:assert/strict"; import { copyFileSync, mkdtempSync, writeFileSync } from "node:fs"; @@ -67,6 +69,35 @@ assert.equal(failed.kind, "error"); assert.ok(workers[1].terminated, "the failed worker is terminated"); assert.equal(host.workerStats().running, 0); +// A `new Worker` that throws (a sandboxed iframe's opaque origin): the job fails, and a frame +// worker is reported dead, instead of the exception escaping into startup. +failStart = false; +const RealWorker = globalThis.Worker; +globalThis.Worker = class { + constructor() { + throw new Error("SecurityError: cannot be accessed from origin 'null'"); + } +}; +// (Take the idle workers out of play first, so that a new one has to be built.) +for (let n = host.workerStats().idle, id = 100; n > 0; n--, id++) { + host.workerRun(id, "{}", [], "http://localhost/", () => {}); + host.workerCancel(id); +} +assert.equal(host.workerStats().idle, 0); +const threw = await run(6, []); +assert.equal(threw.kind, "error"); +assert.match(threw.json, /SecurityError/); +assert.equal(host.workerStats().running, 0); +let frameCall = null; +const frameDone = new Promise((resolve) => { + frameCall = host.frameWorkerStart("http://localhost/", (index, json, bytes, error) => resolve({ index, error }), false); +}); +const dead = await frameDone; +assert.equal(dead.index, frameCall, "the dead worker keeps its index"); +assert.match(dead.error, /SecurityError/); +host.frameWorkerPost(frameCall, { type: "frame" }); // no worker behind it: ignored +globalThis.Worker = RealWorker; + // #216, worker side: a rejected start is re-raised where the page's `onerror` sees it. const dir = mkdtempSync(join(tmpdir(), "ec-worker-")); copyFileSync(join(here, "../web/worker.js"), join(dir, "worker.js")); diff --git a/docs/web.md b/docs/web.md index 23ee1da1..cac4b691 100644 --- a/docs/web.md +++ b/docs/web.md @@ -156,8 +156,9 @@ of the project: Frames without effects that the WebGPU compositor can finish alone still render on the page (they stay on the GPU, no readback); everything else (frames with effects, 3D runs, the Software -Only renderer, WebGL2) goes to the workers. If no worker is usable, frames render on the page's -thread as before. `effectcraft.info().frameWorkers` reports `{workers, alive, busy, rendered, +Only renderer, WebGL2) goes to the workers. If no worker is usable (one that failed to start, or +one the browser refused to create, as in a sandboxed iframe), frames render on the page's thread +as before. `effectcraft.info().frameWorkers` reports `{workers, alive, busy, rendered, failed, lastMs, syncs, syncBytes, gpu, gpuFrames, lastPasses, diskHits}` (`gpu`: each worker's adapter, `{cpu: why}` without one, `null` until it reported). @@ -425,8 +426,9 @@ node apps/effectcraft-web/tests/smoke.mjs --url http://127.0.0.1:8765/ --out tar `apps/effectcraft-web/tests/workers.mjs` checks the job-worker plumbing (`js/host.js`, `web/worker.js`) under Node with a fake `Worker`, without a build: a replaced file is sent to a -reused worker again even at the same size, and a worker that fails to start fails its job and is -terminated (`node apps/effectcraft-web/tests/workers.mjs`). +reused worker again even at the same size, a worker that fails to start fails its job and is +terminated, and a `new Worker` that throws (a sandboxed iframe) fails the job and reports the +frame worker dead instead of stopping startup (`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, and the