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

14 KiB

title, date, status, tags, related
title date status tags related
CPM Web Extension Breakage — Hypothesis Validation 2026-09-08 validated
work
cpm
webkit
web-extension
cpm-web-extension-breakage-findings

CPM Web Extension Breakage — Hypothesis Validation

Validation of cpm-web-extension-breakage-findings against source. Line numbers refer to:

  • WebKit trunk ~/Downloads/WebKit_sparse @ 0c7e7ad97b (2026-08-23). Shipping macOS WebKit is older; every path cited below is long-standing, but re-check line numbers against the shipping tag before quoting them in a bug report.
  • apple-browsers branch cpm-breakage-pixel; bundled extension duckduckgo-embedded-web-extension.zip v2026.9.2.

Verdict table

Claim in findings doc Verdict Where
Failed _loadServiceWorker retains m_backgroundWebView, does not run pending actions, does not call unloadBackgroundWebView Confirmed WebExtensionContextCocoa.mm:2666-2671
Later messages append to the queue and loadBackgroundWebViewIfNeeded returns because the view exists Confirmed WebExtensionContext.cpp:1856-1865, WebExtensionContextCocoa.mm:2608-2616
"The 30-second eviction does not recover the failed-first-load sequence" Refuted WebExtensionContext.cpp:1854 — every wakeUpBackgroundContentIfNecessary calls scheduleBackgroundContentToUnload() before the loaded check. Only the very first call (view still nil) skips the timer. The second CPM message from any tab arms a 30 s one-shot; unloadBackgroundContentIfPossible (Cocoa.mm:2721-2758) unloads the failed view (no ports, no inspector, no permission prompts for CPM); the next message creates a fresh view and retries. Queued actions survive the unload (unloadBackgroundWebView does not clear them) and run on the eventual success.
Debug simulation models a persistent state Corrected Injection is one-shot (WebExtensionManager.swift:648-652). Per the above, the simulated freeze should end ≈30 s after the first post-reload CPM message, at the next message. If it is observed to persist longer than that, shipping WebKit differs from trunk — verify empirically.
Lost-completion variants (completion never called) Mostly refuted WebPageProxy::callLoadCompletionHandlersIfNecessary(false) fires on didFailProvisionalLoad (8592), didFailLoad (9382) and resetState — which close() calls (2002 → 14084). So closing/evicting the view always settles a pending completion. A completion can hang only while the generated page is loaded successfully and the register() job never settles; eviction still resolves it. Client-side job abandonment always notifies the server (ServiceWorkerContainer::stop 711-725, SWClientConnection::startScriptFetchForServer 123-130), so the SWServerJobQueue cannot be wedged by a dead client.
Network-process disconnect during registration → completion(false) Confirmed chain as written
m_safeToLoadBackgroundContent gate blocked by _renameOrigin Confirmed shape, and it is on the critical path on every load See "Base URL rename" below
Listener-gating freeze (RuntimeOnMessage absent after first load) Confirmed shape, no trigger found WebExtensionContext.cpp:1876-1894. Listener counts are only decremented by JS removeListener (APIEventCocoa.mm:88-113); nothing in the bundle removes onMessage. Low probability.
tab not found before wake-up is tab-local Confirmed APIRuntimeCocoa.mm:141-147; lookup iterates openWindows()tabs(for:)webView (Cocoa.mm:1105-1114, MainWindowController+WKWebExtensionWindow.swift:29-35)
tabs.sendMessage(initResp) second leg can silently drop Confirmed APITabsCocoa.mm:474-511 returns {} when tab has no web view or no ContentScript RuntimeOnMessage frame
"Memory pressure by itself" and "routine process crash" not supported Refuted for the extension SW — see sequences A and B
Opening a new window does not reload the context Confirmed didOpenWindow only registers window/tabs
Safari 26.6 SW-registration DB fix is relevant Irrelevant Extension SW registrations are never persisted (SWServerWorker.cpp:561-565); registration is cleared when the SW page client dies (SWServer.cpp:1527-1539)

The gap the findings doc misses

