14 KiB
title, date, status, tags, related
| title | date | status | tags | related | ||||
|---|---|---|---|---|---|---|---|---|
| CPM Web Extension Breakage — Hypothesis Validation | 2026-09-08 | validated |
|
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-browsersbranchcpm-breakage-pixel; bundled extensionduckduckgo-embedded-web-extension.zipv2026.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_backgroundContentIsLoadedand 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, aWeakPtr(Page.cpp:5294-5307) read viaWebFrame::jsContextForServiceWorkerWorld(WebFrame.cpp:1057-1063) insideenumerateFramesAndNamespaceObjects(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, andruntimeSendMessageresolves the content script withundefined(APIRuntimeCocoa.mm:156-175,internalDispatchRuntimeMessageEvent639-694). - There is no hook from SW termination back to
WebExtensionContext(grep: nothing SW-related inUIProcess/Extensionsbesides_loadServiceWorker). No error is recorded inWKWebExtensionContext.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).
- System memory pressure becomes Critical. Each WebProcess's
MemoryPressureHandlersendsWebProcessProxy::MemoryPressureStatusChanged(WebProcess.cpp:554-557). - For the process hosting the CPM SW page,
WebProcessProxy::memoryPressureStatusChanged→WebProcessPool::memoryPressureStatusChangedForProcess(WebProcessProxy.cpp:2415-2423, guarded byENABLE(WEB_PROCESS_SUSPENSION_DELAY)= macOS,PlatformEnableCocoa.h:1144-1145). status == Critical && process.isRunningServiceWorkers()→NetworkProcessProxy::terminateIdleServiceWorkers(pid)(WebProcessPool.cpp:2937-2942).- Network process:
SWServer::terminateIdleServiceWorkers(connection)terminates every idle worker on that context connection, including the CPM worker (SWServer.cpp:1807-1823). - WebProcess:
SWContextManager::terminateWorker→ thread stops →ServiceWorkerGlobalScopedestroyed →Page::m_serviceWorkerGlobalScopebecomes null. SW page, its frame, and itsRuntimeOnMessage/Mainlistener registration remain. - UIProcess
WebExtensionContext: unchanged.m_backgroundWebView != nil,m_backgroundContentIsLoaded == true. - Any tab:
cpm.jssends{messageType:"autoconsent", init}(cpm.js:2896-2901, 3393-3398) →runtimeSendMessage→ tab found →RuntimeOnMessagein listener set →wakeUpBackgroundContentIfNecessary→ timer restarted →backgroundContentIsLoaded()true → dispatch to SW page process. - WebProcess: SW context null → no listeners → null reply → UIProcess aggregator default
{}→ content scriptawait sendMessageresolvesundefined; no error thrown. Background never receivesinit, never sendsinitResp. Content script stays inwaitingForInitResponse. - Repeats for every tab, every frame, every navigation; step 7 keeps the eviction timer armed.
- Ends only when: no extension event for 30 s →
unloadBackgroundContentIfPossible→unloadBackgroundWebView→ next message reloads a fresh SW (health monitor should then firecpmMessagingRecoveredWithoutExtensionReload); or Fire/reload; or app restart.
B. Network process crash or unresponsiveness
- Network process dies or is killed as unresponsive (
NetworkProcessProxy::didClose522-532,didBecomeUnresponsive181-187,ExceededMemoryLimit1850). - UIProcess:
WebProcessPool::networkProcessDidTerminate→terminateServiceWorkers()→disableRemoteWorkers(ServiceWorker)on every SW-running process → sendsWebSWContextManagerConnection::Close(WebProcessPool.cpp:497-509, 1850-1860;WebProcessProxy.cpp:3094-3126). Process is not shut down because it still owns the SW page. - WebProcess independently:
networkProcessConnectionClosed→SWContextManager::stopAllServiceWorkers()(WebProcess.cpp:1467-1468). - 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-124 → WebExtension::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.mainWindowControllers → loadedPinnedTabs + loadedTabs → Tab.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.sendMessagefrom a tab (or via the native-messaginghealthCheckRequestthe background already handles,background-embedded.js:3185) and treatundefinedwitherrors == []as "background loaded, worker dead". - Log
WKWebExtensionContext.errorsand whether the probe got a real reply. - Correlate with
SWServer::terminateIdleServiceWorkers/memoryPressureStatusChangedForProcess/networkProcessDidTerminateinlog 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)
- On
cpmMessagingStuck, unload + load the context throughWKWebExtensionController(the existingreloadExtension(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. - Set a stable
baseURL(above). - Use a persistent background page? Not available on iOS and not needed; alternatively keep the worker "non-idle" — not controllable from the extension.
- Have the content script retry
initwith backoff and treatundefinedas failure; todaycpm.jswaits 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 noinitResp,errors == []. - B:
kill -9thecom.apple.WebKit.Networkingprocess, 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.