diff --git a/work/projects/cpm-web-extension-breakage-findings.md b/work/projects/cpm-web-extension-breakage-findings.md index 655edf01..03e51f28 100644 --- a/work/projects/cpm-web-extension-breakage-findings.md +++ b/work/projects/cpm-web-extension-breakage-findings.md @@ -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.