18 KiB
tags, updated, status
| tags | updated | status | |||||
|---|---|---|---|---|---|---|---|
|
2026-11-11 | draft |
TD: Pixel Definition: CPM Wake Init Detection (macOS/iOS + embedded web extension)
Internal metrics, keep out of public PRs. Builds on cpm-permanent-breakage-metric-tech-design-2026-11-10 (the pixel this design extends). Where we are: cpm-project-summary-2026-11-03. Wake background and what was ruled out: cpm-ios-idle-wake-investigation-2026-10-30, cpm-post-release-analysis-2026-10-27.
Author: Alex M
Reviewer: TBD (previous CPM pixel designs: Michal)
Stakeholders: Michal, Konrad, Russell (from the previous Tech Designs; confirm). Extension change: the privacy-extension owners.
Project: CPM: root-cause the stuck embedded extension messaging (macOS/iOS)
Task: to create ("Extension lifecycle metrics", from the plan in cpm-project-summary-2026-11-03)
Previous Tech Designs: cpm-readiness-telemetry-tech-design-2026-10-15 (background readiness, removed), cpm-m3-telemetry-privacy-triage
Privacy Triage: to request together with cpm-permanent-breakage-metric-tech-design-2026-11-10
Extension PR: duckduckgo-privacy-extension#3727, branch alex/cpm-init-progress
Background & Requirements
Failures after a background wake are the largest group that has no fix. On macOS 1.214 it is 39% of all failures and 36% of permanent ones (0.68% of DAU); on iOS 7.244, 22% and 15% (0.06% of DAU). The data excludes the usual causes:
- The background is ready:
bg_ready_afterwas under one second in 93% of macOS wake episodes. The earlier readiness fix (P1) had no measurable effect on either platform. - A bare idle-unload and wake does not lose messages in the real-WebKit harness: 0 of 1,500 wake navigations in 1,000 cycles on a fixture page.
- A live view, a responsive process and a loaded context describe the container, not whether the init message was processed.
What the app cannot see is where the startup message stops. The chain for one main-frame document is:
content script sends init → message reaches the background listener → config → user setting → site check → initResp to the page + dashboard refresh to native.
The app only sees the last step (dashboard refresh), and that step is a separate branch that does not prove the page received initResp. So today every one of these looks the same: no answer.
Requirements:
- Tell where the init chain stopped for a page that never got its answer.
- Know whether the page actually received
initResp, not only whether the dashboard refreshed. - No change in what the extension does for the page, and no extra work on the path that is suspected to be fragile beyond the marks themselves.
- Backward compatible in both directions: an older app must tolerate a newer extension, and a newer app an older extension.
Problem Statement
For every tab reported silent by the permanent breakage metric, record the furthest init step the extension reported for its page. With that:
- Separate "init never reached the background" from "background stalled on its own storage or native round trip" from "answer could not be delivered to the page".
- Join the result with
background(woken, warm) so wake failures are split from the rest. - Pick the next fix from data, not from the hypotheses list (H1–H5 and R1 in cpm-post-release-analysis-2026-10-27).
Collect no page content, URLs or persistent identifiers.
Recommended Approach
The extension reports progress marks for each main-frame init as fire-and-forget native notifications. The app keeps the furthest mark per tab and adds last_stage of the silent tab to web_extension_cpm_breakage. A mark is never an answer: only the dashboard refresh answers a page, as in the companion design. A report is made only when a tab is silent, so marks never create a report and response_failed does not report anything by itself.
Marks
| Stage | Reported by | When | Rank |
|---|---|---|---|
page_init_sent |
content script, relayed by the background | the content script is about to send init |
0 |
received |
background | first thing when init arrives, before any await |
1 |
config_read |
background | remote config available | 2 |
settings_checked |
background | user setting read from native | 3 |
site_checked |
background | per-site check answered by native | 4 |
response_sent |
background | tabs.sendMessage(initResp) resolved |
5 |
response_failed |
background | the same call rejected | 6 |
Marks are best effort: they carry { tabId, url, stage }, never wait and never reject, never write to the diagnostics error list, and cannot change CPM state or what the page gets. The native side keeps the highest rank seen for the tab's page; an out-of-order arrival never lowers it. A mark is tied to a tab the same way a dashboard refresh is (the learned extension tab identifier, or the one committed page its URL matches); a mark that cannot be tied to exactly one tab is dropped. A mark that arrives within 2 s before the commit is kept for the new page.
Native side
AutoconsentWebExtensionMessageHandlergets acpmInitProgressmethod. Unknown stage names are ignored without an error, so a newer extension cannot make an older app answer with failures.CPMMessagingHealthEvent.initProgresscarries the mark; the monitor stores the furthest stage per tab for the tab's current page only (a mark about another page is not counted) and keeps marks for 2 s so a mark that arrives just before the commit counts for the new page; the stage is reset at every commit, when the tab closes or crashes and on an extension reload.- A mark does not answer a page and does not end a series. The breakage report takes
last_stagefrom the tab it reports, not from any other tab. - The Debug "Simulate CPM Messaging Breakage" switch hides marks as well as dashboard refreshes, and marks from an old embedded extension are ignored.
Pixel change
web_extension_cpm_breakage (defined in cpm-permanent-breakage-metric-tech-design-2026-11-10) gains one parameter:
| Parameter | Values |
|---|---|
last_stage |
none (no mark reached the app or none could be tied to the tab), page_init_sent, received, config_read, settings_checked, site_checked. response_sent can appear: the page got initResp but no dashboard refresh answered the tab. response_failed appears when delivering initResp failed. |
web_extension_cpm_breakage_persistent is unchanged. No new pixel. Names, frequency and the other parameters are unchanged. Both platforms' definitions and params_dictionary.json5 get the parameter.
How to read last_stage
The marks are relayed by the background, so a dead background cannot report. none therefore cannot tell a missing content script, a lost message to the background and a lost notification apart.
last_stage on a breakage |
What it says | Next action |
|---|---|---|
none |
nothing reached native: content script not injected, init and the mark lost on the way to the background, or the background cannot notify native |
check injection (tab_has_extension_user_scripts is gone); WebKit-side loss on wake (R1, H2) |
page_init_sent |
the content script sent; the background never processed init |
message lost between content script and worker listener after a wake: content-script retry of init, WebKit bug report |
received |
init arrived; remote config or session storage did not finish |
H1 (unbounded config/storage reads, shared state queue): bound the awaits with ordering-safe semantics |
config_read |
stuck on the user setting round trip to native | native round-trip stall, H5: answer from cached config first |
settings_checked |
stuck on the site check | same, per-site call |
site_checked |
all checks passed, but neither initResp nor a dashboard refresh arrived |
the page's tab is gone or unreachable (tab not connected) |
response_failed |
tabs.sendMessage rejected |
tab not found or content script gone: the page's context changed under the answer |
A site or a setting that disables CPM legitimately ends the chain at settings_checked or site_checked, but then the extension still sends the dashboard refresh, so those pages are healthy and never appear here.
Extension change
All changes are in duckduckgo-privacy-extension, branch alex/cpm-init-progress. Commit b00cc227 (pushed, PR #3727) has received, response_sent and response_failed; the remaining marks are an uncommitted working-tree change that has not been tested.
| File | Change |
|---|---|
shared/js/background/components/cpm-embedded-messaging.js |
notifyInitProgress(tabId, url, stage): nativeMessaging.notify('cpmInitProgress', …) inside the existing timeout helper; catches every error; never records a diagnostics error. |
shared/js/background/components/cookie-prompt-management.js |
received at the top of the main-frame init branch, before any await; config_read after the config is available; settings_checked after checkAutoconsentSetting; site_checked after checkAutoconsentEnabledForSite; notifyInitResponseProgress wraps tabs.sendMessage(initResp) to report response_sent or response_failed, and rethrows so the failure is still an unhandled rejection reported as extensionException. A page-side cpmInitProgress message is accepted only for the main frame and only for page_init_sent; unknown stages and sub-frames are ignored. |
shared/js/cpm.js |
reportInitProgress('page_init_sent') right before the content script sends init; wrapped in try/catch, never awaited, never delays init. |
unit-test/background/cookie-prompt-management.js |
order of marks, no await before received, received before the state reset, dispatch when the remote config is unavailable, a site that disables CPM ends at site_checked, response_failed keeps the rejection unhandled, sub-frame and unknown-stage marks ignored. |
Compatibility and sequencing:
- The embedded extension ships inside the app bundle, so the extension change reaches users only with an app release after the extension PR merges and the embedded files are updated. The native handler can ship first: it ignores a missing mark, and
last_stage=noneis the value for an app that has no marks yet. - Older app, newer extension: the handler ignores unknown stages. An app without the
cpmInitProgresshandler is expected to answer with an error thatnotifyInitProgressswallows; to confirm against the real router before merge. cpm.jsis one bundle for every build (shared/js/cpm.jsgoes to the Chromium, Chromium-embedded and embedded builds).page_init_senttherefore also reaches the desktop-extension background, wherenotifyInitProgressis absent and the message is dropped by an early return. Cost: one extraruntime.sendMessageper main-frame document in those builds. To confirm before merge: that this does not wake a sleeping MV3 worker earlier than the realinitwould, and whether the message should be limited to the embedded build.- Rollout is by release channel: internal builds carry the new extension first, public builds follow. No remote switch inside the extension:
receivedis sent before the config is read, so the config cannot gate it.
Pros
- Turns "no answer" into a position in a known chain, which maps directly to the hypotheses and to concrete fixes.
response_sentandresponse_failedshow cases the dashboard refresh hides (the page did not getinitRespalthough the refresh arrived); a tab is still reported only when the refresh is also missing.- Stays out of the page's way: fire-and-forget, no waits, no state changes.
- Both sides tolerate the other being older.
Cons
- Marks pass through the same background whose failure we study, so the earliest failures collapse into
none. - Six notifications per main-frame document (five stages before the answer, then
response_sentorresponse_failed), on top of the native round trips init already makes. The load on the path suspected to be fragile is not zero; its size has not been measured. - The extension part reaches users only with an app release; the first data comes about two release cycles after the work is done (data in 1–2 release cycles, per the plan).
- A change to the page-visible content script (an extra message) on every build.
Risks
- The marks change the behavior they measure (a heisenbug). Mitigation: internal builds first, compare breakage rates and the
last_stagemix between releases with and without marks; if the cost is visible, dropconfig_readandsite_checkedin the next extension release. response_sentcan be a false positive. Iftabs.sendMessageresolves without a listener (WebKit can resolveundefinedfor a message with no receiver), a page without the content script would showresponse_sent. It never answers a page, so the metric is not affected; only the reading oflast_stage=response_sentis. A real-WebKit test with no listener should settle it.- Marks arrive out of order or late. Handled by rank; a tab reported before its last mark arrives carries an earlier
last_stage. Marks arriving after the report are ignored. - A mark for a tab that is not mapped yet is dropped when its URL matches two tabs (a mark before the first commit is kept for 2 s and counted for the page that commits), so
nonealso covers marks lost to attribution. - A hostile page forges marks. The content script runs in an isolated world; the page cannot call
chrome.runtime. The background accepts a page-side mark only forpage_init_sentand only from the main frame.
Testing
- Extension:
make unit-test(the cookie-prompt-management suite),npm run tsc,npm run lint. The uncommitted part has not been run. - Native unit tests (PR 4, package suite 1071 tests): the report carries the furthest stage of the silent tab and not of another tab; no marks give
none; a mark is not an answer; unknown stage ignored by the handler; a mark that cannot be tied to one tab dropped; a mark just before the commit belongs to the new page and an older one is forgotten; old extension and simulation ignored; closed tab forgets its marks;last_stagein the pixel on both platforms. Mutation checks (a mark answers, rank lowers, stage kept forever, stage always cleared) each fail a test. App-hosted tests were not run. - Real WebKit (CPM repro harness, PR #7378): the full chain shows every mark in order on a healthy page; a page without the content script yields no
response_sent; an injected stall at each await shows the matchinglast_stage; a delay on the native round trips forconfig_readandsettings_checked. These runs have not been done. - Pixel definitions pass both platforms' validators in CI.
- Privacy check of real payloads: the marks contain
urlon the extension-to-app channel only; the pixel carries the enum only.
Additional Considerations (if applicable)
Privacy
One new enumerated parameter on a pixel that is itself new in cpm-permanent-breakage-metric-tech-design-2026-11-10. The marks carry the tab ID and the URL from the extension to the app inside the app (the dashboard refresh already does), but neither enters a pixel or a log (the monitor's log descriptions omit URLs). Privacy Triage to be requested once for both designs. Not requested yet.
Security
No new private WebKit selectors. A new native message method, cpmInitProgress, is read-only: it parses a stage name and a tab, never executes content and never answers with data. Page-origin spoofing is covered in Risks.
Site Breakage
No intended page-behavior change. The only extension behavior change is the notifications; the initResp call keeps its arguments and its rejection behavior (notifyInitResponseProgress rethrows).
Experimentation
None. The comparison is across releases and channels (see the heisenbug risk).
Operational
- Order of landing: native handler and
last_stage(PR 4,alex/cpm-init-marks,8751a745ab, on top of PR 3a696254ca3) can ship before the extension; the extension PR merges after review and enters the app through the usual embedded-files update. - After the first public release with marks:
last_stage × background × platformover the breakage count, and the share of breakages by stage, to pick the fix according to the table above. Data checks as in the companion design. - The marks and the parameter are temporary investigation tools. Remove
config_read,settings_checked,site_checkedandlast_stagewhen the wake group has a fix or is closed (project plan: close the project and reset the target if the new metrics show nothing actionable). The definitions have no expiry date, so the removal is a follow-up PR. - Not in scope: per-stage durations (a time bucket between marks), the background's own lifecycle metrics, and the idle-unload distance. They are the next candidates if
last_stageleaves the wake group unexplained.
Localization / Internationalization
None.
Open questions
- Six marks per page or fewer: is
config_readandsite_checkedworth the extra notifications, or arereceived,settings_checkedand the response enough for the first read? - Limit
page_init_sentto the embedded build, or accept the extra message on every build? - Is an extension-side switch needed (a remote-config setting read before sending
received), or is release-channel rollout enough? - Who owns the extension review, and can the embedded-files update be timed to the app release that carries the native handler?