From 27af0ab80ea43d2684fb2fe1d3f39a953f5c4d50 Mon Sep 17 00:00:00 2001 From: Ansel Date: Sat, 24 Oct 2026 15:46:48 +0800 Subject: [PATCH] fix(webview): bound worker updates and acquire the expected controller Reuse matching registrations, bound update waits, validate claim requests, and cover worker lifecycle behavior in Chromium. Fixes coder/code-server#8073 --- CHANGELOG.md | 6 + patches/csp-hashes.diff | 2 +- patches/series | 1 + patches/webview-service-worker.diff | 149 ++++++++++++++++++ test/e2e/webviewServiceWorker.test.ts | 216 ++++++++++++++++++++++++++ 5 files changed, 373 insertions(+), 1 deletion(-) create mode 100644 patches/webview-service-worker.diff create mode 100644 test/e2e/webviewServiceWorker.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ba693eb6d5b..28d16e46e98f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,12 @@ Code v99.99.999 ## Unreleased +### Fixed + +- Prevent slow service worker updates from blocking webview initialization when + the matching worker is already available, and require the correct webview + controller before loading content. + ## [4.143.1](https://github.com/coder/code-server/releases/tag/v4.143.1) - 2026-10-23 Code v1.143.1 diff --git a/patches/csp-hashes.diff b/patches/csp-hashes.diff index 6eac9f88aff6..3584735c4b7a 100644 --- a/patches/csp-hashes.diff +++ b/patches/csp-hashes.diff @@ -7,7 +7,7 @@ Index: code-server/lib/vscode/src/vs/workbench/contrib/webview/browser/pre/index -+ content="default-src 'none'; script-src 'sha256-FYdHnaDkSHwSK3TmWDrs/tT+X4bK+aaoa9yy432BONk=' 'self'; frame-src 'self'; style-src 'unsafe-inline';"> ++ content="default-src 'none'; script-src 'sha256-Vz4UIv0xEHz4RBI4bj15gaqFCBuY6Sh8B8MuGcHuyAA=' 'self'; frame-src 'self'; style-src 'unsafe-inline';"> } */ +- const workerReady = new Promise((resolve, reject) => { ++ const workerReady = (async () => { + if (disableServiceWorker) { +- return resolve(); ++ return; + } + + if (!areServiceWorkersEnabled()) { +- return reject(new Error('Service Workers are not enabled. Webviews will not work. Try disabling private/incognito mode.')); +- } +- +- const swPath = encodeURI(`service-worker.js?v=${expectedWorkerVersion}&vscode-resource-base-authority=${searchParams.get('vscode-resource-base-authority')}&remoteAuthority=${searchParams.get('remoteAuthority') ?? ''}&platform=${searchParams.get('platform')}`); +- navigator.serviceWorker.register(swPath) +- .then(async registration => { +- if (navigator.serviceWorker.controller) { +- // A previous SW is already controlling. Force an update +- // check so we don't serve stale resources after a VS Code +- // update. register() resolves before the update check +- // completes, so we must call update() explicitly to wait +- // for the download + byte-comparison to finish. +- registration = await registration.update(); +- } +- +- // If a new worker was found, wait for it to take control via skipWaiting() + clients.claim() in the SW. +- if ( +- // New worker +- registration.installing || registration.waiting +- // First ever load +- || !navigator.serviceWorker.controller +- ) { +- await new Promise(r => { +- navigator.serviceWorker.addEventListener('controllerchange', r, { once: true }); +- }); +- } +- +- return resolve(); +- }).catch(error => { +- if (!onElectron && error instanceof Error && error.message.includes('user denied permission')) { +- return reject(new Error(`Could not register service worker. Please make sure third party cookies are enabled: ${error}`)); +- } +- return reject(new Error(`Could not register service worker: ${error}.`)); +- }); +- }); ++ throw new Error('Service Workers are not enabled. Webviews will not work. Try disabling private/incognito mode.'); ++ } ++ ++ // A distinct URL prevents reusing a worker without the claim message handler. ++ const swPath = encodeURI(`service-worker.js?v=${expectedWorkerVersion}&vscode-resource-base-authority=${searchParams.get('vscode-resource-base-authority')}&remoteAuthority=${searchParams.get('remoteAuthority') ?? ''}&platform=${searchParams.get('platform')}&code-server-webview=1`); ++ const expectedScriptURL = new URL(swPath, location.href).href; ++ const expectedScope = new URL('./', location.href).href; ++ try { ++ const existing = await navigator.serviceWorker.getRegistration(location.href); ++ const hasMatchingActiveWorker = existing?.scope === expectedScope && existing.active?.scriptURL === expectedScriptURL; ++ // register() can also queue behind an update job. Reuse only this exact registration. ++ const registration = hasMatchingActiveWorker ? existing : await navigator.serviceWorker.register(swPath); ++ if (hasMatchingActiveWorker) { ++ // Keep update checks, but do not hold usable content behind a slow browser job. ++ let timer; ++ try { ++ await Promise.race([ ++ registration.update(), ++ new Promise(resolve => { timer = setTimeout(resolve, 3000); }) ++ ]); ++ } catch (error) { ++ console.warn('Webview service worker update failed; using matching active worker.', error); ++ } finally { ++ clearTimeout(timer); ++ } ++ } ++ ++ // A root PWA controller does not make webview resource requests usable. ++ if (navigator.serviceWorker.controller?.scriptURL !== expectedScriptURL) { ++ await new Promise((resolve, reject) => { ++ const cleanup = () => { ++ clearTimeout(timer); ++ clearInterval(claimInterval); ++ navigator.serviceWorker.removeEventListener('controllerchange', check); ++ }; ++ const check = () => { ++ if (navigator.serviceWorker.controller?.scriptURL === expectedScriptURL) { ++ cleanup(); ++ resolve(undefined); ++ } else if (registration.active?.scriptURL === expectedScriptURL) { ++ registration.active.postMessage({ channel: 'code-server-claim-webview' }); ++ } ++ }; ++ const timer = setTimeout(() => { ++ cleanup(); ++ reject(new Error('Timed out waiting for the matching webview service worker to take control.')); ++ }, 10000); ++ const claimInterval = setInterval(check, 250); ++ navigator.serviceWorker.addEventListener('controllerchange', check); ++ check(); ++ }); ++ } ++ } catch (error) { ++ if (!onElectron && error instanceof Error && error.message.includes('user denied permission')) { ++ throw new Error(`Could not register service worker. Please make sure third party cookies are enabled: ${error}`); ++ } ++ throw new Error(`Could not register service worker: ${error}.`); ++ } ++ })(); + + /** + * @type {import('../webviewMessages').WebviewHostMessaging} +Index: code-server/lib/vscode/src/vs/workbench/contrib/webview/browser/pre/service-worker.js +=================================================================== +--- code-server.orig/lib/vscode/src/vs/workbench/contrib/webview/browser/pre/service-worker.js ++++ code-server/lib/vscode/src/vs/workbench/contrib/webview/browser/pre/service-worker.js +@@ -258,6 +258,21 @@ + /** @type {Client} */ + const source = event.source; + switch (event.data.channel) { ++ case 'code-server-claim-webview': { ++ // Only an outer webview document on this origin may request a claim. ++ if (source.type !== 'window') { ++ return; ++ } ++ const url = new URL(source.url); ++ if (url.origin === sw.location.origin && ( ++ url.pathname === `${rootPath}/` ++ || url.pathname === `${rootPath}/index.html` ++ || url.pathname === `${rootPath}/index-no-csp.html` ++ )) { ++ event.waitUntil(sw.clients.claim()); ++ } ++ return; ++ } + case 'did-load-resource': { + /** @type {ResourceResponse} */ + const response = event.data.data; diff --git a/test/e2e/webviewServiceWorker.test.ts b/test/e2e/webviewServiceWorker.test.ts new file mode 100644 index 000000000000..f80973c7f12a --- /dev/null +++ b/test/e2e/webviewServiceWorker.test.ts @@ -0,0 +1,216 @@ +import { expect, test } from "@playwright/test" +import { createHash } from "crypto" +import { readFileSync } from "fs" +import { createServer, Server } from "http" +import * as path from "path" + +// Exercise the patched bootstrap with real browser registrations and controllers, +// without depending on extension activation or a compiled workbench. +const pre = path.resolve(__dirname, "../../lib/vscode/src/vs/workbench/contrib/webview/browser/pre") +const html = readFileSync(path.join(pre, "index.html"), "utf8") +const worker = readFileSync(path.join(pre, "service-worker.js"), "utf8") +const bootstrap = html.slice(html.indexOf("const workerReady ="), html.indexOf("const hostMessaging =")) +const documentBody = `` + +let server: Server +let origin: string +let suppressActivationClaim = false +const workerURL = + "/webview/service-worker.js?v=6&vscode-resource-base-authority=vscode-resource.vscode-cdn.net&remoteAuthority=&platform=web&code-server-webview=1" + +test.beforeAll(async () => { + server = createServer((req, res) => { + const url = new URL(req.url!, "http://localhost") + if (url.pathname === "/root-worker.js") { + res.setHeader("Content-Type", "text/javascript") + res.end( + "self.addEventListener('install', e => e.waitUntil(self.skipWaiting())); self.addEventListener('activate', e => e.waitUntil(self.clients.claim()));", + ) + } else if (url.pathname === "/webview/service-worker.js") { + res.setHeader("Content-Type", "text/javascript") + res.setHeader("Cache-Control", "no-store") + res.end( + (suppressActivationClaim ? "self.addEventListener('activate', e => e.stopImmediatePropagation());\n" : "") + + worker, + ) + } else { + res.setHeader("Content-Type", "text/html") + res.end( + url.pathname === "/webview/index.html" && !url.searchParams.has("manual") + ? documentBody + : "Root", + ) + } + }) + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) + const address = server.address() + if (!address || typeof address === "string") throw new Error("Missing test server address") + origin = `http://127.0.0.1:${address.port}` +}) + +test.afterAll(async () => { + server.closeAllConnections() + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))) +}) + +test.beforeEach(async ({ page }) => { + suppressActivationClaim = false + await page.goto(origin) + await page.evaluate(async () => { + await navigator.serviceWorker.register("/root-worker.js") + await navigator.serviceWorker.ready + }) + await expect + .poll(() => page.evaluate(() => navigator.serviceWorker.controller?.scriptURL)) + .toBe(`${origin}/root-worker.js`) +}) + +test("first registration replaces the root PWA controller without an extra update", async ({ page }) => { + await page.goto(`${origin}/webview/index.html`) + await expect.poll(() => page.evaluate(() => (window as any).readyState), { timeout: 5000 }).toBe("ready") + expect(await page.evaluate(() => (window as any).updateCalls)).toBe(0) + expect(await page.evaluate(() => navigator.serviceWorker.controller?.scriptURL)).toContain("code-server-webview=1") +}) + +test("reuses the matching registration when register and update would hang", async ({ page }) => { + await page.goto(`${origin}/webview/index.html`) + await expect.poll(() => page.evaluate(() => (window as any).readyState)).toBe("ready") + await page.goto(`${origin}/webview/index.html?mode=reuse`) + await expect.poll(() => page.evaluate(() => (window as any).readyState), { timeout: 5000 }).toBe("ready") + expect(await page.evaluate(() => (window as any).registerCalls)).toBe(0) + expect(await page.evaluate(() => (window as any).updateCalls)).toBe(1) +}) + +test("continues with the matching active worker when the update fails", async ({ page }) => { + await page.goto(`${origin}/webview/index.html`) + await expect.poll(() => page.evaluate(() => (window as any).readyState)).toBe("ready") + await page.goto(`${origin}/webview/index.html?mode=reject`) + await expect.poll(() => page.evaluate(() => (window as any).readyState)).toBe("ready") + expect(await page.evaluate(() => (window as any).updateCalls)).toBe(1) +}) + +test("requests a claim when an existing active worker has not acquired the document", async ({ page }) => { + suppressActivationClaim = true + await page.goto(`${origin}/webview/index.html?manual=1&mode=reject`) + await page.evaluate(async (url) => { + const registration = await navigator.serviceWorker.register(url) + await new Promise((resolve) => { + const worker = registration.installing! + worker.addEventListener("statechange", () => { + if (worker.state === "activated") resolve() + }) + }) + }, workerURL) + expect(await page.evaluate(() => navigator.serviceWorker.controller?.scriptURL)).toBe(`${origin}/root-worker.js`) + await page.addScriptTag({ content: documentBody.split("")[0] }) + await expect.poll(() => page.evaluate(() => (window as any).readyState), { timeout: 5000 }).toBe("ready") + expect(await page.evaluate(() => navigator.serviceWorker.controller?.scriptURL)).toContain("code-server-webview=1") +}) + +test("rejects a claim from an inner document in the same worker scope", async ({ page }) => { + suppressActivationClaim = true + await page.goto(`${origin}/webview/inner.html`) + await page.evaluate(async (url) => { + const registration = await navigator.serviceWorker.register(url) + await new Promise((resolve) => { + const worker = registration.installing! + worker.addEventListener("statechange", () => { + if (worker.state === "activated") resolve() + }) + }) + registration.active!.postMessage({ channel: "code-server-claim-webview" }) + }, workerURL) + await page.waitForTimeout(200) + expect(await page.evaluate(() => navigator.serviceWorker.controller?.scriptURL)).toBe(`${origin}/root-worker.js`) +}) + +test("fails and stops claim retries when the expected controller never arrives", async ({ page }) => { + suppressActivationClaim = true + await page.goto(`${origin}/webview/index.html?manual=1&mode=reject`) + await page.evaluate(async (url) => { + const registration = await navigator.serviceWorker.register(url) + await new Promise((resolve) => { + const worker = registration.installing! + worker.addEventListener("statechange", () => { + if (worker.state === "activated") resolve() + }) + }) + ;(window as any).claimCalls = 0 + ServiceWorker.prototype.postMessage = function () { + ;(window as any).claimCalls++ + } + }, workerURL) + await page.addScriptTag({ content: documentBody.split("")[0] }) + await expect.poll(() => page.evaluate(() => (window as any).readyState), { timeout: 12000 }).toBe("error") + expect(await page.evaluate(() => (window as any).workerError)).toContain("Timed out waiting for the matching webview") + const calls = await page.evaluate(() => (window as any).claimCalls) + await page.waitForTimeout(300) + expect(await page.evaluate(() => (window as any).claimCalls)).toBe(calls) +}) + +test("does not reuse an active worker with a different complete script URL", async ({ page }) => { + await page.evaluate(async () => { + const registration = await navigator.serviceWorker.register("/webview/service-worker.js?old=1") + if (registration.active) return + await new Promise((resolve) => { + const worker = registration.installing! + worker.addEventListener("statechange", () => { + if (worker.state === "activated") resolve() + }) + }) + }) + await page.goto(`${origin}/webview/index.html`) + await expect.poll(() => page.evaluate(() => (window as any).readyState), { timeout: 5000 }).toBe("ready") + expect(await page.evaluate(() => (window as any).registerCalls)).toBe(1) + expect(await page.evaluate(() => navigator.serviceWorker.controller?.scriptURL)).toContain("code-server-webview=1") +}) + +test("preserves the third-party-cookie diagnostic on registration failure", async ({ page }) => { + await page.goto(`${origin}/webview/index.html?mode=denied`) + await expect.poll(() => page.evaluate(() => (window as any).readyState)).toBe("error") + expect(await page.evaluate(() => (window as any).workerError)).toContain("third party cookies") +}) + +test("preserves explicitly disabled service workers", async ({ page }) => { + await page.goto(`${origin}/webview/index.html?mode=disabled`) + await expect.poll(() => page.evaluate(() => (window as any).readyState)).toBe("ready") + expect(await page.evaluate(() => (window as any).registerCalls)).toBe(0) +}) + +test("reports when service workers are unavailable", async ({ page }) => { + await page.goto(`${origin}/webview/index.html?mode=unavailable`) + await expect.poll(() => page.evaluate(() => (window as any).readyState)).toBe("error") + expect(await page.evaluate(() => (window as any).workerError)).toContain("Service Workers are not enabled") +}) + +test("the inline bootstrap retains a matching CSP hash", async () => { + const script = html.split('")[0] + const hash = createHash("sha256").update(script).digest("base64") + expect(html).toContain(`script-src 'sha256-${hash}' 'self'`) +})