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

This commit is contained in:
Alexey Martemyanov
2026-09-08 19:11:08 +06:00
parent 6702931dd2
commit e99c188480
@@ -0,0 +1,254 @@
---
title: CPM Web Extension Breakage Findings
date: '2026-09-08'
status: investigation
tags:
- work
- cpm
- webkit
- web-extension
---
# CPM Web Extension Breakage Findings
> Investigation summary as of 2026-09-08. This note distinguishes confirmed code paths from hypotheses that still need production evidence.
## Executive summary
The same visible symptom, CPM no longer managing consent, can come from three materially different failures:
| Failure mode | Scope | Expected recovery | Confidence |
|---|---|---|---|
| CPM content script misses a document | Current document | A navigation or reload after the extension finishes loading | Confirmed |
| WebKit loses routing for an individual tab | Tab or WebContent process | A fresh tab/window, or rebuilding the affected tab | Confirmed historically; the known app lifecycle cause is fixed |
| WebKit retains a background service worker that failed to load | Extension context/controller | No ordinary message can recover it; the context must be unloaded or the failed background view cleaned up | Confirmed WebKit state machine; natural production trigger remains unknown |
The debug-menu simulation enters the third state deliberately. It proves the health detection and failure shape, but its exact trigger cannot happen naturally in the same way because only the debug delegate closes the newly created background web view in that synchronous interval.
The strongest production hypothesis is therefore narrower than “a race”: the extension service-worker registration must fail, and WebKit must keep the failed background web view attached. We have not yet established which real system condition makes registration fail.
## CPM architecture
The bundled extension is Manifest V3:
- Background entry point: `public/js/background-embedded.js`
- CPM content script: `public/js/content-scripts/cpm.js`
- Injection: `document_start`, isolated world, all frames
- Content-to-background transport: `chrome.runtime.sendMessage`
- Background receiver: `runtime.onMessage`, registered by `MessageRouter`
The content script sends an `autoconsent` message to the background service worker. The background routes that message and, where required, forwards it through native messaging.
## Normal message path
The relevant WebKit path is:
1. CPM content code calls `chrome.runtime.sendMessage(...)`.
2. The WebProcess implementation enters `WebExtensionAPIRuntime::sendMessage`.
3. IPC sends `Messages::WebExtensionContext::RuntimeSendMessage` to the UIProcess.
4. `WebExtensionContext::runtimeSendMessage` resolves the sender with `getTab(senderParameters.pageProxyIdentifier)`.
5. WebKit calls `wakeUpBackgroundContentIfNecessaryToFireEvents(RuntimeOnMessage)`.
6. When the background is available, WebKit dispatches `WebExtensionContextProxy::DispatchRuntimeMessageEvent`.
7. The service worker's `runtime.onMessage` listener receives and routes the CPM message.
This gives four distinct failure boundaries:
- The content script was never injected into the document.
- The sender's page identifier no longer resolves to a registered WebExtension tab.
- WebKit decides there is no listener to wake, or cannot load the worker.
- The worker receives the message but the extension/native handler fails afterward.
Those cases should not be treated as interchangeable.
## Retained failed background worker
This is the persistent WebKit state reproduced by the debug menu.
### First message or initial load
The handler chain is:
1. `wakeUpBackgroundContentIfNecessary`
2. Append the pending operation to `m_actionsToPerformAfterBackgroundContentLoads`
3. `loadBackgroundWebViewIfNeeded`
4. `loadBackgroundWebView`
5. Assign `m_backgroundWebView`
6. Call `_loadServiceWorker`
The service-worker registration then follows:
1. `WebPageProxy::loadServiceWorker`
2. Generated page invokes `navigator.serviceWorker.register(...)`
3. `ServiceWorkerContainer::register`
4. Registration job settles
5. `ServiceWorkerContainer::willSettleRegistrationPromise(success)`
6. `WebLocalFrameLoaderClient::didFinishServiceWorkerPageRegistration(success)`
7. IPC reaches `WebPageProxy::didFinishServiceWorkerPageRegistration`
8. Completion returns to `WebExtensionContext::loadBackgroundWebView`
### Failure state
When registration returns `success == false`, WebKit records `BackgroundContentFailedToLoad` and returns. In this path it does not call `unloadBackgroundWebView()`.
The important retained state is:
- `m_backgroundWebView` is still non-null.
- Pending actions have not been dispatched.
- The service worker is not running.
Every later CPM message appends another pending action, calls `loadBackgroundWebViewIfNeeded`, and immediately returns because `m_backgroundWebView` already exists. The pending actions therefore never run and the worker is never replaced.
That is a stable stuck state, not merely a slow startup.
### Normal cleanup paths
These paths do clean up the background web view:
- Navigation failure: `didFailNavigation` records the error and calls `unloadBackgroundWebView`.
- WebContent process termination: `webViewWebContentProcessDidTerminate` calls `unloadBackgroundWebView`.
- Normal nonpersistent-worker eviction: `unloadBackgroundContentIfPossible` calls `unloadBackgroundWebView`.
Consequently, ordinary idle eviction, memory pressure, or a conventional process crash alone do not explain the retained failed-view state.
## Debug simulation
The debug command performs this exact sequence:
1. Reload the CPM extension context.
2. WebKit creates its background web view.
3. The private delegate callback `_webExtensionController:didCreateBackgroundWebView:forExtensionContext:` runs synchronously after creation and before `_loadServiceWorker`.
4. The debug implementation removes the navigation delegate and closes the page with `_close`.
5. `_loadServiceWorker` sees a closed `WebPageProxy` and completes with `false`.
6. Because the normal navigation/process callbacks were suppressed, WebKit retains the failed `m_backgroundWebView`.
The following CPM message encounters the retained state. This explains why failure becomes visible one page load after activating the command.
This is a useful deterministic simulation, but not proof that production closes the page at that point.
## Possible production entry
From the inspected WebKit code, `_loadServiceWorker` can return failure when:
- The `WebPageProxy` is already closed.
- A service-worker launch completion handler is unexpectedly already present.
- The actual `navigator.serviceWorker.register` job settles as a failure.
The first condition is manufactured by the debug command. The natural production candidate is therefore a real registration failure, potentially involving script/resource loading, service-worker registration storage, or the Network process. The source establishes that these categories can fail; it does not identify which one occurred in reported sessions.
A re-entrancy problem involving an already-present launch completion handler is also possible from the API shape, but there is currently no observed production sequence proving it.
## Known transient paths
### Startup and state restoration
The app initializes the WebExtension manager before state restoration, but extension synchronization and loading continue asynchronously:
1. `AppDelegate.applicationDidFinishLaunching`
2. `setupWebExtensions()`
3. Manager/controller creation
4. Async `coordinator.loadAndSync()`
5. `stateRestorationManager.applicationDidFinishLaunching()`
6. Restored tabs and web views are created with the shared controller
7. A restored document may commit before extension loading reaches `addInjectedContent`
The WebKit extension-load side is:
1. `WebExtensionLoader.loadWebExtension`
2. `WKWebExtensionController.load(context)`
3. `WebExtensionController::load`
4. `WebExtensionContext::load`
5. Storage migration completion
6. `m_safeToInjectContent = true`
7. Background loading and `dispatchDidLoad`
8. `addInjectedContent`
If the restored page has already committed, its `document_start` opportunity is gone. A later reload injects CPM and recovers. This explains the reproduced startup miss, but not a permanently stuck extension.
### WebKit dropped-first-message bug
[WebKit bug 317981](https://bugs.webkit.org/show_bug.cgi?id=317981) describes a first message being silently dropped when persisted listener state is empty before the background has loaded once.
Before the fix, `wakeUpBackgroundContentIfNecessaryToFireEvents` interpreted the empty set as “no listener” instead of “listener state not known yet,” so it did not wake or queue the message. The fix introduced `m_backgroundContentHasLoadedOnce` and treats the pre-first-load listener state as unknown.
This is another transient startup failure. It does not retain a failed background web view and therefore does not explain cross-navigation stuck behavior.
### Scriptlet extension reload
The scriptlet update path unloads the old context and loads a newly created one. During that interval:
- Existing documents may still have old JavaScript whose extension context proxy has been removed.
- A navigation can commit before the replacement context adds its injected content.
A navigation after the new context finishes loading should recover. This becomes persistent only if the replacement service worker itself enters the retained failed-load state.
### Fire
Fire unloads extension contexts, clears website data, may reopen a window, and only then reloads extensions. A reopened page can commit while there is no loaded CPM extension context and miss `document_start`.
Again, a later navigation should recover unless the new background-worker load fails persistently.
### Application or embedded-extension update
Extension replacement previously had lifecycle windows in which an old context was removed before its replacement was ready. That known app-side lifecycle problem was fixed in `60dc9c2795` and is historical context only; this note intentionally does not preserve its detailed sequence.
## Window-scope implication
DuckDuckGo attaches the same `webExtensionManager.controller` to tab configurations across browser windows.
Opening another window reports `didOpenWindow` to the controller/context and registers the new window and tabs. It does not normally reload the extension context.
Therefore, if WebKit truly retains one globally failed background view in that shared context, the failure should normally affect every window using the controller. The observation that a new window works while the original remains broken points more strongly to per-tab or per-WebContent-process routing than to the global retained-worker state.
A decisive manual discriminator is:
1. Make the original window fail.
2. Confirm CPM works in a new window.
3. Return to the original window and navigate again.
4. If only the original remains broken, inspect tab/page registration.
5. If the original also recovers, the new-window event caused some global recovery and that handler chain must be traced separately.
This implication is based on controller ownership in the app and WebKit. The exact cause of the observed window behavior is not yet proven.
## Scenarios not supported by current evidence
The following ideas do not yet have a traced path into a stable stuck state:
- Multiple installed browser versions
- Sleep/wake by itself
- Memory pressure by itself
- Normal service-worker idle termination
- A routine WebContent-process crash
- Compiled content-rule cache corruption
They may alter timing or storage pressure, but should not be presented as reproduction scenarios without a corresponding failing handler sequence or production trace.
Safari 26.6 includes a fix for service-worker registration database files accumulating on launch, and WebKit has also fixed cold-launch cleanup that delayed extension content injection. These can widen or shrink startup timing windows, but neither source demonstrates the retained failed-background state.
## Evidence needed next
The next useful instrumentation should classify the failure before attempting more scenarios:
- Inspect `WKWebExtensionContext.errors` for `WKWebExtensionContextErrorBackgroundContentFailedToLoad`.
- Invoke `loadBackgroundContent(completionHandler:)` diagnostically after health detection.
- A failure or completion that never arrives points to the background load/pending-action path.
- A successful background load while CPM messaging still fails points to tab/content-process routing.
- Capture WebKit logs around “Tab not found for message for content script message.”
- Correlate “Loading background content” with a missing “Background content loaded” and any registration error.
Without that split, repeated UI scenarios can reproduce the symptom while exercising unrelated failure modes.
## Source locations
Local WebKit sparse checkout:
- `Source/WebKit/UIProcess/Extensions/Cocoa/WebExtensionContextCocoa.mm`
- `Source/WebKit/UIProcess/Extensions/WebExtensionContext.cpp`
- `Source/WebKit/UIProcess/WebPageProxy.cpp`
- `Source/WebCore/workers/service/ServiceWorkerContainer.cpp`
- `Source/WebKit/WebProcess/WebCoreSupport/WebLocalFrameLoaderClient.cpp`
References:
- [WebKit bug 317981: first runtime message not waking the background worker](https://bugs.webkit.org/show_bug.cgi?id=317981)
- [WebKit bug 292378: content scripts stopping after back/forward navigation](https://bugs.webkit.org/show_bug.cgi?id=292378)
- [Safari 26.6 release notes](https://developer.apple.com/documentation/safari-release-notes/safari-26_6-release-notes)
- [WebKit cold-launch extension cleanup fix](https://chromium.googlesource.com/external/github.com/WebKit/webkit/+/7f8d70c0622228116a14f5100cf0e3e6fd333de7)