Files
obsidian-vault/work/projects/cpm-web-extension-repro-and-fix-2026-10-08.md
T
2026-10-15 09:50:58 +00:00

300 lines
51 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.
---
title: CPM Web Extension — воспроизведение и фикс
date: '2026-10-08'
status: fix-uncommitted
tags:
- work
- cpm
- webkit
---
# CPM Web Extension — воспроизведение и фикс
Продолжение [[cpm-web-extension-breakage-session-summary-2026-10-07]] (§8 «План точного воспроизведения»).
Среда: macOS 26.7.1 (25G241), system WebKit, production embedded extension 2026.9.29.
Код: worktree `alex/cpm-web-extension-ready-handshake`, пакет `SharedPackages/WebExtensions`. **Ничего не закоммичено.**
## Статус (9 октября)
Карта кода по строкам: [[cpm-web-extension-code-guide-2026-10-09]]. Сводки на английском: [[cpm-web-extension-data-analysis-summary-2026-10-08]], [[cpm-web-extension-repro-and-fixes-summary-2026-10-08]]. **Ничего не закоммичено.**
- **Фиксы** — worktree `alex/cpm-web-extension-ready-handshake`: gate первой загрузки посреди сессии, watchdog Network-процесса, перезапуск по первому сбою. Тесты зелёные (490 в пакете на момент фикса).
- **Расследование без фиксов** — worktree `alex/cpm-web-extension-remaining-failures`: error 6 (причина в WebKit, репро на macOS), задержка очистки при смене `baseURL` (репро на macOS), медленный старт worker'а на пробуждении — сигнатура группы 1 воспроизведена.
- **Телеметрия фазы 1** — worktree `alex/cpm-messaging-readiness-telemetry` от свежего `main`: готовность background, возраст view, состояние приложения, вкладки, error 6, детали восстановления. 677 тестов, SwiftLint, валидатор пикселей, сборка iOS и macOS — зелёные.
- **Эксперимент** — ветка `alex/cpm-stable-base-url`: стабильный `baseURL` (на старой базе, не прогнан через harness).
- Не воспроизведены: привязка вкладок на macOS, «нет background view», код 7, первый сбой после M4; error 6 на iPhone.
## Что нашли
### Отказ одной вкладки — потеря `init` при первой загрузке background
- WebKit при *первой* загрузке extension с новым идентификатором (первая установка или обновление — апгрейд ставит новый UUID) грузит background сразу. `runtime.sendMessage`, пришедший до выполнения background-скрипта, резолвится `undefined`, listener не вызывается.
- Без gate: 14/20 при первой установке, 18/20 при обновлении; с busy-wait 1 с в скрипте — 3/3 детерминированно.
- При обычном перезапуске (extension уже установлен), после reload (M4) и при пробуждении после idle WebKit ставит сообщения в очередь — потерь нет.
- **Поправка к первому отчёту:** при запуске это уже закрывает gate в `WebExtensionLifecycleCoordinator` (флаг `webExtensionStateRestorationGate`). Дыры были: установка или обновление посреди сессии (`sync()`, отложенная установка на iOS до protected data), таймаут gate 5 с.
### Полный отказ — мёртвый worker при живом background
- **Краш Network-процесса** (`SIGKILL`, аналог jetsam на iOS): background view и его PID прежние, WebKit считает background загруженным, worker мёртв. Теряются сообщения из всех вкладок и уже открытых документов. Монитор: init failed → stuck → M4. После M4 новые навигации работают, **документы, открытые до сбоя, сломаны навсегда**. Без M4 держится, пока пользователь активен; снимается после ~30 с idle-выгрузки. 20/20.
- **Смерть worker'а при живом Network-процессе:** `_terminateServiceWorkers` (SPI) или удаление service worker registrations из data store (то, что делает очистка данных). Состояние то же.
- Fire в обоих приложениях выгружает extensions на время очистки и загружает потом, поэтому сам Fire сюда не приводит. Других путей в проде, которые убивают только worker, не нашёл.
- **Не триггеры:** `org.WebKit.lowMemory`, заморозка background на 10–75 с, kill background-процесса (WebKit перезапускает сам).
- Если background занят или заморожен дольше ~30 с, WebKit выгружает его, и сообщение «в полёте» теряется навсегда (одна вкладка, не каскад).
### Восстановление
- Убить **только background WebContent-процесс** (`_killWebContentProcessAndResetState`) — WebKit поднимет новый worker на следующем сообщении; новые навигации **и уже открытые документы** работают.
- Reload extension (M4) чинит только новые навигации.
- `loadBackgroundContent()` ничего не даёт: WebKit считает background загруженным.
### Почему мёртвый worker не обнаружить из приложения
- Регистрация SW остаётся `activated`.
- `runtime.sendMessage` из page world background web view до worker'а не доходит (WebKit считает страницу самим background-контекстом) — и у живого, и у мёртвого worker'а сразу `undefined`.
- Background CPM слушает только `onMessage`, `onConnect` (порт privacy-dashboard) и `onAlarm` — приложение не может их вызвать.
## Фикс (app, пакет WebExtensions)
1. **Gate для первой загрузки посреди сессии.** После `installEmbeddedExtension` менеджер запоминает задачу `loadBackgroundContent()` нового контекста; `coordinator.initialLoadWaiter` (его уже вызывают обе платформы на каждой main-frame навигации) после открытия стартового gate отдаёт этот waiter, не дольше 5 с.
2. **`EmbeddedBackgroundLivenessWatchdog`.** Запоминает PID Network-процесса background'а; при смене перезапускает background-процесс. Проверка на каждой навигации и раз в 1 с.
3. **Перезапуск по первому сбою.** Монитор вызывает `onEpisodeStarted` на первом сбое обычной навигации; менеджер перезапускает background, **если процесс отвечает** (`callAsyncJavaScript("return 1")`, 1 с). Замороженный или занятый не трогаем: убийство остановленного процесса давало глухой новый worker.
4. Пункты 2 и 3 включаются одним переключателем `isEmbeddedBackgroundRecoveryEnabled` (по умолчанию `true`).
| Сценарий | Без фикса | С фиксом |
|---|---|---|
| Установка посреди сессии | 3/3 потерь | 5/5 доставлено |
| Обновление посреди сессии | 5/5 потерь | 5/5 доставлено |
| Краш Network-процесса, 4 вкладки + открытые документы | все сломаны | всё работает |
| Краш Network-процесса без M4 | не восстанавливается | работает |
| Смерть worker'а (SPI / удаление регистраций) | 2 таймаута → M4, старые документы сломаны | 1 потерянная навигация, затем всё работает без reload |
## Фиксы ↔ гипотезы
| Фикс | Гипотеза | Подтверждена данными? |
|---|---|---|
| Gate на первую загрузку посреди сессии | Потеря `init` при eager-загрузке background нового идентификатора (317981), не про Network-процесс | Нет. Косвенно — `app_version_change=updated`, но запуск и так под gate; частота установок посреди сессии неизвестна |
| Watchdog по PID Network-процесса | Краш Network-процесса → мёртвый worker при «живом» background | Частично (см. ниже) |
| Перезапуск по первому сбою | Мёртвый worker по **любой** причине при отзывчивом процессе background'а | Не проверена: из app причину не видно |
## Сверка с собранными данными (5 октября)
Источник: `apple-browsers.git/cpm-public-attribution-2026-10-06/events.tsv` (строки `stuck` .count) и `events_daily.tsv` (в скобках). Группы по приоритету, как в атрибуции.
| Группа по снимку | iOS: всего / `network_restarted=true` | macOS: всего / `network_restarted=true` |
|---|---|---|
| Вкладка: controller/scripts/tab | 81 / 10% (13%) | 102 / 1% (2%) |
| Нет background view | 158 / 11% (14%) | 35 / 3% (3%) |
| Error 6 (background не загрузился) | **579** / 10% (14%) | 3 / – |
| `died_*` (процесс background умер) | 14 / 86% (85%) | 0 |
| Снимок без видимой ошибки | **1 410** / **17% (21%)** | **705** / **30% (45%)** |
**За гипотезу о Network-процессе:** смена Network-процесса заметно чаще в группе «без видимой ошибки» — там, где её предсказывает механизм. В остальных группах ~10% на iOS и 1–3% на macOS. Смерть процесса background'а почти всегда вместе со сменой Network-процесса — похоже на jetsam на iOS, убивающий оба.
**Почему это не доказательство:** `network_restarted` сравнивает PID с загрузкой *контекста*, а не с последним созданием background view. В почти всех таких строках последние события — `view, proxy_on` за 30 с – 5 мин до снимка. Если view пересоздан *после* смены процесса, worker свежий и механизм не применим. Порядок из текущих данных не восстановить.
**Итог:** механизм объясняет в лучшем случае 20–45% «здоровых» `stuck`. Остальное — другие дефекты, которые текущими фиксами не покрыты:
- **Error 6 — ~1/3 всех строк iOS.** WebKit сам сообщает, что background не загрузился (`dealloc → error:6 → view…`). Worker не поднимается, а не тихо умирает. Перезапуск по сбою почти не поможет: после error 6 view обычно нет. **Отдельный приоритет для iOS.**
- **Вкладка не привязана** (macOS: неверный controller и нет scripts; iOS: нет scripts) — привязка вкладки к контексту, фиксы не трогают.
- **Нет background view** — WebKit не пересоздал background; обоим механизмам нужен живой view.
- **«Здоровые» без смены Network-процесса — крупнейший необъяснённый остаток** (1 170 iOS / 497 macOS). Кандидаты: другая смерть worker'а (поймает перезапуск по сбою), поздний или несопоставленный `refreshCpmDashboardState` при живом worker'е (перезапуск — ложное срабатывание), первая загрузка вне gate. Без `native_seen` не разделить.
## Непокрытые группы: расследование (8 октября)
Worktree `alex/cpm-web-extension-remaining-failures`, ветка от того же коммита `a0717cf098`, **без фиксов** — чтобы воспроизводить на коде как в проде. Фиксы остаются незакоммиченными в `alex/cpm-web-extension-ready-handshake`.
- В harness перенесена инфраструктура (без watchdog/recovery) и подключён production `CPMMessagingDiagnosticsRecorder`: воспроизведённые сбои дают те же параметры пикселей, что и прод (`bg_events`, `context_errors`, `network_restarted`, …). Watcher логирует `context.errors`.
- Новый набор `CPMReproBackgroundLoadFailureTests`: idle-выгрузка (~32 с), затем пробуждение навигацией при заданном условии.
### Error 6 (`BackgroundContentFailedToLoad`, ~1/3 строк `stuck` на iOS)
WebKit (`WebExtensionContextCocoa.mm`): error 6 ставится, когда `_loadServiceWorker` вернул `success=false` (`:2796`) или упала навигация background-страницы (`:3052`). **Код 7 — внутренний `ScriptExecutionError`** (`didEncounterScriptError`, `:284`): JS-ошибка в скрипте extension во время работы. В проде: `7,6` — 283, только `6` — 259, `6,7` — 103 из 645 строк iOS с error 6.
| Условие при пробуждении (macOS) | Результат |
|---|---|
| Без помех | доставлено |
| Network-процесс заморожен 5/10/60 с | пробуждение потеряно, error 6 нет, потом всё работает |
| Network-процесс убит перед пробуждением | доставлено |
| Background убит во время загрузки | пробуждение потеряно (`died_client`), error 6 нет |
| Файлы extension отсутствуют | error 6 + `WKWebExtensionErrorDomain:2` — **в проде кода 2 нет, не причина** |
| Background-скрипт бросает на верхнем уровне / нет `browser`/`chrome` | error 6 **без** кода 7 — точная продовая сигнатура «только 6», но продовой причины падения скрипта не нашлось |
| **Новый процесс background заморожен ≥30 с во время загрузки** | **таймаут загрузки SW → `background none` + error 6**, цепочка `view, proxy_on, unresponsive, error:6, dealloc`; 10 с — error 6 нет; после разморозки работает |
**Причина в коде WebKit (подтверждено):** `WebExtensionContext::wakeUpBackgroundContentIfNecessary` (`WebExtensionContext.cpp`) первым делом вызывает `scheduleBackgroundContentToUnload()` — взводит 30-секундный idle-таймер выгрузки **до** того, как service worker загрузился. Если загрузка не закончилась за 30 с, таймер вызывает `unloadBackgroundContentIfPossible` → `unloadBackgroundWebView()` → view закрывается посреди загрузки → колбэк `_loadServiceWorker(success=false)` → error 6. Это объясняет продовый порядок `dealloc → error:6 → view` и репро с заморозкой: 45 с → error 6, 10 с → нет.
**Тест-репро на macOS** (`Tests/WebExtensionsTests/CPMRepro/CPMReproBackgroundLoadFailureTests.swift`, worktree `alex/cpm-web-extension-remaining-failures`), с проверками, все 9 зелёные:
```bash
cd SharedPackages/WebExtensions && CPM_REPRO=1 swift test --filter CPMReproBackgroundLoadFailureTests
```
- `testBackgroundStalledPastUnloadTimerFailsToLoad` — новый процесс background заморожен на 45 с при пробуждении → `context_errors` = только `:6`, `bg_load_error_after=25s_to_35s`, `bg_load_error_app_state=active`, следующее пробуждение работает.
- `testBackgroundStalledLessThanUnloadTimerLoads` — 10 с → error 6 нет.
- `testMissingExtensionFilesFailWithAFileError`, `testThrowingWorkerScriptFailsToLoad`, `testWorkerWithoutExtensionBindingsFailsToLoad` — error 6 через `under_5s` (отличаются от таймера по новому параметру).
- `testWakeWithoutInterferenceLoads`, `testFrozenNetworkProcessDoesNotFailTheLoad`, `testNetworkProcessCrashBeforeWakeDoesNotFailTheLoad`, `testBackgroundKilledDuringLoadDoesNotFailTheLoad` — error 6 нет.
**iOS Simulator:** тесты пакета непоказательны — процесс `xctest` не получает RunningBoard assertions, background не работает с начала. Приложение DDG в симуляторе работает, но фоновое приложение не приостанавливается. Видно, что CPM сам будит background будильником `cpm-summary` (через 2 мин после активности), в том числе когда приложение в фоне.
**iPhone 12 mini, iOS 26.7.1, debug-сборка DDG (установлена поверх 7.221.0)** — error 6 **не воспроизвёлся**:
| Сценарий | Результат |
|---|---|
| Свернуть на 2,5 мин при стоящем будильнике | приложение приостановлено целиком, будильник срабатывает при возврате, загрузка ~0,1 с |
| Разбудить и сразу свернуть (4 задержки) | загрузка успевает за ~0,1 с |
| Звук играет в фоне, будильник будит background в фоне | background создаётся и грузится, через 30 с штатно выгружается |
| Фоновый запуск системой (`--no-activate`) | не проверено: консоль не подключается, `log collect` требует `sudo` |
Вывод: на ненагруженном устройстве загрузка всегда укладывается в 30 с. Оставшиеся кандидаты: нехватка памяти / jetsam на нагруженных устройствах; фоновый запуск системой (`fetch`/`processing`), когда приложение приостанавливают посреди загрузки, а при возврате просроченный таймер срабатывает раньше её завершения. Решено не подбирать условия вслепую, а добавить телеметрию (ниже).
**Телеметрия для error 6 — реализована (не закоммичено, worktree `alex/cpm-web-extension-remaining-failures`):**
- Новые параметры у всех 8 CPM-пикселей с диагностикой на iOS и macOS (`debug_web_extension_cpm_initialization_failed_after_*`, `debug_web_extension_cpm_messaging_stuck_*`):
- `bg_load_error_app_state` — состояние приложения, когда контекст последний раз записал error 6: `none` / `active` / `inactive` / `background`;
- `bg_load_error_after` — время от создания view, чья загрузка упала, до error 6: `none` / `under_5s` / `5s_to_25s` / `25s_to_35s` / `over_35s`. `25s_to_35s` — idle-таймер WebKit.
- Код: `CPMAppSessionDiagnostics` получил провайдер `applicationState` (iOS — `UIApplication.applicationState`, macOS — `NSApp.isActive`); `CPMMessagingDiagnosticsRecorder` фиксирует состояние и задержку в момент записи error 6, сбрасывает при новом контексте; `CPMMessagingDiagnostics` отдаёт параметры (`none`, если ошибки не было; опускает, если неизвестно).
- JSON5: `params_dictionary.json5` и определения (iOS `debug_pixels.json5`, macOS `web_extensions.json5`); `validate-pixel-defs` проходит на обеих платформах.
- Тесты: 2 unit-теста в `CPMMessagingDiagnosticsTests.swift` (захват состояния и задержки, бакеты) + сквозная проверка в тесте-репро. Весь пакет: 459 тестов, 0 падений. SwiftLint чистый.
- Ожидаемое чтение данных: `25s_to_35s` + `background` → загрузка, застрявшая из-за фона; `25s_to_35s` + `active` → медленная загрузка на переднем плане (память, нагрузка); `under_5s` → сломанный скрипт или файлы.
Возможный фикс после данных: при возврате приложения на передний план, если `context.errors` содержит error 6, — перезапустить background; либо не будить background, пока приложение в фоне (изменение extension или WebKit).
### Группа 1 («здоровый» снимок без смены Network-процесса) — разбор 8 октября
**Данные (`events_daily.tsv`, `stuck`):** iOS 748, macOS 228.
- Самый частый хвост `bg_events` — `dealloc → view → proxy_on`, последний `view` моложе 30 с до снимка: iOS 219 (+154 при 1m), macOS 120 из 228. Сбой совпадает с **пробуждением background после idle-выгрузки**.
- Около четверти iOS (и ~15% macOS) — после загрузки контекста: хвосты `load → view` / `base_url_changed → view`, `reason=extension.reload` (iOS 71, macOS 39).
- macOS: `app_version_change=updated` у 144 из 228 (63%; в среднем по stuck — 52%).
- `context_errors=none` у 675/748 iOS; память (`critical_memory_age`) почти везде `none`.
- ~~Ответ часто опаздывает, а не теряется~~ — **поправка 8 октября:** `recovered_without_extension_reload(initialization_failed)` к stuck не относится. Он закрывает только эпизод, который ещё **не** stuck: пришёл опоздавший ответ на упавшее измерение или следующая навигация в той же вкладке ответила. Stuck-эпизод сразу уходит в M4, и его восстановление считается как `recovered_after_extension_reload(messaging_stuck)`; без reload из stuck — 0 строк.
- У recovery-пикселей только `appVersion` и `cpm_recovery_from`: хвостов, задержки и признака «тот же документ / следующая навигация» нет.
- `initialization_failed` — только `.daily` (на причину), recovery — daily+count. Сравнимо только daily/daily за 5 октября: iOS 1 555 / 6 624 = 23% (33% от `after_other`), macOS 353 / 1 405 = 25% (33% от `after_other`). count/daily recovery ≈ 1,9 на устройство-день на iOS — у одних и тех же устройств повторяется.
- Stuck → recovered after M4 (daily): iOS 916 / 1 440 = 64%, macOS 362 / 489 = 74%.
- Хвосты «здоровых» init-сбоев vs stuck (iOS): `load/base_url_changed → view` — ~32% init против ~18% stuck; `dealloc → view` (пробуждение) — ~44% init против ~61% stuck (macOS: ~36% → 52% только `@30s`). То есть сбои после загрузки контекста чаще проходят сами (согласуется с задержкой чистки при смене `baseURL`), а пробуждение чаще доходит до stuck.
- Нужно в телеметрию: на recovery-пиксель — задержку ответа от начала измерения (бакеты 4–8 с / 8–30 с / >30 с), `same_document` vs `next_navigation` и хвост `bg_events` на момент сбоя.
- Версии ОС: на macOS «здоровые» сбои есть и на 26.7, и на 27.0 (92 и 178 init в срезе сравнения ОС). Версии iOS в выгрузке нет — нужен запрос в ClickHouse.
**WebKit-баги (bugs.webkit.org, компонент WebKit Extensions, изменения с мая 2026):**
- **320812 / 312231@main — медленная чистка при загрузке контекста (главная находка).** Начиная с 26.6, `WebExtensionContext::load()` при смене `baseURL` переименовывает localStorage/IndexedDB и удаляет **все** website data предыдущего origin, включая DiskCache (обход всего сетевого кэша на главном потоке Network-процесса). **`m_safeToInjectContent`, `addInjectedContent()` и загрузка background ждут окончания этой чистки.** У DDG `baseURL` новый при каждом запуске и каждом M4 reload. Код есть и в 7624.5.1.11.3 (macOS 26.7.1), и в 7625.1.29 (27.0). Фикс 318756@main (7 авг) попал в ветку safari-7625 (`316606.407@safari-7625-branch`); в какую сборку ОС — не проверено.
- **324743** (fixed 22 сен, нет в 26.7/27.0) — worker считается загруженным до завершения регистрации. Зона «пробуждение после idle»; наши тесты пробуждения на Mac проходят, детерминированного репро нет.
- **322881** (fixed 31 авг) — worker теряет clients после выгрузки и перезагрузки; CPM clients не использует.
- **314194** (открыт) — в **Lockdown Mode** блокируются все service worker'ы, включая background extension → CPM никогда не работает. Доля пользователей DDG с Lockdown Mode неизвестна; сигнатура в наших данных не определена.
- **319976** (fixed) — `loadBackgroundContentWithCompletionHandler` вызывал completion дважды при ошибке; мы используем этот API в gate.
**Репро медленной чистки на Mac** (`CPMReproExtensionLoadCleanupTests.swift`, нужен локальный сервер `CPM_REPRO_CACHE_URL` с `/fill.html?n=N`): кэш тестового процесса наполнен ~24 тыс. записями (у реального DDG на этом Mac — 18,7 тыс., 877 МБ).
| | Пустой кэш | ~24 тыс. записей |
|---|---:|---:|
| Перезапуск: content script выполнился на мс документа | 23 | 770–1 350 |
| Перезапуск: background создан через | 0,2 с | 0,9–1,5 с |
| Reload (как M4): content script на мс документа | — | 780–790 |
На быстром Mac задержка меньше 4 с монитора, поэтому сбоя нет. На iPhone с большим кэшем и медленным CPU нужно мерить (на тестовом iPhone кэш маленький: «Context will load» → «Background web view created» за 30–70 мс).
**Проверено и отброшено:**
- Блокировка устройства: у iOS-приложения нет entitlement'а `default-data-protection` → класс `CompleteUntilFirstUserAuthentication`; файлы доступны при блокировке после первой разблокировки. Исключение — запуск до первой разблокировки, для него есть `protectedDataLoadGate`.
- Устаревший URL вкладки на iOS (`addressBarURLFilter` не обновляет URL при межоригинном переходе со страницы): в `didCommit` `url = webView.url` выставляется синхронно до `init` → для обычной навигации ответ приходит с верным URL.
**Медленный старт worker'а на пробуждении — сигнатура группы 1 воспроизведена (8 октября, эксперимент, не закоммичено).** Тест `CPMReproWakeExperimentTests` в обоих worktree: после idle-выгрузки в начало background-скрипта на диске добавляется busy-wait (только на этот wake), через 1 с скрипт восстанавливается.
- busy 0,3 / 1 / 2,5 с и задержка `activate` 1–3 с: сообщения ставятся в очередь и доставляются, задержка ответа = время старта. Потерь нет.
- busy 5 с: `initialization_failed_after_other` с `bg_process_responsive=true`, `context_errors=none`, хвост `dealloc/view/proxy_on@30s` — как в проде. Ответ приходит через ~1 с после сбоя → `recovered_without_extension_reload(initialization_failed)`.
- busy 12 с + следующая навигация после сбоя: второй сбой → `messaging_stuck_other` с той же сигнатурой → M4. M4 убивает медленный, но живой worker: `init` уже открытых документов теряется, новые навигации работают (`recovered_after_extension_reload`).
- С фиксами (worktree A) то же самое. Рестарт на первом сбое (`onEpisodeStarted`) не сработал: проба отзывчивости по 1 с истекла на занятом процессе. Если же медленный старт не загружает CPU (например, ждёт I/O), проба ответит, и рестарт убьёт worker, который ещё грузится. Это нужно закрыть.
- Чем реально вызван старт дольше 4 с на устройствах, неизвестно. Кандидаты: конкуренция за CPU с загружаемой вкладкой, запуск процесса без prewarm на iOS, низкий приоритет процесса extension (RunningBoard), занятый Network-процесс. Мерить на iPhone: от wake до первого `recv init`.
- Speculative fix: (1) будить background заранее — при старте навигации или при выходе приложения на передний план, без блокировки; (2) не эскалировать в M4 и не рестартовать, пока background view моложе ~15 с; (3) телеметрия: возраст background view в секундах на момент сбоя и задержка позднего ответа на recovery-пикселе; (4) опционально держать background живым, пока приложение на переднем плане (цена — память одного WebContent-процесса).
**Сигналы готовности background (тест `testReadinessSignalsDuringSlowWake`, worker стартует 5 с):**
- `isLoading=false` у background view через 0,11 с — бесполезен: страница загружается задолго до worker'а.
- `loadBackgroundContent` completion пришёл через 5,096 с, сразу после выполнения кода worker'а верхнего уровня («prelude evaluated» на 5,08 с). Это точный сигнал. Вызов сам будит выгруженный background.
- Первое нативное сообщение worker'а (`getResourceIfNew`) — на 5,088 с. Тоже точный сигнал, менять расширение не нужно.
- SPI для готовности нет: у `WKWebExtensionContext` из относящегося к background есть только `_backgroundWebView`, `_backgroundContentURL`, `isLoaded` (это контекст, не background) и `loadBackgroundContentWithCompletionHandler:`.
- В диагностике нет числа вкладок и числа загружающихся вкладок; гипотезу «много вкладок → медленный старт» проверить нечем.
**Стабильный `baseURL` (ветка `alex/cpm-stable-base-url`, не закоммичено): оценка.**
- Объясняет только подгруппу «после load/reload» (~¼ iOS, ~15% macOS группы 1) и, вероятно, первый сбой после M4. Основная часть (пробуждение после idle) не затронута: чистка идёт только в `load()`.
- Изменение: `context.baseURL = webkit-extension://<uniqueIdentifier>/`, где `uniqueIdentifier` = `UUID()` на установку/обновление (`WebExtensionManager+EmbeddedExtensions.swift:157`).
- Приватность: во всех 4 встроенных манифестах нет `web_accessible_resources`; `cpm.js` не вызывает `runtime.getURL`; в background `getURL` только для `tabs.create` (страница расширения). Страница не может прочитать или проверить URL. Данные origin расширения и сейчас переживают перезапуск: WebKit их переносит (`_renameOrigin`). Риск появится, если расширение с WAR получит стабильный URL. Поэтому стабильный URL нужен только для встроенных расширений без WAR.
- Обновление: новый UUID → новый URL; старый контекст выгружается до загрузки нового — проверить. M4 reload с тем же URL: проверить, что нет `BaseURLAlreadyInUse` и что битая SW-регистрация не переживает reload (сценарии network-crash + watchdog).
- До принятия: прогнать harness (cleanup, M4, network crash) на ветке; телеметрия длительности load→view.
### Привязка вкладок на macOS (`tab_controller_match=false` + нет scripts, ~100 строк)
- **Гипотеза атрибуции не подтвердилась.** Путь в коде есть: выключение флага `webExtensions` вызывает `uninstallAllExtensions()` и обнуляет менеджер, включение создаёт новый с новым controller, а `WKWebViewConfiguration` старых вкладок не меняется. Но флаг `.webExtensions` = `remoteReleasable(WebExtensionsSubfeature.featureEnabled)` с `defaultValue: .enabled`, а подфичи `featureEnabled` нет ни во встроенном, ни в текущем удалённом `macos-config.json` → флаг всегда `enabled` (отсутствующая подфича возвращает `defaultValue`). Мигать нечему.
- **Порядок запуска не объясняет:** `setupWebExtensions()` (`AppDelegate.swift:1509`) вызывается до восстановления сессии (`:1526`), раньше вкладки не создаются.
- `tab_controller_match` считается как `tabController != nil && tabController === context.webExtensionController` (`CPMMessagingDiagnosticsRecorder.swift:378`) — `false` и при **отсутствующем** controller у вкладки, и при устаревшей ссылке на контекст.
- Следующие кандидаты: вкладки из `createWebViewWith` (popup / `target=_blank`) и кэш закреплённых вкладок; пересборка user scripts в `UserContentController` приложения при обновлении правил content blocking — может стирать скрипты extension из вкладки (на iOS есть группа «скриптов нет при верном controller»).
### Ещё не начато
- **Нет background view** (158 iOS / 35 macOS).
- Причина долгого старта worker'а на реальных устройствах (группа 1): мерить на iPhone от пробуждения до первого `recv init` при загружающейся вкладке; дождаться `bg_ready_after` / `bg_view_age` / `open_tabs` из прода.
## Телеметрия для валидации
Выпустить раньше фиксов или вместе с ними в A/B — иначе эффект фиксов не отделить. Все значения — bool/enum/bucket, без URL и идентификаторов; оформить через `ddg-add-pixel`, privacy triage (прецедент — [[cpm-m3-telemetry-privacy-triage]]).
**Подтвердить сбои — параметры к существующим `initialization_failed` / `stuck`:**
1. `network_replaced_since_view` (bool) — PID Network-процесса при последнем создании background view ≠ текущему. Прямая подпись мёртвого worker'а. Recorder уже вычисляет это (`CPMMessagingDiagnosticsRecorder.swift:285-289`), но только логирует. Плюс токен `net_replaced` в `bg_events`, чтобы видеть порядок. **Самое дешёвое и самое ценное.**
2. `native_seen` (`none` / `site_check` / `refresh_other_url`) — были ли нативные вызовы от background для этой навигации (background зовёт `isFeatureEnabled` с URL вкладки при обработке `init`). Делит сбой на «не дошло до worker'а» / «worker обработал, refresh опоздал» / «рассогласование URL на native». Только app.
3. `bg_first_load` (bool) + токены `install` / `upgrade` / `bg_loaded` в `bg_events` — подпись бага первой загрузки.
4. `gate` (`none` / `launch` / `startup` / `timeout`) — прошла ли навигация мимо gate или он открылся по таймауту 5 с.
**Подтвердить, что фиксы работают:**
1. Пиксель `debug_web_extension_cpm_background_restarted` (daily+count): `trigger` = `network_process_replaced` / `initialization_failed`; `outcome` = `restarted` / `skipped_unresponsive` / `spi_unavailable`.
2. Атрибуция восстановления: `recovery_action` (`none` / `background_restart`) к `recovered_without_extension_reload`, либо пара `recovered_after_background_restart` / `background_restart_failed` (следующая навигация после перезапуска тоже упала → M4).
3. Ожидаемый эффект: падают `messaging_stuck` и M4 (`recovered_after_extension_reload`); почти исчезают вторые сбои эпизода с `native_seen=none` + `network_replaced_since_view=true`; исчезают сбои с `bg_first_load=true` вне запуска.
4. **A/B, а не просто флаг:** recovery и новый gate — за экспериментальным флагом (`ddg-add-feature-flag`), когорта в CPM-пикселях. Сравнение версий (1.208 → 1.209) уже оказалось неинформативным.
Не измерить без extension: восстановление уже открытых документов (монитор меряет только навигации) и причину смерти worker'а при живом Network-процессе.
### Фаза 1 реализована — worktree `alex/cpm-messaging-readiness-telemetry` (от `main` 92e09cdbbc, 8 октября, не закоммичено)
Только телеметрия, поведение детектора не меняется.
- На `initialization_failed_*` и `messaging_stuck_*` (iOS + macOS):
- `bg_ready_after` — `none` / `pending` / `failed` / `under_1s` … `over_30s`. Источник — completion у `loadBackgroundContent`. Он вызывается один раз на следующем шаге run loop после `didCreateBackgroundWebView`: view уже есть, поэтому вызов лишь присоединяется к идущей загрузке и ничего не будит. Отключается тем же флагом, что и delegate proxy (`cpmBackgroundDelegateProxy`). Код — `CPMBackgroundReadinessObserver.swift`.
- `bg_view_age` — `under_4s` / `4s_to_8s` / `8s_to_30s` / `30s_to_2m` / `over_2m`.
- `app_state` — состояние приложения в момент сбоя.
- `open_tabs` — размер `context.openTabs`, бакетами; `loading_tabs` — сколько из них в `isLoading`.
- Перенесены из worktree B: `bg_load_error_app_state` и `bg_load_error_after` (error 6).
- На `recovered_without/after_extension_reload`:
- `recovery_document` — `failed_navigation` (опоздавший ответ), `later_navigation`, `unattributed`;
- `recovery_after` — время от первого сбоя эпизода.
- Проверено на реальном WebKit (harness, медленный старт worker'а 5 с на пробуждении): сбой ушёл с `bg_ready_after=pending`, `bg_view_age=4s_to_8s`, а восстановление — с `recovery_document=failed_navigation`, `recovery_after=under_4s`.
- Пакет: 677 тестов зелёные. SwiftLint чистый. `validate-pixel-defs` проходит на iOS и macOS.
- Замечание по harness: `network_restarted` там иногда `true` без падения Network-процесса; вероятная причина — тестовый процесс перезапускает Network-процесс между загрузкой контекста и сбоем (не проверено). К продовой телеметрии это не относится, но в тестах этот флаг для классификации не годится.
## Тесты
Папка `Tests/WebExtensionsTests/CPMRepro/`, запуск из `SharedPackages/WebExtensions`:
- `CPM_REPRO=1 swift test --filter CPMRepro` — путь приложения: `CPMReproSingleTabTests` (10), `CPMReproFullFailureTests` (8). Должны быть зелёными.
- `CPM_REPRO=1 CPM_REPRO_WEBKIT=1` — ещё `CPMReproWebKitTests` (7): защиты выключены, тесты утверждают, что баги WebKit всё ещё есть. Покраснеют, когда Apple исправит — сигнал убрать обходы.
- Ручки: `CPM_REPRO_ITERATIONS`, `CPM_REPRO_FREEZE`, `CPM_REPRO_INSTALL_DIR` (настоящий перезапуск процесса: запустить дважды).
- Unit-тесты без env: `EmbeddedBackgroundLivenessWatchdogTests` (11), 4 новых в `WebExtensionLifecycleCoordinatorTests`, 3 новых в `CPMMessagingHealthMonitorTests`.
## Открытые вопросы — решить до коммита
- [ ] **Feature flag.** Watchdog и перезапуск по первому сбою сейчас включены по умолчанию (`isEmbeddedBackgroundRecoveryEnabled = { true }`), в приложениях не подключены к удалённому флагу. Завести флаг (`ddg-add-feature-flag`), желательно раздельно для watchdog и для перезапуска по сбою.
- [ ] **Риск перезапуска по первому сбою.** Первых сбоев много (~6.6k установок/день на iOS); часть — не мёртвый worker (нет инъекции, медленный native). Перезапуск дешевле M4, но рвёт незавершённую работу background для других вкладок. Решить: включать сразу или через эксперимент.
- [ ] **Телеметрия.** Восстановление после перезапуска сейчас попадает в `recovered_without_extension_reload` и неотличимо. Реализовать план из раздела «Телеметрия для валидации»; минимум — `network_replaced_since_view`, `native_seen` и пиксель `background_restarted`.
- [ ] **Порядок выкатки.** Телеметрия раньше фиксов или вместе с ними в A/B-эксперименте с когортой в CPM-пикселях.
- [ ] **Error 6 на iOS** (~1/3 строк `stuck`) не покрыт фиксами. Причина найдена в WebKit (idle-таймер 30 с взводится до окончания загрузки), репро на macOS есть. На iPhone (iOS 26.7.1) в четырёх сценариях не воспроизвёлся. Выпустить телеметрию `bg_load_error_app_state` / `bg_load_error_after`, по данным выбрать фикс. Опционально: фоновый запуск системой с `sudo log collect` на Mac пользователя.
- [ ] На тестовом iPhone стоит debug-сборка DDG вместо 7.221.0 — вернуть из App Store, когда тесты закончатся.
- [ ] **Привязка вкладок на macOS:** гипотеза мигания флага отброшена; проверить popup-вкладки, кэш закреплённых вкладок и пересборку user scripts.
- [x] Телеметрия для error 6: состояние приложения и задержка до ошибки — реализовано в `alex/cpm-web-extension-remaining-failures` (не закоммичено). Нужны privacy triage и решение, в каком PR выпускать.
- [ ] **Остаток «здоровых» сбоев без смены Network-процесса** (группа 1): около половины — пробуждение, сигнатура воспроизведена медленным стартом worker'а; около четверти iOS — после загрузки контекста (задержка очистки при смене `baseURL`). Подтвердить телеметрией фазы 1.
- [ ] **SPI.** Перезапуск через `_killWebContentProcessAndResetState`, детекция через `_networkProcessIdentifier`; есть `responds(to:)`. Согласовать с командой (приложение уже использует SPI WebKit в этом месте).
- [ ] **Сборка приложений с фиксами.** Worktree с фиксами (`alex/cpm-web-extension-ready-handshake`) iOS и macOS не собирал; изменения обратно совместимы. Worktree телеметрии собран на обеих платформах.
- [ ] **Перезапуск по первому сбою vs медленное пробуждение.** Если медленный старт не занимает CPU, проба ответит и перезапуск убьёт грузящийся worker. Не перезапускать и не эскалировать в M4, пока background не готов (`bg_ready_after=pending`) или view моложе ~15 с — это фаза 2 детектора.
- [ ] **Фаза 2 детектора:** отсчёт 4 с от готовности background; отдельный пиксель «background не стартовал за 30 с»; stuck / M4 / перезапуск только при готовом background.
- [ ] **Раннее пробуждение background** при начале навигации и выходе приложения на передний план (`loadBackgroundContent`, без блокировки).
- [ ] **Стабильный `baseURL`:** ограничить встроенными расширениями без `web_accessible_resources` (сейчас диф меняет URL для всех в `makeContext`), прогнать harness (M4, краш Network-процесса, обновление), перенести на свежий `main`.
- [ ] **Выпуск телеметрии фазы 1:** privacy triage, решить, отдельным PR или вместе с фиксами в A/B.
- [ ] **Номер сборки ОС с фиксом 318756@main** (очистка при смене `baseURL`) — не проверен.
- [ ] **Zero-loss для смерти worker'а.** Нужен ping-канал в extension (native port с heartbeat или обработчик ping) — задача в репозитории extension.
- [ ] **Не воспроизведено:** продовое «первый probe после M4 падает» (670 iOS / 232 macOS). В тестах после M4 навигации проходят.
- [ ] **WebKit-баги для репорта:** (1) потеря сообщения при eager-загрузке background первой загрузки; (2) после краша Network-процесса или удаления регистраций `WebExtensionContext` не замечает мёртвый worker.
- [ ] **Прогнать на macOS 27 VM** — фикс 317981 может уже стоять; тогда `testWebKitDropsInitDuringFirstBackgroundLoad` покраснеет.
- [ ] Отчёт `cpm-web-extension-repro-results-2026-10-07.md` в `apple-browsers.git` устарел: он не учитывает gate приложения и фиксы. Актуальное — эта заметка.