Files
obsidian-vault/work/projects/cpm-web-extension-breakage-findings.md
T

1248 lines
128 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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.
>
> **2026-09-10: two additional app-side tab-lifecycle violations found in production-reachable code** — `Close Other Tabs` sends `didCloseTab` for the one loaded tab it keeps, then puts the same `Tab` object back without `didOpenTab`; `Duplicate Tab` invoked from a popup inserts the original `Tab` into the main window instead of a copy, so closing the popup makes WebKit forget the still-live tab. Both produce a deterministic **single-tab** routing failure: reload does not help, new tabs work, and Fire / extension reload / tab recreation repairs it. The first chain is source-confirmed and has a minimal manual repro below; the popup chain is source-confirmed and still needs a manual run.
>
> **2026-09-10: one production cause CONFIRMED** — closing a window while shared pinned tabs exist makes WebKit forget those tabs (`didCloseWindow` → `didCloseTab`/`forgetTab` for every tab the closing window reports, and DDG reports shared pinned tabs from every window). CPM dies in the pinned tabs only; new tabs work; new window / Fire / extension reload repairs. See "✅ CONFIRMED" section near the end. Everything WebKit-internal below (retained failed view, dead worker, listener gating) remains unconfirmed on shipping WebKit.
>
> **Updated 2026-09-09 after source and shipping-WebKit validation** (WebKit trunk `0c7e7ad97b`, see [[cpm-web-extension-breakage-validation]] for file:line evidence). The retained-failed-view state is real but **self-heals via the 30 s idle eviction**. The source-level dead-worker state matches "stuck across all tabs", but neither critical memory pressure nor Network-process termination reproduced it on the tested macOS 26.x build. No permanent all-tabs production trigger is currently confirmed. `removeAllUserScripts()` is explicitly implemented as `RemoveWebExtensions::No` and does not remove CPM's WebExtension scripts.
> **2026-09-10 — ✅ CONFIRMED all-tabs cause:** background WebContent process death → Network process re-creates the SW context connection for the dead page → UI spins up a page-less service-worker process → every new background registration installs its worker there → "Script error." → `BackgroundContentFailedToLoad` loop. 100 % repro with `kill -9 <background pid>`; Fire fixes because the new base URL is a new registrable domain. See "✅ CONFIRMED by the third run" near the end. Second confirmed (per-tab) cause: shared pinned tabs forgotten on window close.
## Executive summary
The same visible symptom, CPM no longer managing consent, can come from 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 |
| App reports a still-live tab as closed (`Close Other Tabs`, popup duplication, shared pinned tabs on window close) | One retained tab, popup-moved tab, or shared pinned tabs | Re-register/recreate the affected tab, open a window that reports that same shared pinned tab, Fire, extension reload, or relaunch | Confirmed in source; shared-pinned variant manually reproduced |
| WebKit retains a background service worker that failed to load | Extension context/controller | **[corrected]** Recovers by itself: the next wake-up arms the 30 s idle-eviction timer, the failed view is unloaded, and the following message retries. Persistent only if every retry fails (e.g. SW script missing on disk) | Confirmed WebKit state machine; self-healing unless the failure cause is repeatable |
| Background service worker terminated while WebKit still reports the background as loaded | Extension context (all tabs) | Only after ≥30 s with no extension events (idle eviction), or context unload/reload | Confirmed state shape in source; proposed memory-pressure and Network-process triggers did **not** reproduce it on tested shipping WebKit |
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 explanation for the observed **old/specific tabs dead, new tabs fine** shape is now the app violating WebKit's tab lifecycle contract. It has three concrete instances, all ending in the same `m_tabMap` loss. The separate **all tabs including new tabs** shape still points to the background-service-worker lifecycle and must not be conflated with the tab-routing failure.
## Current conclusions (authoritative, 2026-09-10)
Use the scope first when classifying an episode:
| Hypothesis | Exact condition required | Resulting sequence | Current status |
|---|---|---|---|
| `Close Other Tabs` closes the retained loaded tab in WebKit | Run `Close Other Tabs` while the kept unpinned tab is already materialized | `removeAll(andAppend:)` calls `tabsWillClose` over the complete old array, including the exception → `didCloseTab` → WebKit `forgetTab`; the same object is assigned back without `didOpenTab` → sender lookup fails on every later navigation in that tab | **Source-confirmed, production-reachable, strongest new per-tab cause.** Manual repro still to be recorded in this note. |
| Popup `Duplicate Tab` aliases one `Tab` into two windows | Invoke the normal `Duplicate Tab` command in a popup | Popup branch redirects the original loaded `Tab` to the main window and returns before constructing `tabCopy`; the popup still reports the same object; closing it sends `didCloseWindow`/`didCloseTab` and WebKit forgets the tab that remains in the main window | **Source-confirmed, production-reachable.** Manual repro still needed. |
| Closing a window while shared pinned tabs exist | Shared pinned-tab mode; close any window or merge windows | Closing window reports shared pinned `Tab` objects as its own → WebKit closes/forgets them although other windows still display them | **Manually reproduced 2026-09-10.** Affects shared pinned tabs only; opening a new window re-registers them. |
| Background WebContent-process death / stale service-worker registration | The CPM background process dies or an old background page loses the unregister-vs-register race | WebKit retains or recreates a loaded background view with no usable worker/listener destination → every tab, including new tabs, receives no CPM response until idle eviction/reload | **All-tabs failure manually reproduced with `kill -9`; natural production trigger/frequency unproven.** Self-healed after about a minute. |
| Background event starvation from page-generated traffic | One or more pages create/navigate frames or otherwise generate CPM messages faster than the shared background worker can finish them. CPM is injected into `all_frames`; handlers have no global concurrency limit or backpressure. | Each frame sends `init` -> the background starts concurrent native/config/rule work -> queued tasks and replies grow -> messages from unrelated tabs are delayed -> repeated traffic keeps the worker active and prevents the 30 s idle reset. | Concrete production input exists; no stable repro yet. Expected to recover after the producer stops and work drains, unless it pushes WebKit into another failure state. |
| Background JavaScript/JSC stops making progress | A synchronous non-yielding task, runaway task production, or WebKit/JSC deadlock blocks the single background event loop. An unresolved `Promise` alone is insufficient. | WebKit dispatches every tab's `RuntimeOnMessage` to the same worker -> no handler runs or replies -> all tabs miss `initResp` -> incoming traffic can keep the hidden background view alive. | Symptom match; no reachable infinite loop found. Current `compactRuleList` regexes were benchmarked and are not pathological. |
| Listener bookkeeping becomes empty or stale | `RuntimeOnMessage` is absent from `m_backgroundContentEventListeners`, or `m_eventListenerFrames` contains no valid receiving frame after worker unload/restart/update. | WebKit concludes there is no listener before wake-up, or finds no destination after wake-up -> messages are dropped/completed without the CPM background handler -> every tab fails. | Source-valid state; no bundle code removes the listener and no production transition into this state has been found. |
| Background registration/load never settles or repeatedly fails | `_loadServiceWorker` neither completes, or each retry fails; a pending completion/actions vector keeps the context alive. A one-off failure is insufficient because normal idle eviction recovers it. | First message starts load -> actions queue behind it -> later messages append to the queue or retry into the same failing condition -> no background handler runs for any tab. | Debug simulation proves the failure shape. A retained one-off failed view self-heals; persistent production cause unknown. |
| Native messaging stops producing usable replies | The background receives `init`, but one of `getResourceIfNew`, `isAutoconsentSettingEnabled`, or `isFeatureEnabled` repeatedly times out/errors, or the app-side request/router is stalled. | `handleAutoConsentMessage` waits -> `_request` returns `undefined` after timeout/error -> setting resolves disabled or processing aborts -> no `initResp`; the same shared path fails for all tabs. | Extension-side sequence confirmed. The debug no-handler switch is only a simulation; no production path that removes handlers was found. A router/request deadlock remains possible. |
| Downloaded CPM configuration is malformed or internally inconsistent | The running background obtains a new `compactRuleList` whose structure or another synchronous transform throws before `tabs.sendMessage(initResp)`. The extension context itself is not reloaded by the privacy-config update. | Every later `init` reaches the same background code and fails at the same deterministic point -> no tab receives CPM initialization until valid state replaces it or the context restarts. | Low-priority data-corruption hypothesis. Current config is valid and regex performance has been ruled out; no failing historical payload is known. |
| Other per-tab sender/response routing is stale | The frame identifier is stale, or `tabs.sendMessage(initResp)` targets a document/frame that no longer exists | That document never completes initialization; another tab with valid routing continues to work | Residual bucket after the three concrete app lifecycle triggers above. |
Explicitly not active production hypotheses: critical-memory-pressure termination (manual test negative), cold-launch/restoration miss (transient and fixed), feature-flag transitions (not a production event), privacy-config scriptlet extension reload (different extension context), `removeAllUserScripts()` (WebExtension scripts are excluded), and a normal page's long-running JavaScript by itself (limited to that page's WebContent thread).
## 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.
**[corrected]** This is not stable. `wakeUpBackgroundContentIfNecessary` calls `scheduleBackgroundContentToUnload()` before checking whether the background is loaded (`WebExtensionContext.cpp:1854`). The first message finds no view and arms nothing, but the **second** message (any tab) finds the retained view and arms the 30 s one-shot. `unloadBackgroundContentIfPossible` then calls `unloadBackgroundWebView()` (CPM holds no ports, is not inspected, has no permission prompts), the pending actions are kept, and the next message creates a fresh view and retries. The state persists only if every retry fails for the same reason.
### 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`.
**[corrected]** Ordinary idle eviction *does* clear the retained failed view (see above). Memory pressure and Network-process termination do not produce the retained-failed-view state, but they produce a different and worse one: they terminate the running service worker while the background view, its frame, and its listener registration remain, so WebKit keeps reporting the background as loaded (see "Ranked production trigger chains").
## 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~~ **[corrected]** Critical system memory pressure is a traced trigger on macOS: `WebProcessPool::memoryPressureStatusChangedForProcess``NetworkProcess::TerminateIdleServiceWorkers``SWServer::terminateIdleServiceWorkers` kills the extension worker, which the SW server always considers idle because extension messages bypass it.
- ~~Normal service-worker idle termination~~ **[corrected]** same mechanism as above; the extension worker *is* idle-terminable under pressure.
- A routine WebContent-process crash (still unsupported: the background process crash path unloads the view and the next message reloads it)
- **[corrected]** A Network-process crash or unresponsive-kill is a traced trigger: `WebProcessPool::terminateServiceWorkers()` and `WebProcess::networkProcessConnectionClosed → SWContextManager::stopAllServiceWorkers()` stop the extension worker without touching the background view.
- 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. **[corrected]** The registration-database fix is irrelevant here: extension service workers are never persisted (`SWServerWorker::shouldPersistToDisk` returns false for service-worker-page registrations) and the registration is cleared whenever the SW page client goes away.
## 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 **[corrected] — or to a terminated worker under a loaded view.** Discriminate by sending a probe `runtime.sendMessage` from a tab (or the native `healthCheckRequest`): `undefined` reply + empty `errors` + no `Tab not found` log = dead worker; `tab not found` error = 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)
## 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
- Explicit extension-context unload/reload
- App process restart
**[corrected]** The 30-second eviction *does* recover this sequence. Only the first wake-up (view still nil) skips the timer; every later wake-up sees `m_backgroundWebView != nil` and arms it. Expected timeline: message 1 → load fails; message 2 → timer armed; +30 s → view unloaded; message 3 → fresh load. The debug simulation is one-shot (`WebExtensionManager.swift:648-652`), so it should recover on that schedule — verify empirically; persistence beyond that would mean shipping WebKit differs from trunk.
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.
## Historical production trigger ranking (superseded)
> Kept as investigation history. Do not use this section as the current conclusion; see **Current working hypotheses (authoritative, 2026-09-09)** above. Later validation rejected or demoted several entries.
Ordered by estimated probability of producing the observed "CPM dead in every tab, reload doesn't help" state on macOS. Line refs: WebKit trunk `0c7e7ad97b`; details in [[cpm-web-extension-breakage-validation]].
### 1. Critical memory pressure terminates the "idle" extension worker — source hypothesis, manual repro failed
This was initially ranked first from source reading. Running `sudo memory_pressure -S -l critical` on the target macOS 26.x system did not break CPM, so it is no longer an active reproduction candidate. Keep the chain below as a description of the source path, not evidence that production reaches the broken state.
Chain:
1. CPM context loaded; background SW registered and activated. ≥10 s pass. The SW server marks the worker idle (`SWServerWorker::isIdle`, `SWServerWorker.cpp:341`) — extension `runtime.sendMessage` traffic goes over WebKit IPC and never calls `needsRunning()`, so the worker is *always* idle from the server's view.
2. System memory pressure → Critical. The WebProcess hosting the SW page reports `MemoryPressureStatusChanged` (`WebProcess.cpp:554`).
3. `WebProcessPool::memoryPressureStatusChangedForProcess`: `Critical && isRunningServiceWorkers()``NetworkProcess::TerminateIdleServiceWorkers(pid)` (`WebProcessPool.cpp:2937`).
4. `SWServer::terminateIdleServiceWorkers` terminates the CPM worker (`SWServer.cpp:1807`).
5. WebProcess: worker thread stops, `ServiceWorkerGlobalScope` destroyed, `Page::m_serviceWorkerGlobalScope` → null. SW page, frame, and `RuntimeOnMessage/Main` listener entry survive.
6. UIProcess `WebExtensionContext` unchanged: `m_backgroundWebView != nil`, `m_backgroundContentIsLoaded == true`. No error recorded, no delegate callback.
7. Any tab, any frame: `cpm.js``runtime.sendMessage(init)``runtimeSendMessage` → tab found → listener present → `wakeUpBackgroundContentIfNecessary` re-arms the 30 s eviction timer → `backgroundContentIsLoaded()` true → `DispatchRuntimeMessageEvent` to the SW page process.
8. `enumerateFramesAndNamespaceObjects``jsContextForServiceWorkerWorld` → null → frame skipped → null reply → `{}` → content script's `await sendMessage` resolves `undefined`. No `initResp`; `AutoConsent` stays in `waitingForInitResponse`.
9. Every navigation in every tab repeats 78 and keeps re-arming the timer (`all_frames: true` makes this continuous while browsing).
10. Exit: ≥30 s with zero extension events → `unloadBackgroundContentIfPossible` → view unloaded → next message reloads a fresh worker (`cpmMessagingRecoveredWithoutExtensionReload`); or Fire/extension reload; or relaunch.
Repro:
1. Launch DDG, open one tab, wait ≥15 s.
2. `sudo memory_pressure -S -l critical` (simulate) or `memory_pressure -l critical` (real), hold ~5 s. Watch `log stream --predicate 'subsystem == "com.apple.WebKit" AND (eventMessage CONTAINS "terminateIdleServiceWorkers" OR eventMessage CONTAINS "memoryPressureStatusChangedForProcess")'`.
3. In Safari-style Web Inspector target list (Develop → DuckDuckGo) the "DuckDuckGo Embedded Extension — Extension Service Worker" target should disappear while the extension stays loaded.
4. Load any CMP page in any tab / new tab / new window → banner not handled; health monitor fires `cpmInitializationFailed` after 4 s, `cpmMessagingStuck` on the second navigation. `WKWebExtensionContext.errors` is empty; no "Tab not found" log line.
5. Stop navigating for ≥35 s, load a CMP page → works; `cpmMessagingRecoveredWithoutExtensionReload`.
### 2. Network process crash / unresponsive termination
Why second: less frequent than memory pressure, but every occurrence lands in the same dead-worker state; also fits an "after the network hiccuped, cookie banners stopped being handled" report.
Chain:
1. `NetworkProcessProxy::didClose` (crash), `didBecomeUnresponsive` (UIProcess kills it), or `ExceededMemoryLimit`.
2. UIProcess: `WebProcessPool::networkProcessDidTerminate``terminateServiceWorkers()``WebProcessProxy::disableRemoteWorkers(ServiceWorker)``WebSWContextManagerConnection::Close` to the SW page process (`WebProcessPool.cpp:497-509, 1850`; `WebProcessProxy.cpp:3094`). Process survives because it owns the SW page.
3. WebProcess: `networkProcessConnectionClosed``SWContextManager::stopAllServiceWorkers()` (`WebProcess.cpp:1467`).
4. Steps 510 of chain 1. The in-memory registration is gone; nothing re-registers until the view is recreated.
Repro: launch, wait 15 s, `kill -9` DuckDuckGo's `com.apple.WebKit.Networking` process (pick the one whose parent is the DDG PID), wait for the network process to relaunch, then step 45 of chain 1.
### 3. Startup / session-restoration miss (transient, per-tab, very common)
Not the stuck state, but the most frequent CPM miss and the one most likely to be *misread* as stuck. Amplified by an app-side detail: the context's `baseURL` is a random UUID per `WKWebExtensionContext` object and the app never sets it, so every load runs `_renameOrigin` and content injection waits on a Network-process round trip (`WebExtensionContextCocoa.mm:222, 297-311, 516-525`).
Chain: launch → `setupWebExtensions()` → async `loadAndSync()` → restored tabs commit before `addInjectedContent` → no `document_start` → no CPM until reload. Recovery: any navigation after the extension finishes loading.
Repro: 10+ restored tabs, quit, relaunch; several restored tabs have no CPM until reloaded. Fix: set `context.baseURL = webkit-extension://<uniqueIdentifier>/` in `WebExtensionLoader.makeContext`.
### 4. Tab/window routing: sender page not found
The only class that explains "specific old tab(s) broken, new tabs fine". **[corrected 2026-09-10]** `getCurrentTab` iterates WebKit's own `openTabs()` = `m_tabMap` entries with `m_isOpen && isValid()` (`WebExtensionContextCocoa.mm:1105-1114, 1251-1260`; `WebExtensionTabCocoa.mm:373`), i.e. the set built by `populateWindowsAndTabs()` at load plus `didOpenTab`/`didCloseTab` afterwards — not a live query of the app's window provider. A `Tab` followed by a stray `didCloseTab` fails with `runtime.sendMessage(): tab not found` before any wake-up (`WebExtensionContextAPIRuntimeCocoa.mm:141-147`). Directly testable from the app: `context.openTabs.contains { $0 === tab }`.
Three production-reachable app triggers have now been identified: `Close Other Tabs` closes its retained loaded tab without reopening it; popup `Duplicate Tab` aliases the original tab into the main window and popup close forgets it; closing a window reports shared pinned tabs as closed. The first two are traced in source below; the shared-pinned variant is manually reproduced.
### 5. Repeatable registration failure (SW script unreadable)
The retained-failed-view state becomes persistent only if each retry fails. Only repeatable cause found: `background-embedded.js` is read from disk on every SW load, uncached (`WebExtensionURLSchemeHandlerCocoa.mm:120`, `WebExtension::resourceDataForPath` default `CacheResult::No`). If the installed extension directory is removed/unreadable while loaded, every retry → `NSURLErrorFileDoesNotExist``completion(false)`. Content scripts (read at context load) keep running → exactly "content script talks, background silent". Unlike chains 12, `errors` contains `BackgroundContentFailedToLoad` on every retry. No in-app path deletes a loaded extension's files; external causes only. Low probability.
Repro: with the extension loaded, `mv` the `public/js/background-embedded.js` inside `~/Library/Application Support/DuckDuckGo/…/<uuid>/`; wait for the worker to be evicted (30 s idle) or force via memory pressure; load a CMP page.
### 6. Transient registration failure (retained failed view, self-healing)
Debug-simulation shape. Any one-off `_loadServiceWorker` failure (Network-process disconnect mid-registration, etc.) retains the view, but the second message arms the eviction timer and the third message (≥30 s later) recovers. Visible as at most ~30 s of failure plus one extra navigation. Low impact.
### 7. Listener-gating freeze
`RuntimeOnMessage` absent from `m_backgroundContentEventListeners` after first load would drop every message before wake-up. Counts only decrement via JS `removeListener`, and the bundle never removes `onMessage`. No trigger found; theoretical.
### Discriminating the three observable classes
| Probe result after a stuck episode | Class |
|---|---|
| `errors == []`, probe `runtime.sendMessage` resolves `undefined`, no "Tab not found" log, SW inspector target gone | Dead worker under loaded view (chains 1, 2) |
| `errors` contains `BackgroundContentFailedToLoad`, repeating every ~30 s | Repeatable registration failure (chain 5) |
| Content script rejected with `tab not found`, log "Tab not found for message for content script message" | Routing (chain 4) |
## Diagnostics: what we have and what WebKit lets us add (2026-09-09)
### What Site Breakage Reports carry today
`BrokenSiteReport.swift:309-321` serialises `CookieConsentInfo`:
| Param | Source | Discriminating power for the stuck state |
|---|---|---|
| `consentManaged`, `consentOptoutFailed`, `consentSelftestFailed`, `consentReloadLoop`, `consentRule`, `consentHeuristicEnabled` | background → native `refreshDashboardState` | Only populated if the background processed `init`. In every stuck chain they stay at defaults. |
| `cpmDashboardState` | `.waiting` set per navigation (`PrivacyDashboardTabExtension.swift:185`), `.applied` when the background answers | `waiting` = "background never processed `init` for this document". Cannot say why. |
| `cpmStage` | background (`not_started`, `config_unavailable`, `settings_missing`, `setting_disabled`, `site_disabled`, `init_received`, `popup_found`, `optout_failed`, `done`) | `not_started` in every stuck chain (it is the app default, never overwritten). |
| `cpmErrors` | background: `tab_<method>` / `glob_<method>` native-messaging timeouts, `multiple_cmps` (`background-embedded.js:2926, 2803`) | Background → native failures only. Empty in every content→background failure. |
| `cpmQueueSize`, `cpmConfigVersion` | background | Same — absent when the background is silent. |
| `cpmExtensionLoaded` | `controller.extensionContexts` contains the embedded type (`WebExtensionManaging.swift:174`) | UIProcess "context loaded". **True in chains 1, 2, 4, 5** — it does not see a dead worker, a retained failed view, or a missing tab. Only false during the Fire/reload window. |
| `cpmExtensionDroppedCallbacks` | `WebExtensionEventsListener` calls made while `controller == nil` | Only meaningful for chain 4 (missed `didOpenTab`), and only if the drop happened while `controller` was nil. |
Net: a stuck-state report reads `cpmDashboardState=waiting, cpmStage=not_started, cpmExtensionLoaded=1, cpmErrors=""` for chains 1, 2, 4, 5 alike, and also for "content script never injected" (chain 3). **Nothing in the report separates the five.** Everything below is about adding that separation.
### Hooks per hypothesis
Legend: **P** = public API, **S** = SPI (same class as the `_webExtensionController:didCreateBackgroundWebView:` selector the app already uses), **L** = readable from the app's own `OSLogStore`, **E** = extension (JS) change.
#### Chain 1/2 — worker terminated under a "loaded" background view
WebKit gives the app nothing directly: no delegate, no notification, no error. Indirect signals:
- **S** `WKWebExtensionContext._backgroundWebView` (`WKWebExtensionContextPrivate.h:38`), or keep the `WKWebView` handed to `didCreateBackgroundWebView` (the app already receives it). Then, with **P** `callAsyncJavaScript` on that view (it is the generated SW *page*, main world):
`const r = await navigator.serviceWorker.getRegistration(); return { hasRegistration: !!r, state: r?.active?.state ?? null, controller: !!navigator.serviceWorker.controller }`
`hasRegistration == false` while `context.errors` is empty ⇒ **chain 2** (registration was in-memory in the dead Network process).
`hasRegistration == true, state == "activated"` while the content-script probe (below) gets `undefined`**chain 1** (worker killed, registration intact). The client-side `ServiceWorker.state` does not reflect termination, so this needs the probe pair, not the state alone.
Pixel params: `cpmBgViewExists`, `cpmBgSwRegistration`, `cpmBgSwState`.
- **S** `WKWebsiteDataStore.default()._networkProcessIdentifier` / `_networkProcessExists` (`WKWebsiteDataStorePrivate.h:124-126`, macOS 12+). Record at context load; on a stuck episode compare. PID changed ⇒ Network process restarted ⇒ chain 2. Param: `cpmNetworkProcessRestarted` (+ seconds since).
- **L** `NetworkProcessProxy::didClose` logs `RELEASE_LOG_ERROR(Process, "... NetworkProcessProxy::didClose (Network Process %d crash)")` (`NetworkProcessProxy.cpp:525`) and `didBecomeUnresponsive` logs an error too (`:183`). These run **in the app process**, so `OSLogStore(scope: .currentProcessIdentifier)` with predicate `subsystem == "com.apple.WebKit" AND category == "Process"` returns them without entitlements. Param: `cpmNetworkProcessCrashCount` in the last N minutes.
- **P** app-side `DispatchSource.makeMemoryPressureSource(eventMask: [.warning, .critical])` — the same kernel event the WebProcess reacts to in chain 1. Record timestamp of last `.critical`. Params: `cpmMemPressureCriticalRecently`, `cpmSecondsSinceCritical`. This is the single most valuable new field for chain 1 and costs nothing.
- **S** `WKWebView._webProcessIdentifier` (`WKWebViewPrivate.h:226`) on the retained background view: `0` ⇒ SW-page process gone (WebKit *does* handle that case via `webViewWebContentProcessDidTerminate`, so it should be rare — useful as a sanity check). Param: `cpmBgProcessAlive`.
- **P** `WKWebExtensionContext.loadBackgroundContent(completionHandler:)` — returns immediately with `nil` in chains 1/2 (WebKit thinks it is loaded), with the load error in chain 5, never/late in chain 6. Param: `cpmBgLoadProbe` = `ok | error:<code> | timeout`.
- **E + P** Content-script probe: evaluate in the extension's content world from the app — `WKContentWorld.world(name: "WebExtension-\(context.uniqueIdentifier)")` maps to the same `API::ContentWorld::sharedWorldWithName` WebKit uses for injection (`WebExtensionContextCocoa.mm:293`, `WKContentWorld.mm:59-64`). Have `cpm.js` publish `globalThis.__ddgCPM = { injectedAt, lifecycle, initSentAt, initResp: bool, lastSendError }` (a few lines; `sendContentMessage` currently `await`s without a catch, `cpm.js:3393-3398`). Then `evaluateJavaScript("globalThis.__ddgCPM", in: nil, in: world)` from the tab yields:
`undefined` ⇒ content script never injected ⇒ **chain 3** (startup/Fire window) — `cpmContentScriptInjected=0`.
`lifecycle == "waitingForInitResponse"`, `lastSendError == null` ⇒ background silent ⇒ chains 1/2/5 (use the other params to split).
`lastSendError` contains `tab not found`**chain 4**.
Do not use `typeof chrome` as the marker: evaluating in the world can itself trigger `globalObjectIsAvailableForFrame` and inject the `browser` namespace.
**Recovery, not just diagnosis (chain 1):** from the SW page, `r.active.postMessage({type:"ping"})` goes through the SW server (`SWServer::runServiceWorkerIfNecessary`, `SWServer.cpp:1221`), which starts a not-running worker. On start, `ServiceWorkerGlobalScope::notifyServiceWorkerPageOfCreationIfNecessary` re-binds the page and fires `dispatchServiceWorkerGlobalObjectAvailable``serviceWorkerGlobalObjectIsAvailableForFrame` re-installs `browser` (`ServiceWorkerGlobalScope.cpp:118-132`, `WebLocalFrameLoaderClient.cpp:1968-1980`) and the background script re-runs, re-registering `onMessage`. That would revive CPM without unloading the context. Needs a manual test; if it works it is a one-line remediation on `cpmMessagingStuck`. It cannot help chain 2 (no registration) — that needs the context reload.
#### Chain 3 — content script not injected (startup / Fire / reload window)
- **P** `WKWebExtensionContext.isLoaded` and `hasInjectedContent(for: url)` at `navigationCommitted`. Param: `cpmContextLoadedAtCommit`, `cpmInjectedContentForURL`.
- **E + P** the content-world probe above (`cpmContentScriptInjected`).
- **P** timing: `Date()` at `controller.load(context)` minus the first `loadBackgroundContent` completion = time the `_renameOrigin` gate held the load. Param: `cpmContextReadyMs`. Expect this to collapse once `baseURL` is set.
- Already available: `WebExtensionLifecycleEvent` stream (`loaded/willReload/reloaded/reloadFailed`) — attach `cpmMsSinceLastReload` to the report.
#### Chain 4 — tab not resolvable
- **P** `WKWebExtensionContext.openTabs` (`WKWebExtensionContext.h:683`) → `openTabs.contains { $0 === currentTab }`. This is exactly the set `getCurrentTab` searches. Param: `cpmTabKnownToWebKit`. Also `openWindows.contains(windowController)`: `cpmWindowKnownToWebKit`.
- **L** `RELEASE_LOG_ERROR(Extensions, "Tab not found for message for content script message")` (`WebExtensionContextAPIRuntimeCocoa.mm:144`) is emitted in the app process → `OSLogStore` predicate `category == "Extensions" AND eventMessage CONTAINS "Tab not found"`. Param: `cpmTabNotFoundLogCount`.
- Already available: `cpmExtensionDroppedCallbacks` (keep).
#### Chain 5 — repeatable registration failure
- **P** `WKWebExtensionContext.errors` + `WKWebExtensionContext.errorsDidUpdateNotification` (`WKWebExtensionContext.h:73, 179`). Serialise `code` (`WKWebExtensionContextErrorBackgroundContentFailedToLoad = 6`) and the underlying `NSError` domain/code (`NSURLErrorFileDoesNotExist`, etc.). Param: `cpmContextErrors` = `"6:NSURLErrorDomain/-1100"`.
- **S** count `didCreateBackgroundWebView` callbacks per context lifetime: >1 means WebKit is retrying background loads. Param: `cpmBgViewCreateCount`.
- **L** `WebExtensionContext::recordError` logs `RELEASE_LOG_ERROR(Extensions, "Error recorded: …")` (`WebExtensionContextCocoa.mm:283`) — redundant with `errors`, but timestamps each retry.
- **P** `FileManager.default.isReadableFile(atPath: installedPath/public/js/background-embedded.js)` at report time. Param: `cpmBundleReadable`.
#### Chain 6 — transient registration failure
Covered by `cpmContextErrors` + `cpmBgViewCreateCount`; the health monitor's `recoveredWithoutExtensionReload` closes it.
#### Chain 7 — listener gating
No hook; `m_backgroundContentEventListeners` is not exposed. If everything else reads healthy (`cpmBgSwRegistration=1`, content-script probe shows `waitingForInitResponse`, `errors == []`, no memory pressure, no Network restart, background revives on `postMessage`), this is the residual bucket.
### What is *not* catchable from the app
- SW-server side logs (`SWServer::terminateIdleServiceWorkers`, `runRegisterJob`) live in the Network process; `OSLogStore` only covers our own PID without the `com.apple.private.logging.*` entitlement. `log collect` from users' machines is the only route.
- `RELEASE_LOG_DEBUG` lines ("Loading background content", "Background content loaded", "Scheduling background content to unload") are debug level and not persisted unless the device has WebKit logging enabled; do not depend on them.
- There is no notification for background-view unload/reload or for worker start/stop.
### Suggested minimal pixel/report additions (ranked by value ÷ cost)
1. `cpmMemPressureCriticalRecently` + `cpmSecondsSinceCritical` — public, in-app, ~20 lines. Separates chain 1.
2. `cpmNetworkProcessRestarted` (PID compare) or `cpmNetworkProcessCrashCount` (OSLogStore) — separates chain 2.
3. `cpmContextErrors` + `cpmBgViewCreateCount` — separates chains 5/6.
4. `cpmTabKnownToWebKit` + `cpmTabNotFoundLogCount` — separates chain 4.
5. `cpmContentScriptInjected` / `cpmContentScriptLifecycle` / `cpmLastSendError` via the content-world probe — separates chain 3 and confirms "background silent" for the rest. Needs the small `cpm.js` global.
6. `cpmBgSwRegistration` / `cpmBgSwState` via the SW-page probe — confirms 1 vs 2. SPI-adjacent (needs the background `WKWebView` reference).
Items 14 are enough to attribute the current stuck pixels; 56 turn the health monitor into a classifier instead of a timer.
## Investigation update: rejected triggers and JavaScript paths (2026-09-09)
### Rejected or demoted production triggers
- **Critical memory pressure is not an active reproduction candidate.** Running `sudo memory_pressure -S -l critical` did not break the extension and CPM continued working. The source-level termination path remains real, but the proposed user-reachable sequence was not reproduced on the target system and must not be ranked as the most probable cause without new evidence.
- **Cold launch before WebKit fix `7682d9817b` is transient.** It reproduces a missed initial message but recovers on the next navigation. It does not explain persistent cross-tab breakage.
- **Feature flags are not toggled for production users.** Flag transition scenarios are test controls, not plausible production triggers.
- **Privacy-config scriptlet refresh does not reload the embedded CPM extension.** `ScriptletConfigProvider.privacyFeature(for:)` maps only `.adBlockingExtension`; `.embedded` returns `nil`. The update chain (`privacyConfigurationManager.updatesPublisher` -> `ScriptletManager.refreshAllActiveExtensions` -> `WebExtensionScriptletCoordinator` -> `reloadExtension(.adBlockingExtension)`) unloads/reloads only the ad-blocking extension context. CPM config itself is fetched by the already-running embedded background worker and does not replace that context.
### Can JavaScript in a site freeze CPM?
A synchronous infinite loop or very long task in page JavaScript blocks the tab's WebContent main thread. CPM runs in an isolated world but on that same thread, so its content script in that document also stops. This explains a per-tab failure, not an extension-wide background freeze.
The background view is not related to an ordinary tab. `WebExtensionContext::relatedWebView()` enumerates only extension-origin pages; `_relatedWebView` is weak and is used for process grouping. Therefore a normal site's JavaScript does not place the hidden background view in the same WebContent process through this mechanism.
Page JavaScript can affect the background indirectly by generating extension traffic. CPM is injected with `all_frames: true`; every new or navigated frame sends `init`. A page that continuously creates, destroys, or navigates frames can produce an unbounded stream of `runtime.sendMessage` events. For each frame the background starts an async CPM handler, performs native queries, filters compact rules, and sends a rules/config payload back. There is no global admission limit or backpressure. This can:
1. keep restarting WebKit's 30-second background eviction timer;
2. create many concurrent async handlers and native requests;
3. monopolize the background worker event loop and delay messages from all tabs.
This is currently a **starvation hypothesis**, not a permanent-state explanation. Once the frame storm stops, queued work should drain and the worker should recover unless the load/event bookkeeping enters a separate WebKit failure state. A stable repro should therefore compare behavior while the producer tab is open, after it is closed, and after 35 seconds with no extension events.
An arbitrary unresolved `Promise` does not block a JavaScript event loop. To freeze the background worker itself requires synchronous non-yielding work, runaway task production faster than it can drain, or a WebKit/JSC deadlock.
The current `compactRuleList` URL regular expressions were checked as a concrete synchronous-hang candidate: 569 unique patterns were matched against adversarial URLs up to 100 KB in isolated workers. No match exceeded 0.27 ms and none timed out. Catastrophic regexp backtracking is therefore not supported by the current config.
### Background-worker JavaScript freeze
A synchronous long task inside `background-embedded.js` does match the all-tabs symptom:
```text
background event loop stops
-> every tab's RuntimeOnMessage is dispatched to the same worker
-> handlers cannot execute or reply
-> no initResp and no native dashboard update
-> incoming events keep the background view's eviction timer refreshed
-> all tabs remain affected while traffic continues
```
WebKit does not automatically terminate this particular unresponsive process. `WebProcessProxy::didBecomeUnresponsive` auto-terminates only a worker-only process whose `m_pageMap` is empty. The extension service worker is hosted through a hidden `WKWebView`, so the process has a page and WebKit only reports unresponsiveness to the navigation client. DuckDuckGo has no handler for the private unresponsive callback. Current bundle inspection found no reachable infinite loop at worker startup or in CPM message routing; the iframe/message storm remains the concrete production-input candidate.
### What retains the background WKWebView
There is one direct WebKit owner:
```text
WKWebExtensionController
-> WebExtensionController::m_extensionContexts
-> WebExtensionContext::m_backgroundWebView (RetainPtr<WKWebView>)
```
The app also keeps the controller/context loaded for the browser lifetime. The view is released only when `WebExtensionContext::unloadBackgroundWebView()` closes it and assigns `nil`, or when the whole context is unloaded.
Additional lifetime mechanisms are:
- `m_backgroundWebViewActivity` holds a foreground process activity assertion. It keeps the WebContent process runnable; it is not another `WKWebView` owner.
- The background configuration disables hidden-page timer throttling, visibility-based process suppression, and inactive scheduling. These preserve execution, not object ownership.
- A pending `_loadServiceWorker` completion captures `protectedThis = Ref { *this }`; `WebPageProxy::serviceWorkerLaunchCompletionHandler` owns that block. This can keep the **context** alive if registration never settles. The context then keeps `m_backgroundWebView` alive. In the normal controller-loaded case this is an additional cycle, not the root owner.
- Pending `m_actionsToPerformAfterBackgroundContentLoads` closures retain their message completions and often a protected context until the background load completes or the context unloads. The vector belongs to the context; it does not independently own the view.
- Open active extension ports, pending permission requests, active inspection, an inspector background page, and repeated extension events cause `unloadBackgroundContentIfPossible()` to postpone or continually re-arm eviction. They do not add a strong view reference, but they make the context retain its view longer.
- `WKWebViewConfiguration._relatedWebView`, the background navigation/UI delegates, `WebPageProxy::cocoaView`, and the CPM diagnostics recorder are all weak in the relevant direction. The recorder stores `currentBackgroundWebView` weakly and its live-view list uses `WeakBox`; it does not perturb the lifecycle.
The practical distinction is: almost everything suspected of "retaining" the view merely prevents `unloadBackgroundWebView()` from being called. The only direct strong view reference found in WebKit's extension layer is `WebExtensionContext::m_backgroundWebView`.
## Next steps (2026-09-09)
Ordered so each step de-risks the next. Days are rough effort, not calendar.
### Phase 0 — confirm trunk matches shipping WebKit (½ day, manual, no code)
1. Debug sim persistence: enable "fail CPM background load", load one CMP page (fails), wait 35 s without navigating, load again. Expected: recovers. If it stays broken, shipping WebKit lacks the idle-eviction-on-wake-up path and chain 5/6 ranking changes.
2. Chain 1 repro: launch, wait 15 s, `sudo memory_pressure -S -l critical` for ~5 s, load a CMP page. Expected: no banner handling, `errors` empty, SW inspector target gone, recovery after 35 s idle.
3. Chain 2 repro: `kill -9` DDG's `com.apple.WebKit.Networking`, load a CMP page. Same expectation.
4. Chain 1 revive test: after step 2, in the background `WKWebView` (grab it from `didCreateBackgroundWebView` behind a debug flag) run `navigator.serviceWorker.getRegistration().then(r => r.active.postMessage({type:"ping"}))`, then load a CMP page. If CPM works again, we have a remediation that avoids the reload window.
Outcome: which chains are real on macOS 26.x, and whether `postMessage` revive is viable.
### Phase 1 — attribution in existing pixels (23 days)
Add to `CPMMessagingHealthMonitor` failure/stuck/recovered pixels and to `BrokenSiteReport`:
1. `cpmMemPressureCriticalRecently` / `cpmSecondsSinceCritical` — in-app `DispatchSource.makeMemoryPressureSource`.
2. `cpmNetworkProcessRestarted` — compare `WKWebsiteDataStore.default()._networkProcessIdentifier` against the value recorded at context load (SPI; fall back to OSLogStore crash-line count if SPI is off the table).
3. `cpmContextErrors` (`errors` codes + underlying NSURLError) and `cpmBgViewCreateCount` (count `didCreateBackgroundWebView`).
4. `cpmTabKnownToWebKit` (`context.openTabs.contains(tab)`) and `cpmTabNotFoundLogCount` (OSLogStore, own PID, `category == "Extensions" AND eventMessage CONTAINS "Tab not found"`).
5. `cpmContextLoadedAtCommit`, `cpmMsSinceLastReload`.
Ship behind the existing pixel plumbing; no extension change needed. After one release the stuck pixels split into chains 1 / 2 / 3 / 4 / 5.
### Phase 2 — probes that classify at failure time (23 days, needs `cpm.js` + app)
1. `cpm.js`: publish `globalThis.__ddgCPM = { injectedAt, lifecycle, initSentAt, gotInitResp, lastSendError }`; wrap the `sendMessage` `await` in try/catch to capture `tab not found`.
2. App: on `cpmInitializationFailed`, evaluate `globalThis.__ddgCPM` in `WKContentWorld.world(name: "WebExtension-\(uniqueIdentifier)")` for the failing tab; attach `cpmContentScriptInjected`, `cpmContentScriptLifecycle`, `cpmLastSendError`.
3. App: on `cpmMessagingStuck`, run the SW-page probe on the background web view (`hasRegistration`, `active.state`) and `loadBackgroundContent` with a 2 s timeout; attach `cpmBgSwRegistration`, `cpmBgLoadProbe`.
Outcome: health monitor becomes a classifier; every stuck pixel names its chain.
### Phase 3 — fixes (in order of confidence)
1. **Stable base URL** (½ day): `context.baseURL = URL(string: "webkit-extension://\(identifier)/")` in `WebExtensionLoader.makeContext`. Removes `_renameOrigin` from every load, shrinks the startup miss window (chain 3), and stops migrating extension web storage on every launch. Verify existing `storage.local` is unaffected (it is SQLite, not origin-keyed web storage) and that the first launch after the change performs one final rename from the last random URL.
2. **Self-heal on stuck** (1 day): on `cpmMessagingStuck`, if Phase 0 step 4 works → `postMessage` revive first; else `reloadExtension(identifier:, trigger: .healthRecovery)`. Gate by a 5-minute cooldown; fire `cpmMessagingRecoveredAfterExtensionReload` (already exists).
3. **Content-script retry** (½ day, `cpm.js`): if no `initResp` within ~3 s, resend `init` (max 23 times, backoff). Covers chain 6 and any transient drop, and gives chain 1/2 a natural retry after the 30 s eviction instead of waiting for the next navigation.
4. **Keep the worker alive** (research, 1 day): the SW server only refreshes `needsRunning()` on server-routed events. A background `chrome.alarms` tick does not go through the SW server either. Options: (a) from the SW page, periodic `registration.active.postMessage` keep-alive — would also count as "not idle" for `terminateIdleServiceWorkers`; (b) file a WebKit bug: extension service workers should be exempt from `terminateIdleServiceWorkers`/`terminateServiceWorkers`, or `WebExtensionContext` should observe worker termination and unload/reload the background view. (b) is the real fix; (a) is the interim.
5. **Tab registration audit** (only if Phase 1 shows chain 4 volume): log every `didOpenTab`/`didCloseTab` with tab id in debug builds and diff against `context.openTabs` on stuck.
### WebKit bug reports to file (after Phase 0 confirms)
1. Extension background service worker terminated by `terminateIdleServiceWorkers` / `terminateServiceWorkers` while `WebExtensionContext` keeps `m_backgroundContentIsLoaded == true`; messages resolve `undefined` with no error. Attach chain 1 and 2 repro steps.
2. `_loadServiceWorker` failure path does not schedule `scheduleBackgroundContentToUnload()`, so recovery depends on a second message arriving (minor).
3. Documentation/API request: a delegate callback or notification for background content load/unload and worker start/stop.
## Status 2026-09-09 (evening)
### Manual checks on shipping WebKit (macOS 26.x)
| Check | Result | Meaning |
|---|---|---|
| Debug sim, wait 35 s idle, navigate | **recovered** | Idle eviction in `wakeUpBackgroundContentIfNecessary` is present in shipping WebKit; retained-failed-view is self-healing, as traced. |
| `sudo memory_pressure -S -l critical`, then CMP page | **CPM kept working** | Chain 1 not reproduced. Either `memoryPressureStatusChangedForProcess → TerminateIdleServiceWorkers` is not in this WebKit (it is under `ENABLE(WEB_PROCESS_SUSPENSION_DELAY)`, recent), or `-S` did not reach the WebContent process. Verify with `log stream --predicate 'process CONTAINS "WebKit.Networking" AND eventMessage CONTAINS "terminateIdleServiceWorkers"'`; until then chain 1 is downgraded to "future macOS". |
| Network process kill (Debug → "Terminate WebKit Network Process", `_terminateNetworkProcess`) | **CPM kept working** | Chain 2 not reproduced either. Snapshot after the kill: `network_process_restarted=true`, `extension_context_errors=none`, `background_view_alive=true`, `background_view_create_count=3_to_5`. The background view had been recreated during the session, so WebKit recovers on its own here — via idle eviction or via `webViewWebContentProcessDidTerminate`/`didFailNavigation` of the SW page; which one is not yet known (recorder now logs web-process PID and Network PID per creation to tell them apart). WebKit XPC processes are launchd children (ppid 1), so they cannot be found by parent PID. |
**Conclusion so far:** on shipping macOS 26.x WebKit, none of the traced "dead worker" triggers reproduce and the retained-failed-view state self-heals. There is currently **no reproduced trigger** for the stuck state; the chains below remain hypotheses ranked by source reading, not by evidence. Fix proposals are parked until a reproduction or production classification exists.
### Implemented (branch `cpm-breakage-pixel`, not yet compiled here — build before trusting)
`SharedPackages/WebExtensions`:
- `Autoconsent/CPMMessagingDiagnostics.swift` — the fact struct and its bucketed, PII-free `pixelParameters`.
- `Autoconsent/CPMMessagingDiagnosticsRecorder.swift``CPMMessagingDiagnosticsProviding` + recorder: critical-memory-pressure dispatch source; Network-process PID at context load vs now (`_networkProcessIdentifier` via guarded KVC); `errorsDidUpdateNotification` observer; background web view create count, current view (weak), leak count via `onDeinit` and a weak list, `_webProcessIdentifier` liveness; `openTabs` membership; probes: `loadBackgroundContent` with timeout, `navigator.serviceWorker.getRegistration()` in the background page, `globalThis.__ddgCPM` in `WKContentWorld.world(name: "WebExtension-<uniqueIdentifier>")`.
- `CPMMessagingHealthMonitor.diagnosticsProvider` — failure pixels get content-script probe + snapshot; stuck pixels also get background probes. Without a provider behaviour is unchanged (tests rely on that).
- `WebExtensionPixelEvent.cpmInitializationFailed/.cpmMessagingStuck` carry `diagnostics:`; `CPMWebExtensionPixelMetadata.parameters` renders them.
- `WebExtensionManager`: owns the recorder, feeds `willLoad`, unload (uninstall / unloadAll / reload) and the private `didCreateBackgroundWebView` delegate into it.
`macOS`:
- `WebExtensionManagerFactory` creates the recorder with a `Tab.uuid``Tab` resolver (`WindowControllersManager.loadedTab(withUUID:)`, new).
- Debug menu: "Terminate WebKit Network Process", "Print CPM Diagnostics Snapshot".
Tests: `CPMMessagingDiagnosticsTests` (bucketing, sanitization, descriptors, recorder lifecycle), `CPMMessagingHealthMonitorDiagnosticsTests` (async attachment, probe levels).
### Pixel parameters now attached
`extension_context_loaded`, `memory_pressure_critical` (none/under_1_min/under_5_min/under_30_min/over_30_min), `network_process_restarted`, `extension_context_errors` (`background_failed_to_load:NSURLErrorDomain:-1100` style, or `none`), `background_view_create_count` (0/1/2/3_to_5/over_5), `background_view_alive`, `background_view_leaked_count`, `background_web_process_alive`, `tab_known_to_webkit`, `content_script` (present/marker_absent/unavailable), `content_script_lifecycle`, `content_script_init_response`, `content_script_send_error` (none/tab_not_found/extension_id_mismatch/other); stuck only: `background_load_probe` (loaded/failed:<code>/timed_out/unavailable), `background_sw_registration` (present/missing), `background_sw_state`.
### Required extension change (probe 1 reads `marker_absent` until this ships)
In the autoconsent content script (`cpm.js` source, `shared/js/cpm.js` in the extension repo), publish the marker on the isolated-world global:
```js
globalThis.__ddgCPM = { injectedAt: Date.now(), lifecycle: 'created', receivedInitResponse: false, lastSendError: null };
const consent = new AutoConsent(async (msg) => {
try {
await chrome.runtime.sendMessage({ messageType: 'autoconsent', autoconsentPayload: msg });
} catch (e) {
globalThis.__ddgCPM.lastSendError = String(e && e.message || e);
}
});
chrome.runtime.onMessage.addListener((message) => {
if (message && message.type === 'initResp') globalThis.__ddgCPM.receivedInitResponse = true;
return Promise.resolve(consent.receiveMessageCallback(message));
});
// in AutoConsent.updateState({ lifecycle }): globalThis.__ddgCPM.lifecycle = lifecycle;
```
Keys read by the recorder: `lifecycle` (string), `receivedInitResponse` (bool), `lastSendError` (string|null). Values are sanitized/classified before reaching the pixel.
### Added after the Network-kill check
- Recorder logs (level `info`, `[CPM Diagnostics]`) on every background view creation: `previousViewAlive`, `previousWebProcessPID`, `previousWebProcessAlive`, `secondsSincePreviousCreation`, `networkProcessPID`, `networkProcessChangedSinceLastCreation`, then the new view's `webProcessPID` one turn later. A recreation with `previousWebProcessAlive=false` or a changed web-process PID means the SW page process died (WebKit's `webViewWebContentProcessDidTerminate` path); `previousWebProcessAlive=true` with ≥30 s gap means idle eviction.
- "Terminate WebKit Network Process" prints a diagnostics snapshot before killing, so before/after can be compared.
- No event hook exists for Network-process termination in the app; WebKit's own `NetworkProcessProxy::didClose` error line is visible in Console.app (`subsystem == "com.apple.WebKit"`, category `Process`) but is not captured by the recorder.
### Next (data first, no fixes yet)
1. Repeat the Network-kill with the new logging; read whether the recreation right after the kill is process-death or eviction.
2. Build, run `WebExtensions` tests, land the diagnostics; add the `__ddgCPM` marker to `cpm.js`.
3. Read `debug_web_extension_cpm_messaging_stuck_*` parameters after a release to see which class exists in production.
4. Show the diagnostics parameters in the internal-user stuck notification so reports arrive classified.
### Not done on purpose
- OSLogStore-based counters (per request).
- iOS wiring (factory passes no recorder; behaviour unchanged there).
- Breakage-report fields — `CPMMessagingDiagnosticsRecorder.snapshot().pixelParameters` is ready to be merged into `CookieConsentInfo` when wanted.
## New failure class: background → app native messaging (2026-09-09, late)
Found by reading the actual `init` handler in `background-embedded.js` instead of WebKit. This class needs **no WebKit anomaly** and produces the exact all-tabs symptom and the exact health-monitor "stuck" signature.
### Mechanism (extension side, `background-embedded.js`)
`handleAutoConsentMessage` (2537) awaits three native requests before it ever sends `initResp`: `remoteConfigJson` (`getResourceIfNew`, 2555), `checkAutoconsentSetting` (`isAutoconsentSettingEnabled`, 2590), `checkAutoconsentEnabledForSite` (`isFeatureEnabled`, 2613). `_request` (2954) swallows any error or 20 s timeout and returns `undefined`; `checkAutoconsentSetting` turns that into `{ enabled: false }` (3040) → heuristic mode `""`**"autoconsent setting not enabled" → `return` without `initResp`** (2592-2606). The follow-up `refreshDashboardState(setting_disabled)` is itself a native call, so when native messaging is broken nothing reaches the app → the health monitor sees no dashboard response → `cpmInitializationFailed``cpmMessagingStuck`. Every tab, every navigation, until the extension is reloaded. Content↔background works fine the whole time, so all WebKit-side probes read healthy.
### App side: how native messaging breaks
`WebExtensionContext::sendNativeMessage` calls the controller delegate directly (`WebExtensionContextAPIRuntimeCocoa.mm:338`), no permission or app-id check. The app (`WebExtensionManager+NativeMessaging.swift:122-151`) routes by `(context.uniqueIdentifier, featureName)` and returns **`nil`** on `.noHandler` and `.failure`; the extension turns `nil` into `throw "unexpected response type"``undefined` → disabled. Handlers exist only if `willLoad` registered them for the live context's identifier.
Paths that remove handlers while a context stays loaded:
- `reloadExtension(identifier:)` (`WebExtensionManager.swift:414-449`): unload → `unregisterHandlers``loadWebExtension`. `WebExtensionLoader.loadWebExtension` has an idempotent branch (`WebExtensionLoader.swift:70-84`): if a context with that identifier is already in the controller it returns **without calling `willLoad`** → handlers stay unregistered. Reachable when another load of the same identifier interleaves between the unload and the load (e.g. scriptlet-triggered `reloadExtension(for:)` from `WebExtensionScriptletCoordinator.swift:165` racing the Fire `reloadInstalledExtensions`; the coordinator serializes only its own ops). Pinned by `testWhenExtensionIsAlreadyLoaded_ThenSecondLoadDoesNotCallDelegate`.
- Any future code path calling `unregisterHandlers` without a matching `willLoad`.
Also possible without unregistering: a handler that never replies (delegate is `async`; a hang on the main actor) → 20 s timeout per request → three requests per `init` → same "disabled" outcome ~60 s later.
### Deterministic reproduction (added)
Debug → Web Extensions → **Break CPM Native Messaging (unregister handlers)** (`WebExtensionManager.unregisterNativeMessageHandlersForDebugging`). Then load a CMP page in any tab/window: banner not handled everywhere; Console shows `⚠️ No handler for extension '<uuid>' feature 'autoconsent'`; snapshot shows `native_handler_registered=false` with everything else green; health monitor fires `initialization_failed` then `stuck`. Reload the extension from the debug menu → recovers. This is the first reproduction that matches the production signature end to end.
### Diagnostics added
`native_handler_registered` (router lookup identical to `routeMessage`) in snapshot/pixels. In production stuck pixels, `native_handler_registered=false` = this class; `=true` with all probes green points at the timeout variant (handler hang) or at the content-script marker being absent.
### What this does and does not prove
Proves the class produces the symptom and that the code has a path (idempotent load after unregister) that leaves the live context without handlers. Does **not** yet prove that path fires in production — that is what `native_handler_registered` in the stuck pixels and Console `No handler for extension` lines on internal machines will tell.
## Correction and a traced production path (2026-09-09, night)
### What "Break CPM Native Messaging" is and is not
It only reproduces the **end state** (context loaded, no native handlers). It does not model Fire or any other real path. Traced every `unregisterHandlers` / `willLoad` pairing for the CPM context: Fire (`unloadAllExtensions``reloadInstalledExtensions`/`loadInstalledExtensions`), `syncEmbeddedExtensions` upgrade, launch `loadAndSync`, flag-off uninstall — all re-register or unload the context together. The idempotent-load race needs a concurrent `reloadExtension(identifier:)`, which in production only the scriptlet coordinator issues, and scriptlets exist **only for `adBlockingExtension`** (`ScriptletConfigProvider.swift:70-77`). **No production path to the no-handler state was found for CPM.** The class stays as a mechanism (native failure ⇒ background disables itself, no `initResp`) with only the "handler does not answer within 20 s" variant left, which is transient.
### Traced path that does reproduce "old tabs dead, new tabs fine": controller replacement
A tab's `WKWebViewConfiguration.webExtensionController` is set once, at tab creation, from `NSApp.delegateTyped.webExtensionManager?.controller` (`WKWebViewConfigurationExtensions.swift:74-76`). The manager — and with it the `WKWebExtensionController` — is created/destroyed by the `webExtensions` feature flag (`AppDelegate.swift:2063-2127`):
- flag **off**: `handleWebExtensionsFlagDisabled``uninstallAllExtensions()` (unloads contexts from controller A) → `webExtensionManager = nil`.
- flag **on**: `initializeWebExtensions()`**new** manager, **new** controller B → `loadAndSync` loads CPM into B.
Every tab created while the manager was nil has **no controller**; every tab created before an off→on cycle keeps **controller A with zero contexts**. In both cases nothing is ever injected into those tabs again: page reload does not help, the health monitor times out on every navigation (`extensionIsLoaded` reads the *new* manager → true → 4 s grace → `initialization_failed``stuck`), new tabs/windows work (`recovered_without_reload`). This is the only chain so far that reproduces the reported window-scoped observation.
The flag is `remoteReleasable(WebExtensionsSubfeature.featureEnabled)` (`FeatureFlag.swift:612`). It changes mid-session on any privacy-config refresh that flips it: **rollout ramp** (user enters the cohort while the app is running → all existing tabs, including the restored session, never get CPM), remote kill-switch off→on, internal-user overrides. `.removeDuplicates()` on the publisher means only real flips fire, but a single flip is enough.
Also covered by the same mechanism: tabs restored at launch before the manager exists (acknowledged in the code comment at `AppDelegate.swift:2088-2089`), when the flag is off at startup and turns on later.
### Reproduction (deterministic, uses existing internal debug menu)
1. Launch with the `webExtensions` flag ON. Open two tabs on CMP sites, confirm CPM works.
2. Debug → Feature Flags → turn `webExtensions` OFF, then ON again. (`uninstallAll` → manager nil → new manager/controller, extensions reinstalled.)
3. In the two old tabs navigate to CMP sites: banners not handled, reload does not help. `initialization_failed``stuck`.
4. Open a new tab/window with a CMP site: works; `recovered_without_reload`. Return to the old tabs: still broken, new failures → new episode.
Variant: start with the flag OFF, open tabs, turn ON — the tabs opened before never get CPM.
### Diagnostic added
`tab_controller_matches_context` = `tab.webView.configuration.webExtensionController === context.webExtensionController`. `false` in a stuck pixel is this chain, unambiguously. Combined with `native_handler_registered` and the WebKit probes this now separates: controller mismatch (this) / no native handler / registration failure / dead worker / routing.
### Open question that decides the fix design
Is CPM breakage in production reported for **old tabs only** (consistent with this chain) or for **new tabs too**? The stuck/recovered pixel sequence with `tab_controller_matches_context` answers it. Fix design deferred until that is known, per agreement.
### Controller-replacement chain: not triggered in production (checked 2026-09-09)
- `privacy-configuration/overrides/macos-override.json`: `webExtensions` block (`enabled`, `minSupportedVersion 1.183.0`, subfeature `embedded: enabled`) unchanged since June; no commit touched it.
- App flag `.webExtensions` = `remoteReleasable(webExtensions.featureEnabled)`, `defaultValue: .enabled`. `featureEnabled` is absent from the config → `.disabled(.featureMissing)` → default → `true` (`AppPrivacyConfiguration.swift:191-197`, `FeatureFlagger.swift:431-432`). Stable across launches and config reloads; `.removeDuplicates()` suppresses repeats. Only a parent-state change or an internal override can flip it.
- Browser update: bundle swap happens at relaunch; on first launch of the new version the old UUID loads, then `syncEmbeddedExtensions` installs/loads the new UUID into the same controller and unloads the old. Documents holding old-context content scripts lose messaging until their next navigation — transient. Handlers are keyed by UUID; nothing from the previous version lingers.
Status: memory pressure, Network-process death, lost native handlers and controller replacement are all either not reproducible on shipping WebKit or not triggered by production conditions. Retained-failed-view self-heals. **Next input must be production data**: `cpm_messaging_stuck_<reason>` split and recovered-with/without-reload ratio; breakage-report split of `cpmDashboardState=waiting` vs `applied` + `cpmStage ∈ {setting_disabled, config_unavailable, settings_missing}`.
## Traced production path: `removeAllUserScripts()` strips the extension's content scripts from a tab (2026-09-09, late night)
### Mechanism (WebKit)
WebKit injects an extension's content scripts into a `WKUserContentController` exactly once per controller: when the controller is first seen (`WebExtensionController::addUserContentController`, `WebExtensionController.cpp:303-320``WebExtensionContext::addInjectedContent(ucc)`, `WebExtensionContext.cpp:1459`) and again for all known controllers when the context (re)loads or its match patterns change. Nothing re-injects on navigation. Any app call to `WKUserContentController.removeAllUserScripts()` on a tab's controller therefore removes `cpm.js` (and the ad-blocking scriptlets, Dark Reader) from that tab **until the extension context is reloaded** (Fire) or the app relaunches. The tab's WKWebView, its controller and `hasInjectedContent` all look healthy; the script is just gone. Every later navigation in that tab: no `init`, `cpmDashboardState=waiting`, health monitor `initialization_failed``stuck` on the second navigation. Other tabs fine, new tabs fine, reload useless — the reported shape.
DDG already knows this hazard: `UserContentController.removeInstalledUserScripts()` (BSK, `UserContentController.swift:261-276`) uses the `_removeUserScript:` SPI on macOS explicitly "to avoid removal of web extension scripts". Two places bypass that guard.
### macOS: Duck.ai native-storage bootstrap refresh
`AIChatTabExtension.decidePolicy(for:)``refreshNativeStorageBootstrapIfNeeded` (`AIChatTabExtension.swift:277, 325-345`) → `DuckAiNativeStorageBootstrapScriptRefresher.refresh(on:staticScripts:)` (`DuckAiNativeStorageBootstrapScriptRefresher.swift:83-93`): **`userContentController.removeAllUserScripts()`**, then re-adds only DDG's `contentBlockingAssets.wkUserScripts` + the bootstrap script. Runs on every cross-document navigation to a `duck.ai` URL in any regular tab, gated by `aiChatNativeStorage` = `remoteReleasable(aiChat.nativeStorage)`, which is **at 100% on macOS since 2026-05-06** (`privacy-configuration` commit `ba4e44e03`, `macos-override.json:428-446`, minSupportedVersion 1.187.0).
Consequence: every tab in which the user has opened duck.ai loses CPM for the rest of the session. Users who use Duck.ai in a tab and then browse in that tab, or who keep several Duck.ai tabs and reuse them, see "CPM stopped working in these tabs, new tabs are fine, reload doesn't help, Fire fixes it".
### iOS: every content-blocking assets update
`removeInstalledUserScripts()` on iOS is the plain `removeAllUserScripts()` branch (`UserContentController.swift:272-274`), executed in `contentBlockingAssets.willSet` on **every** TDS / privacy-config / protections change (`UserContentController.swift:94-107`). On iOS 18.4+ with the embedded extension, every such update strips `cpm.js` from **every open tab** at once — the all-tabs variant, and it recurs several times a day. (Same for ad-blocking scriptlets.)
### Reproduction (macOS, deterministic, no code changes)
1. Open tab A on a CMP site — CPM works.
2. In tab A navigate to `https://duck.ai` (address bar).
3. In tab A navigate to a CMP site: banner not handled; reload does not help. Health monitor: `initialization_failed` then `stuck` on the next CMP navigation in the same tab.
4. Open tab B on a CMP site: works (`recovered_without_reload`); back to tab A: still broken.
5. Fire → tab A works again (`recovered_after_extension_reload`).
Verification without the marker: Safari Develop → DuckDuckGo → tab A → the "WebExtension-<uuid>" content world has no `cpm.js` after step 2; or check `webView.configuration.userContentController.userScripts` for a script whose `contentWorld.name == "WebExtension-<uuid>"`.
### Diagnostic added
`tab_has_extension_user_scripts` = `userContentController.userScripts.contains { $0.contentWorld.name == "WebExtension-<uniqueIdentifier>" }` (public API). `false` in a stuck pixel with `tab_controller_matches_context=true` is this chain.
### Other `removeAllUserScripts()` callers checked
`WebView.deinit` (own controller, tab dying — harmless), `UserContentController.cleanUpBeforeClosing` (tab closing), `SuggestionsReader.tearDown` (own web view), `NewTabPageUserContentController` (NTP-only controller), DBP/HeadlessWebView (own web views), iOS `DuckPlayerWebView` (own view). Only the two above touch a live browsing tab's controller.
### Answer to "can one crashed/hung page affect extensions in other tabs?"
Not through WebKit: the background service-worker page gets its own WebContent process (fresh/prewarmed process at creation, `_relatedWebView` is nil for CPM), content-script messaging is per tab/process, and a WebContent crash only drops that process's frames from the listener map. The only cross-tab couplings are app-side: the shared `WebExtensionManager` state (router, controller) and the background SW's single JS thread, whose per-tab handlers are independent except the `_stateQueue` (which only carries dashboard state, and catches errors). The breakage reports with `waiting` on crashed pages are the *page* not committing, not CPM being affected by another tab.
### RETRACTED: `removeAllUserScripts()` does not strip extension scripts (tested 2026-09-09)
Manual test: `removeAllUserScripts()` on a tab's controller leaves CPM working. Source confirms: the public API maps to `WebUserContentControllerProxy::removeAllUserScripts(RemoveWebExtensions::No)`, which skips every script whose URL is a `webkit-extension://` URL (`WebUserContentControllerProxy.cpp:227-266`). The Duck.ai refresher and the iOS `removeInstalledUserScripts()` path are therefore harmless to CPM. `tab_has_extension_user_scripts` stays in the diagnostics (cheap, still a valid sanity check) but is no longer expected to flip.
### RETRACTED: tab lifecycle paths are not all correct (2026-09-10)
The earlier audit covered `pinTab`/`unpinTab`, ordinary moves, drag-out, suspend and materialize, but missed two production-reachable paths that leave a live tab unknown to WebKit. The old conclusion "No path found" is false.
## New root cause: app closes a tab in WebKit while keeping the same `Tab` alive (2026-09-10)
This is not a service-worker race and does not originate from the earlier boundary hypotheses. It is an app → WebKit lifecycle contract violation. WebKit's tab registry is stateful: `didCloseTab` makes the wrapper closed and `forgetTab()` removes it from `m_tabMap`. Selection, activation, property changes and page reload do not reopen it. Only a later `didOpenTab`/window population, extension-context reload, or a new `Tab` object repairs the routing.
The immediate CPM failure happens before the background worker is involved:
```text
cpm.js: runtime.sendMessage(init)
-> WebExtensionContext::runtimeSendMessage
-> getTab(senderParameters.pageProxyIdentifier)
-> getCurrentTab scans openTabs()/m_tabMap
-> no entry
-> "Tab not found for message for content script message"
-> no background wake-up and no initResp
```
This class can affect other WebExtensions using sender-tab routing, not only CPM.
### Trigger A: `Close Other Tabs` forgets the tab it retains
The code path is deterministic:
1. `TabBarViewController.tabBarViewItemCloseOtherAction` calls `tabCollectionViewModel.removeAllTabs(except:)` (`TabBarViewController.swift:2751-2758`).
2. `removeAllTabs(except:)` obtains the exception from `tabCollection.tabs`, so its type is `AnyTab`, and calls `tabCollection.removeAll(andAppend: exceptionTab)` (`TabCollectionViewModel.swift:762-777`).
3. `TabCollection.removeAll(andAppend:)` calls `tabsWillClose(range: 0..<tabs.count)` over **every** old entry, including the exception, then directly assigns `tabs = [tab]` (`TabCollection.swift:203-213`).
4. For a loaded exception, `tabsWillClose` sends `eventsListener.didCloseTab` (`TabCollection.swift:304-312`).
5. WebKit forwards this to `WebExtensionContext::didCloseTab`; `tab.didClose()` flips `m_isOpen` and `forgetTab()` removes the tab/page mapping (`WebExtensionContextCocoa.mm:1380-1395, 1159-1166`).
6. Direct assignment back into `tabs` does not call the collection's normal append/insert path, so no `didOpenTab` is emitted.
7. `selectUnpinnedTab` only emits selection/activation. WebKit ignores those for a wrapper whose `isOpen` is false; it does not reconstruct the page mapping.
Why the loaded qualifier matters: an unloaded exception is not sent through `didCloseTab`; materializing it later takes the normal open path. The selected user-visible tab is normally loaded, so the UI action takes the broken branch in ordinary use.
Scope and recovery:
- Exactly the one retained loaded unpinned tab is broken.
- Tabs actually closed by the command are gone; unrelated existing tabs/windows and all new tabs remain healthy.
- Reloading or navigating the retained tab does not help because it is still absent from WebKit's `m_tabMap`.
- Opening an unrelated new window does not re-register this unpinned tab.
- Recreating/moving the affected tab through a path that emits `didOpenTab`, Fire, extension reload, or relaunch repairs it.
Minimal manual reproduction:
1. Open two ordinary tabs and make sure the tab to keep is loaded.
2. Invoke **Close Other Tabs** on that tab.
3. Navigate the retained tab to a CMP test page.
4. Expected: CPM does not initialize; WebKit logs `Tab not found for message for content script message`.
5. Open the same page in a new tab. Expected: CPM works.
The bug is present in the current `origin/main`; it is not specific to the investigation branch. `removeAll(andAppend:)` predates WebExtension integration, while the later tab-lifecycle bridge gave its existing UI semantics an unintended WebKit meaning.
### Trigger B: `Duplicate Tab` from a popup aliases the original tab
`TabCollectionViewModel.duplicateTab` has a special popup branch before construction of `tabCopy` (`TabCollectionViewModel.swift:835-862`):
1. It materializes the current popup tab as `loadedTab`.
2. It calls `redirectOpenOutsidePopup(loadedTab)` and returns.
3. `WindowControllersManager.openTab` inserts that same `Tab` object into the main window (`WindowControllersManager.swift:435-448`).
4. The object remains in the popup's `TabCollection`; there are now two collections referring to one `Tab`/`WKWebView`.
5. When the popup closes, `MainWindowController.windowWillClose` sends `didCloseWindow` (`MainWindowController.swift:584-586`). WebKit enumerates the popup's tabs, calls `didCloseTab`, and forgets the object that is still visible in the main window (`WebExtensionContextCocoa.mm:1312-1334`).
6. CPM messaging in that moved tab now fails with the same `tab not found` path.
The normal `Duplicate Tab` menu action is available for popup windows; validation checks whether content can be duplicated but does not exclude popups (`MainMenuActions.swift:1646-1649, 1944-1946`). This makes the branch production-reachable.
Scope: only the aliased/moved tab breaks after the popup closes. Existing and new tabs remain healthy. This chain is source-confirmed but still needs an end-to-end manual reproduction.
### Fix direction for both new triggers
The model currently conflates two operations with different lifecycle semantics:
- **Remove all except an existing tab:** emit `didCloseTab` only for tabs actually removed. Do not close/reopen the retained tab.
- **Replace all with a genuinely new tab:** close the old tabs, assign the new tab, then emit `didOpenTab` for that new object.
Implement separate APIs (`removeAll(except:)` and `replaceAll(with:)`) rather than continuing to overload `removeAll(andAppend:)`. Popup duplication must construct a new `Tab`/`tabCopy` first and redirect that copy, never insert the source popup `Tab` into another collection.
Regression coverage should assert WebExtension lifecycle events and object identity, not only the resulting array/selection: the existing tests verify that the retained tab appears selected but do not verify that it was never reported closed.
## ✅ CONFIRMED: closing a window forgets shared pinned tabs in WebKit (2026-09-10)
Reproduced manually by Alex on 2026-09-10. Scope as observed: **only the pinned tab(s) lose CPM**; a newly opened tab works (it goes through `didOpenTab`). Fits the dogfood reports: several (pinned) tabs at once, page reload does not help, Fire / extension reload fixes it, opening a new window also fixes it.
Same class, second instance: **Merge All Windows** (`MainMenuActions.swift:1670-1695`) closes the source windows via `WindowsManager.closeWindows(except:)` after moving their tabs; in shared-pinned mode each closing window still reports the shared pinned tabs in `tabs(for:)` → same `didCloseTab`/`forgetTab` on tabs that stay open in the remaining window.
General rule: any `didCloseWindow` while another window still displays the same `Tab` objects (shared pinned tabs) forgets them in WebKit. Fire's `.window`/`.allWindows` burns and pinned-mode migrations are safe because they close+reopen or `replaceTab` (→ `didReplaceTab`) every pinned tab.
### Chain
1. User is in **shared pinned tabs** mode (`TabsPreferences.pinnedTabsMode == .shared`; default for existing users who had pinned tabs at migration, `TabsPreferences.swift:161-165`; new installs default to `.separate`). The same `Tab` objects are reported by every window: `MainWindowController.tabs(for:)` returns `loadedPinnedTabs + loadedTabs` (`MainWindowController+WKWebExtensionWindow.swift:29-35`).
2. User closes **any** window (second window, popup window, a window burned by Fire while pinned tabs exist). `windowWillClose``eventsListener.didCloseWindow(self)` (`MainWindowController.swift:584-586`).
3. WebKit `WebExtensionContext::didCloseWindow``for tab in window.tabs()` (live delegate query, `WebExtensionWindowCocoa.mm:167-176`) → `didCloseTab(tab, WindowIsClosing::Yes)``tab.didClose()` + `forgetTab()` removes the tab from `m_tabMap` (`WebExtensionContextCocoa.mm:1312-1334, 1380-1395, 1159-1166`). The shared pinned tabs are now unknown to WebKit although they are open in the remaining windows.
4. Every `runtime.sendMessage` from a pinned tab's content script: `runtimeSendMessage``getTab(pageProxyIdentifier)``getCurrentTab` searches `openTabs()` = `m_tabMap` → miss → `tab not found` (`WebExtensionContextAPIRuntimeCocoa.mm:141-147`, RELEASE_LOG_ERROR "Tab not found for message for content script message" in the app process). `cpm.js`'s `await sendMessage` rejects, no `init`, `cpmDashboardState=waiting`, banner not handled. Health monitor: `initialization_failed`, `stuck` on the next navigation in any pinned tab.
5. Page reload: same (the tab object is still forgotten). Recovery: open a new window (`didOpenWindow``didOpenTab` for its tabs incl. pinned → `getOrCreateTab` re-registers), Fire (windows reopen / extension reload), extension reload (`populateWindowsAndTabs()`), relaunch.
Also affected: every other extension API keyed on the sender tab for those tabs (ad-blocking messaging, Dark Reader).
### Reproduction (no code changes)
1. Shared pinned tabs on; pin a tab on a CMP site; confirm CPM works there.
2. Open a second window, then close it.
3. In the pinned tab navigate to a CMP site: banner not handled, reload does not help. Console.app (`subsystem == "com.apple.WebKit"`): `Tab not found for message for content script message`. Health monitor fires `initialization_failed` then `stuck`.
4. Open a new window → the pinned tab works again. Fire or Reload extension → same.
### Diagnostics
Already covered: `tab_known_to_webkit=false` with `tab_controller_matches_context=true`, `extension_context_errors=none`. `content_script_send_error=tab_not_found` once the `__ddgCPM` marker ships.
### Fix direction (not applied; manual repro completed)
App side: on `didCloseWindow`, tabs that remain open in another window must not be reported as closed. Options: `MainWindowController.tabs(for:)` excluding shared pinned tabs when the window is closing, or the events listener re-issuing `didOpenTab` for shared pinned tabs after `didCloseWindow`. WebKit side: `didCloseWindow` could skip tabs whose `window()` still resolves to an open window — worth a bug report either way.
## 🔥 New top candidate for the all-tabs freeze: service-worker registration reuse across background page unload/reload (2026-09-10)
Traced from WebKit trunk `0c7e7ad97b`. Unlike the pinned-tab bug this one is **global**: every tab, every new tab, survives page reload, cleared only by Fire / extension reload / ≥30 s of no CPM traffic. Diagnostics in this state read exactly like the snapshot posted after the Network-kill test: `extension_context_loaded=true background_view_alive=true background_web_process_alive=true extension_context_errors=none`.
### Mechanism
The MV3 background is a "service worker page": a hidden `WKWebView` that loads `<script>navigator.serviceWorker.register('webkit-extension://<uuid>/public/js/background-embedded.js')</script>` (`WebPageProxy.cpp:18459-18480`). The SW registration lives in the **Network process** (`SWServer`) keyed by scope `webkit-extension://<uuid>/`; the registration remembers the *page* that created it (`SWServerRegistration::m_serviceWorkerPageIdentifier`, immutable) and is destroyed when **that page's document** unregisters as a client (`SWServer.cpp:1527-1538` `unregisterServiceWorkerClientInternal``removeFromScopeToRegistrationMap(key)` + `registration->clear()`; `removeFromScopeToRegistrationMap` removes by key only, `SWServer.cpp:1658-1664`). The document sends that unregister from `Document::willBeRemovedFromFrame``setServiceWorkerConnection(nullptr)` (`Document.cpp:3628-3639`, `11585-11592`), i.e. when the old WebContent process (**P1**) processes `WebPage::Close`.
Sequence on every idle cycle (≥30 s without a CPM message):
1. `unloadBackgroundContentIfPossible` (`WebExtensionContextCocoa.mm:2722-2757`) → `unloadBackgroundWebView` (2677-2685): drops the foreground process activity, `[webView _close]`, `m_backgroundWebView = nil`. **No check for in-flight replies.**
2. `WebPageProxy::close()` does **not** send `WebPage::Close` synchronously — it is deferred to the next run-loop cycle ("Delay sending close message to next runloop cycle to avoid white flash", `WebPageProxy.cpp:2021-2030`). P1 stays alive: `canBeAddedToWebProcessCache()==false` and `canTerminateAuxiliaryProcess()==false` while it runs the SW (`WebProcessProxy.cpp:1877-1893`, `1940-1943`).
3. Next content-script `runtime.sendMessage``wakeUpBackgroundContentIfNecessary``loadBackgroundWebView`**new** `WKWebView`. `processForSite(webkit-extension://<uuid>)` cannot reuse P1 (not in the cache) → prewarmed/new process **P2** (`WebProcessPool.cpp:1289-1314`). P2 runs `navigator.serviceWorker.register(...)`.
4. Race in the Network process between P1's `unregisterServiceWorkerClient(oldPageId)` and P2's `Register` job. If P2 wins: `SWServerJobQueue::runRegisterJob` finds the old registration with the same script URL → **"Found directly reusable registration … (DONE)"** → resolves immediately (`SWServerJobQueue.cpp:341-349`). No new worker, no script evaluation for P2's page; the registration still points at the *old* page id.
5. `register()` settles → `ServiceWorkerContainer::willSettleRegistrationPromise``didFinishServiceWorkerPageRegistration(true)` (`ServiceWorkerContainer.cpp:256-268`, `WebPageProxy.cpp:8703-8710`) → `performTasksAfterBackgroundContentLoads``m_backgroundContentIsLoaded = true` (`WebExtensionContextCocoa.mm:2849-2874`). WebKit now believes the background is loaded.
6. P1 processes `Close` → unregister → registration cleared, worker terminated. P2's page now has **no worker at all**; nothing re-registers (P2's page is not the registration's SW page).
7. Every `runtime.sendMessage`: `wakeUpBackgroundContentIfNecessaryToFireEvents``m_backgroundContentEventListeners` still contains `RuntimeOnMessage` (only cleared on context unload, `WebExtensionContextCocoa.mm:364`) → `wakeUpBackgroundContentIfNecessary``backgroundContentIsLoaded()==true``processes(RuntimeOnMessage, Main)` walks `m_eventListenerFrames` (weak frames registered by the **old** page; `frame->page()` is null → skipped, `WebExtensionContext.cpp:1735-1739`) → empty → `completionHandler({ })` (`WebExtensionContextAPIRuntimeCocoa.mm:154-158`). `cpm.js` receives `undefined`, never gets `initResp`, stays `waitingForInitResponse` → banner not handled, dashboard `waiting`.
8. Persistence: every message re-arms the 30 s unload timer first (`WebExtensionContext.cpp:1854`), so while the user keeps browsing (or background tabs keep reloading) the dead view is never unloaded. Only ≥30 s of silence → unload → next wake → fresh registration; or Fire / extension reload (new context, new base URL, new scope).
Why earlier tests did not hit it: killing the Network process or the background process (`webViewWebContentProcessDidTerminate`) also kills the client connection, so the Network process clears the registration before any wake can register again. The race needs P1 **alive but slow** to process `Close` (paged-out after 30 s idle, App Nap/process suppression right after the foreground activity is dropped, main thread busy in the SW — the SW of a SW-page runs on P1's main thread, `SWServerWorker.cpp:427-432`), or the wake landing in the same run-loop turn as the unload timer (the dispatched `Close` block then runs *after* the already-queued `RuntimeSendMessage`).
### Deterministic reproduction (DEBUG build, no code change)
1. Open a CMP site so the background loads. Take the background WebContent PID **P1** from the recorder log (`didCreateBackgroundWebView … webProcessPID=`) or Activity Monitor ("DuckDuckGo Embedded Extension Web Extension").
2. Once the page is quiet: `kill -STOP <P1>`. Do not touch the browser for 35 s (unload timer fires; `Close` is queued to the stopped P1; `m_backgroundWebView=nil`).
3. Reload any tab with a cookie banner. Expected: new background process P2 appears, `register()` resolves against the old registration (P1 never unregistered), WebKit reports background loaded, but the banner is **not** handled and the dashboard shows `waiting`.
4. `kill -CONT <P1>`. Registration is cleared; state is now identical to production: every tab dead, reload no help, new tab no help.
5. Recovery check: leave the browser alone for 35 s → next navigation works again; or Fire → works immediately.
Log confirmation (Network process): `log stream --predicate 'category == "ServiceWorker"' | grep -i "reusable registration"` right after "Unloading non-persistent background content" for the `webkit-extension://` scope.
### What the added diagnostics will show
- Probe 2 (`background_sw_registration`) is decisive: healthy = registration with an `active` worker; this state = **no registration** (after step 6) while `background_view_alive=true`. That combination is impossible in any other traced path.
- `background_view_create_count` ≥ 2 within the episode, `background_web_process_alive=true`.
- Content probe: `content_script=present`, `lifecycle=waitingForInitResponse`, `send_error=none`.
### Not proven yet
Production frequency. The mechanism is unguarded in WebKit; what is unknown is how often P1 loses the race on user machines. The pixel data (probe 2) settles that. No fix proposed until the manual repro above is done.
### ✅ REPRODUCED 2026-09-10: `kill -9 <background WebContent PID>` → CPM dead in all tabs (new tabs too), self-healed after ~1 min
Manual run by Alex: `kill -9` on the process reported by "Print CPM Diagnostics Snapshot" (`background_web_process_pid`). Result: banners stop being handled everywhere, new tabs included; revived on its own about a minute later (matches the 30 s idle-unload → next wake → fresh registration cycle).
This **contradicts** the earlier claim that a dead P1 is cleaned up before any wake. Two candidate sub-mechanisms, both ending in the same state (view alive, `m_backgroundContentIsLoaded` or a pending load, no working SW):
- **A. Stale SW server state at re-registration.** UI process: `didClose``processDidTerminateOrFailedToLaunch``webViewWebContentProcessDidTerminate``unloadBackgroundWebView` (`WebProcessProxy.cpp:1565-1617`, `WebExtensionContextCocoa.mm:2950-2957`); the next queued content-script message immediately creates the new page in a prewarmed process and registers. Network process: cleanup runs from `NetworkConnectionToWebProcess::didClose``unregisterSWConnection``SWServer::removeConnection``~WebSWServerConnection` → unregister clients → `registration->clear()` (`NetworkConnectionToWebProcess.cpp:470-518`, `SWServer.cpp:1368-1378`, `WebSWServerConnection.cpp:96-105`). If P2's `Register` lands first → "directly reusable registration" against the dead worker (same outcome as the idle-unload race above), or "needs updating" against a registration whose page id is the dead page.
- **B. New worker cannot start.** Old registration is gone, but the SW context connection for `webkit-extension://<uuid>` is still being torn down (`m_swContextConnection->stop()`), so the new worker's `createContextConnection` is deferred and the register job does not settle → `_loadServiceWorker` completion never fires → `m_backgroundContentIsLoaded=false`, actions queue, view retained until the 30 s timer → unload → next wake works.
**Log from the second run (2026-09-10 10:50, PID 54017) narrows it to a fast rejection, not a hang:**
```
10:50:49.435 Loading background content
10:50:49.436 [CPM Diagnostics] Background web view created #10 previousViewAlive=false previousWebProcessPID=8105 …
10:50:49.446 Scheduled task for after background content loads (×2) ← content-script messages already queued
10:50:49.446 Registered event listener for type 28 in content script world ← cpm.js onMessage, tab side
10:50:49.490 Tab for page 2118 was not found (×3)
10:50:49.498 Error recorded: WKWebExtensionContextErrorDomain Code=6 ← BackgroundContentFailedToLoad, 63 ms after load start
10:50:49.540 [CPM Diagnostics] Background web view #10 webProcessPID=8415
10:51:19.447 Unloading non-persistent background content ← 30 s timer; view deallocated; next wake works
```
Not a single `WebContent[…] Called getter browser.*` line for the new worker. In a healthy load (other instance, PID 11621, 10:51:29) the first thing the worker does is `browser.runtime` / `runtime.id` (the polyfill's "am I in an extension" check), then registers `runtime.onConnect`, `runtime.onMessage`, `alarms.onAlarm`. So `_loadServiceWorker` failed **before the worker script touched any extension API**`register()` was rejected (not B), and it was not the "reusable registration" resolve either (that returns success, not error). Neither A nor B as written; the hang variant B is ruled out.
### ✅ CONFIRMED by the third run (10:58, with the ServiceWorker category): the worker is installed into a page-less service-worker process
```
10:58:37.779 [CPM Diagnostics] Background web view deallocated ← previous failed view, 30 s timer
10:58:37.783 [ServiceWorker] WebProcessProxy::enableWorkers: workerType=1 (PID=0)
10:58:37.790 [ServiceWorker] establishRemoteWorkerContextConnectionToNetworkProcess creating a NEW service worker process (PID=0) ← becomes 13665
10:58:42.856 content script: runtime.sendMessage ×3, onMessage.addListener ← tab reload
10:58:42.856 Loading background content → view #14, webProcessPID=13710 ← P2
10:58:42.973 WebContent[13710] ServiceWorkerContainer::addRegistration jobID=16
10:58:42.976 WebContent[13710] jobFinishedLoadingScript: Successfully finished fetching script
10:58:42.976 WebContent[13665] Created service worker 424 in process PID 13665 ← NOT 13710: worker lands in the stray process
10:58:42.978 WebContent[13710] jobFailedWithException: Job 16 failed with error Script error.
10:58:42.979 WebContent[13665] SWContextManager::terminateWorker 424
10:58:42.979 [Extensions] Error recorded: WKWebExtensionContextErrorDomain Code=6 ← BackgroundContentFailedToLoad
10:59:12.861 Unloading non-persistent background content ← 30 s later; cycle repeats
```
Chain, all WebKit, all confirmed by the log:
1. Background WebContent process P1 dies (here `kill -9`; in production: crash, WebKit `ExceededMemoryLimit` kill, jetsam).
2. Network process `NetworkConnectionToWebProcess::didClose` (`NetworkConnectionToWebProcess.cpp:470-518`): **first** `m_swContextConnection->stop()` (line 474) → `SWServer::removeContextConnection` (`WebSWServerToContextConnection.cpp:92-108`, `SWServer.cpp:1785-1804`); **only at the end** `unregisterSWConnection()` (line 518) → `~WebSWServerConnection` → the dead SW page's client is unregistered → registration cleared.
3. Inside `removeContextConnection`, between those two steps, `needsContextConnectionForRegistrableDomain(webkit-extension://<uuid>)` is **true** (the dead page is still a registered client, `SWServer.cpp:1666-1669`) → `createContextConnection(site, serviceWorkerPageIdentifier = DEAD page id)` (`SWServer.cpp:1802-1803`, `1839-1875`).
4. UI `WebProcessPool::establishRemoteWorkerContextConnectionToNetworkProcess` (`WebProcessPool.cpp:685-760+`): the page's process (712) is terminated, the requesting process is P1 (717, terminated), no other process has that site (722-742) → **creates a dedicated service-worker process S** ("creating a new service worker process"). S hosts no SW page.
5. Next content-script message → new background view in P2 → `register()`. Registration was cleared in step 2 → "constructing a new one" → script fetched → `updateWorker``tryInstallContextData` (`SWServer.cpp:1068-1084`): a context connection for the domain **exists (S)** → worker installed in S, not in P2.
6. In S, `ServiceWorkerGlobalScope::serviceWorkerPage()` is null (`Document::allDocumentsMap()` is process-local, `ServiceWorkerGlobalScope.cpp:118-140`, `Page.cpp:5208-5212`) → `chrome`/`browser` never attached → polyfill throws at top level → `scriptContextFailedToStart` → job rejected → "Script error." → `register()` rejects → `didFinishServiceWorkerPageRegistration(false)``BackgroundContentFailedToLoad`. WebKit keeps the failed view (`m_backgroundWebView` non-nil, `m_backgroundContentIsLoaded=false`), queues every `runtime.sendMessage` completion in `m_actionsToPerformAfterBackgroundContentLoads`, `loadBackgroundWebViewIfNeeded` returns early → **CPM dead in every tab**.
7. 30 s after the last message the failed view is unloaded; the next message repeats 5-6 as long as S (or a successor created by the same `removeContextConnection``needsContextConnection` logic when the failed view's own process is torn down while its client is still registered — exactly what 10:58:37.78 shows) is the domain's context connection. In this session the loop ran from 10:50 to at least 10:59 (`background_view_create_count` 10 → 14). It ends only when S's connection is dropped without re-creation (idle termination of S), or immediately on Fire / extension reload (new base URL = new registrable domain = clean SW-server state). Page reload and new tabs never help.
Symptom match: all tabs, new tabs too, survives reload, banners not handled, dashboard `waiting`, Fire fixes, no visible error in the app. Pixel signature: `extension_context_errors=background_failed_to_load` + `background_view_alive=true` + `background_view_create_count` ≥ 2 + `network_process_restarted=false`.
**Why fetches do not matter / what keeps it dead.** The background is woken only by `runtime.sendMessage` from a *new document* (cpm.js at `document_start`, `all_frames: true`, `match_origin_as_fallback: true`) and by the one-shot `cpm-summary` alarm; the healthy worker registers only `runtime.onMessage`, `runtime.onConnect`, `alarms.onAlarm`. Realistic sustained sources: ad-slot iframes refreshing every 1530 s, embeds/chat widgets creating frames, `meta refresh`/`location.reload()` pages, the user's own navigation. Regimes: gaps < 30 s → the failed view is never unloaded at all (timer re-armed on every wake); 3040 s → unload/retry loop that fails; > 40 s → self-heal.
**Test page:** `cpm-ad-churn.html` in the repo root — privacy-test-pages autoconsent markup (`#privacy-test-page-cmp-test` / `#reject-all`, no reload guard) plus a fake ad iframe recreated every `?period=` s (default 10), each frame with its own fake CMP; per-frame ✅/⏳ table shows whether CPM handled it. Deploy to Eagle Pages: `sudo cp ~/DuckDuckGo/apple-browsers.git/cpm-breakage-pixel/cpm-ad-churn.html /Library/WebServer/Documents/``http://localhost/local-pages/cpm-ad-churn.html` (dashboard reads the directory live). Debug → Web Extensions now shows a disabled "CPM Background WebContent PID: …" item for the `kill -9`.
**Diagnostics added for this class (2026-09-10):** `CPMBackgroundWebViewDelegateProxy` — a forwarding `WKNavigationDelegate` installed in `didCreateBackgroundWebView` in front of WebKit's own `_WKWebExtensionContextDelegate` (which WebKit assigns before the callback and needs for its own unload). Forwards every selector the original answers (WebKit snapshots `respondsToSelector:` at assignment), retains the original for the view's life, and intercepts: `webViewWebContentProcessDidTerminate:` + private `_webView:webContentProcessDidTerminateWithReason:` (`_WKProcessTerminationReason`: memory_limit / cpu_limit / requested_by_client / crash / shared_crash_limit; WebKit calls only the private one when both exist, so the proxy re-dispatches to whichever the original implements), and WebKit's hang detection `_webViewWebProcessDidBecomeUnresponsive:` / `…Responsive:` (UI-side `ResponsivenessTimer`, 3 s per replied message; `BackgroundProcessResponsivenessTimer` for page-less processes: ping every 20 s doubling to 8 h, 90 s to answer — the same signals Safari's "not responding" uses). Recorder → pixel: `background_process_terminations`, `background_process_terminated` (age bucket), `background_process_termination_reason`, `background_process_unresponsive`, `background_process_unresponsive_count`, plus `background_web_process_responsive` from `WKWebView._webProcessIsResponsive` at snapshot time. Health-monitor state machine untouched; it receives these through the diagnostics provider.
**Reproduction (DEBUG, 100 %):** "Print CPM Diagnostics Snapshot" → `kill -9 <background_web_process_pid>` → reload any tab with a cookie banner. Log with
`log stream --level debug --predicate '(subsystem == "com.apple.WebKit" AND category == "ServiceWorker") OR category == "Extensions" OR composedMessage CONTAINS "[CPM"'`
and look for "creating a new service worker process" followed by "Created service worker N in process PID <≠ webProcessPID of the view>" and "Job N failed with error Script error."
**Why the 30 s unload does not guarantee recovery.** The stray process S is dropped only by `SWServer::removeContextConnectionIfPossible(domain)` (`SWServer.cpp:1623-1645`), which runs from `terminateServiceWorkersTimer` when the origin's **last client** unregisters (`SWServer.cpp:1546-1578`; delay 0 s if the client's registration was cleared, else 10 s). It bails out with `ShouldDelayRemoval::No` **without removing anything** if `m_clientsByRegistrableDomain` contains the domain again — i.e. if a new background view (a new client of `webkit-extension://<uuid>`) already exists when the timer fires. So recovery needs *two* quiet windows back to back: 30 s without CPM messages (failed view unloads → its client leaves) **plus** 010 s more without any message (otherwise the next wake creates a new client before the timer, S survives, the new worker lands in S again, `Script error.`, another 30 s). With several open tabs, auto-refreshing pages or a user who keeps browsing, that gap never comes → indefinite outage until Fire. The first manual run recovered "after a minute" only because Alex stopped touching the browser. Additionally `removeContextConnection` itself re-creates a connection (→ a new S) whenever a client is still registered at that instant (the 10:58:37.78 "creating a new service worker process" right after the failed view's deallocation); the exact trigger there needs the `[ServiceWorker]` lines preceding 10:58:37.779.
**WebKit bugs to report (two independent defects, either fix breaks the chain):**
- `NetworkConnectionToWebProcess::didClose` removes the context connection before unregistering the dead process's SW clients, so `removeContextConnection` re-creates a connection for an origin whose only client is dead. Swapping the order (or having `removeContextConnection` ignore clients belonging to the closing connection) removes the trigger.
- `establishRemoteWorkerContextConnectionToNetworkProcess` falls back to a standalone worker process for a request that carries a `serviceWorkerPageIdentifier`; a SW-page worker can never work outside the page's process, so the request should be dropped when that process is gone. Additionally `tryInstallContextData` prefers an existing domain connection over the SW page's process.
- `WebExtensionContext`: a `BackgroundContentFailedToLoad` view is retained for 30 s with no retry, so one transient failure costs a 30 s outage per cycle.
**App-side mitigation options (not applied; for discussion):** on `WKWebExtensionContext.errorsDidUpdateNotification` with `BackgroundContentFailedToLoad`, force `unload`+`load` of the embedded context (new base URL → clean SW-server state) — the same thing Fire does, without burning data; guard with a back-off so a genuinely broken bundle does not loop.
Earlier reasoning kept for the record — superseded by the confirmed chain above:
Most consistent mechanism: **the worker was started in a process that does not host the new SW page's document**. Bindings are installed only from `ServiceWorkerGlobalScope::notifyServiceWorkerPageOfCreationIfNecessary``Page::serviceWorkerPage(m_contextData.serviceWorkerPageIdentifier)``Document::allDocumentsMap()` (**process-local**, `ServiceWorkerGlobalScope.cpp:118-140`, `Page.cpp:5208-5212`). No page in that process → no `chrome`/`browser` → polyfill throws at top level → `SWServer::scriptContextFailedToStart` → job rejected → `register()` rejects → `didFinishServiceWorkerPageRegistration(false)``BackgroundContentFailedToLoad`, view retained until the 30 s timer. Which process the worker lands in is decided by `SWServer::tryInstallContextData` (`SWServer.cpp:1068-1084`): an **existing** context connection for `webkit-extension://<uuid>` wins over the page's process; otherwise `createContextConnection` → UI `establishRemoteWorkerContextConnectionToNetworkProcess` picks the page's process by `serviceWorkerPageIdentifier` (`WebProcessPool.cpp:709-714`), falling back to the requesting process or any process with the same site (716-742), else a new one. Right after `kill -9`, the Network process may still hold P1's context connection (or a pending connection creation keyed by domain, `m_pendingConnectionDomains`) when P2's job arrives — the worker is then installed away from P2's page. Still a hypothesis: the ServiceWorker-category lines were not in the captured log (the predicate only matched `Extensions`).
Discriminating evidence to collect on the next run (kill → immediately reload a tab → within 10 s):
0. Run the log stream with the **Networking process included**:
`log stream --level debug --predicate '(subsystem == "com.apple.WebKit" AND category == "ServiceWorker") OR category == "Extensions" OR composedMessage CONTAINS "[CPM"'`
Look for, in order: `runRegisterJob: …` (reusable / needs updating / constructing a new one), `establishRemoteWorkerContextConnectionToNetworkProcess reusing an existing web process (PID=…)` vs the view's `webProcessPID=`, and `SWServer::scriptContextFailedToStart: Failed to start SW … error: <JS error text>` or `fetch resulted in error`.
1. "Print CPM Diagnostics Snapshot": `background_view_alive`, `background_view_create_count` (2 = new view was created → A or B; 1 = delegate never fired), `background_web_process_pid` (new PID vs 0).
2. Our log: `Background web view created #2 … previousWebProcessAlive=false`, then `webProcessPID=<new>`; any `extension_context_errors` (`background_content_failed_to_load` → load actually failed).
3. `log stream --predicate 'category == "ServiceWorker" || category == "Extensions"'` during the dead minute: "Found directly reusable registration" / "needs updating" / "No existing registration … constructing a new one", and whether the register job ever resolves.
4. Stuck pixel / probe 2 (`background_sw_registration`) if the health monitor fires within the window.
Production relevance: any death of the background WebContent process — WebKit's own memory kill (`ExceededMemoryLimit`), jetsam on low-memory Macs, a JSC/WebCore crash in the worker — now maps to "CPM dead everywhere until 30 s of silence". `background_view_create_count` + `network_process_restarted=false` + `previousWebProcessAlive=false` in the pixel will measure how often this happens in the field.
### Side note checked (low value)
`cpm.js` sends `init` inside the `AutoConsent` constructor and only afterwards calls `chrome.runtime.onMessage.addListener` (`cpm.js:3392-3401`). `tabs.sendMessage(initResp)` requires that listener to be registered in `m_eventListenerFrames` (`WebExtensionContextAPITabsCocoa.mm:483-497`). Both IPCs travel on the same connection and the background round trip is far slower, so this is not a realistic path — noted so nobody re-derives it.