Skip to content
Open
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion patches/csp-hashes.diff
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Index: code-server/lib/vscode/src/vs/workbench/contrib/webview/browser/pre/index

<meta http-equiv="Content-Security-Policy"
- content="default-src 'none'; script-src 'sha256-7cKy9iQfWRXcgRS74x6I4q5cRDkv8nQz51jecqMmQ5w=' 'self'; frame-src 'self'; style-src 'unsafe-inline';">
+ 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';">

<!-- Disable pinch zooming -->
<meta name="viewport"
Expand Down
1 change: 1 addition & 0 deletions patches/series
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ base-path.diff
proposed-api.diff
marketplace.diff
webview.diff
webview-service-worker.diff
disable-builtin-ext-update.diff
insecure-notification.diff
update-check.diff
Expand Down
149 changes: 149 additions & 0 deletions patches/webview-service-worker.diff
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
Bound webview worker update waits and acquire the expected controller

A root PWA controller is not the webview worker. Reuse only the exact
registration scope and active script URL, bound update checks to three seconds,
and still require the matching controller before loading webview resources.
Initial registration remains mandatory. A narrowly validated claim message
handles documents created under the root worker after activation.

Test: test/e2e/webviewServiceWorker.test.ts (real browser registrations).

Index: code-server/lib/vscode/src/vs/workbench/contrib/webview/browser/pre/index.html
===================================================================
--- code-server.orig/lib/vscode/src/vs/workbench/contrib/webview/browser/pre/index.html
+++ code-server/lib/vscode/src/vs/workbench/contrib/webview/browser/pre/index.html
@@ -256,47 +256,71 @@
}

/** @type {Promise<void>} */
- 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;
216 changes: 216 additions & 0 deletions test/e2e/webviewServiceWorker.test.ts
Original file line number Diff line number Diff line change
@@ -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 = `<!doctype html><script>
const mode = new URLSearchParams(location.search).get('mode');
const disableServiceWorker = mode === 'disabled';
const areServiceWorkersEnabled = () => mode !== 'unavailable';
const expectedWorkerVersion = 6;
const onElectron = false;
const searchParams = new URLSearchParams('vscode-resource-base-authority=vscode-resource.vscode-cdn.net&platform=web');
window.updateCalls = 0;
window.registerCalls = 0;
window.readyState = 'pending';
const register = navigator.serviceWorker.register.bind(navigator.serviceWorker);
navigator.serviceWorker.register = (...args) => {
window.registerCalls++;
if (mode === 'reuse') return new Promise(() => {});
if (mode === 'denied') return Promise.reject(new Error('user denied permission'));
return register(...args);
};
ServiceWorkerRegistration.prototype.update = function () {
window.updateCalls++;
return mode === 'reject' ? Promise.reject(new Error('offline')) : new Promise(() => {});
};
${bootstrap}
workerReady.then(() => { window.readyState = 'ready'; }, error => {
window.readyState = 'error';
window.workerError = String(error);
});
</script>`

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
: "<!doctype html><title>Root</title>",
)
}
})
await new Promise<void>((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<void>((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<void>((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("<script>")[1].split("</script>")[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<void>((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<void>((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("<script>")[1].split("</script>")[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<void>((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('<script async type="module">')[1].split("</script>")[0]
const hash = createHash("sha256").update(script).digest("base64")
expect(html).toContain(`script-src 'sha256-${hash}' 'self'`)
})