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

This commit is contained in:
Alexey Martemyanov
2026-09-08 20:27:23 +06:00
parent e99c188480
commit 635ac99393
@@ -252,3 +252,243 @@ References:
- [WebKit bug 292378: content scripts stopping after back/forward navigation](https://bugs.webkit.org/show_bug.cgi?id=292378) - [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) - [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) - [WebKit cold-launch extension cleanup fix](https://chromium.googlesource.com/external/github.com/WebKit/webkit/+/7f8d70c0622228116a14f5100cf0e3e6fd333de7)
## Queue and delivery failure taxonomy
The visible symptom does not by itself prove that one queue stopped. CPM messaging can fail at four different stages:
1. The WebKit background wake-up queue is entered but never drained.
2. Listener bookkeeping prevents messages from entering the wake-up queue.
3. The worker loads, but WebKit finds no process to receive the event.
4. The content-to-background message succeeds, but the background-to-tab `initResp` is lost.
There is also a CPM-owned JavaScript state queue. Its failure characteristics are described separately below.
### Literal WebKit wake-up queue
The literal queue is `m_actionsToPerformAfterBackgroundContentLoads`.
It is stuck when all of the following remain true:
```text
m_actionsToPerformAfterBackgroundContentLoads is non-empty
backgroundContentIsLoaded() is false
performTasksAfterBackgroundContentLoads() never runs
the extension context is not unloaded
```
Only `performTasksAfterBackgroundContentLoads()` executes the queued actions. Extension-context unload clears them without executing them.
#### The load gate never opens
`m_safeToLoadBackgroundContent` remains false until `moveLocalStorageIfNeeded` completes. When the extension base URL changed, that operation waits for the website-data store's `_renameOrigin` completion.
If the rename operation remains alive but never replies, `loadBackgroundWebViewIfNeeded` keeps returning before creating a background view. Work already queued through `wakeUpBackgroundContentIfNecessary` cannot run.
A normal Network-process disconnection should cancel the async IPC reply and invoke its completion. The problematic condition is an operation that remains pending without the connection being invalidated.
#### Service-worker launch completes with failure
`WebPageProxy::loadServiceWorker` can complete with `false` when:
- Its page is already closed.
- `serviceWorkerLaunchCompletionHandler` is already occupied.
- Service-worker registration is rejected.
- The worker script cannot be fetched.
- Its MIME type, scope, origin, or response is invalid.
- The worker script fails to start.
- The Network process disconnects while registration is pending.
A Network-process disconnection during registration follows this exact chain:
```text
NetworkProcessConnection::didClose
-> WebSWClientConnection::connectionToServerLost
-> WebSWClientConnection::clear
-> SWClientConnection::clearPendingJobs
-> ServiceWorkerJob::failedWithException
-> ServiceWorkerContainer::jobFailedWithException
-> ServiceWorkerContainer::willSettleRegistrationPromise(false)
-> WebLocalFrameLoaderClient::didFinishServiceWorkerPageRegistration(false)
-> WebPageProxy::didFinishServiceWorkerPageRegistration(false)
-> _loadServiceWorker completion(false)
-> WebExtensionContext records BackgroundContentFailedToLoad
-> return without performTasksAfterBackgroundContentLoads
-> return without unloadBackgroundWebView
```
At that point the queued actions and background web view are both retained.
#### Service-worker launch never completes
The launch completion can also be lost rather than called with `false`:
- `ServiceWorkerContainer::addRegistration` rejects through an early validation branch that does not call `willSettleRegistrationPromise(false)`. These branches cover a stopped container, Trusted Types rejection, empty or invalid URL, CSP rejection, invalid scheme, encoded slash/backslash, and invalid scope.
- `startScriptFetchForJob` finds no `ScriptExecutionContext`, notifies the server, destroys the job, and does not call `willSettleRegistrationPromise`.
- `ServiceWorkerContainer::stop` removes pending jobs without directly notifying the service-worker-page launch completion. This becomes a stall only when the associated WebPage failure/reset does not independently complete the launch.
- `willSettleRegistrationPromise` returns because it finds no document, no page, a page no longer marked as a service-worker page, or no local main frame.
- `WebLocalFrameLoaderClient::didFinishServiceWorkerPageRegistration` finds no `WebPage`.
- The `DidFinishServiceWorkerPageRegistration` IPC cannot be delivered while the UIProcess continues retaining the page.
- Origin import, registration-domain validation, script fetch, or worker startup remains pending without a process disconnection.
- `SWServerJobQueue` receives a callback after its registration or pre-installation worker disappeared and returns through one of its invariant-failure branches without resolving or rejecting the client job.
These paths leave `serviceWorkerLaunchCompletionHandler` installed and prevent `performTasksAfterBackgroundContentLoads`.
### Messages bypass the wake-up queue
A message can be discarded before anything is queued:
- On WebKit before fix `7682d9817b`, an empty persisted `m_backgroundContentEventListeners` set was interpreted as “no listener,” even before the background had loaded once. `runtime.sendMessage` completed without waking the worker.
- On current WebKit, after `m_backgroundContentHasLoadedOnce == true`, an absent `RuntimeOnMessage` entry is considered authoritative. Every later message completes without waking the worker.
- A content-script message whose `pageProxyIdentifier` does not resolve through `getTab` fails with `tab not found` before background wake-up.
The second condition can become extension-wide if `RuntimeOnMessage` was lost from the shared background-listener bookkeeping. The third condition is normally tab-local.
### Worker loaded, but no receiving process
After wake-up, `runtimeSendMessage` obtains `processes(RuntimeOnMessage, Main)`. The set is empty when:
- `m_eventListenerFrames` has no matching entry.
- Its weak listener frame has expired.
- The frame no longer has a page.
- The process cannot send messages.
- Session/private-data filtering excludes the page.
- The listener is registered under a different context proxy.
WebKit then completes the call with an empty response and does not retry.
If the set contains a process but that process remains alive and unresponsive, the async reply remains pending. IPC invalidation cancels it; an indefinitely unresponsive connection is the condition that makes it an indefinite wait.
A JavaScript listener can similarly retain the reply by returning `true` without calling `sendResponse`, or by returning a promise that never settles.
### Background-to-tab init response
The CPM handshake is bidirectional:
```text
content script: chrome.runtime.sendMessage(init)
background: chrome.tabs.sendMessage(tabId, initResp, { frameId })
```
The second leg does not deliver when:
- `getTab(tabIdentifier)` fails.
- The tab has no `WKWebView`.
- `tab->processes(RuntimeOnMessage, ContentScript)` is empty.
- The target frame identifier belongs to the previous document after navigation.
- The frame/document target no longer matches.
- The content-script listener mapping is absent from the tab's WebContent process.
The extension does not await or catch this `tabs.sendMessage(initResp)`. Therefore the original `init` can finish successfully while the content script never receives configuration. This is a strong explanation for a failure confined to one tab or one WebContent process.
### CPM JavaScript state queue
CPM serializes state mutations through `_stateQueue`:
```text
previous _stateQueue
-> getCpmState()
-> mutation callback
-> updateCpmState()
```
It remains pending if:
- `storage.session.get("cpmState")` never settles.
- `storage.session.set(...)` never settles.
- A mutation callback awaits another method that calls `modifyCpmState`, causing a self-deadlock.
- A callback returns another permanently pending promise.
Current mutation callbacks are synchronous and do not contain the nested-await error. Native messaging is protected by a 20-second timeout.
The normal enabled `init` path does not await its dashboard-state update before sending `initResp`. A stuck `_stateQueue` can break diagnostics and state persistence, but does not normally explain total consent-management failure.
## Exact all-tabs extension freeze sequence
The following is the exact sequence for the extension-context-wide freeze reproduced by the failed-background-load simulation. It applies to all tabs that share the same `WKWebExtensionController` and `WKWebExtensionContext`.
### Entry
1. The MV3 background service worker is not currently loaded.
2. A CPM content script sends `chrome.runtime.sendMessage({ messageType: "autoconsent", ... })`.
3. WebProcess sends `RuntimeSendMessage` to `WebExtensionContext::runtimeSendMessage`.
4. Sender-tab lookup succeeds. This is important: a failed lookup would be a tab-local failure instead.
5. Listener gating accepts `RuntimeOnMessage`.
6. `wakeUpBackgroundContentIfNecessary` sees `backgroundContentIsLoaded() == false`.
7. It appends the message-dispatch closure to `m_actionsToPerformAfterBackgroundContentLoads`.
8. `loadBackgroundWebViewIfNeeded` creates `m_backgroundWebView`.
9. `_loadServiceWorker` begins registration.
10. Registration either completes with `false` or never completes through one of the paths listed above.
11. `performTasksAfterBackgroundContentLoads` is not called.
12. No cleanup path calls `unloadBackgroundWebView`.
13. The first CPM dispatch closure remains queued.
The retained state is:
```text
m_backgroundWebView != nil
m_backgroundContentIsLoaded == false
m_actionsToPerformAfterBackgroundContentLoads is non-empty
```
For a lost-completion variant, `serviceWorkerLaunchCompletionHandler` is also still installed.
### Every later tab
For each later CPM message from any tab sharing that context:
1. `runtimeSendMessage` resolves that tab normally.
2. Listener gating accepts `RuntimeOnMessage`.
3. `wakeUpBackgroundContentIfNecessary` again sees the background as not loaded.
4. It appends another dispatch closure to the same shared action vector.
5. `loadBackgroundWebViewIfNeeded` sees `m_backgroundWebView != nil` and returns.
6. No replacement worker is created.
7. No dispatch closure runs.
8. The content script's `runtime.sendMessage` promise remains pending.
9. The background never handles the CPM `init`.
10. The background never sends `tabs.sendMessage(initResp)`.
11. Consent management does not start in that tab.
Because the queue and background view belong to the shared extension context, this repeats for every existing and newly created tab attached to the same controller. A page reload cannot repair it.
### Recovery
This state ends only when something calls `unloadBackgroundWebView` or unloads/reloads the entire extension context. Relevant cleanup paths are:
- Background WebContent-process termination callback
- Background navigation failure callback
- Normal background eviction, if its timer is able to reach the unload path
- Explicit extension-context unload/reload
- App process restart
Opening an ordinary new browser window does not itself unload or reload the shared extension context. Therefore:
- If a new window works while the original remains broken, this exact all-tabs freeze is not the state currently being observed; the failure is more likely tab/process routing.
- If creating a new window reloads or replaces the extension context in the app's surrounding lifecycle, the new window can work because it is no longer using the poisoned context.
- The discriminator is whether previously broken tabs recover after the new window starts working.
### Alternative extension-wide freeze without queued actions
A separate extension-wide failure is possible when the shared listener bookkeeping says `RuntimeOnMessage` is absent:
```text
background worker was unloaded
m_backgroundContentHasLoadedOnce == true
RuntimeOnMessage absent from m_backgroundContentEventListeners
```
Every tab then follows:
```text
runtimeSendMessage
-> wakeUpBackgroundContentIfNecessaryToFireEvents
-> listener considered absent
-> completionHandler called without waking worker
-> no background CPM handler
-> no initResp
```
This freezes CPM across all tabs without growing `m_actionsToPerformAfterBackgroundContentLoads`. It recovers only when another event loads the worker and repairs listener registration, or when the extension context is reloaded.
On WebKit versions before `7682d9817b`, the same sequence can occur before the first successful background load because an empty persisted listener set was incorrectly considered authoritative.