26 KiB
title, date, status, tags
| title | date | status | tags | ||||
|---|---|---|---|---|---|---|---|
| CPM Web Extension Breakage Findings | 2026-09-08 | investigation |
|
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 byMessageRouter
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:
- CPM content code calls
chrome.runtime.sendMessage(...). - The WebProcess implementation enters
WebExtensionAPIRuntime::sendMessage. - IPC sends
Messages::WebExtensionContext::RuntimeSendMessageto the UIProcess. WebExtensionContext::runtimeSendMessageresolves the sender withgetTab(senderParameters.pageProxyIdentifier).- WebKit calls
wakeUpBackgroundContentIfNecessaryToFireEvents(RuntimeOnMessage). - When the background is available, WebKit dispatches
WebExtensionContextProxy::DispatchRuntimeMessageEvent. - The service worker's
runtime.onMessagelistener 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:
wakeUpBackgroundContentIfNecessary- Append the pending operation to
m_actionsToPerformAfterBackgroundContentLoads loadBackgroundWebViewIfNeededloadBackgroundWebView- Assign
m_backgroundWebView - Call
_loadServiceWorker
The service-worker registration then follows:
WebPageProxy::loadServiceWorker- Generated page invokes
navigator.serviceWorker.register(...) ServiceWorkerContainer::register- Registration job settles
ServiceWorkerContainer::willSettleRegistrationPromise(success)WebLocalFrameLoaderClient::didFinishServiceWorkerPageRegistration(success)- IPC reaches
WebPageProxy::didFinishServiceWorkerPageRegistration - 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_backgroundWebViewis 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:
didFailNavigationrecords the error and callsunloadBackgroundWebView. - WebContent process termination:
webViewWebContentProcessDidTerminatecallsunloadBackgroundWebView. - Normal nonpersistent-worker eviction:
unloadBackgroundContentIfPossiblecallsunloadBackgroundWebView.
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:
- Reload the CPM extension context.
- WebKit creates its background web view.
- The private delegate callback
_webExtensionController:didCreateBackgroundWebView:forExtensionContext:runs synchronously after creation and before_loadServiceWorker. - The debug implementation removes the navigation delegate and closes the page with
_close. _loadServiceWorkersees a closedWebPageProxyand completes withfalse.- 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
WebPageProxyis already closed. - A service-worker launch completion handler is unexpectedly already present.
- The actual
navigator.serviceWorker.registerjob 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:
AppDelegate.applicationDidFinishLaunchingsetupWebExtensions()- Manager/controller creation
- Async
coordinator.loadAndSync() stateRestorationManager.applicationDidFinishLaunching()- Restored tabs and web views are created with the shared controller
- A restored document may commit before extension loading reaches
addInjectedContent
The WebKit extension-load side is:
WebExtensionLoader.loadWebExtensionWKWebExtensionController.load(context)WebExtensionController::loadWebExtensionContext::load- Storage migration completion
m_safeToInjectContent = true- Background loading and
dispatchDidLoad 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 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:
- Make the original window fail.
- Confirm CPM works in a new window.
- Return to the original window and navigate again.
- If only the original remains broken, inspect tab/page registration.
- 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.errorsforWKWebExtensionContextErrorBackgroundContentFailedToLoad. - 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.mmSource/WebKit/UIProcess/Extensions/WebExtensionContext.cppSource/WebKit/UIProcess/WebPageProxy.cppSource/WebCore/workers/service/ServiceWorkerContainer.cppSource/WebKit/WebProcess/WebCoreSupport/WebLocalFrameLoaderClient.cpp
References:
- WebKit bug 317981: first runtime message not waking the background worker
- WebKit bug 292378: content scripts stopping after back/forward navigation
- Safari 26.6 release notes
- WebKit cold-launch extension cleanup fix
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:
- The WebKit background wake-up queue is entered but never drained.
- Listener bookkeeping prevents messages from entering the wake-up queue.
- The worker loads, but WebKit finds no process to receive the event.
- The content-to-background message succeeds, but the background-to-tab
initRespis 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:
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.
serviceWorkerLaunchCompletionHandleris 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:
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::addRegistrationrejects through an early validation branch that does not callwillSettleRegistrationPromise(false). These branches cover a stopped container, Trusted Types rejection, empty or invalid URL, CSP rejection, invalid scheme, encoded slash/backslash, and invalid scope.startScriptFetchForJobfinds noScriptExecutionContext, notifies the server, destroys the job, and does not callwillSettleRegistrationPromise.ServiceWorkerContainer::stopremoves 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.willSettleRegistrationPromisereturns because it finds no document, no page, a page no longer marked as a service-worker page, or no local main frame.WebLocalFrameLoaderClient::didFinishServiceWorkerPageRegistrationfinds noWebPage.- The
DidFinishServiceWorkerPageRegistrationIPC 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.
SWServerJobQueuereceives 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 persistedm_backgroundContentEventListenersset was interpreted as “no listener,” even before the background had loaded once.runtime.sendMessagecompleted without waking the worker. - On current WebKit, after
m_backgroundContentHasLoadedOnce == true, an absentRuntimeOnMessageentry is considered authoritative. Every later message completes without waking the worker. - A content-script message whose
pageProxyIdentifierdoes not resolve throughgetTabfails withtab not foundbefore 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_eventListenerFrameshas 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:
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:
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
- The MV3 background service worker is not currently loaded.
- A CPM content script sends
chrome.runtime.sendMessage({ messageType: "autoconsent", ... }). - WebProcess sends
RuntimeSendMessagetoWebExtensionContext::runtimeSendMessage. - Sender-tab lookup succeeds. This is important: a failed lookup would be a tab-local failure instead.
- Listener gating accepts
RuntimeOnMessage. wakeUpBackgroundContentIfNecessaryseesbackgroundContentIsLoaded() == false.- It appends the message-dispatch closure to
m_actionsToPerformAfterBackgroundContentLoads. loadBackgroundWebViewIfNeededcreatesm_backgroundWebView._loadServiceWorkerbegins registration.- Registration either completes with
falseor never completes through one of the paths listed above. performTasksAfterBackgroundContentLoadsis not called.- No cleanup path calls
unloadBackgroundWebView. - The first CPM dispatch closure remains queued.
The retained state is:
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:
runtimeSendMessageresolves that tab normally.- Listener gating accepts
RuntimeOnMessage. wakeUpBackgroundContentIfNecessaryagain sees the background as not loaded.- It appends another dispatch closure to the same shared action vector.
loadBackgroundWebViewIfNeededseesm_backgroundWebView != niland returns.- No replacement worker is created.
- No dispatch closure runs.
- The content script's
runtime.sendMessagepromise remains pending. - The background never handles the CPM
init. - The background never sends
tabs.sendMessage(initResp). - 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
- Explicit extension-context unload/reload
- App process restart
The ordinary 30-second nonpersistent-background eviction does not recover the failed-first-load sequence. The wake-up call tries to schedule eviction before the background view is created, so it returns without creating a timer; the failure path never schedules the timer afterward.
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:
background worker was unloaded
m_backgroundContentHasLoadedOnce == true
RuntimeOnMessage absent from m_backgroundContentEventListeners
Every tab then follows:
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.