NEW327297
A navigation that switches browsing context group (COOP) sometimes never commits, leaving the old document in place
https://bugs.webkit.org/show_bug.cgi?id=327297
Summary A navigation that switches browsing context group (COOP) sometimes never comm...
hlibwondertan
Reported 2026-10-12 13:48:03 PDT
Seen on Linux. Stock WebKitGTK 2.54.1 (MiniBrowser), and Playwright's WebKit 26.6 (WPE). Apple ports untested. SUMMARY A top-level document calls location.replace() to a same-origin URL whose response has a different Cross-Origin-Opener-Policy, so the navigation switches browsing context group. Usually the destination commits at once. Sometimes it never commits: nothing loads, no error is reported, and the old document stays. It remains alive, and its timers keep firing. Calling location.replace() again from it usually gets the navigation through. Chromium and Firefox never stall with the same pages. We hit this in a popup that moves between a cross-origin-isolated document and an ordinary one. It reproduces without any of our code using the two pages below. STEPS TO REPRODUCE 1. Save server.mjs (below) and run: node server.mjs 2. Open http://localhost:8000/a in the browser. - /a is served with "Cross-Origin-Opener-Policy: same-origin" and "Cross-Origin-Embedder-Policy: require-corp"; /b has no COOP. - Each page immediately replaces itself with the other, so every hop switches browsing context group. - A page that is still present 5 s after its location.replace() sets its title to "STALLED at hop N". - The server logs each request, so a stall also shows as the log stopping. 3. It stalls faster under CPU contention. On Linux we pinned the browser to one core: taskset -c 0 MiniBrowser http://localhost:8000/a RESULTS 10 runs of up to 200 hops per browser, each browser pinned to a single CPU: Browser Runs reaching 200 hops Stalled at hop WebKitGTK 2.54.1 MiniBrowser 0/10 4 to 94 Playwright WebKit 26.6 (WPE), headless 2/10 19 to 127 Chromium 155 10/10 - Firefox 156 10/10 - The Playwright WebKit row was run without automation attached: MiniBrowser was launched directly with the URL. Driving it through Playwright stalls at a similar rate. Same WPE build, with the page calling location.replace() again every 3 s while it is still present: 10/10 runs reached 200 hops; the retry fired 22 times. OBSERVATIONS - In a stall, the old document is never unloaded: its timers fire, and a new location.replace() from it starts a new navigation. - Under Playwright, the inspector protocol shows the following sequence: 1. The provisional page in the new process reports provisionalLoadFailed with "Load request cancelled". 2. The request is then reissued in the old process. 3. The page never commits. Playwright's network log records the stalled request as failed (no status) after about 2.7 s. - Hops stall in both directions: from the isolated page to the plain one, and back. EXPECTED The destination commits, or the navigation fails visibly. ACTUAL The navigation silently never completes, and the old document stays as if it had never navigated. WORKAROUND None that is safe in general. Calling location.replace() again after a few seconds unsticks a stalled document. But it also restarts navigations that are only slow under load, and it can cut off WebKit's own retry. In our tests, where the destination had to load within 5 s, it made failures more frequent: - a 3 s retry: 11 and 12 failures in 30, against 2 and 4 without it; - a 1 s retry: nearly every run failed. FILES --- server.mjs --- // node server.mjs [port], then open http://localhost:8000/a in the browser under test. // /a is cross-origin isolated (COOP same-origin + COEP require-corp), /b has no COOP. Each page // replaces itself with the other at once, so every hop switches browsing context group. A page // still present 5 s after its location.replace() marks itself STALLED. import { createServer } from 'node:http' const port = Number(process.argv[2] ?? 8000) const HOPS = 1000 const STALL_MS = 5000 const page = (next) => `<!doctype html><meta charset="utf-8"><title>hop</title><pre id="out"></pre> <script> const n = Number(new URLSearchParams(location.search).get('n') ?? 0) const out = document.getElementById('out') out.textContent = 'hop ' + n + ', crossOriginIsolated: ' + crossOriginIsolated if (n >= ${HOPS}) document.title = 'done: ${HOPS} hops' else setTimeout(() => { location.replace('/${next}?n=' + (n + 1)) setTimeout(() => { document.title = 'STALLED at hop ' + n out.textContent += '\\nSTALLED: this document is still here ${STALL_MS} ms after location.replace()' }, ${STALL_MS}) }) </script>` createServer((req, res) => { const { pathname } = new URL(req.url, 'http://localhost') if (pathname !== '/a' && pathname !== '/b') return res.writeHead(404).end() const isolated = pathname === '/a' res .writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', ...(isolated && { 'Cross-Origin-Opener-Policy': 'same-origin', 'Cross-Origin-Embedder-Policy': 'require-corp', }), }) .end(page(isolated ? 'b' : 'a')) console.log(new Date().toISOString(), req.url) }).listen(port, () => console.log(`open http://localhost:${port}/a`))
Attachments
Reproduction: node server.mjs, then open http://localhost:8000/a (1.69 KB, text/plain)
2026-10-12 13:50 PDT, hlibwondertan
no flags
hlibwondertan
Comment 1 2026-10-12 13:50:11 PDT
Context: we hit this in libID's popup package (https://github.com/libid-org/libID). Its popup moves between cross-origin-isolated and ordinary documents. Our tracking issue: https://github.com/libid-org/libID/issues/121 The page has no safe workaround (see WORKAROUND above), so until this is fixed we retry our WebKit browser tests: https://github.com/libid-org/libID/pull/128
hlibwondertan
Comment 2 2026-10-12 13:50:51 PDT
Created attachment 481769 [details] Reproduction: node server.mjs, then open http://localhost:8000/a
Radar WebKit Bug Importer
Comment 3 2026-10-19 13:48:21 PDT
Note You need to log in before you can comment on or make changes to this bug.