Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions web/Trunk.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '''
<template id="wasm-preloads">
<link rel="modulepreload" href="{base}{js}" crossorigin="{crossorigin}">
<link rel="preload" href="{base}{wasm}" as="fetch" type="application/wasm" crossorigin="{crossorigin}">
</template>
<script>
if (window.crossOriginIsolated) {
document.head.append(document.getElementById("wasm-preloads").content);
} else {
Promise.resolve(window.oxidezapIsolationReady).then(() => {
document.head.append(document.getElementById("wasm-preloads").content);
});
}
</script>
'''

# 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 = '''
<script type="module">
await window.oxidezapIsolationReady;
const { default: init } = await import('{base}{js}');
const wasm = await init({ module_or_path: '{base}{wasm}' });
dispatchEvent(new CustomEvent("TrunkApplicationStarted", { detail: { wasm } }));
</script>
'''

[serve]
addresses = ["127.0.0.1"]
port = 8080
Expand Down
50 changes: 42 additions & 8 deletions web/coi-serviceworker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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();
});
}
}
201 changes: 201 additions & 0 deletions web/coi-serviceworker.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(/<script(?: type="module")?>([\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, /<template[^>]*>[\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);
});
16 changes: 10 additions & 6 deletions web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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. -->
<link data-trunk rel="copy-file" href="coi-serviceworker.js" />
<script src="coi-serviceworker.js"></script>

Expand Down
Loading
Loading