diff --git a/work/projects/cpm-web-extension-breakage-findings.md b/work/projects/cpm-web-extension-breakage-findings.md index fec11f97..1fdfef66 100644 --- a/work/projects/cpm-web-extension-breakage-findings.md +++ b/work/projects/cpm-web-extension-breakage-findings.md @@ -859,3 +859,43 @@ Is CPM breakage in production reported for **old tabs only** (consistent with th - Browser update: bundle swap happens at relaunch; on first launch of the new version the old UUID loads, then `syncEmbeddedExtensions` installs/loads the new UUID into the same controller and unloads the old. Documents holding old-context content scripts lose messaging until their next navigation — transient. Handlers are keyed by UUID; nothing from the previous version lingers. Status: memory pressure, Network-process death, lost native handlers and controller replacement are all either not reproducible on shipping WebKit or not triggered by production conditions. Retained-failed-view self-heals. **Next input must be production data**: `cpm_messaging_stuck_` split and recovered-with/without-reload ratio; breakage-report split of `cpmDashboardState=waiting` vs `applied` + `cpmStage ∈ {setting_disabled, config_unavailable, settings_missing}`. + + +## Traced production path: `removeAllUserScripts()` strips the extension's content scripts from a tab (2026-09-09, late night) + +### Mechanism (WebKit) + +WebKit injects an extension's content scripts into a `WKUserContentController` exactly once per controller: when the controller is first seen (`WebExtensionController::addUserContentController`, `WebExtensionController.cpp:303-320` → `WebExtensionContext::addInjectedContent(ucc)`, `WebExtensionContext.cpp:1459`) and again for all known controllers when the context (re)loads or its match patterns change. Nothing re-injects on navigation. Any app call to `WKUserContentController.removeAllUserScripts()` on a tab's controller therefore removes `cpm.js` (and the ad-blocking scriptlets, Dark Reader) from that tab **until the extension context is reloaded** (Fire) or the app relaunches. The tab's WKWebView, its controller and `hasInjectedContent` all look healthy; the script is just gone. Every later navigation in that tab: no `init`, `cpmDashboardState=waiting`, health monitor `initialization_failed` → `stuck` on the second navigation. Other tabs fine, new tabs fine, reload useless — the reported shape. + +DDG already knows this hazard: `UserContentController.removeInstalledUserScripts()` (BSK, `UserContentController.swift:261-276`) uses the `_removeUserScript:` SPI on macOS explicitly "to avoid removal of web extension scripts". Two places bypass that guard. + +### macOS: Duck.ai native-storage bootstrap refresh + +`AIChatTabExtension.decidePolicy(for:)` → `refreshNativeStorageBootstrapIfNeeded` (`AIChatTabExtension.swift:277, 325-345`) → `DuckAiNativeStorageBootstrapScriptRefresher.refresh(on:staticScripts:)` (`DuckAiNativeStorageBootstrapScriptRefresher.swift:83-93`): **`userContentController.removeAllUserScripts()`**, then re-adds only DDG's `contentBlockingAssets.wkUserScripts` + the bootstrap script. Runs on every cross-document navigation to a `duck.ai` URL in any regular tab, gated by `aiChatNativeStorage` = `remoteReleasable(aiChat.nativeStorage)`, which is **at 100% on macOS since 2026-05-06** (`privacy-configuration` commit `ba4e44e03`, `macos-override.json:428-446`, minSupportedVersion 1.187.0). + +Consequence: every tab in which the user has opened duck.ai loses CPM for the rest of the session. Users who use Duck.ai in a tab and then browse in that tab, or who keep several Duck.ai tabs and reuse them, see "CPM stopped working in these tabs, new tabs are fine, reload doesn't help, Fire fixes it". + +### iOS: every content-blocking assets update + +`removeInstalledUserScripts()` on iOS is the plain `removeAllUserScripts()` branch (`UserContentController.swift:272-274`), executed in `contentBlockingAssets.willSet` on **every** TDS / privacy-config / protections change (`UserContentController.swift:94-107`). On iOS 18.4+ with the embedded extension, every such update strips `cpm.js` from **every open tab** at once — the all-tabs variant, and it recurs several times a day. (Same for ad-blocking scriptlets.) + +### Reproduction (macOS, deterministic, no code changes) + +1. Open tab A on a CMP site — CPM works. +2. In tab A navigate to `https://duck.ai` (address bar). +3. In tab A navigate to a CMP site: banner not handled; reload does not help. Health monitor: `initialization_failed` then `stuck` on the next CMP navigation in the same tab. +4. Open tab B on a CMP site: works (`recovered_without_reload`); back to tab A: still broken. +5. Fire → tab A works again (`recovered_after_extension_reload`). +Verification without the marker: Safari Develop → DuckDuckGo → tab A → the "WebExtension-" content world has no `cpm.js` after step 2; or check `webView.configuration.userContentController.userScripts` for a script whose `contentWorld.name == "WebExtension-"`. + +### Diagnostic added + +`tab_has_extension_user_scripts` = `userContentController.userScripts.contains { $0.contentWorld.name == "WebExtension-" }` (public API). `false` in a stuck pixel with `tab_controller_matches_context=true` is this chain. + +### Other `removeAllUserScripts()` callers checked + +`WebView.deinit` (own controller, tab dying — harmless), `UserContentController.cleanUpBeforeClosing` (tab closing), `SuggestionsReader.tearDown` (own web view), `NewTabPageUserContentController` (NTP-only controller), DBP/HeadlessWebView (own web views), iOS `DuckPlayerWebView` (own view). Only the two above touch a live browsing tab's controller. + +### Answer to "can one crashed/hung page affect extensions in other tabs?" + +Not through WebKit: the background service-worker page gets its own WebContent process (fresh/prewarmed process at creation, `_relatedWebView` is nil for CPM), content-script messaging is per tab/process, and a WebContent crash only drops that process's frames from the listener map. The only cross-tab couplings are app-side: the shared `WebExtensionManager` state (router, controller) and the background SW's single JS thread, whose per-tab handlers are independent except the `_stateQueue` (which only carries dashboard state, and catches errors). The breakage reports with `waiting` on crashed pages are the *page* not committing, not CPM being affected by another tab.