From fd887adcbdf1302fd8276ad1bd40dfc289c736d6 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Wed, 1 Jul 2026 09:37:28 -0400 Subject: [PATCH] Gate the test-realm SW so it stops intercepting when unclaimed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test-realm service worker relays http://test-realm/ browser fetches to the window and 503s ("No responsive client available") when no client answers within its 1500ms timeout. `unregister()` does not evict an already-active worker from a still-loaded page, so once an image/audio module registers it, it keeps controlling the QUnit runner into later modules. Those modules never install a `test-realm-fetch` responder, so a boot-time fetch (e.g. MatrixService.setSystemCard) gets a 503 instead of the expected 404 — boot fails, the login form renders, and the module cascades or times out. It's shard-composition-sensitive, which is why it surfaces intermittently. Gate interception on an `active` flag: the worker defaults to passthrough and only intercepts while a module has turned it on (in its beforeEach, after the responder is listening), turning it back off on teardown. A lingering worker then passes requests straight through, behaving exactly as if no SW were installed — the state non-intercepting modules already expect and pass under. The flag defaults off so a terminated-and-restarted worker fails safe toward passthrough rather than resurrecting the leak; the owning module re-asserts it each beforeEach. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/host/public/test-realm-sw.js | 29 ++++++++++++++ .../helpers/test-realm-service-worker.ts | 38 +++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/packages/host/public/test-realm-sw.js b/packages/host/public/test-realm-sw.js index 82a67e19d8e..6a8e25301f6 100644 --- a/packages/host/public/test-realm-sw.js +++ b/packages/host/public/test-realm-sw.js @@ -6,7 +6,36 @@ self.addEventListener('activate', (event) => { event.waitUntil(self.clients.claim()); }); +// Interception is opt-in per module. `unregister()` does not evict an already +// active worker from a still-loaded page, so once a module registers this SW it +// keeps controlling the QUnit runner into later modules. If it kept intercepting +// there, those modules (which never installed a `test-realm-fetch` responder) +// would get a 503 for every test-realm fetch and cascade. Gating on `active` +// lets a module turn interception on for its own tests and off on teardown, so a +// lingering worker passes requests straight through — behaving exactly as if no +// SW were installed, which is the state non-intercepting modules expect. +// +// Default off so a cold-started/terminated-and-restarted worker (which loses +// this in-memory flag) fails safe toward passthrough rather than resurrecting +// the leak; the owning module re-asserts `active` in its beforeEach. +let active = false; + +self.addEventListener('message', (event) => { + let data = event.data; + if (!data || data.type !== 'test-realm-sw-set-active') { + return; + } + active = Boolean(data.active); + let port = event.ports && event.ports[0]; + if (port) { + port.postMessage({ ok: true, active }); + } +}); + self.addEventListener('fetch', (event) => { + if (!active) { + return; + } if (!event.request.url.startsWith('http://test-realm/')) { return; } diff --git a/packages/host/tests/helpers/test-realm-service-worker.ts b/packages/host/tests/helpers/test-realm-service-worker.ts index 5bb865207ed..77a2d04d768 100644 --- a/packages/host/tests/helpers/test-realm-service-worker.ts +++ b/packages/host/tests/helpers/test-realm-service-worker.ts @@ -36,6 +36,36 @@ async function ensureRegistered(): Promise { return swReady; } +// Toggle the SW's interception. The worker defaults to passthrough and only +// intercepts test-realm fetches while a module has turned it on, so a worker +// that lingers past `unregister()` stops intercepting for later modules. Awaits +// an ack (bounded, so a missing controller/ack never hangs a hook) to guarantee +// the flag is applied before the module's tests issue their fetches. +async function setSwActive(active: boolean): Promise { + let controller = navigator.serviceWorker.controller; + if (!controller) { + return; + } + await new Promise((resolve) => { + let channel = new MessageChannel(); + let settled = false; + let finish = () => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + channel.port1.onmessage = null; + resolve(); + }; + let timeout = setTimeout(finish, 500); + channel.port1.onmessage = finish; + controller.postMessage({ type: 'test-realm-sw-set-active', active }, [ + channel.port2, + ]); + }); +} + // Sets up a service worker that intercepts requests to http://test-realm/ // and relays them to the VirtualNetwork so that browser-native resource loads // (which bypass VirtualNetwork) can reach the test realm's files. @@ -70,6 +100,8 @@ export function setupTestRealmServiceWorker(hooks: NestedHooks) { } }; navigator.serviceWorker.addEventListener('message', handler); + // Only intercept once the responder above is listening. + await setSwActive(true); }); hooks.afterEach(function () { @@ -83,6 +115,12 @@ export function setupTestRealmServiceWorker(hooks: NestedHooks) { // doesn't persist and replace the auth service worker (which would break // the app if the user navigates from /tests back to / during ember serve). hooks.after(async function () { + // Stop intercepting before we let go of the registration: the worker keeps + // controlling the loaded page after unregister(), and later modules must + // see it as passthrough rather than a 503 source. + if ('serviceWorker' in navigator) { + await setSwActive(false); + } if (swRegistration) { await swRegistration.unregister(); swRegistration = undefined;