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(/ 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 }); +}