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

This commit is contained in:
Alexey Martemyanov
2026-09-10 10:44:37 +06:00
parent a60f8af473
commit cb94151a45
@@ -12,41 +12,44 @@ tags:
> 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.
## Executive summary
The same visible symptom, CPM no longer managing consent, can come from four materially different failures:
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 |
| 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 |
| 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.
There is currently no single strongest production hypothesis. The authoritative list below supersedes the older ranking retained later in this note as investigation history.
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 working hypotheses (authoritative, 2026-09-09)
## Current conclusions (authoritative, 2026-09-10)
> 2026-09-10: two later sections supersede this table for the ranking — **✅ CONFIRMED shared-pinned-tab/window-close** (per-tab) and **🔥 SW registration reuse across background unload/reload** (all-tabs, deterministic repro with `kill -STOP`, see the last section).
No persistent production trigger has been reproduced yet. The remaining hypotheses are:
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. |
| 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. |
| 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).
@@ -571,7 +574,9 @@ Repro: 10+ restored tabs, quit, relaunch; several restored tabs have no CPM unti
### 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.
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)
@@ -993,9 +998,85 @@ Not through WebKit: the background service-worker page gets its own WebContent p
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.
### Tab lifecycle paths checked (all correct)
### RETRACTED: tab lifecycle paths are not all correct (2026-09-10)
`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.
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)
@@ -1027,7 +1108,7 @@ Also affected: every other extension API keyed on the sender tab for those tabs
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)
### 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.