The findings doc assumes WebKit's view of "background loaded" and the service worker actually running are the same thing. They are not coupled:

  • WebKit UIProcess tracks only m_backgroundWebView, m_backgroundContentIsLoaded and the listener-frame set (m_eventListenerFrames), all keyed on the SW page frame, which outlives the worker.
  • Message dispatch reaches the worker only through Page::m_serviceWorkerGlobalScope, a WeakPtr (Page.cpp:5294-5307) read via WebFrame::jsContextForServiceWorkerWorld (WebFrame.cpp:1057-1063) inside enumerateFramesAndNamespaceObjects (WebExtensionContextProxy.cpp:220-250).
  • If the worker thread has been stopped, that context is null, JSContextGetGlobalObject(nullptr) returns null in release builds (JSContextRef.cpp:188-193), the frame is skipped, no listener replies, and runtimeSendMessage resolves the content script with undefined (APIRuntimeCocoa.mm:156-175, internalDispatchRuntimeMessageEvent 639-694).
  • There is no hook from SW termination back to WebExtensionContext (grep: nothing SW-related in UIProcess/Extensions besides _loadServiceWorker). No error is recorded in WKWebExtensionContext.errors.

Because every wake-up restarts the 30 s idle timer (startOneShot at Cocoa.mm:2718), and CPM sends init from every frame of every navigation (all_frames: true), the dead view is never evicted while the user browses. Recovery only happens after ≥30 s with no extension events, or an extension reload. This produces exactly "stuck across all tabs, page reload does not help, later recovered without extension reload".

Prod-plausible exact sequences

A. Critical memory pressure kills the "idle" extension worker (macOS only)

Preconditions: CPM context loaded, background SW running, no waitUntil pending. WebKit's SW server never learns about extension messages (they bypass it), so SWServerWorker::isIdle() (SWServerWorker.cpp:341-344) is true ~10 s after activation; needsRunning() is only called from SWServer::runServiceWorkerIfNecessary (SWServer.cpp:1226) and on entering Running (SWServerWorker.cpp:398).

  1. System memory pressure becomes Critical. Each WebProcess's MemoryPressureHandler sends WebProcessProxy::MemoryPressureStatusChanged (WebProcess.cpp:554-557).
  2. For the process hosting the CPM SW page, WebProcessProxy::memoryPressureStatusChangedWebProcessPool::memoryPressureStatusChangedForProcess (WebProcessProxy.cpp:2415-2423, guarded by ENABLE(WEB_PROCESS_SUSPENSION_DELAY) = macOS, PlatformEnableCocoa.h:1144-1145).
  3. status == Critical && process.isRunningServiceWorkers()NetworkProcessProxy::terminateIdleServiceWorkers(pid) (WebProcessPool.cpp:2937-2942).
  4. Network process: SWServer::terminateIdleServiceWorkers(connection) terminates every idle worker on that context connection, including the CPM worker (SWServer.cpp:1807-1823).
  5. WebProcess: SWContextManager::terminateWorker → thread stops → ServiceWorkerGlobalScope destroyed → Page::m_serviceWorkerGlobalScope becomes null. SW page, its frame, and its RuntimeOnMessage/Main listener registration remain.
  6. UIProcess WebExtensionContext: unchanged. m_backgroundWebView != nil, m_backgroundContentIsLoaded == true.
  7. Any tab: cpm.js sends {messageType:"autoconsent", init} (cpm.js:2896-2901, 3393-3398) → runtimeSendMessage → tab found → RuntimeOnMessage in listener set → wakeUpBackgroundContentIfNecessary → timer restarted → backgroundContentIsLoaded() true → dispatch to SW page process.
  8. WebProcess: SW context null → no listeners → null reply → UIProcess aggregator default {} → content script await sendMessage resolves undefined; no error thrown. Background never receives init, never sends initResp. Content script stays in waitingForInitResponse.
  9. Repeats for every tab, every frame, every navigation; step 7 keeps the eviction timer armed.
  10. Ends only when: no extension event for 30 s → unloadBackgroundContentIfPossibleunloadBackgroundWebView → next message reloads a fresh SW (health monitor should then fire cpmMessagingRecoveredWithoutExtensionReload); or Fire/reload; or app restart.

B. Network process crash or unresponsiveness

  1. Network process dies or is killed as unresponsive (NetworkProcessProxy::didClose 522-532, didBecomeUnresponsive 181-187, ExceededMemoryLimit 1850).
  2. UIProcess: WebProcessPool::networkProcessDidTerminateterminateServiceWorkers()disableRemoteWorkers(ServiceWorker) on every SW-running process → sends WebSWContextManagerConnection::Close (WebProcessPool.cpp:497-509, 1850-1860; WebProcessProxy.cpp:3094-3126). Process is not shut down because it still owns the SW page.
  3. WebProcess independently: networkProcessConnectionClosedSWContextManager::stopAllServiceWorkers() (WebProcess.cpp:1467-1468).
  4. From here identical to A steps 5-10. Extension SW registration is gone (in-memory only), nothing re-registers it until the view is recreated.

C. Repeatable registration failure (only if a persistent cause exists)

