Files
obsidian-vault/work/projects/cpm-status-2026-10-15.md
T

242 lines
40 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 — единый статус (PR, данные, гипотезы, телеметрия)'
date: '2026-10-15'
tags:
- work
- cpm
- status
---
# CPM Web Extension: единый статус на 15 октября 2026
Внутренний документ. **Цифры из ClickHouse, в публичные PR, комментарии и issues не переносить** (репозитории `duckduckgo/apple-browsers` и `duckduckgo/duckduckgo-privacy-extension` публичные). Заменяет заметку `cpm-telemetry-data-requirements-2026-10-15` (теперь ссылка сюда). Старые документы с устаревшими числами: [[cpm-web-extension-public-attribution-2026-10-05]], [[cpm-web-extension-data-analysis-summary-2026-10-08]], [[cpm-web-extension-repro-and-fix-2026-10-08]], [[cpm-web-extension-code-guide-2026-10-09]] (карта «большого» worktree, **больше не актуальна**: всё нарезано на PR ниже).
## 1. Коротко
- Фикс **B** (падение Network-процесса) проверен локально и красным прогоном на CI. **Основание в данных есть, но неполное:** обогащение в stuck есть, а доказать, что строки это именно мёртвый worker, нельзя (не хватает `network_replaced_since_view`).
- Фикс **A** (первая загрузка посреди сессии) реален, но мелкий: около 1% stuck на iOS, на macOS почти не виден, чаще самоизлечивается.
- Три самые крупные группы сбоев фиксами A и B **не закрыты**: пробуждение после idle, error 6 (iOS), «нет background view» на запуске (iOS). Механизмы воспроизведены локально на macOS, но проверенного рычага в приложении нет.
- Телеметрия PR0 пока в виде, вызвавшем вопросы о склейке пикселей. Решение по правкам не принято (раздел 8).
## 2. Цепочки PR (все в `duckduckgo/apple-browsers`, задачи Asana в телах PR; не читать)
**Телеметрия:**
| PR | Ветка | База | Что | Статус |
|---|---|---|---|---|
| #7376 (PR0) | `alex/cpm-messaging-readiness-telemetry` | main | параметры готовности и тайминга на failure/recovery пикселях (без новых пикселей) | CI зелёный, нужен ревью. Ревьюер по пикселям задал вопросы JA1–JA3 о склейке, ответ не отправлен |
| #7384 (0b) | `alex/cpm-readiness-baseline` | #7376 | ежедневные базлайн-пиксели `cpm_background_ready_{context_load,wake}`, нагрузка устройства, возраст возобновления | CI в основном зелёный |
| #7385 (0c) | `alex/cpm-init-progress-native` | #7384 | `init_stage` на failure-пикселях (нужен JS из privacy-extension#3727) | CI в основном зелёный |
| privacy-extension #3727 | `alex/cpm-init-progress` | | уведомления `cpmInitProgress` из встроенного расширения | открыт, нужен ревью |
**Harness и фиксы** (красный коммит, затем зелёный; не ребейзить и не форс-пушить, иначе пропадёт доказательство красного прогона):
| PR | Ветка | База | Что | Статус |
|---|---|---|---|---|
| #7378 | `alex/cpm-repro-harness` | main | harness (только тесты); 4 быстрых теста в CI около 3 с, остальное локально через `CPM_REPRO=1` | CI зелёный, нужен ревью. Danger: 1570 добавленных строк (предупреждение). В ветке есть коммит другой сессии «Require initResp in the page…» |
| #7379 | `alex/cpm-fix-init-loss` | harness | фикс A: ожидание первой загрузки background после установки/апгрейда посреди сессии | красный прогон и зелёный: CI зелёный |
| #7381 | `alex/cpm-fix-network-crash` | фикс A | фикс B: перезапуск background при замене Network-процесса и мёртвом worker'е | **красный прогон подтверждён** (падает `testNetworkProcessCrashKeepsEveryTabWorking`, 3 ретрая, Non-Sandbox). Фикс-коммит `f1085846e7` **локально, не запушен** |
| (нет PR) | `alex/cpm-fix-pixels` | фикс B + PR0 | пиксели `background_restart` и `first_load_wait` для измерения эффекта | держим до слияния PR0, A, B |
Локальные проверки фикса A: на коде без фикса оба repro-теста красные (5 из 5 итераций), с фиксом 0 из 10 потерь, 761 тест пакета зелёный. Фикс B: на `main` красные тесты падения Network-процесса и смерти worker'а, 5 из 5 итераций.
Недоделано в harness и CI: CI гоняет тесты с `-test-iterations 3 -retry-tests-on-failure`, красный тест должен падать стабильно; тесты WebExtensions идут внутри схем `macOS Browser` и `iOS Browser` на `osx-tahoe-27`. Медленные эксперименты (заморозка 45 с, заполнение кэша около 10 минут, медленный старт worker'а) локальные и не для коммита.
## 3. Данные (ClickHouse, 12–14 октября, версии iOS 7.240+ и macOS 1.210+)
Единица: `daily`-пиксель = устройство-день на причину, не пользователь и не эпизод. `initialization_failed` только `.daily`; `stuck`, `recovered_*`, `reload_failed`: `daily`+`count`. Параметры снимка есть только на init_failed и stuck.
| | iOS | macOS |
|---|---|---|
| `initialization_failed.daily` | 3 273 025 | 119 950 |
| `stuck.daily` | 511 660 | 40 842 |
| init восстановились без reload | 1 135 477 (34.7%) | 30 738 (25.6%) |
| stuck → восстановились после M4 reload | 293 557 (57.4%) | 21 865 (53.5%) |
| `reload_failed.daily` | 140 921 (27.5% от stuck) | 17 796 (43.6%) |
**Когорты** (по приоритету: error 6 → нет view → вкладка не подключена → Network заменён → пробуждение (view <30 с после `dealloc`) → недавняя загрузка (`load`/`base_url_changed` ≤2 мин) → other). Доля init → stuck:
| Когорта | iOS | macOS | Смысл роста или падения |
|---|---|---|---|
| error 6 | 6.1% → 21.5% | 1.0% → 1.9% | остаётся сломанной |
| нет background view | 48.1% → 10.9% | 3.3% → 1.8% | самоизлечивается |
| вкладка не подключена | 0.03% → 0.05% | 6.2% → 8.9% | остаётся сломанной |
| Network заменён | 16.8% → 39.7% | 13.6% → 26.6% | остаётся сломанной |
| пробуждение | 7.9% → 9.5% | 32.5% → 32.2% | без изменений |
| недавняя загрузка контекста | 15.7% → 6.0% | 20.0% → 6.4% | самоизлечивается |
| other | 5.3% → 12.5% | 23.4% → 22.2% | |
Объёмы `stuck.count` в разных версиях на порядки выше `daily`: дашборды сравнивать только по версии и частоте.
## 4. Основание фиксов
- **B:** доля «Network заменён» растёт в 2–2.4× при переходе init → stuck. `reload_failed` высок: reload расширения мёртвый worker не лечит. **Оговорка:** `network_restarted` сравнивает PID с загрузкой *контекста*, а не с последним view. Если view пересоздан после смены процесса, worker свежий и механизм неприменим. По заметке 8 октября механизм объясняет в лучшем случае 20–45% «здоровых» stuck. Причинность подтвердят `network_replaced_since_view` (нет ни в одной ветке) и `init_stage=none` (#7385).
- **A:** сигнатура «загрузка контекста ≤2 мин, не при запуске, первый view»: iOS 129k init (3.9%) → 4.6k stuck (0.9%); macOS 505 → 118. Разовая потеря одной навигации (0.23× при переходе в stuck). Сигнатура неточна (захватывает M4-reload). Название связанной Asana-задачи («init loss on background unload») относится скорее к группе пробуждения, чем к фиксу A; несоответствие надо снять с автором задачи.
## 5. Картины событий (stuck.daily, 14 октября, хвост `bg_events` без возрастов) и воспроизведение
| Картина | Хвост | iOS / macOS | Воспроизведено? | Рычаг в приложении |
|---|---|---|---|---|
| **P1. Цикл пробуждения** | `view>proxy_on>dealloc>view>proxy_on` | 16.0k / 4.3k (wake) + 16.3k / 2.4k (старше 30 с) | только с искусственно занятым worker'ом (5 с: init_failed и поздний ответ; 12 с: stuck → M4); органическое пробуждение в тестах проходит. WebKit-баг 324743 (исправлен 22 сентября, нет в 26.7/27.0) | гипотезы: отсчёт 4 с от готовности background («фаза 2 детектора»), ожидание готовности при пробуждении, предпрогрев |
| **P2. «Пробуждение → error 6»**, циклы | `proxy_on>dealloc>view>proxy_on>error_E:6` (23.6k), `error>dealloc>view>proxy_on>error` (5.7k), после `died_crash` (1.5k) | iOS; macOS 118 | macOS: заморозка нового процесса на ≥30 с даёт error 6, корзина 25–35 с. iPhone: 4 сценария безуспешно. Главный iOS-хвост **без** `unresponsive` (в воспроизведении он есть) | корень в WebKit (`wakeUpBackgroundContentIfNecessary` взводит 30-секундный таймер до загрузки worker'а). Гипотеза: перезагрузка расширения при возврате на передний план |
| **P3. Контекст загружен, view нет** | `load` / `load>base_url_changed` | 17.1k + 0.9k / 162 + 81 | задержка очистки при смене `baseURL` воспроизведена на macOS при большом кэше (около 10 минут заполнения); iOS-гонка запуска (`bg_view_creations=0`, восстановление сессии, первые 30 с) не воспроизведена: холодный старт в harness'е отвечает за миллисекунды | кандидат: стабильный `baseURL` (ветка `cpm-stable-base-url`: не доведён, меняет URL всем расширениям, не прогнан через harness, старая база). Фикс WebKit 318756@main, номер сборки ОС не проверен |
| **P4. Первая загрузка** | `load>view>proxy_on` | 6.8k + 2.3k / 570 | да (фикс A) | фикс A |
| **P5. Фон выгружен в момент сбоя** | `proxy_on>dealloc>view>proxy_on>dealloc` | 1.9k / 494 | да для test-only потери регистрации живой вкладки; другие гипотезы проверяются (§10) | кандидат для tab_in_context=false: восстановить корректный tab lifecycle; естественный вход не доказан |
| **P6. macOS: вкладка без расширения** | `load>view>proxy_on>dealloc` / `load>view>proxy_on`; варианты с `extension_updated` отдельно | iOS ≈0 / 627 + 235 | до idle воспроизведено отсутствием controller; полный прогон см. §10 | гипотеза: view создана без текущего controller или сохраняет controller прежнего менеджера; естественный вход не найден |
| **Network заменён** | | см. раздел 4 | да (краш, worker мёртв при том же view) | фикс B |
| **«Здоровый» остаток, other** | | iOS 12.5%, macOS 22% | нет | нет гипотезы |
Размер группы не равен возможности её починить: P1 и P3 в основном опаздывающие ответы, а часть «сбоев» это срабатывание детектора («нет ответа за 4 с», опоздавший ответ тоже считается). iOS-когорта «other» на самом деле тот же рисунок P1, но со view старше 30 секунд.
## 6. Телеметрия
**Что делает PR0 (#7376):** на 8 failure-пикселей и 2 recovery-пикселя добавлены параметры `bg_ready_after`, `bg_view_age`, `bg_load_error_after`, `bg_load_error_app_state`, `cpm_app_state`, `cpm_open_tabs`, `cpm_loading_tabs` (failure) и `cpm_recovery_document`, `cpm_recovery_after` (recovery). Новых пикселей нет. Recovery-пиксели получают только свои два параметра, `extension_reload_failed` без параметров.
**Замечания ревьюера (JA1–JA3):** корреляция между пикселями из-за множества перечислений; сколько пикселей на ошибку; константность `cpm_open_tabs`/`cpm_app_state`; что такое background view.
**Что верно по пикселям (исправление моей прежней ошибки):** `initialization_failed` только daily (не больше одного на устройство в день на причину); `stuck` daily+count (daily и count одного события идут с одинаковыми параметрами); snapshot пересобирается при каждой отправке, поэтому быстро меняющиеся поля (`bg_view_age`, `bg_ready_after`) между init_failed и stuck различаются, медленные (`app_state`, `open_tabs`) повторяются.
**Уникальность строк `stuck.daily` внутри дня** (доля уникальных по всем параметрам):
| Вариант | iOS | macOS |
|---|---|---|
| сейчас, `bg_events` из 12 событий | 8.3% | 30% |
| информативные токены, последние 4 | 2.1% | 5.2% |
| скаляры вместо `bg_events` | 0.96% | 4.4% |
| без `bg_events` | 0.25% | 2.0% |
Верхняя граница прироста от новых полей (синтетическое независимое равновероятное поле): со старым `bg_events` при десяти значениях iOS 21%, macOS 65%; со скалярами 3.6% / 15.8%. **Вывод:** «хуже не будет» можно утверждать только со скалярами. Главный идентификатор уже выкачен (`bg_events`).
**`bg_events`:** единственный источник давности загрузки (`load`, `base_url_changed`) и причины смерти (`died_*`). `view`, `dealloc`, `proxy_on`, `error_*` дублируют `bg_view_creations` и `context_errors`; `hold/release/proxy_off` равны нулю. Скаляры воспроизводят когорты загрузки и пробуждения на 100% строк. Теряется: поиск неожиданных последовательностей в остатке (смягчить удалённым флагом).
**Минимум данных по гипотезам:**
| Поле / пиксель | Вопрос | Где |
|---|---|---|
| `bg_ready_after`, `bg_view_age` | идёт ли загрузка, пробуждение или давно | PR0 |
| `bg_load_error_after`, `bg_load_error_app_state` (только при error 6) | таймер 30 с, состояние приложения | PR0 |
| `recovery_document`, `recovery_after` | опоздавший ответ или потерянный | PR0 |
| `app_state`, `open_tabs` (грубо, 1 / 2–5 / >5), только на `initialization_failed` | когорта «нет view» на запуске | PR0 |
| **новое:** `context_load_cause`, `context_load_age` (вместо `load@` и `extension_updated`) | точная сигнатура A | PR0 |
| **новое:** `bg_died_reason`, `base_url_changed` | смерть процесса, очистка при смене baseURL | PR0 |
| **новое:** мелкие корзины `app_launch_age` для первых 30 с (<4 / 4–10 / 10–30) | гонка запуска | PR0 |
| **новое на stuck:** `stuck_after` | сколько от первого сбоя до stuck | PR0 |
| **новое, срочно:** `network_replaced_since_view` | доля реальных мёртвых worker'ов в когорте B (рекордер вычисляет, но только логирует; предложено 8 октября, **нигде не реализовано**) | PR0 |
| `init_stage` | дошёл ли init до background | #7385 (срочно) |
| `cpm_background_ready_{context_load,wake}` | базлайн здоровых загрузок и пробуждений | #7384 |
| `background_restart` (причина, исход; **убрать из него `bg_ready_after`**, он дублирует соседний failure-пиксель), `first_load_wait` | эффект B и A | удержанная ветка |
| убрать | `loading_tabs`; из таймлайна `view`, `dealloc`, `proxy_on`, `error_*`, `hold/release/proxy_off` | |
Связность стека: #7384 добавляет ещё 5 измерений на те же 8 failure-пикселей (`context_load_to_background_view`, `cpm_low_power_mode`, `cpm_thermal_state`, `cpm_lockdown_mode`, `cpm_resume_age`); оценку склейки надо повторять и по нему. `background_restart` срабатывает вместе с первым `initialization_failed`: общий ключ `bg_ready_after`.
## 7. Критерии успеха и опровержения
- **B** подтверждается, если после релиза падают доля «Network заменён» среди stuck и `reload_failed`, `background_restart` показывает восстановление, а у таких stuck `init_stage=none`.
- **A** подтверждается, если сокращается когорта «мид-сессионная первая загрузка» (`context_load_cause`). Если нет, основание слабое.
- Остаток P1/P3: гипотеза «медленный старт, а не потеря» подтверждается, если `bg_ready_after` у упавших заметно хуже базлайна здоровых загрузок и `recovery_document=failed_navigation`.
## 8. Открытые решения и следующие шаги
1. **Правки PR0** (ждут вашего «да»): скаляры вместо `bg_events`, `context_load_cause`, `stuck_after`, `network_replaced_since_view`, мелкие корзины `app_launch_age`, убрать `loading_tabs`, константы только на `initialization_failed`, `open_tabs` грубо, `bg_load_error_*` только при error 6; после этого ребейз #7384 и #7385. Альтернатива «оставить `bg_events`» требует ответа ревьюеру без утверждения «хуже не будет» и обязательства перемерить после релиза.
2. **Ответ ревьюеру** (публично, **без цифр**): JA1 (init_failed daily-only; stuck daily+count с одинаковыми параметрами; recovered несут только `recovery_*`), JA2 (медленные поля повторяются, быстрые различаются; планируется сократить набор), JA3 (background view = скрытый `WKWebView`, в котором WebKit запускает фоновый скрипт расширения; выгружается через около 30 с простоя и пересоздаётся при пробуждении). Обязательство: перемерить различимость строк после первого релиза.
3. **Запушить фикс B** (`f1085846e7`): красный прогон подтверждён, ждёт зелёного.
4. **Судьба фикса A:** слабое основание в данных, не совпадает с названием задачи; решить, сливать ли без зелёного измерения эффекта через `first_load_wait`.
5. **P3:** довести стабильный `baseURL` (ограничить встроенными расширениями без `web_accessible_resources`, прогнать harness, перенести на свежий `main`), проверить номер сборки ОС с фиксом 318756@main.
6. **Локальные repro для P1–P3** отдельным PR без фиксов (по образцу harness'а, `requireLocalRun`), чтобы гонять после релизов WebKit.
7. **iOS:** воспроизведение error 6 на реальном устройстве; сценарий «вкладка без расширения» на macOS.
8. Блокеры по ревью: #7376 и #7378 ждут ревью; #7384/#7385 стоят на #7376.
## 9. Методика и инструменты
- Доступ: `~/Developer/grafana-data-tools` (Grafana → ClickHouse `metrics.pixels`, read-only; авторизация через `grafana-auth`). Ad-hoc SQL запускался отдельным раннером через `~/DuckDuckGo/grafana-dashboards/tools/grafana-query/read-dashboard.py`. Сканы по `pixel LIKE` без фильтра по дате, агенту и `pixel_id='debug'` превышают лимит строк. Временные запросы лежали в папке сессии и могут пропасть; ключевая классификация ниже.
- Версии с диагностикой: iOS 7.240+, macOS 1.210+. Окно: 12–14 октября (3 полных дня). Ограничения: сигнатуры эвристические; `network_restarted` только прокси; устройства нельзя проследить от init до stuck, сравниваются только доли; доля не равна пользовательскому эффекту.
```sql
-- классификация когорт для init_failed.daily / stuck.daily
multiIf(
errs LIKE '%ContextErrorDomain:6%', '1_error6',
creations = '0', '2_no_background_view',
scripts = 'false', '3_tab_not_connected',
net = 'true', '4_network_replaced',
has(ev,'view@30s') AND arrayExists(x -> startsWith(x,'dealloc@'), ev), '5_wake_view_lt30s',
arrayExists(x -> x IN ('load@30s','load@1m','load@2m','base_url_changed@30s','base_url_changed@1m','base_url_changed@2m'), ev), '6_context_load_recent',
'7_other')
-- параметры: network_restarted, context_errors, bg_view_creations, tab_has_ext_scripts, bg_events (decodeURLComponent)
```
## 10. Журнал воспроизведения P5/P6 — 15 октября 2026
Работа начата по запросу: добавить тесты новых гипотез и попытаться воспроизвести наблюдаемые цепочки. Продакшен-фиксы и публичные PR этим запросом не меняются.
**Выбор worktree:** `/Users/admin/DuckDuckGo/apple-browsers.git/alex/cpm-web-extension-remaining-failures`, ветка `alex/cpm-web-extension-remaining-failures`, HEAD `a0717cf098cd1cbd32d5bc9d19aa7db0fab2399f`. Здесь общий harness, production recorder, локальные эксперименты пробуждения, error 6 и очистки baseURL (P1–P3), без watchdog/фиксов A/B. Полного единого набора всех сценариев сейчас нет: базовые A/B находятся в `cpm-repro-harness`/фикс-ветках, init-progress E2E — в `cpm-e2e-init`. Существующие dirty-изменения сохранены; добавления этой работы ограничиваются тестовым harness.
**Среда:** macOS 26.7.1 (25G241), Xcode 27.1 (27A9275), системный WebKit. Прогон на macOS проверяет механизм; перенос на iOS требует отдельной проверки.
**Уточнение данных перед тестами:** read-only перепроверка ClickHouse за 14 октября, `stuck.daily`, iOS 7.240+/macOS 1.210+:
- P5: в выборке хвоста `proxy_on>dealloc>view>proxy_on>dealloc` без error 6/Network replacement/scripts=false — 1953 iOS и 499 macOS, `bg_view_alive=false` во всех снимках; последняя `view` везде старше 30 с. Это определение чуть шире строки P5 выше, поэтому 499 не подменяет прежние 494 без сверки дополнительных фильтров. На macOS 68 снимков с `tab_in_context=false`; на iOS у 1299 не разрешилась вкладка для табличных диагностических полей.
- P6: `load>view>proxy_on>dealloc` — 627 macOS; `load>view>proxy_on` — 235. В обеих подгруппах `context_loaded=true`, `tab_in_context=true`, `tab_controller_match=false`, `tab_has_ext_scripts=false`. В первых двух минутах запуска — 468/627 и 234/235. **235 — это хвост без dealloc, а не маркер extension_updated**; варианты с extension_updated существуют отдельно.
**Проверяемые гипотезы:**
1. P5: потеря регистрации вкладки или runtime listener приводит к отсутствию пробуждения после штатного idle; сравнить content-script send/error, background receive, текущую view и параметры failure-пикселя.
2. P5: долгий документ/пауза приложения позволяют background выгрузиться до диагностического снимка; отличить запоздалый снимок от неработающего worker.
3. P6: вкладка создана без CPM controller, затем попала в openTabs при загрузке контекста.
4. P6: существующая вкладка сохранила controller предыдущего менеджера; новый контекст видит вкладку через openTabs, но не внедряет в неё скрипты.
**Текущий статус:** читаю точки расширения harness; тесты и результаты ещё не получены. Контролируемая инъекция состояния будет явно отделена от естественного воспроизведения.
### P5/P6 — тесты добавлены, начат первый прогон
В `CPMReproLifecycleHypothesisTests.swift` добавлены 7 локальных XCTest-проверок, отдельный opt-in `CPM_REPRO_P5_P6=1` поверх `CPM_REPRO=1`:
- P6: отсутствующий controller до idle; тот же случай после штатной выгрузки и после reload контекста; перенос существующего WKWebView в replacement manager с сохранением прежнего controller.
- P5: забытая в WebKit, но оставшаяся открытой вкладка после двух idle-циклов; отсутствие runtime listener после следующего пробуждения; два здоровых документа с реальной задержкой image-subresource на 35 с; те же документы с искусственно потерянными dashboard-уведомлениями.
Harness позволяет отдельно выбирать controller вкладки, переносить существующую view и подавлять конкретный native method только в тестах. `CPMReproDelayedResource.swift` удерживает реальный subresource через WKURLSchemeHandler; навигационные finish-события не подделываются. Проверки требуют actual `stuck` от production monitor, полный набор диагностических признаков, content-script `initResp` для здоровых контролей и восстановление после снятия инъекции.
Первым запущен `testTabWithoutControllerMatchesP6BeforeIdleUnload`; пока идёт сборка, результата нет. Логи сессии: `/private/tmp/cpm-p5-p6-20261015/`. Команда сборки: `CPM_REPRO=1 CPM_REPRO_P5_P6=1 swift test --disable-automatic-resolution --filter CPMReproLifecycleHypothesisTests/testTabWithoutControllerMatchesP6BeforeIdleUnload`.
### Первый результат P6 до idle
Сборка прошла. Первый тест получил реальный `initialization_failed` и затем `stuck` с `load>view>proxy_on`, `context_loaded=true`, `tab_in_context=true`, `tab_controller_match=false`, `tab_has_ext_scripts=false`, `bg_view_alive=true`, `bg_view_creations=1`. Content marks пустые: CPM не внедрён. Новая вкладка с current controller получила `initResp` и вызвала recovery.
**Прогон пока красный (1 assertion):** дополнительное ожидание `network_restarted=false` не выполнено — поле true. Падения/замены Network-процесса тест не инъецирует. Проверяю холодный старт Network до контекста: recorder сравнивает PID с context-load, что может смешивать первичный запуск и замену. Результат нельзя объявлять полным совпадением всех признаков до этой проверки. Лог: `build-and-p6-before-idle.log`. SwiftLint пяти изменённых файлов прошёл с `--no-cache`, 0 нарушений; обычный режим упёрся в недоступный для записи кэш.
### Уточнение harness и первый зелёный P6
Проверенный прогрев default WKWebsiteDataStore перед context-load даёт ненулевой Network PID; в последующем failure `network_restarted=false`. Это сняло лишнее отличие первого холодного прогона. **`testTabWithoutControllerMatchesP6BeforeIdleUnload` теперь зелёный:** совпадают хвост и все проверяемые признаки, новая правильно подключённая вкладка работает. Механизм получен контролируемым отсутствием controller; естественный путь создания такой вкладки в приложении ещё не найден.
Проверка replacement manager сначала не дошла до failure-пикселя: teardown первого harness снимает navigationDelegate, и при переносе view его надо восстановить. Исправлен только тестовый `adoptTab`; это ошибка постановки эксперимента, не результат о CPM. Начат полный прогон семи тестов с исправлением: `p5-p6-full.log`.
### Дополнительный контроль P6
Добавлен восьмой тест `testCurrentControllerBeforeContextLoadRemainsHealthy`: создать вкладку до завершения context-load, но сразу с правильным controller, и потребовать `initResp`, скрипты и отсутствие initialization_failed. Это отделяет обычный ранний startup от отсутствующей привязки controller. Он будет выполнен после текущего запущенного бинарника; результат пока не получен.
### Подтверждены два контролируемых механизма
**P6, replacement manager — зелёный, 9.186 с.** Сначала существующая вкладка получила initResp со старым менеджером. После teardown старого менеджера её тот же WKWebView и прежний controller зарегистрированы в replacement manager. Получены два failure и actual stuck с `load>view>proxy_on`, context/tab=true, controller/scripts=false, view=true, creations=1, errors=none, network_restarted=false. Новая вкладка replacement manager получила initResp. Это воспроизводит сохранённую привязку; естественный триггер замены менеджера не доказан.
**P5, забытая живая вкладка — зелёный, 72.744 с.** Два нормальных initResp и две реальные idle-выгрузки, затем test-only `didCloseTab` без удаления WKWebView. Получен actual stuck с `load>view>proxy_on>dealloc>view>proxy_on>dealloc`, creations=2, view=false, context=true, tab=false, controller/scripts=true, errors=none, network_restarted=false. Content script отправляет init и получает init-error. После `didOpenTab` эта же вкладка снова получила initResp. Механизм подходит под P5-подгруппу с tab_in_context=false; его доля в проде и естественное действие приложения, создающее нарушение lifecycle, этим тестом не установлены.
Оба результата находятся в `p5-p6-full.log`; другие сценарии продолжают выполняться.
### Полный первый прогон: положительные и отрицательные результаты
Прогон `p5-p6-full.log` завершён: 7 тестов, 4 зелёных и 3 красных (11 assertions), 273.8 с. Не все красные результаты означают дефект harness:
- **P6 после idle — зелёный, 38.658 с.** Получена цепочка `load>view>proxy_on>dealloc` с исходной сигнатурой controller/scripts=false, tab/context=true. Reload контекста не исправил существующую вкладку; новая правильно подключённая вкладка получила initResp.
- **Потеря runtime listener только на одном wake не воспроизвела P5.** Инъекция действительно потеряла первый init (native dashboard не пришёл), но после восстановления файла и следующего idle новый worker принял следующий init и прислал initResp, stuck=0. Ожидание устойчивого P5 в тесте было неверным; превращаю его в отрицательную characterization-проверку этого результата.
- **Долгий документ:** первый image-subresource действительно удерживался 35 с. Background выгрузился примерно на 32 с, но на finish навигации в 35 с WebKit снова создал background; на failure в 39 с view уже жива. При искусственной потере dashboard получен stuck с живой view, а не P5. Здоровый документ получил initResp и не вызвал failure.
- **Ошибка постановки второго долгого документа:** одинаковый URL image взялся из кэша, поэтому второй load не был долгим. Исправляю уникальный URL на каждую навигацию и повторю оба контроля.
- **Наблюдение о trace:** watcher держал strong reference на view через 50-мс await. При переходе сразу после `context._backgroundWebView=nil` dealloc старой view иногда попадал после создания новой. Убираю удержание через await и буду ждать одновременно отсутствия WebKit background и dealloc в production recorder, чтобы не менять исследуемую последовательность самим наблюдением.
Все неподтверждённые результаты выше остаются отрицательными/ограниченными; совпадение P5 пока подтверждено только для test-only потери регистрации живой вкладки.
### Уточнённые проверки P5 — начат повтор
Теперь 9 тестов. В новом `testNoRuntimeListenerMetadataFromFirstStartupMatchesP5` runtime listener не регистрируется с самого первого старта; затем background явно загружается второй раз и оба раза штатно выгружается. Это отдельно проверяет пустые listener metadata, которые нельзя было получить простым пропуском регистрации на одном wake после здорового старта. Условия искусственные; естественная утрата metadata не доказана. После проверки инъекция снимается, выполняются explicit background load и проверка initResp.
Первоначальный listener-тест переименован в `testMissingRuntimeListenerOnOneWakeRecoversAfterNextIdle` и проверяет реально наблюдённое восстановление, отсутствие stuck и третью view.
Оба 35-секундных документа теперь используют разные URL image. Ожидания изменены по наблюдению первого прогона: здоровый длинный документ — no failure; потеря dashboard — stuck с живой view, не P5. Расширен test-only background trace на все типы autoconsent-сообщений, чтобы установить, какое сообщение повторно будит worker на finish.
Watcher больше не удерживает WKWebView через await, а idle-helper требует и освобождения background в WebKit, и dealloc по recorder. Повтор пяти новых/уточнённых проверок: `p5-refined-and-controls.log`.