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

62 KiB
Raw Blame History

title, date, status, tags
title date status tags
CPM Web Extension Breakage Findings 2026-09-08 investigation
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.

Updated 2026-09-09 after source validation (WebKit trunk 0c7e7ad97b, see cpm-web-extension-breakage-validation for file:line evidence). Corrections are marked [corrected]; the ranked production sequences are in the new section "Ranked production trigger chains" at the end. Headline: the retained-failed-view state is real but self-heals via the 30 s idle eviction; the state that actually matches "stuck across all tabs" is a terminated service worker under a background view WebKit still considers loaded, which nothing in WebKit's extension layer detects.

Executive summary

The same visible symptom, CPM no longer managing consent, can come from three materially different failures:

Failure mode Scope Expected recovery Confidence
CPM content script misses a document Current document A navigation or reload after the extension finishes loading Confirmed
WebKit loses routing for an individual tab Tab or WebContent process A fresh tab/window, or rebuilding the affected tab Confirmed historically; the known app lifecycle cause is fixed
WebKit retains a background service worker that failed to load Extension context/controller [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
[new] 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 by source: triggered by critical memory pressure (macOS) and by Network-process termination; WKWebExtensionContext.errors stays empty

The debug-menu simulation enters the third state deliberately. It proves the health detection and failure shape, but its exact trigger cannot happen naturally in the same way because only the debug delegate closes the newly created background web view in that synchronous interval.

The strongest production hypothesis is therefore narrower than “a race”: the extension service-worker registration must fail, and WebKit must keep the failed background web view attached. We have not yet established which real system condition makes registration fail.

CPM architecture

The bundled extension is Manifest V3:

  • Background entry point: public/js/background-embedded.js
  • CPM content script: public/js/content-scripts/cpm.js
  • Injection: document_start, isolated world, all frames
  • Content-to-background transport: chrome.runtime.sendMessage
  • Background receiver: runtime.onMessage, registered 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 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::memoryPressureStatusChangedForProcessNetworkProcess::TerminateIdleServiceWorkersSWServer::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:

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:

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:

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:

content script: chrome.runtime.sendMessage(init)
background: chrome.tabs.sendMessage(tabId, initResp, { frameId })

The second leg does not deliver when:

  • getTab(tabIdentifier) fails.
  • The tab has no WKWebView.
  • tab->processes(RuntimeOnMessage, ContentScript) is empty.
  • The target frame identifier belongs to the previous document after navigation.
  • The frame/document target no longer matches.
  • The content-script listener mapping is absent from the tab's WebContent process.

The extension does not await or catch this tabs.sendMessage(initResp). Therefore the original init can finish successfully while the content script never receives configuration. This is a strong explanation for a failure confined to one tab or one WebContent process.

CPM JavaScript state queue

CPM serializes state mutations through _stateQueue:

previous _stateQueue
-> getCpmState()
-> mutation callback
-> updateCpmState()

It remains pending if:

  • storage.session.get("cpmState") never settles.
  • storage.session.set(...) never settles.
  • A mutation callback awaits another method that calls modifyCpmState, causing a self-deadlock.
  • A callback returns another permanently pending promise.

Current mutation callbacks are synchronous and do not contain the nested-await error. Native messaging is protected by a 20-second timeout.

The normal enabled init path does not await its dashboard-state update before sending initResp. A stuck _stateQueue can break diagnostics and state persistence, but does not normally explain total consent-management failure.

Exact all-tabs extension freeze sequence

The following is the exact sequence for the extension-context-wide freeze reproduced by the failed-background-load simulation. It applies to all tabs that share the same WKWebExtensionController and WKWebExtensionContext.

Entry

  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:

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:

background worker was unloaded
m_backgroundContentHasLoadedOnce == true
RuntimeOnMessage absent from m_backgroundContentEventListeners

Every tab then follows:

runtimeSendMessage
-> wakeUpBackgroundContentIfNecessaryToFireEvents
-> listener considered absent
-> completionHandler called without waking worker
-> no background CPM handler
-> no initResp

This freezes CPM across all tabs without growing m_actionsToPerformAfterBackgroundContentLoads. It recovers only when another event loads the worker and repairs listener registration, or when the extension context is reloaded.

On WebKit versions before 7682d9817b, the same sequence can occur before the first successful background load because an empty persisted listener set was incorrectly considered authoritative.

Ranked production trigger chains (added 2026-09-09)

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 — most probable

Why first: needs no crash, no user action, no app bug; happens routinely on 8/16 GB Macs with many tabs; leaves WKWebExtensionContext.errors empty; matches "stuck, then recovered without reload" once the user pauses browsing for 30 s. macOS-only (ENABLE(WEB_PROCESS_SUSPENSION_DELAY)), which fits a macOS-only report.

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.jsruntime.sendMessage(init)runtimeSendMessage → tab found → listener present → wakeUpBackgroundContentIfNecessary re-arms the 30 s eviction timer → backgroundContentIsLoaded() true → DispatchRuntimeMessageEvent to the SW page process.
  8. enumerateFramesAndNamespaceObjectsjsContextForServiceWorkerWorld → 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::networkProcessDidTerminateterminateServiceWorkers()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: networkProcessConnectionClosedSWContextManager::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 chain that explains "one window broken, new window fine". [corrected 2026-09-09] 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 whose didOpenTab was dropped (droppedCallbacksCount), suppressed (withTabLifecycleEventsSuppressed), or 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 }. Trigger not identified in source; needs the "Tab not found for message for content script message" log correlated with window registration. Probability unknown; ranked here because it matches a reported observation, not because a trigger is known.

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 → NSURLErrorFileDoesNotExistcompletion(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 undefinedchain 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 awaits 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 foundchain 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 dispatchServiceWorkerGlobalObjectAvailableserviceWorkerGlobalObjectIsAvailableForFrame 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.

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.swiftCPMMessagingDiagnosticsProviding + 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.uuidTab 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:/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:

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.