The retained-failed-view state from the findings doc becomes persistent only when every retry fails. The one repeatable cause found: the SW script is read from disk on every load, uncached (WebExtensionURLSchemeHandlerCocoa.mm:120-124WebExtension::resourceDataForPath, default CacheResult::No, WebExtensionCocoa.mm:252-265, WebExtension.h:250). If <Application Support>/…/<uuid>/public/js/background-embedded.js is missing or unreadable while the context stays loaded, each retry gets NSURLErrorFileDoesNotExist → script fetch rejected → completion(false) → retained view → 30 s later eviction → retry → fail again. Content scripts keep working (they were read at context load), so the symptom is exactly "content script talks, background never answers".

Repeat cycle per retry: message → loadBackgroundWebView → fail → second message arms timer → 30 s → unload → third message → fail again. Every retry records BackgroundContentFailedToLoad, so unlike A/B this variant is visible in WKWebExtensionContext.errors.

App-side removal paths checked: cleanupOrphanedExtensions (WebExtensionStorageProviding.swift:180-199) only deletes identifiers not in the installation store; upgrade installs the new UUID before uninstalling the old (WebExtensionManager+EmbeddedExtensions.swift:90-108); scriptlet installer only creates directories and overwrites individual files (ScriptletInstaller.swift:29-59), never removes the bundle. No in-app path found that removes a loaded extension's files; external causes (disk cleaners, sync tools, disk full) remain possible.

D. Tab-local: sender page not resolvable

getCurrentTab iterates windowControllersManager.mainWindowControllersloadedPinnedTabs + loadedTabsTab.webView (WebExtensionWindowTabProvider+macOS.swift:33-40, MainWindowController+WKWebExtensionWindow.swift:29-35). Any WKWebView whose Tab is not in a registered window's loaded tab list fails with runtime.sendMessage(): tab not found (logged as "Tab not found for message for content script message"). This is the only path that explains "one window broken, new window fine": a MainWindowController missing from mainWindowControllers while its window is still visible (WindowControllersManager.swift:158-177). Not proven from source; needs the log line correlated with window state.

Base URL rename on every load (startup window, not a freeze)

WebExtensionContext builds m_baseURL from the random default identifier at construction (WebExtensionContextCocoa.mm:222); setUniqueIdentifier does not update it (WebExtensionContext.cpp:154-166) and the app never calls setBaseURL (WebExtensionLoader.swift:153-170). The persisted lastSeenBaseURL therefore never matches, so every launch and every reload runs _renameOrigin (Cocoa.mm:297-298, 311, 516-525) and content injection + background load wait on a Network-process round trip. This is the mechanism behind the session-restoration miss. Fix: set context.baseURL = webkit-extension://<uniqueIdentifier>/ in makeContext; the rename then short-circuits synchronously (Cocoa.mm:518-520).

Instrumentation corrections

The "Evidence needed next" plan would misclassify A/B: errors is empty and loadBackgroundContent(completionHandler:) returns success immediately because WebKit believes the background is loaded. Add a third class:

  • After a health failure, send a probe runtime.sendMessage from a tab (or via the native-messaging healthCheckRequest the background already handles, background-embedded.js:3185) and treat undefined with errors == [] as "background loaded, worker dead".
  • Log WKWebExtensionContext.errors and whether the probe got a real reply.
  • Correlate with SWServer::terminateIdleServiceWorkers / memoryPressureStatusChangedForProcess / networkProcessDidTerminate in log show --predicate 'subsystem == "com.apple.WebKit"'.
  • The 30 s idle eviction means the health monitor's 5-minute episode should often close with recoveredWithoutExtensionReload; check pixel data for that pattern.

Fix candidates (app side, no WebKit change needed)

  1. On cpmMessagingStuck, unload + load the context through WKWebExtensionController (the existing reloadExtension(identifier:) path). loadBackgroundContent(completionHandler:) alone does not help in A/B because WebKit already considers the background loaded; there is no public API to drop only the background view.
  2. Set a stable baseURL (above).
  3. Use a persistent background page? Not available on iOS and not needed; alternatively keep the worker "non-idle" — not controllable from the extension.
  4. Have the content script retry init with backoff and treat undefined as failure; today cpm.js waits forever.

Repro proposals

  • A: run with memory_pressure -l critical (macOS) for a few seconds after the extension is idle >10 s, then load a CMP page. Expect no initResp, errors == [].
  • B: kill -9 the com.apple.WebKit.Networking process, then load a CMP page.
  • Debug sim persistence check: enable the sim, load one page (fails), wait 35 s with no navigation, load again — expected recovery per trunk source.