Files
obsidian-vault/work/projects/cpm-wake-init-detection-tech-design-2026-11-10.md
T

17 KiB
Raw Blame History

tags, updated, status
tags updated status
work
cpm
tech-design
pixel
web-extension
2026-11-10 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_after was 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 document judged broken by the permanent breakage metric, record the furthest init step the extension reported for it. With that:

  1. 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".
  2. Join the result with background (woken, warm) so wake failures are split from the rest.
  3. 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.

The extension reports progress marks for each main-frame init as fire-and-forget native notifications. The app keeps the furthest mark per document, reports the page at once on response_failed, and adds last_stage to web_extension_cpm_breakage. response_sent is recorded but not counted as the page's answer until it is verified against real WebKit.

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 document; an out-of-order arrival never lowers it. Marks about a document the monitor cannot tie to exactly one tab are dropped.

Native side

  • AutoconsentWebExtensionMessageHandler gets a cpmInitProgress method. Unknown stage names are ignored without an error, so a newer extension cannot make an older app answer with failures.
  • CPMMessagingHealthEvent.initProgress carries the mark; the monitor stores lastStage per document and clears it on a new navigation, a cancelled navigation and an extension reload.
  • Only the dashboard refresh counts as the page's answer. response_sent is recorded as a stage; response_failed reports a pending page at once.
  • The Debug "Simulate CPM Messaging Breakage" switch hides marks as well as dashboard responses.

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), page_init_sent, received, config_read, settings_checked, site_checked. response_sent can appear: the page got initResp but no dashboard refresh was attributed. response_failed appears when it was the reason.

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=none is the value for an app that has no marks yet.
  • Older app, newer extension: the handler ignores unknown stages. An app without the cpmInitProgress handler is expected to answer with an error that notifyInitProgress swallows; to confirm against the real router before merge.
  • cpm.js is one bundle for every build (shared/js/cpm.js goes to the Chromium, Chromium-embedded and embedded builds). page_init_sent therefore also reaches the desktop-extension background, where notifyInitProgress is absent and the message is dropped by an early return. Cost: one extra runtime.sendMessage per main-frame document in those builds. To confirm before merge: that this does not wake a sleeping MV3 worker earlier than the real init would, 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: received is 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_sent is closer to the real criterion (the page got initResp) than a dashboard refresh.
  • 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_sent or response_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_stage mix between releases with and without marks; if the cost is visible, drop config_read and site_checked in the next extension release.
  • response_sent is a false positive. If tabs.sendMessage resolves without a listener (WebKit can resolve undefined for a message with no receiver, and the shipped router already returns undefined for a successful runtime.sendMessage(init)), a page without the content script would be counted healthy and breakage underestimated. Mitigation: a real-WebKit test with no listener must show response_failed or a missing response_sent; until that passes, response_sent is recorded but not counted (open question 1).
  • Marks arrive out of order or late. Handled by rank; a document judged before its last mark arrives would carry an earlier last_stage. Marks arriving after the report are ignored.
  • 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 for page_init_sent and 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: stage ranking and not lowering; response_sent as healthy; response_failed as an immediate judgement; unknown stage ignored; a mark that cannot be tied to one tab dropped; lastStage cleared on a new navigation and on reload; last_stage in the pixel; simulation hides marks.
  • 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 matching last_stage; a delay on the native round trips for config_read and settings_checked. These runs have not been done.
  • Pixel definitions pass both platforms' validators in CI.
  • Privacy check of real payloads: the marks contain url on 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 in the stack of the companion design) 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 × platform over 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_checked and last_stage when 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 expiry date follows the pixel definition (2027-01-15).
  • 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_stage leaves the wake group unexplained.

Localization / Internationalization

None.

Open questions

  1. Count response_sent as healthy in the first release, or only record it until the no-listener test proves it cannot be a false positive?
  2. Six marks per page or fewer: is config_read and site_checked worth the extra notifications, or are received, settings_checked and the response enough for the first read?
  3. Limit page_init_sent to the embedded build, or accept the extra message on every build?
  4. Is an extension-side switch needed (a remote-config setting read before sending received), or is release-channel rollout enough?
  5. Who owns the extension review, and can the embedded-files update be timed to the app release that carries the native handler?