[2026-09-10] eagle: work/projects/cpm-web-extension-breakage-findings.md

This commit is contained in:
Alexey Martemyanov
2026-09-10 11:04:57 +06:00
parent 5e1415abbe
commit a6034a956c
@@ -18,6 +18,8 @@ tags:
>
> **Updated 2026-09-09 after source and shipping-WebKit validation** (WebKit trunk `0c7e7ad97b`, see [[cpm-web-extension-breakage-validation]] for file:line evidence). The retained-failed-view state is real but **self-heals via the 30 s idle eviction**. The source-level dead-worker state matches "stuck across all tabs", but neither critical memory pressure nor Network-process termination reproduced it on the tested macOS 26.x build. No permanent all-tabs production trigger is currently confirmed. `removeAllUserScripts()` is explicitly implemented as `RemoveWebExtensions::No` and does not remove CPM's WebExtension scripts.
> **2026-09-10 — ✅ CONFIRMED all-tabs cause:** background WebContent process death → Network process re-creates the SW context connection for the dead page → UI spins up a page-less service-worker process → every new background registration installs its worker there → "Script error." → `BackgroundContentFailedToLoad` loop. 100 % repro with `kill -9 <background pid>`; Fire fixes because the new base URL is a new registrable domain. See "✅ CONFIRMED by the third run" near the end. Second confirmed (per-tab) cause: shared pinned tabs forgotten on window close.
## Executive summary
The same visible symptom, CPM no longer managing consent, can come from materially different failures:
@@ -1177,6 +1179,48 @@ This **contradicts** the earlier claim that a dead P1 is cleaned up before any w
Not a single `WebContent[…] Called getter browser.*` line for the new worker. In a healthy load (other instance, PID 11621, 10:51:29) the first thing the worker does is `browser.runtime` / `runtime.id` (the polyfill's "am I in an extension" check), then registers `runtime.onConnect`, `runtime.onMessage`, `alarms.onAlarm`. So `_loadServiceWorker` failed **before the worker script touched any extension API**`register()` was rejected (not B), and it was not the "reusable registration" resolve either (that returns success, not error). Neither A nor B as written; the hang variant B is ruled out.
### ✅ CONFIRMED by the third run (10:58, with the ServiceWorker category): the worker is installed into a page-less service-worker process
```
10:58:37.779 [CPM Diagnostics] Background web view deallocated ← previous failed view, 30 s timer
10:58:37.783 [ServiceWorker] WebProcessProxy::enableWorkers: workerType=1 (PID=0)
10:58:37.790 [ServiceWorker] establishRemoteWorkerContextConnectionToNetworkProcess creating a NEW service worker process (PID=0) ← becomes 13665
10:58:42.856 content script: runtime.sendMessage ×3, onMessage.addListener ← tab reload
10:58:42.856 Loading background content → view #14, webProcessPID=13710 ← P2
10:58:42.973 WebContent[13710] ServiceWorkerContainer::addRegistration jobID=16
10:58:42.976 WebContent[13710] jobFinishedLoadingScript: Successfully finished fetching script
10:58:42.976 WebContent[13665] Created service worker 424 in process PID 13665 ← NOT 13710: worker lands in the stray process
10:58:42.978 WebContent[13710] jobFailedWithException: Job 16 failed with error Script error.
10:58:42.979 WebContent[13665] SWContextManager::terminateWorker 424
10:58:42.979 [Extensions] Error recorded: WKWebExtensionContextErrorDomain Code=6 ← BackgroundContentFailedToLoad
10:59:12.861 Unloading non-persistent background content ← 30 s later; cycle repeats
```
Chain, all WebKit, all confirmed by the log:
1. Background WebContent process P1 dies (here `kill -9`; in production: crash, WebKit `ExceededMemoryLimit` kill, jetsam).
2. Network process `NetworkConnectionToWebProcess::didClose` (`NetworkConnectionToWebProcess.cpp:470-518`): **first** `m_swContextConnection->stop()` (line 474) → `SWServer::removeContextConnection` (`WebSWServerToContextConnection.cpp:92-108`, `SWServer.cpp:1785-1804`); **only at the end** `unregisterSWConnection()` (line 518) → `~WebSWServerConnection` → the dead SW page's client is unregistered → registration cleared.
3. Inside `removeContextConnection`, between those two steps, `needsContextConnectionForRegistrableDomain(webkit-extension://<uuid>)` is **true** (the dead page is still a registered client, `SWServer.cpp:1666-1669`) → `createContextConnection(site, serviceWorkerPageIdentifier = DEAD page id)` (`SWServer.cpp:1802-1803`, `1839-1875`).
4. UI `WebProcessPool::establishRemoteWorkerContextConnectionToNetworkProcess` (`WebProcessPool.cpp:685-760+`): the page's process (712) is terminated, the requesting process is P1 (717, terminated), no other process has that site (722-742) → **creates a dedicated service-worker process S** ("creating a new service worker process"). S hosts no SW page.
5. Next content-script message → new background view in P2 → `register()`. Registration was cleared in step 2 → "constructing a new one" → script fetched → `updateWorker``tryInstallContextData` (`SWServer.cpp:1068-1084`): a context connection for the domain **exists (S)** → worker installed in S, not in P2.
6. In S, `ServiceWorkerGlobalScope::serviceWorkerPage()` is null (`Document::allDocumentsMap()` is process-local, `ServiceWorkerGlobalScope.cpp:118-140`, `Page.cpp:5208-5212`) → `chrome`/`browser` never attached → polyfill throws at top level → `scriptContextFailedToStart` → job rejected → "Script error." → `register()` rejects → `didFinishServiceWorkerPageRegistration(false)``BackgroundContentFailedToLoad`. WebKit keeps the failed view (`m_backgroundWebView` non-nil, `m_backgroundContentIsLoaded=false`), queues every `runtime.sendMessage` completion in `m_actionsToPerformAfterBackgroundContentLoads`, `loadBackgroundWebViewIfNeeded` returns early → **CPM dead in every tab**.
7. 30 s after the last message the failed view is unloaded; the next message repeats 5-6 as long as S (or a successor created by the same `removeContextConnection``needsContextConnection` logic when the failed view's own process is torn down while its client is still registered — exactly what 10:58:37.78 shows) is the domain's context connection. In this session the loop ran from 10:50 to at least 10:59 (`background_view_create_count` 10 → 14). It ends only when S's connection is dropped without re-creation (idle termination of S), or immediately on Fire / extension reload (new base URL = new registrable domain = clean SW-server state). Page reload and new tabs never help.
Symptom match: all tabs, new tabs too, survives reload, banners not handled, dashboard `waiting`, Fire fixes, no visible error in the app. Pixel signature: `extension_context_errors=background_failed_to_load` + `background_view_alive=true` + `background_view_create_count` ≥ 2 + `network_process_restarted=false`.
**Reproduction (DEBUG, 100 %):** "Print CPM Diagnostics Snapshot" → `kill -9 <background_web_process_pid>` → reload any tab with a cookie banner. Log with
`log stream --level debug --predicate '(subsystem == "com.apple.WebKit" AND category == "ServiceWorker") OR category == "Extensions" OR composedMessage CONTAINS "[CPM"'`
and look for "creating a new service worker process" followed by "Created service worker N in process PID <≠ webProcessPID of the view>" and "Job N failed with error Script error."
**WebKit bugs to report (two independent defects, either fix breaks the chain):**
- `NetworkConnectionToWebProcess::didClose` removes the context connection before unregistering the dead process's SW clients, so `removeContextConnection` re-creates a connection for an origin whose only client is dead. Swapping the order (or having `removeContextConnection` ignore clients belonging to the closing connection) removes the trigger.
- `establishRemoteWorkerContextConnectionToNetworkProcess` falls back to a standalone worker process for a request that carries a `serviceWorkerPageIdentifier`; a SW-page worker can never work outside the page's process, so the request should be dropped when that process is gone. Additionally `tryInstallContextData` prefers an existing domain connection over the SW page's process.
- `WebExtensionContext`: a `BackgroundContentFailedToLoad` view is retained for 30 s with no retry, so one transient failure costs a 30 s outage per cycle.
**App-side mitigation options (not applied; for discussion):** on `WKWebExtensionContext.errorsDidUpdateNotification` with `BackgroundContentFailedToLoad`, force `unload`+`load` of the embedded context (new base URL → clean SW-server state) — the same thing Fire does, without burning data; guard with a back-off so a genuinely broken bundle does not loop.
Earlier reasoning kept for the record — superseded by the confirmed chain above:
Most consistent mechanism: **the worker was started in a process that does not host the new SW page's document**. Bindings are installed only from `ServiceWorkerGlobalScope::notifyServiceWorkerPageOfCreationIfNecessary``Page::serviceWorkerPage(m_contextData.serviceWorkerPageIdentifier)``Document::allDocumentsMap()` (**process-local**, `ServiceWorkerGlobalScope.cpp:118-140`, `Page.cpp:5208-5212`). No page in that process → no `chrome`/`browser` → polyfill throws at top level → `SWServer::scriptContextFailedToStart` → job rejected → `register()` rejects → `didFinishServiceWorkerPageRegistration(false)``BackgroundContentFailedToLoad`, view retained until the 30 s timer. Which process the worker lands in is decided by `SWServer::tryInstallContextData` (`SWServer.cpp:1068-1084`): an **existing** context connection for `webkit-extension://<uuid>` wins over the page's process; otherwise `createContextConnection` → UI `establishRemoteWorkerContextConnectionToNetworkProcess` picks the page's process by `serviceWorkerPageIdentifier` (`WebProcessPool.cpp:709-714`), falling back to the requesting process or any process with the same site (716-742), else a new one. Right after `kill -9`, the Network process may still hold P1's context connection (or a pending connection creation keyed by domain, `m_pendingConnectionDomains`) when P2's job arrives — the worker is then installed away from P2's page. Still a hypothesis: the ServiceWorker-category lines were not in the captured log (the predicate only matched `Extensions`).
Discriminating evidence to collect on the next run (kill → immediately reload a tab → within 10 s):