[2026-09-09] eagle: work/projects/cpm-web-extension-breakage-findings.md

This commit is contained in:
Alexey Martemyanov
2026-09-09 17:48:04 +06:00
parent 33df213ea6
commit 6ad7ba8df4
@@ -12,7 +12,7 @@ tags:
> Investigation summary as of 2026-09-08. This note distinguishes confirmed code paths from hypotheses that still need production evidence. > 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. > **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. A separate app-side `removeAllUserScripts()` path is confirmed and persistent, but affects individual tabs rather than the shared background worker.
## Executive summary ## Executive summary
@@ -23,7 +23,7 @@ The same visible symptom, CPM no longer managing consent, can come from three ma
| CPM content script misses a document | Current document | A navigation or reload after the extension finishes loading | Confirmed | | 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 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 | | 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 | | 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 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.
@@ -503,9 +503,9 @@ On WebKit versions before `7682d9817b`, the same sequence can occur before the f
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]]. 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 ### 1. Critical memory pressure terminates the "idle" extension worker — source hypothesis, manual repro failed
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. 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: 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. 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.
@@ -663,6 +663,71 @@ No hook; `m_backgroundContentEventListeners` is not exposed. If everything else
Items 14 are enough to attribute the current stuck pixels; 56 turn the health monitor into a classifier instead of a timer. 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) ## Next steps (2026-09-09)
Ordered so each step de-risks the next. Days are rough effort, not calendar. Ordered so each step de-risks the next. Days are rough effort, not calendar.