From 211d3138700bc4bb5f3af5148fa602d01644b031 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Wed, 9 Sep 2026 18:32:28 +0600 Subject: [PATCH] [2026-09-09] eagle: work/projects/cpm-web-extension-breakage-findings.md --- .../cpm-web-extension-breakage-findings.md | 55 ++++++++++++++++++- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/work/projects/cpm-web-extension-breakage-findings.md b/work/projects/cpm-web-extension-breakage-findings.md index fe9c9f54..e0fda9da 100644 --- a/work/projects/cpm-web-extension-breakage-findings.md +++ b/work/projects/cpm-web-extension-breakage-findings.md @@ -16,7 +16,7 @@ tags: ## Executive summary -The same visible symptom, CPM no longer managing consent, can come from three materially different failures: +The same visible symptom, CPM no longer managing consent, can come from four materially different failures: | Failure mode | Scope | Expected recovery | Confidence | |---|---|---|---| @@ -27,7 +27,24 @@ The same visible symptom, CPM no longer managing consent, can come from three ma 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. +There is currently no single strongest production hypothesis. The authoritative list below supersedes the older ranking retained later in this note as investigation history. + +## Current working hypotheses (authoritative, 2026-09-09) + +No persistent production trigger has been reproduced yet. The remaining hypotheses are: + +| Hypothesis | Exact condition required | Resulting sequence | Current status | +|---|---|---|---| +| 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. | +| WebKit says the background is loaded although its worker is gone | `m_backgroundWebView != nil` and `m_backgroundContentIsLoaded == true`, while the page has no usable `serviceWorkerGlobalScope`/receiving worker. | Wake-up returns success without loading a new worker -> runtime messages are dispatched toward a page with no receiver -> all tabs fail until background unload/context reload repairs the state. | Exact all-tabs state exists in source. Critical memory pressure and Network-process termination did not produce it; the production lifecycle transition is unknown. | +| 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. | +| Per-tab sender/response routing is stale | `getTab(senderParameters.pageProxyIdentifier)` fails, 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. | Valid explanation for isolated tab failures, not for an extension-wide all-tabs freeze. | + +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 @@ -499,7 +516,9 @@ This freezes CPM across all tabs without growing `m_actionsToPerformAfterBackgro 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) +## 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]]. @@ -973,3 +992,33 @@ Manual test: `removeAllUserScripts()` on a tab's controller leaves CPM working. ### Tab lifecycle paths checked (all correct) `pinTab`/`unpinTab`, `moveTab(at:to:at:)` between windows, drag-out `moveToNewWindow`, `suspendTab`/`materialize` (`TabCollectionViewModel.swift:864-878, 885-897, 734-757`, `TabBarViewController.swift:1508-1524`, `TabCollection.swift:255-283`) all either suppress open/close or emit the correct pair. No path found that leaves a live tab unknown to WebKit. + + +## Traced production path: closing a window forgets shared pinned tabs in WebKit (2026-09-10) + +Fits every reported fact: dogfood users, several tabs at once, page reload does not help, Fire / extension reload fixes it, a new window also fixes it. + +### 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, per agreement — waiting for the manual repro) + +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.