[2026-09-09] eagle: work/projects/cpm-web-extension-breakage-findings.md
This commit is contained in:
@@ -33,6 +33,8 @@ There is currently no single strongest production hypothesis. The authoritative
|
|||||||
|
|
||||||
## Current working hypotheses (authoritative, 2026-09-09)
|
## Current working hypotheses (authoritative, 2026-09-09)
|
||||||
|
|
||||||
|
> 2026-09-10: two later sections supersede this table for the ranking — **✅ CONFIRMED shared-pinned-tab/window-close** (per-tab) and **🔥 SW registration reuse across background unload/reload** (all-tabs, deterministic repro with `kill -STOP`, see the last section).
|
||||||
|
|
||||||
No persistent production trigger has been reproduced yet. The remaining hypotheses are:
|
No persistent production trigger has been reproduced yet. The remaining hypotheses are:
|
||||||
|
|
||||||
| Hypothesis | Exact condition required | Resulting sequence | Current status |
|
| Hypothesis | Exact condition required | Resulting sequence | Current status |
|
||||||
@@ -1028,3 +1030,48 @@ Already covered: `tab_known_to_webkit=false` with `tab_controller_matches_contex
|
|||||||
### Fix direction (not applied, per agreement — waiting for the manual repro)
|
### Fix direction (not applied, per agreement — waiting for the manual repro)
|
||||||
|
|
||||||
App side: on `didCloseWindow`, tabs that remain open in another window must not be reported as closed. Options: `MainWindowController.tabs(for:)` excluding shared pinned tabs when the window is closing, or the events listener re-issuing `didOpenTab` for shared pinned tabs after `didCloseWindow`. WebKit side: `didCloseWindow` could skip tabs whose `window()` still resolves to an open window — worth a bug report either way.
|
App side: on `didCloseWindow`, tabs that remain open in another window must not be reported as closed. Options: `MainWindowController.tabs(for:)` excluding shared pinned tabs when the window is closing, or the events listener re-issuing `didOpenTab` for shared pinned tabs after `didCloseWindow`. WebKit side: `didCloseWindow` could skip tabs whose `window()` still resolves to an open window — worth a bug report either way.
|
||||||
|
|
||||||
|
## 🔥 New top candidate for the all-tabs freeze: service-worker registration reuse across background page unload/reload (2026-09-10)
|
||||||
|
|
||||||
|
Traced from WebKit trunk `0c7e7ad97b`. Unlike the pinned-tab bug this one is **global**: every tab, every new tab, survives page reload, cleared only by Fire / extension reload / ≥30 s of no CPM traffic. Diagnostics in this state read exactly like the snapshot posted after the Network-kill test: `extension_context_loaded=true background_view_alive=true background_web_process_alive=true extension_context_errors=none`.
|
||||||
|
|
||||||
|
### Mechanism
|
||||||
|
|
||||||
|
The MV3 background is a "service worker page": a hidden `WKWebView` that loads `<script>navigator.serviceWorker.register('webkit-extension://<uuid>/public/js/background-embedded.js')</script>` (`WebPageProxy.cpp:18459-18480`). The SW registration lives in the **Network process** (`SWServer`) keyed by scope `webkit-extension://<uuid>/`; the registration remembers the *page* that created it (`SWServerRegistration::m_serviceWorkerPageIdentifier`, immutable) and is destroyed when **that page's document** unregisters as a client (`SWServer.cpp:1527-1538` `unregisterServiceWorkerClientInternal` → `removeFromScopeToRegistrationMap(key)` + `registration->clear()`; `removeFromScopeToRegistrationMap` removes by key only, `SWServer.cpp:1658-1664`). The document sends that unregister from `Document::willBeRemovedFromFrame` → `setServiceWorkerConnection(nullptr)` (`Document.cpp:3628-3639`, `11585-11592`), i.e. when the old WebContent process (**P1**) processes `WebPage::Close`.
|
||||||
|
|
||||||
|
Sequence on every idle cycle (≥30 s without a CPM message):
|
||||||
|
|
||||||
|
1. `unloadBackgroundContentIfPossible` (`WebExtensionContextCocoa.mm:2722-2757`) → `unloadBackgroundWebView` (2677-2685): drops the foreground process activity, `[webView _close]`, `m_backgroundWebView = nil`. **No check for in-flight replies.**
|
||||||
|
2. `WebPageProxy::close()` does **not** send `WebPage::Close` synchronously — it is deferred to the next run-loop cycle ("Delay sending close message to next runloop cycle to avoid white flash", `WebPageProxy.cpp:2021-2030`). P1 stays alive: `canBeAddedToWebProcessCache()==false` and `canTerminateAuxiliaryProcess()==false` while it runs the SW (`WebProcessProxy.cpp:1877-1893`, `1940-1943`).
|
||||||
|
3. Next content-script `runtime.sendMessage` → `wakeUpBackgroundContentIfNecessary` → `loadBackgroundWebView` → **new** `WKWebView`. `processForSite(webkit-extension://<uuid>)` cannot reuse P1 (not in the cache) → prewarmed/new process **P2** (`WebProcessPool.cpp:1289-1314`). P2 runs `navigator.serviceWorker.register(...)`.
|
||||||
|
4. Race in the Network process between P1's `unregisterServiceWorkerClient(oldPageId)` and P2's `Register` job. If P2 wins: `SWServerJobQueue::runRegisterJob` finds the old registration with the same script URL → **"Found directly reusable registration … (DONE)"** → resolves immediately (`SWServerJobQueue.cpp:341-349`). No new worker, no script evaluation for P2's page; the registration still points at the *old* page id.
|
||||||
|
5. `register()` settles → `ServiceWorkerContainer::willSettleRegistrationPromise` → `didFinishServiceWorkerPageRegistration(true)` (`ServiceWorkerContainer.cpp:256-268`, `WebPageProxy.cpp:8703-8710`) → `performTasksAfterBackgroundContentLoads` → `m_backgroundContentIsLoaded = true` (`WebExtensionContextCocoa.mm:2849-2874`). WebKit now believes the background is loaded.
|
||||||
|
6. P1 processes `Close` → unregister → registration cleared, worker terminated. P2's page now has **no worker at all**; nothing re-registers (P2's page is not the registration's SW page).
|
||||||
|
7. Every `runtime.sendMessage`: `wakeUpBackgroundContentIfNecessaryToFireEvents` → `m_backgroundContentEventListeners` still contains `RuntimeOnMessage` (only cleared on context unload, `WebExtensionContextCocoa.mm:364`) → `wakeUpBackgroundContentIfNecessary` → `backgroundContentIsLoaded()==true` → `processes(RuntimeOnMessage, Main)` walks `m_eventListenerFrames` (weak frames registered by the **old** page; `frame->page()` is null → skipped, `WebExtensionContext.cpp:1735-1739`) → empty → `completionHandler({ })` (`WebExtensionContextAPIRuntimeCocoa.mm:154-158`). `cpm.js` receives `undefined`, never gets `initResp`, stays `waitingForInitResponse` → banner not handled, dashboard `waiting`.
|
||||||
|
8. Persistence: every message re-arms the 30 s unload timer first (`WebExtensionContext.cpp:1854`), so while the user keeps browsing (or background tabs keep reloading) the dead view is never unloaded. Only ≥30 s of silence → unload → next wake → fresh registration; or Fire / extension reload (new context, new base URL, new scope).
|
||||||
|
|
||||||
|
Why earlier tests did not hit it: killing the Network process or the background process (`webViewWebContentProcessDidTerminate`) also kills the client connection, so the Network process clears the registration before any wake can register again. The race needs P1 **alive but slow** to process `Close` (paged-out after 30 s idle, App Nap/process suppression right after the foreground activity is dropped, main thread busy in the SW — the SW of a SW-page runs on P1's main thread, `SWServerWorker.cpp:427-432`), or the wake landing in the same run-loop turn as the unload timer (the dispatched `Close` block then runs *after* the already-queued `RuntimeSendMessage`).
|
||||||
|
|
||||||
|
### Deterministic reproduction (DEBUG build, no code change)
|
||||||
|
|
||||||
|
1. Open a CMP site so the background loads. Take the background WebContent PID **P1** from the recorder log (`didCreateBackgroundWebView … webProcessPID=`) or Activity Monitor ("DuckDuckGo Embedded Extension Web Extension").
|
||||||
|
2. Once the page is quiet: `kill -STOP <P1>`. Do not touch the browser for 35 s (unload timer fires; `Close` is queued to the stopped P1; `m_backgroundWebView=nil`).
|
||||||
|
3. Reload any tab with a cookie banner. Expected: new background process P2 appears, `register()` resolves against the old registration (P1 never unregistered), WebKit reports background loaded, but the banner is **not** handled and the dashboard shows `waiting`.
|
||||||
|
4. `kill -CONT <P1>`. Registration is cleared; state is now identical to production: every tab dead, reload no help, new tab no help.
|
||||||
|
5. Recovery check: leave the browser alone for 35 s → next navigation works again; or Fire → works immediately.
|
||||||
|
|
||||||
|
Log confirmation (Network process): `log stream --predicate 'category == "ServiceWorker"' | grep -i "reusable registration"` right after "Unloading non-persistent background content" for the `webkit-extension://` scope.
|
||||||
|
|
||||||
|
### What the added diagnostics will show
|
||||||
|
|
||||||
|
- Probe 2 (`background_sw_registration`) is decisive: healthy = registration with an `active` worker; this state = **no registration** (after step 6) while `background_view_alive=true`. That combination is impossible in any other traced path.
|
||||||
|
- `background_view_create_count` ≥ 2 within the episode, `background_web_process_alive=true`.
|
||||||
|
- Content probe: `content_script=present`, `lifecycle=waitingForInitResponse`, `send_error=none`.
|
||||||
|
|
||||||
|
### Not proven yet
|
||||||
|
|
||||||
|
Production frequency. The mechanism is unguarded in WebKit; what is unknown is how often P1 loses the race on user machines. The pixel data (probe 2) settles that. No fix proposed until the manual repro above is done.
|
||||||
|
|
||||||
|
### Side note checked (low value)
|
||||||
|
|
||||||
|
`cpm.js` sends `init` inside the `AutoConsent` constructor and only afterwards calls `chrome.runtime.onMessage.addListener` (`cpm.js:3392-3401`). `tabs.sendMessage(initResp)` requires that listener to be registered in `m_eventListenerFrames` (`WebExtensionContextAPITabsCocoa.mm:483-497`). Both IPCs travel on the same connection and the background round trip is far slower, so this is not a realistic path — noted so nobody re-derives it.
|
||||||
|
|||||||
Reference in New Issue
Block a user