[2026-09-08] eagle: work/projects/cpm-web-extension-breakage-findings.md
This commit is contained in:
@@ -252,3 +252,243 @@ References:
|
||||
- [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)
|
||||
|
||||
|
||||
## 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.
|
||||
|
||||
Reference in New Issue
Block a user