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

203 lines
21 KiB
Markdown
Executable File
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.
# CPM Web Extension Breakage — статус и проверка причин
> **Текущий milestone, 2026-10-05: проверить attribution data и найти причины CPM breakage.** Исторический PR-план ниже — не текущая оценка готовности. Подробная трассировка: [[cpm-web-extension-breakage-findings]]; архитектура: [[cpm-service-worker-architecture-and-diagnostics]].
## Сейчас: attribution и breakage reasons
**Граница данных.** До 2026-10-05 macOS 1.210 использовалась в internal testing примерно неделю; iOS 7.240 ещё не опубликована в App Store. macOS 1.211 / iOS 7.241 — ранние internal-сборки. Считать public incidence только по данным после начала public rollout (ожидается 2026-10-06), отдельно по платформе и версии. Internal-тесты могли специально воспроизводить failure, поэтому частоту и причинность по ним не выводим.
**Что видно в attribution-bearing `debug.web.extension.cpm.messaging.stuck.*` за завершённые 2026-09-28—10-04:**
| Версия | `.count` | `.daily` | Статус |
|---|---:|---:|---|
| macOS 1.210 | 131 | 82 | Internal testing; не оценка затронутых пользователей. |
| iOS 7.240 | 6 | 5 | До App Store release. |
| macOS 1.211 / iOS 7.241 | 0 | 0 | Включены в проверку; attributed stuck пока нет. |
`.count` — репорты, `.daily` — дневные пиксели, не уникальные пользователи. Большой поток iOS stuck на 7.238/7.239 в основном **без** новых attribution-полей; его нельзя смешивать с 7.240. Сверка фильтров Kibana и Grafana ещё нужна. [Breakage board](https://grafana.duckduckgo.com/d/dfon7whlilnggb/cpm-weird-error-investigation?orgId=1).
**Проверяемые сигнатуры, пока не root causes:**
1. `bg_events`: повторный `dealloc` есть в 88/131 macOS 1.210 `.count` stuck. Internal forced-failure testing может полностью объяснять этот сигнал; он **не доказывает** естественную смерть WebContent-процесса или эффект graveyard.
2. Tab-side: в 24/131 macOS stuck отсутствуют extension scripts. В 17 из 24 `tab_controller_match=false`, хотя `tab_in_context=true`; 10 из 17 помечены `app_version_change=updated`. Гипотеза: старая/restored вкладка сохраняет прежний `webExtensionController` после создания или замены WebExtensionManager. В остальных 7 controller совпадает, но `bg_view_creations=0` (6 после update): это больше похоже на недошедший до background-view/injection startup, а не на доказанное удаление scripts. `removeAllUserScripts()` остаётся отдельной кодовой гипотезой без подтверждения этими пикселями.
3. `base_url_changed`, reload и другие причины анализировать отдельно: совпадение с stuck ещё не подтверждает причинность.
**Следующая проверка:** на public macOS 1.210 / iOS 7.240 с 2026-10-06 раздельно считать affected daily users (recovered и permanent) против DAU той же версии; разбирать ordered `bg_events` и tab-поля по каждой сигнатуре. Воспроизвести на macOS 1.210 update/session restore и off→on Web Extensions с уже открытой вкладкой: сравнить старую и новую вкладки по controller, scripts и CPM outcome. До этого не ранжировать причины по internal `.count`.
## Как получить и проверить attribution data
**Источник:** Grafana ClickHouse datasource `dekdbd6bqq1hcd` (`grafana-clickhouse-datasource`), таблица `metrics.pixels`. Использовался API через `/Users/admin/DuckDuckGo/grafana-dashboards/tools/grafana-query/read-dashboard.py`, **не** браузер и не локальный Apple worktree. Авторизация этого helper — `grafana-auth`/SSO либо `GS_GRAFANA_API_KEY`; токены в документ не записывать. [Board](https://grafana.duckduckgo.com/d/dfon7whlilnggb/cpm-weird-error-investigation?orgId=1) — визуальная сверка, SQL ниже — источник приведённых чисел.
1. Сохранить нужный SQL-блок ниже в `/tmp/cpm-query.sql`. Для исторической проверки оставить UTC-окно `[2026-09-28, 2026-10-05)`; для public заменить начало на фактический день публикации и указать **только завершённые UTC-дни**. Сверять `date`, `agent`, `appVersion`, суффикс `.count`/`.daily` и наличие `context_loaded` с Kibana.
2. Из `/Users/admin/DuckDuckGo/grafana-dashboards` выполнить этот read-only API-запрос. Заменить `FROM`/`TO` вместе с датами SQL; пустой Grafana frame без ошибки перепроверить повторным запросом, а не трактовать как ноль:
```bash
uv run --with requests python3 - /tmp/cpm-query.sql 2026-09-28T00:00:00Z 2026-10-05T00:00:00Z <<'PY'
import importlib.util
import sys
from pathlib import Path
path = Path('tools/grafana-query/read-dashboard.py').resolve()
spec = importlib.util.spec_from_file_location('grafana_reader', path)
reader = importlib.util.module_from_spec(spec)
spec.loader.exec_module(reader)
target = {'refId': 'A', 'rawSql': Path(sys.argv[1]).read_text(),
'editorType': 'sql', 'queryType': 'table', 'format': 1}
raw = reader.query_ds_query('dekdbd6bqq1hcd', 'grafana-clickhouse-datasource',
[target], reader.parse_time(sys.argv[2]), reader.parse_time(sys.argv[3]))
result = raw.get('results', {}).get('A', {})
if result.get('error'):
raise SystemExit(result['error'])
if not result.get('frames'):
raise SystemExit('No data frame returned; retry/inspect Grafana response')
for frame in result['frames']:
print('\t'.join(f['name'] for f in frame['schema']['fields']))
for row in zip(*frame['data']['values']):
print('\t'.join(map(str, row)))
PY
```
**SQL 1 — покрытие по всем версиям, включая пиксели без attribution.** Не фильтровать версии заранее: так обнаруживаются 1.211/7.241 и большой legacy iOS-поток без новых полей. `attributed` — строки с live-параметром `context_loaded`; `rows` — все stuck-пиксели. `.count` и `.daily` не складывать.
```sql
SELECT
toString(date) AS day,
agent,
arrayStringConcat(arraySlice(splitByChar('.', extractURLParameter(request, 'appVersion')), 1, 3), '.') AS app_version,
multiIf(match(pixel, '\\.daily(\\.ios\\.(phone|tablet))?$'), 'daily',
match(pixel, '\\.count(\\.ios\\.(phone|tablet))?$'), 'count', 'other') AS frequency,
count() AS rows,
countIf(extractURLParameter(request, 'context_loaded') != '') AS attributed,
countIf(extractURLParameter(request, 'bg_events') != '') AS chain_present
FROM metrics.pixels
WHERE date >= toDate('2026-09-28') AND date < toDate('2026-10-05')
AND agent IN ('ddg_ios', 'ddg_mac_desktop')
AND pixel_id = 'debug'
AND pixel LIKE 'debug.web.extension.cpm.messaging.stuck.%'
GROUP BY day, agent, app_version, frequency
ORDER BY day, agent, app_version, frequency;
```
**SQL 2 — macOS 1.210 tab-сигнатуры и предшествующие события.** Только attributed `.count` stuck; каждое число — репорты, не люди. `bg_events` упорядочены oldest→newest, но содержат лишь ограниченный хвост и грубые age buckets; `dealloc` не равен доказанному process death. Проверить эту же выборку с `.daily` для чувствительности к повторным репортам.
```sql
WITH events AS (
SELECT
extractURLParameter(request, 'tab_in_context') AS tab_in_context,
extractURLParameter(request, 'tab_controller_match') AS controller_match,
extractURLParameter(request, 'tab_has_ext_scripts') AS scripts_present,
extractURLParameter(request, 'bg_view_creations') AS view_creations,
extractURLParameter(request, 'app_version_change') AS version_change,
splitByChar(',', decodeURLComponent(extractURLParameter(request, 'bg_events'))) AS chain
FROM metrics.pixels
WHERE date >= toDate('2026-09-28') AND date < toDate('2026-10-05')
AND agent = 'ddg_mac_desktop' AND pixel_id = 'debug'
AND pixel LIKE 'debug.web.extension.cpm.messaging.stuck.%'
AND match(pixel, '\\.count$')
AND extractURLParameter(request, 'appVersion') LIKE '1.210.%'
AND extractURLParameter(request, 'context_loaded') != ''
)
SELECT
count() AS reports,
countIf(arrayCount(x -> startsWith(x, 'dealloc@'), chain) >= 2) AS repeated_dealloc,
countIf(scripts_present = 'false') AS scripts_absent,
countIf(scripts_present = 'false' AND controller_match = 'false' AND tab_in_context = 'true') AS controller_mismatch,
countIf(scripts_present = 'false' AND controller_match = 'false' AND version_change = 'updated') AS mismatch_after_update,
countIf(scripts_present = 'false' AND controller_match = 'true' AND view_creations = '0') AS same_controller_zero_views,
countIf(scripts_present = 'false' AND controller_match = 'true' AND view_creations = '0' AND version_change = 'updated') AS zero_views_after_update
FROM events;
```
**Методика интерпретации:** сначала SQL 1 проверяет покрытие версии/платформы и отсутствие attribution; затем SQL 2 группирует совместные признаки вместо чтения одиночного поля как root cause. Для конкретной сигнатуры дополнительно сгруппировать `bg_events` с сохранением порядка, reason из имени pixel, `app_version_change` и `bg_view_alive`; сверить с исходным кодом `CPMMessagingDiagnosticsRecorder` и созданием `WebExtensionManager` на `apple-browsers.git/main`. Internal forced-failure testing, app update и повторные `.count` могут смещать выборку. Public user-rate и recovered/permanent требуют отдельного user-level числителя и DAU **той же версии**; эти SQL их не вычисляют.
## Исторический статус и PR-план (2026-09-23—09-25)
> Ниже сохранён прежний план. Его формулировки о shipped status, готовности PR и дизайне graveyard A/B не являются текущим release/experiment contract; сверять с `apple-browsers.git/main` и актуальной privacy config.
## Решение
Есть два независимых класса проблем:
- **Per-tab lifecycle** исправлен в M1 и уже merged.
- **All-tabs CPM outage:** M3 telemetry merged; M4 hung recovery и объединённый M5+M6 graveyard A/B — независимые follow-up PRs.
M4 перезагружает Web Extensions при первом сигнале `hung`, который подтверждает global CPM outage после предшествующего `messaging init failure`. Сам `messaging init failure` остаётся диагностическим: он не меняет состояние расширений.
## Статус проблем
| Проблема | Статус | Что делаем |
|---|---|---|
| Forced background-process death → persistent all-tabs CPM outage | **Подтверждено** | M3 измеряет production incidence; объединённый M5+M6 PR лечит и сравнивает control/treatment. |
| Natural crash/memory/CPU termination → production outage | **Не проверено в production** | Один production release M3 до включения M5/M6. |
| Close Other Tabs, popup duplicate, pin/unpin/reorder/move/merge/window close | **Исправлено, merged** | M1 PR #6827. |
| Нестабильный Web Extension base URL | **Исправлено локально** | M2 задаёт deterministic base URL до load/reload и отвергает malformed identifier без crash. |
| Startup/session-restore content-script miss | **Подтверждено как transient** | Не объясняет persistent all-tabs outage. |
| Failed background load навсегда блокирует WebKit | **Отвергнуто** | WebKit повторяет load; нужна повторяемая причина/traffic. |
| Critical memory pressure как root cause | **Не воспроизведено** | Остаётся diagnostic correlation M3. |
| Event starvation / JSC hang / native messaging stall | **Не проверено** | Остаётся в M3 diagnostic buckets. |
| Network process restart | **Отвергнуто** | На проверенной сборке CPM восстановился. |
| removeAllUserScripts / flag transition / scriptlet reload / page JS как all-tabs cause | **Отвергнуто** | Это не persistent global failure mode. |
## Milestones
| Milestone | Branch / worktree | Готовность |
|---|---|---|
| M1 tab lifecycle | `codex/cpm-tab-lifecycle-fixes` | **Merged:** [PR #6827](https://github.com/duckduckgo/apple-browsers/pull/6827), CI green. |
| M2 stable base URL | `alex/cpm-stable-base-url` — `/Users/admin/DuckDuckGo/apple-browsers.git/cpm-stable-base-url` | **Production-ready:** 2 files; 11/11 targeted tests. Добавлена safe URL construction и negative validation вместо force-unwrap. |
| M3 attribution telemetry | `alex/cpm-failure-telemetry` — `/Users/admin/DuckDuckGo/apple-browsers.git/cpm-failure-telemetry` | **Merged 2026-09-23:** [PR #6844](https://github.com/duckduckgo/apple-browsers/pull/6844), CI green. Bucketed diagnostics, app launch/version attribution, `extension_updated`, `base_url_changed`, `recovery_from`, reload-error pixel; recorder и proxy имеют отдельные flags. [[cpm-m3-telemetry-privacy-triage]]. |
| M4 hung recovery | `codex/cpm-hung-recovery` — `/Users/admin/DuckDuckGo/apple-browsers.git/cpm-hung-recovery` | **In progress:** первый `messaging init failure` открывает diagnostic episode; первый последующий `hung` перезагружает Web Extensions. |
| M5+M6 graveyard mitigation + A/B | `alex/cpm-graveyard-mitigation` — `/Users/admin/DuckDuckGo/apple-browsers.git/cpm-graveyard-mitigation` | **Production-ready, default off; one PR over merged M3:** one retained view maximum, 3-second bounded hold, weak scheduled release, runtime cleanup, lazy stable cohort enrollment, control/treatment isolation, bounded pending outcomes, operational and experiment pixels. 431/431 WebExtensions tests; iOS/macOS builds and pixel validators pass; earlier real forced-kill iOS E2E recovered with release at ≈3.15 s. |
| M7 state-restoration readiness gate | `alex/cpm-state-restoration-race` — `/Users/admin/DuckDuckGo/apple-browsers.git/cpm-state-restoration-race` | **Implemented on macOS and iOS from current `origin/main` (`b93091e769`):** initial main-frame HTTP(S) navigation waits for Web Extension `loadAndSync`, bounded by shared `withTimeout(3)`. Timeout stops only the cancellation-aware waiter; the underlying load continues and its retained task is cleared on completion/cancellation. WebExtensions lifecycle suite: 20/20. iOS Swift compilation reached the app target successfully; the generic-simulator build then stopped in existing environment-dependent scripts because `TARGET_DEVICE_OS_VERSION` and a signing identity were unavailable. The 100-cycle macOS app-restart/session-restore UI regression was imported from the WIP worktree; its run was skipped by request. SwiftLint and diff/project-file checks pass. |
All milestone diffs pass `git diff --check`; changed Swift lines are within 150 columns.
## M7 plan — state restoration / Web Extension load race
**Confirmed race:** `setupWebExtensions()` creates the manager/controller before state restoration, but starts `WebExtensionLifecycleCoordinator.loadAndSync()` without awaiting it. `stateRestorationManager.applicationDidFinishLaunching()` runs immediately afterward. A restored tab can therefore begin its main-frame navigation while the CPM `WKWebExtensionContext` is not loaded, so WebKit cannot inject the CPM content script at document start. The existing Content Blocking navigation responder already waits for internal user scripts and rule lists; M7 adds the equivalent readiness boundary for Web Extensions.
1. Retain only the first coalesced `loadAndSync` task in `WebExtensionLifecycleCoordinator`, clear it on completion/cancellation, and expose a cancellation-aware waiter that never cancels the underlying load. **Done.**
2. Inject the waiter into both macOS `PrivacyDashboardTabExtension` and iOS `TabViewController`. Gate initial main-frame HTTP(S) navigation with the shared `WebExtensionNavigationGate`, which uses project `withTimeout(3)`. **Done.**
3. Keep the wait startup-only: after initial `loadAndSync`, the provider returns `nil`, so later navigations avoid an async hop. Non-web and subframe navigations do not wait. **Done.**
4. Cover blocking/release/cancellation, timeout without cancellation of the underlying load, late completion, default timeout, and navigation scope. WebExtensions lifecycle suite is 20/20; SwiftLint and structural checks pass. **Done.**
5. Add the existing WIP UI regression that restarts the macOS app 100 times, restores the Autoconsent page, and verifies CPM injection after every restart. **Included; execution intentionally skipped by request.**
Technical decision: awaiting `initialLoadAndSync.value` directly inside structured `withTimeout` was hang-tested and rejected. The timeout cancels its child operation, but a child suspended on `Task.value` does not finish from that cancellation, so the task group cannot return. The small cancellation-aware waiter gives `withTimeout` a cancellable wait without cancelling the retained load task.
## Stack and Git state
M3 merged: [PR #6844](https://github.com/duckduckgo/apple-browsers/pull/6844), merge commit `2d58ad8cf4fae728a92e2aae0d3ec6073ddf49f2`.
M2 — independent stable-base-URL fix. Вошедший в M3 `base_url_changed` только сообщает о смене URL между загрузками контекста с одинаковым identifier; URL и identifier не отправляются. Это не фикс M2.
Stacked PRs:
```
M3 telemetry — merged into origin/main
├── M4 hung recovery
└── M5+M6 graveyard mitigation + A/B — one PR
```
M5+M6 объединены на `origin/main` (`2d58ad8cf4fae728a92e2aae0d3ec6073ddf49f2`) без commit: в worktree лежат 19 unstaged файлов mitigation и A/B. Старый M6 worktree сохранён только как источник/backup и не является отдельным PR. M4 не изменён.
Не включать generated `Package.resolved` churn.
## Experiment contract
- Experiment: `cpmBackgroundGraveyardExperiment`.
- Cohorts: `control`, `treatment`.
- Enrollment: only on an actual eligible CPM background-process termination.
- Primary framework metric: `cpmBackgroundGraveyardOutcome`, window `0...1`, values `healthy`, `initialization_failed`, `not_measured`.
- Rollback: disable both `cpmBackgroundGraveyardExperiment` and `cpmBackgroundGraveyard`.
Full definition and config draft: [[cpm-background-graveyard-experiment-definition]].
## Remaining gates
1. Обновить и проверить M4 поверх merged M3. Открыть M2, M4 и объединённый M5+M6 PRs в topology выше.
2. Create separate iOS and macOS experiment-log tasks.
3. Create Privacy Configuration + privacy/schema review tasks.
4. Create Data Science sample-size/design and Native Apps Experiment Analysis task.
5. Ship M3 first; enable M5/M6 only if production telemetry confirms the process-death hypothesis.
M3 merged, но ещё не подтверждён как shipped production release. Feature flag registry tasks созданы отдельно для iOS/macOS; ссылки — в [[cpm-m3-telemetry-privacy-triage]]. Это обновление затрагивает только Obsidian.