diff --git a/web/Trunk.toml b/web/Trunk.toml index 0befef1b..7b5c352a 100644 --- a/web/Trunk.toml +++ b/web/Trunk.toml @@ -11,6 +11,42 @@ target = "index.html" dist = "dist" +# The first visit must reload after the isolation worker takes control. +# Keep both preloads inert until that reload; otherwise it abandons a WASM +# download already in flight. An isolated page activates them during parsing, +# before the module script runs, preserving parallel download and compilation. +# Templates also keep the hashed URLs visible to `xtask bundle check`. +pattern_preload = ''' + + +''' + +# Dynamic import matters here: a static import starts fetching even when the +# call to `init` is deferred. The service worker settles this promise when +# already isolated or when setup failed, preserving GPUI's main-thread fallback. +# A missing worker script leaves it undefined, which also continues normally. +# Match Trunk's default initializer and application-started event. The object +# argument is the wasm-bindgen API used by this workspace's pinned version. +pattern_script = ''' + +''' + [serve] addresses = ["127.0.0.1"] port = 8080 diff --git a/web/coi-serviceworker.js b/web/coi-serviceworker.js index d97ee669..cb4cfad2 100644 --- a/web/coi-serviceworker.js +++ b/web/coi-serviceworker.js @@ -157,6 +157,22 @@ if (typeof window === "undefined") { // is past the bound is a console error rather than another navigation. const RELOADED = "coi-serviceworker:reloaded"; + // The loader waits while this document is about to be replaced. If + // isolation is unavailable, retain the original main-thread fallback. + // A missing script also remains compatible: awaiting an undefined + // property in Trunk's loader continues immediately. + let startApplication; + window.oxidezapIsolationReady = new Promise((resolve) => { + startApplication = resolve; + }); + // The initial document can enter the back/forward cache during the + // isolation reload. If restored later, its unresolved loader must be + // allowed to use the existing main-thread fallback rather than waiting + // for a takeover that already happened in a different document. + window.addEventListener("pageshow", (event) => { + if (event.persisted) startApplication(); + }); + // `sessionStorage` is per tab and survives a reload, which is exactly the // scope the bound wants. A browser may refuse it (a privacy mode, a // blocked storage context), and a refusal must not be read as "not yet @@ -171,6 +187,7 @@ if (typeof window === "undefined") { }; if (window.crossOriginIsolated) { + startApplication(); // Isolated: either this worker is serving us or the host set the // headers itself. Spend the mark, so that a later load which is // somehow not isolated — the registration removed by hand, a browser @@ -188,24 +205,36 @@ if (typeof window === "undefined") { "cannot be cross-origin isolated. Serve it over HTTPS or " + "from localhost.", ); + startApplication(); } else if (!navigator.serviceWorker) { console.error( "coi-serviceworker: this browser has no service workers, so " + - "the window cannot start its background executor.", + "the window will use its main-thread fallback.", ); + startApplication(); } else if (alreadyReloaded()) { // We have been here already in this tab, and came back without the // headers. Reloading again would only ask the same question, so the - // loop stops here and says what it knows: everything below needs a - // `SharedArrayBuffer`, and there will not be one. + // loop stops here and the main-thread fallback can try starting. console.error( "coi-serviceworker: this page reloaded to pick up cross-origin " + - "isolation and is still not isolated. The window will not " + - "start. Check that the service worker is controlling this " + + "isolation and is still not isolated. Trying the main-thread " + + "fallback. Check that the service worker is controlling this " + "page (Application → Service Workers) and that nothing " + "is stripping COOP/COEP from its responses.", ); + startApplication(); } else { + // Do not let an installation that never settles prevent the existing + // main-thread fallback from starting. A completed reload destroys this + // document and its timer. A late takeover can still reload after + // fallback starts, as it could before the loader waited for setup. + const startupDeadline = window.setTimeout(() => { + console.warn("coi-serviceworker: setup timed out; trying the main-thread fallback"); + startApplication(); + }, 10000); + window.oxidezapIsolationReady.then(() => window.clearTimeout(startupDeadline)); + // One reload, whichever of the two paths below asks for it first. A // `location.reload()` does not stop this script — the document is // torn down at the browser's convenience — so an unguarded call is a @@ -255,10 +284,15 @@ if (typeof window === "undefined") { // and no `controllerchange` is coming for us. if (registration.active && !navigator.serviceWorker.controller) { reloadOnce(); + } else if (registration.active && !reloading) { + // Already controlled but still not isolated. No takeover + // is pending, so preserve the previous fallback immediately. + startApplication(); } }) - .catch((error) => - console.error("coi-serviceworker: registration failed:", error), - ); + .catch((error) => { + console.error("coi-serviceworker: registration failed:", error); + startApplication(); + }); } } diff --git a/web/coi-serviceworker.test.mjs b/web/coi-serviceworker.test.mjs index a5652933..85901b92 100644 --- a/web/coi-serviceworker.test.mjs +++ b/web/coi-serviceworker.test.mjs @@ -79,3 +79,204 @@ test("unrelated notification clicks are ignored", () => { click({ notification: { data: { oxidezapTag: "other" }, close: () => { closed = true; } } }); assert.equal(closed, false); }); + +// These are the actual templates Trunk expands, not a second loader. A +// browser test can then exercise the same templates with synthetic assets. +const trunk = readFileSync(new URL("./Trunk.toml", import.meta.url), "utf8"); +const startup = Object.fromEntries([...trunk.matchAll( + /pattern_(preload|script)\s*=\s*'''([\s\S]*?)'''/g, +)].map((match) => [match[1], match[2]])); +const inlineScript = (html) => html.match(/([\s\S]*?)<\/script>/)[1]; + +test("an uncontrolled page leaves the WASM and glue preloads inert", () => { + const activated = []; + vm.runInNewContext(inlineScript(startup.preload), { + window: { crossOriginIsolated: false, oxidezapIsolationReady: new Promise(() => {}) }, + document: { head: { append: (value) => activated.push(value) } }, + }); + assert.deepEqual(activated, []); + assert.match(startup.preload, /]*>[\s\S]*href="\{base\}\{js\}"[\s\S]*href="\{base\}\{wasm\}"[\s\S]*<\/template>/); +}); + +test("an isolated page activates both preloads before starting the module", () => { + const content = {}; + const activated = []; + vm.runInNewContext(inlineScript(startup.preload), { + window: { crossOriginIsolated: true }, + document: { + getElementById: (id) => { assert.equal(id, "wasm-preloads"); return { content }; }, + head: { append: (value) => activated.push(value) }, + }, + }); + assert.deepEqual(activated, [content]); +}); + +test("the loader waits during isolation setup and starts on fallback", async () => { + let ready; + const imports = []; + const events = []; + const wasm = {}; + const script = inlineScript(startup.script).replace("await import(", "await importModule("); + const pending = vm.runInNewContext(`(async () => { ${script} })()`, { + window: { oxidezapIsolationReady: new Promise((resolve) => { ready = resolve; }) }, + importModule: async (url) => { + imports.push(url); + return { default: async (options) => { + assert.equal(options.module_or_path, "{base}{wasm}"); + return wasm; + } }; + }, + CustomEvent: class { constructor(name, options) { this.name = name; this.detail = options.detail; } }, + dispatchEvent: (event) => events.push(event), + }); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(imports, []); + ready(); + await pending; + assert.deepEqual(imports, ["{base}{js}"]); + assert.equal(events[0].name, "TrunkApplicationStarted"); + assert.equal(events[0].detail.wasm, wasm); +}); + +test("a missing isolation script retains the original startup path", async () => { + let imported = false; + const script = inlineScript(startup.script).replace("await import(", "await importModule("); + await vm.runInNewContext(`(async () => { ${script} })()`, { + window: {}, + importModule: async () => { imported = true; return { default: async () => ({}) }; }, + CustomEvent: class {}, + dispatchEvent: () => {}, + }); + assert.equal(imported, true); +}); + +function pageWith({ isolated = false, secure = true, worker, reloaded = false, stored = new Map() } = {}) { + const errors = []; + let reloads = 0; + let deadline; + const pageEvents = new Map(); + if (reloaded) stored.set("coi-serviceworker:reloaded", "1"); + const context = { + window: { + crossOriginIsolated: isolated, + addEventListener: (name, listener) => pageEvents.set(name, listener), + isSecureContext: secure, + document: { currentScript: { src: "https://example.test/client/coi-serviceworker.js" } }, + location: { reload: () => { reloads++; } }, + setTimeout: (callback) => { deadline = callback; return 1; }, + clearTimeout: () => { deadline = undefined; }, + sessionStorage: { + getItem: (key) => stored.get(key) ?? null, + setItem: (key, value) => stored.set(key, value), + removeItem: (key) => stored.delete(key), + }, + }, + navigator: { serviceWorker: worker }, + console: { error: (...args) => errors.push(args.join(" ")), warn: () => {} }, + }; + vm.runInNewContext(script, context); + return { errors, stored, reloads: () => reloads, ready: context.window.oxidezapIsolationReady, deadline: () => deadline?.(), restore: () => pageEvents.get("pageshow")({ persisted: true }) }; +} + +test("a host already providing isolation needs no service worker", async () => { + const page = pageWith({ isolated: true, reloaded: true }); + await page.ready; + assert.deepEqual(page.errors, []); + assert.equal(page.reloads(), 0); + assert.equal(page.stored.has("coi-serviceworker:reloaded"), false); +}); + +test("a browser without service workers reports the failure without reloading", async () => { + const page = pageWith(); + await page.ready; + assert.match(page.errors.join(" "), /no service workers/); + assert.equal(page.reloads(), 0); +}); + +test("service worker registration failure reports the failure without reloading", async () => { + const page = pageWith({ worker: { + addEventListener: () => {}, + register: async () => { throw new Error("registration unavailable"); }, + } }); + await new Promise((resolve) => setImmediate(resolve)); + await page.ready; + assert.match(page.errors.join(" "), /registration failed/); + assert.equal(page.reloads(), 0); +}); + +test("the first visit reloads once and an unsuccessful reload stops", async () => { + const listeners = new Map(); + const page = pageWith({ worker: { + addEventListener: (name, listener) => listeners.set(name, listener), + controller: null, + register: async () => ({ active: true }), + } }); + await new Promise((resolve) => setImmediate(resolve)); + listeners.get("controllerchange")(); + listeners.get("controllerchange")(); + assert.equal(page.reloads(), 1); + assert.equal(page.stored.get("coi-serviceworker:reloaded"), "1"); + const retry = pageWith({ stored: page.stored, worker: { + register: () => { throw new Error("must not register twice"); }, + } }); + assert.equal(retry.reloads(), 0); + assert.match(retry.errors.join(" "), /still not isolated/); +}); + + +test("a stalled installation eventually starts the main-thread fallback", async () => { + const page = pageWith({ worker: { + addEventListener: () => {}, + register: () => new Promise(() => {}), + } }); + let ready = false; + page.ready.then(() => { ready = true; }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(ready, false); + page.deadline(); + await page.ready; + assert.equal(ready, true); +}); + +test("fallback activates the same preloads after setup settles", async () => { + let ready; + const content = {}; + const activated = []; + vm.runInNewContext(inlineScript(startup.preload), { + window: { crossOriginIsolated: false, oxidezapIsolationReady: new Promise((resolve) => { ready = resolve; }) }, + document: { getElementById: () => ({ content }), head: { append: (value) => activated.push(value) } }, + }); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(activated, []); + ready(); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(activated, [content]); +}); + +test("an existing controller without isolation immediately retains fallback", async () => { + const page = pageWith({ worker: { + controller: {}, + addEventListener: () => {}, + register: async () => ({ active: true }), + } }); + await page.ready; + assert.equal(page.reloads(), 0); +}); + +test("an insecure context immediately retains fallback", async () => { + const page = pageWith({ secure: false }); + await page.ready; + assert.equal(page.reloads(), 0); + assert.match(page.errors.join(" "), /not a secure context/); +}); + + +test("restoring the initial document from bfcache releases its pending loader", async () => { + const page = pageWith({ worker: { + addEventListener: () => {}, + register: () => new Promise(() => {}), + } }); + page.restore(); + await page.ready; + assert.equal(page.reloads(), 0); +}); diff --git a/web/index.html b/web/index.html index dd8878ba..73841ca1 100644 --- a/web/index.html +++ b/web/index.html @@ -37,12 +37,16 @@ own generated glue, and it is here because the alternative is a header we have no way to send. - It is declared *before* the module, and that ordering is about - the first visit: registering and reloading is how the headers arrive, - and a reload that lands after the preload has begun throws away - however much of a twenty-megabyte module the browser had got. The - registration is asynchronous, so this narrows the window rather - than closing it — but the other order guarantees the waste. --> + It is declared before the module. Trunk.toml keeps the generated + preloads in an inert template and imports the glue only when this + document is isolated, so the first visit registers and reloads + without starting a WASM download it would have to abandon. On an + isolated page the preloads are activated during parsing, before the + module starts. This also works when the host sends the headers + itself and no service worker is needed. If setup fails or service + workers are unavailable, the loader continues with the existing + main-thread fallback. A stalled installation is bounded to ten + seconds before trying that fallback. --> diff --git a/web/startup-transfer.mjs b/web/startup-transfer.mjs new file mode 100644 index 00000000..78f1688d --- /dev/null +++ b/web/startup-transfer.mjs @@ -0,0 +1,260 @@ +// Run with `node web/startup-transfer.mjs`; needs Chromium on PATH. +// ISOLATED=1 checks a host that supplies COOP/COEP without a reload. +// CHECK_FLOWS=1 additionally checks reload/bfcache and the loaded page offline. +// Alternating base/head order, fresh scopes, one Chromium process, no packages. +import http from "node:http"; +import fs from "node:fs"; +import { spawn } from "node:child_process"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +const root = fileURLToPath(new URL("../", import.meta.url)); +const worker = fs.readFileSync(root + "/web/coi-serviceworker.js", "utf8"); +const config = fs.readFileSync(root + "/web/Trunk.toml", "utf8"); +const patterns = [ + ...config.matchAll(/pattern_(preload|script)\s*=\s*'''([\s\S]*?)'''/g), +]; +if (patterns.length !== 2) + throw new Error("Trunk startup patterns are missing"); +// Synthetic module with a 22 MiB custom section. Measures requests and transfer, +// not application execution, CPU, heap, RSS, or production startup latency. +const moduleSize = 22 * 1024 * 1024; +function leb(n) { + const b = []; + do { + let x = n & 127; + n >>>= 7; + b.push(x | (n ? 128 : 0)); + } while (n); + return b; +} +const payload = Buffer.concat([ + Buffer.from([0, 97, 115, 109, 1, 0, 0, 0, 0, ...leb(moduleSize), 0]), + Buffer.alloc(moduleSize - 1), +]); +const stats = new Map(); +const checkFlows = process.env.CHECK_FLOWS === "1"; +const basePreload = + ''; +const baseScript = + ''; +const server = http.createServer((req, res) => { + const parts = req.url.split("/"); + const key = parts[1]; + const path = parts.at(-1); + const s = stats.get(key); + if (path === "coi-serviceworker.js") { + res.setHeader("Content-Type", "application/javascript"); + res.end(worker); + return; + } + if (path === "app.js") { + res.setHeader("Content-Type", "application/javascript"); + res.end( + "export default async function init({module_or_path}){await WebAssembly.compileStreaming(fetch(module_or_path));return {}}", + ); + return; + } + if (path === "app_bg.wasm") { + s.requests++; + res.setHeader("Content-Type", "application/wasm"); + res.setHeader("Content-Length", payload.length); + let offset = 0; + const timer = setInterval(() => { + if (offset >= payload.length) { + clearInterval(timer); + res.end(); + return; + } + const chunk = payload.subarray(offset, offset + 65536); + offset += chunk.length; + s.bytes += chunk.length; + res.write(chunk); + }, 5); + res.on("close", () => { + clearInterval(timer); + if (offset < payload.length) s.cancelled++; + }); + return; + } + const replace = (x) => + x + .replaceAll("{base}", `/${key}/`) + .replaceAll("{js}", "app.js") + .replaceAll("{wasm}", "app_bg.wasm") + .replaceAll("{crossorigin}", "anonymous"); + const variant = key.split("-")[0]; + const selected = + variant === "head" && patterns.length === 2 + ? Object.fromEntries(patterns.map((m) => [m[1], m[2]])) + : { preload: basePreload, script: baseScript }; + if (process.env.ISOLATED === "1") { + res.setHeader("Cross-Origin-Opener-Policy", "same-origin"); + res.setHeader("Cross-Origin-Embedder-Policy", "require-corp"); + } + res.setHeader("Content-Type", "text/html"); + res.end( + '' + + replace(selected.preload) + + replace(selected.script) + + "test", + ); +}); +await new Promise((r) => server.listen(0, "127.0.0.1", r)); +const port = server.address().port; +const profile = fs.mkdtempSync(join(tmpdir(), "web-startup-profile-")); +const chrome = spawn("chromium", [ + "--headless", + "--no-sandbox", + "--disable-gpu", + "--remote-debugging-port=0", + "--user-data-dir=" + profile, + "about:blank", +]); +const browserUrl = await new Promise((resolve, reject) => { + let output = ""; + chrome.stderr.on("data", (x) => { + output += x; + const m = output.match(/DevTools listening on (ws:\/\/[^\s]+)/); + if (m) resolve(m[1]); + }); + chrome.on("exit", reject); +}); +const debugOrigin = "http://" + new URL(browserUrl).host; +async function connect(url) { + const ws = new WebSocket(url); + await new Promise((r) => ws.addEventListener("open", r, { once: true })); + let id = 0; + const waiting = new Map(); + ws.addEventListener("message", (e) => { + const x = JSON.parse(e.data); + if (x.id) { + waiting.get(x.id)?.(x); + waiting.delete(x.id); + } + }); + return { + ws, + send: (method, params = {}) => + new Promise((resolve) => { + const n = ++id; + waiting.set(n, resolve); + ws.send(JSON.stringify({ id: n, method, params })); + }), + }; +} +const rows = []; +try { + for (let pair = 0; pair < (checkFlows ? 2 : 8); pair++) + for (const variant of pair % 2 ? ["head", "base"] : ["base", "head"]) { + const key = variant + "-" + pair; + const s = { requests: 0, bytes: 0, cancelled: 0 }; + stats.set(key, s); + const target = await ( + await fetch(debugOrigin + "/json/new?about:blank", { method: "PUT" }) + ).json(); + const c = await connect(target.webSocketDebuggerUrl); + await c.send("Runtime.enable"); + await c.send("Page.enable"); + await c.send("Page.addScriptToEvaluateOnNewDocument", { + source: + 'window.docId=Math.random();window.started=false;addEventListener("TrunkApplicationStarted",()=>window.started=true)', + }); + const start = performance.now(); + await c.send("Page.navigate", { + url: `http://127.0.0.1:${port}/${key}/`, + }); + async function started() { + for (let n = 0; n < 200; n++) { + await new Promise((r) => setTimeout(r, 30)); + const result = await c.send("Runtime.evaluate", { + expression: "window.started === true", + }); + if (result.result?.result?.value) return true; + } + return false; + } + const done = await started(); + let flows; + if (checkFlows) { + const oldId = ( + await c.send("Runtime.evaluate", { + expression: "window.docId", + }) + ).result?.result?.value; + await c.send("Page.reload"); + let documentChanged = false; + for (let n = 0; n < 200; n++) { + await new Promise((resolve) => setTimeout(resolve, 20)); + const id = ( + await c.send("Runtime.evaluate", { + expression: "window.docId", + }) + ).result?.result?.value; + if (id !== undefined && id !== oldId) { + documentChanged = true; + break; + } + } + const reloadDone = await started(); + await c.send("Network.enable"); + await c.send("Network.emulateNetworkConditions", { + offline: true, + latency: 0, + downloadThroughput: 0, + uploadThroughput: 0, + }); + // This checks a loaded page. It does not claim offline installation + // or exercise the full client's offline store/session behavior. + const offlineDone = + ( + await c.send("Runtime.evaluate", { + expression: "window.started === true", + }) + ).result?.result?.value === true; + await c.send("Network.emulateNetworkConditions", { + offline: false, + latency: 0, + downloadThroughput: -1, + uploadThroughput: -1, + }); + flows = { documentChanged, reloadDone, offlineDone }; + } + rows.push({ + pair, + variant, + ...s, + ms: Math.round(performance.now() - start), + done, + flows, + }); + c.ws.close(); + await fetch(debugOrigin + "/json/close/" + target.id); + } + console.log( + JSON.stringify( + { + payloadBytes: payload.length, + patterns: patterns.length, + isolatedHost: process.env.ISOLATED === "1", + checkFlows, + rows, + }, + null, + 2, + ), + ); + if ( + rows.some( + (row) => + !row.done || + (row.flows && (!row.flows.documentChanged || !row.flows.reloadDone || !row.flows.offlineDone)), + ) + ) + process.exitCode = 1; +} finally { + chrome.kill(); + await new Promise((resolve) => chrome.once("exit", resolve)); + server.close(); + fs.rmSync(profile, { recursive: true, force: true }); +}