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

189 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags:
- work
- cpm
- tech-design
- pixel
- web-extension
updated: '2026-11-11'
status: 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)](https://app.asana.com/1/137249556945/task/1216761517055116)
**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](https://github.com/duckduckgo/duckduckgo-privacy-extension/pull/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 tab reported silent by the permanent breakage metric, record the furthest init step the extension reported for its page. 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.
## 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
- `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 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_stage` from 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=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` and `response_failed` show cases the dashboard refresh hides (the page did not get `initResp` although 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_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` can be a false positive.** If `tabs.sendMessage` resolves without a listener (WebKit can resolve `undefined` for a message with no receiver), a page without the content script would show `response_sent`. It never answers a page, so the metric is not affected; only the reading of `last_stage=response_sent` is. 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 `none` also 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 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 (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_stage` in 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](https://github.com/duckduckgo/apple-browsers/pull/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, `alex/cpm-init-marks`, `8751a745ab`, on top of PR 3 `a696254ca3`) 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 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_stage` leaves the wake group unexplained.
**Localization / Internationalization**
None.
## Open questions
1. 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?
2. Limit `page_init_sent` to the embedded build, or accept the extra message on every build?
3. Is an extension-side switch needed (a remote-config setting read before sending `received`), or is release-channel rollout enough?
4. Who owns the extension review, and can the embedded-files update be timed to the app release that carries the native handler?