Files
obsidian-vault/work/projects/cpm-ios-idle-wake-investigation-2026-10-30.md
T

34 KiB
Raw Blame History

tags, updated, status
tags updated status
cpm
ios
webkit
investigation
2026-10-30 investigation-and-proposal

CPM iOS idle wake: действия, возможные механизмы и диагностический план

Продолжение с проверенными JavaScript findings: cpm-extension-js-review-2026-10-30 — stale initResp пересекает navigation; config refresh не повторяет исходный init. Есть контролируемые проверки shipped bundle и локального extension source, с указанными границами. Дата: 30 октября 2026. Продолжение cpm-ios-uncovered-groups-2026-10-30.

Основной вывод: idle wake остаётся приоритетным сценарием, но текущий пиксель объединяет разные механизмы. Нужно разделить выполнение content script, доставку init в worker, ожидания внутри worker, dashboard → native → monitor и реальное получение initResp страницей. Проверка shipped bundle выявила две неограниченные storage-зависимости; контролируемые проверки показали, что общая очередь cpmState может остановить dashboard-ответы нескольких вкладок при продолжении отправок initResp. Естественный production-trigger такого storage stall пока не найден.

Что проверено и чего эти результаты не доказывают

Проверены исходники реально измеряемого iOS 7.243.0, tag 7.243.0+ios, commit 9b67ed7c720d274a554a86052881d2d690cccb3e; ZIP extension извлечён из этого tag. Его manifest version — 2026.10.14. Это важнее версии локального extension checkout: там уже есть ещё не выпущенные init progress marks, в shipped ZIP их нет.

Дополнительно: текущий main 3934949758; WebKit source snapshot 99051d5d08cd9f19ec76ce599f7539d070b1ae09 от 29 сентября; проверены upstream patches четырёх связанных WebKit bugs. Наличие исправления в upstream main не устанавливает, в какой iOS версии оно включено. Этот проход не создавал production PR и не менял приложение/флаги.

Перечитаны записи 27–30 октября. В частности, гипотеза неограниченного storage.session.get("config") уже была записана 27 октября; это не впервые обнаруженная идея. Новое здесь — разбор общей state queue и контролируемое разделение dashboard и initResp на shipped методах.

В этом проходе выполнены три изолированные проверки JS-логики с подменёнными browser/native I/O. Новый естественный iOS/WebKit outage не воспроизведён, физический iPhone в этом проходе не использовался. Предыдущие H1/H2, idle и real-page результаты учитываются со своими ограничениями, а не переобъявляются новыми.

1. Какие действия пользователя дают эту картину

A. Достаточно читать страницу в открытом браузере, затем перейти по ссылке

  1. Открыть обычную HTTPS-страницу, получить работающий CPM.
  2. Читать/прокручивать её, не создавая новых extension-событий. Примерно через 30 секунд последней активности WebKit может выгрузить непостоянный background. Это нормальный lifecycle, не crash.
  3. Нажать ссылку, обновить страницу или открыть новую вкладку с URL.
  4. На main-frame HTTP(S) navigation app запрашивает готовность extension на policy stage, до загрузки документа. WebKit создаёт новый background view, recorder устанавливает proxy.
  5. Получаем dealloc → view → proxy_on; фон может успешно загрузиться за <1 секунды.
  6. Документ выполняет CPM content script, отправляет init. Если требуемый dashboard-сигнал не дошёл до monitor за 4 секунды после finish, появляется episode и первый snapshot.

Поэтому view_age=4s_to_8s естественен: время на загрузку страницы плюс четырёхсекундное ожидание. Он не доказывает, что view появился в момент сбоя. Несколько dealloc>view>proxy_on подряд могут быть обычными переходами с интервалами >30 секунд.

Если страница регулярно шлёт extension-сообщения или открыт активный port, выгрузка может откладываться; ровно 30 секунд от открытия вкладки не является гарантией.

B. Home / блокировка телефона / возвращение в браузер

  1. Открыть 2–3 здоровые вкладки, перейти в другое приложение или заблокировать экран.
  2. Во время простоя background может выгрузиться; native callbacks/timers могут отработать после resume.
  3. Вернуться, затем сделать новую navigation: ссылка, reload, новый URL в другой вкладке. Получается та же wake-история, иногда с interruption.
  4. Для подтверждённого cross-tab случая после первого timeout сделать свежую обычную navigation во второй вкладке; один только возврат к старому документу этого не доказывает.

В shipped monitor resume перезапускает уже ожидающие замеры, обычно с 8-секундным grace; он не создаёт новый init/замер на любой показ старой страницы. Чистый back/forward restore исключён из health measurement. Поэтому «разблокировать и посмотреть старую страницу» и «разблокировать и перейти на новую страницу» — разные опыты.

interruption в bg_events говорит о событии в доступной истории; interrupted=true означает, что именно первый измеряемый запрос пересёк suspension. Их нельзя смешивать.

C. Граница unload/wake и две почти одновременные navigation

После 25–35 секунд idle начать переход в A и сразу в B; отдельно — заблокировать телефон во время перехода и завершить после resume. Навигации разделяют startup gate, но документы и сообщения имеют разные поколения. Это сильный сценарий для routing/registration и attribution races.

Чтобы доказать browser-wide failure, после первоначального сбоя нужна ещё одна navigation, начатая уже после него; один пакет одновременно восстановленных вкладок для этого недостаточен.

D. M4 после второго timeout, затем тишина

Второй квалифицирующий timeout запускает context reload. Пользователь может после этого просто остаться на странице и перестать навигировать. В конце активного окна получается no-response, хотя проверки нового контекста свежим документом могло не быть.

Это существенная граница интерпретации прежних чисел. У wake cross-tab no-response 29 октября:

  • reload_state=loaded: 2 140 из 2 920 count-эпизодов.
  • response_timed_out: 776.
  • failed/pending: 4.

loaded доказывает успешный context reload, но не проведённый и проваленный post-reload messaging test. response_timed_out сильнее: monitor зарезервировал post-reload regular measurement, и он истёк. Даже он не доказывает вечную поломку.

Следовательно, фразу «все 2 920 пережили M4 без восстановления» применять нельзя. Для опытов после M4 обязательно отдельно открыть новый документ и проверить actual initResp.

E. Похожий footprint может создать не поломка worker

Валидная страница после idle может:

  • не выполнить ожидаемый content script;
  • выполнить init, но застрять на config/storage;
  • получить initResp, пока dashboard ждёт сохранения state;
  • прислать dashboard, который не прошёл parsing/маршрутизацию/атрибуцию.

Все эти пути способны дать готовый background и одинаковые native flags. Эти флаги проверяют конфигурацию и WebKit verdict, а не прохождение конкретного JS-запроса.

2. Реальная цепочка в shipped коде

navigation policy → native background gate → WebKit background load → content script → runtime.sendMessage(init) → MessageRouter → handleAutoConsentMessage → config/settings/site checks → [dashboard branch || initResp branch].

Ветки после init различаются:

  • Dashboard: updateCpmDashboardState → modifyCpmState → storage.session → refreshCpmDashboardState native message → validation → health monitor correlation.
  • Ответ странице: tabs.sendMessage(initResp) → content-script listener → AutoConsent.initialize.

Dashboard запускается через promise chain и не является ACK доставки initResp. Возможны оба расхождения: dashboard есть, actual initResp потерян; actual initResp отправляется/доставляется, dashboard отсутствует.

В 7.243 MessageRouter завершает autoconsent-handler через .then(() => {}): успешный runtime.sendMessage(init) сам по себе может вернуть undefined. У нас undefined нельзя считать признаком потерянного worker, даже если в чужом ping/pong reproduction это аномалия. Нужны явные stages/ACK.

Полезные source anchors:

3. Конкретные механизмы и места поиска

H1. Unbounded config/storage и общий state queue — проверяемая app/extension-гипотеза

В refreshRemoteConfig первый getFromSessionStorage("config") выполняется перед native request и его timeout. После успешного обновления config запись storage.session.set также ожидается без своего timeout. Все init ждут общий remoteConfigJson.

Отдельно modifyCpmState сериализует обновления разных вкладок через _stateQueue. Операция ожидает getCpmState, затем updateCpmState, включая storage write. Rejection ловится и позволяет очереди продолжить; never-settled promise удерживает всё последующее. Dashboard каждого init зависит от этой очереди. Это глобальная точка блокировки, даже если JS event loop и browser.runtime работают.

Проверки на извлечённых shipped классах:

  1. При удерживаемом config read native request вообще не начался; его 20-секундный timeout ещё не мог помочь. После отпускания storage handler завершился.
  2. Healthy control: два init дали две попытки initResp и два dashboard-вызова.
  3. Pending cpmState write: два init дали две попытки initResp, ноль dashboard-вызовов, выполнена только первая заблокированная запись. После отпускания записи дошли оба dashboard-вызова.

Это логическая демонстрация механизма, не доказательство естественного зависания browser.storage, доставки initResp в настоящий документ или доли production false positives. Browser/native I/O подменены управляемыми promises.

Свежий JS realm после M4 должен очистить чисто in-memory очередь. Поэтому для повторения после M4 нужны повторный storage stall/общая нижележащая проблема либо другой механизм. Не всякий пяти минутный episode доказывает, что очередь оставалась зависшей после reload.

Куда смотреть: config storage get/set; cpmState get/set; возраст головы очереди; глубина очереди; отдельный health/ACK путь. При будущем исправлении persistence не должна безгранично удерживать ответ/наблюдение. Одного Promise.race(timeout) недостаточно для безопасных записей: старая незавершённая запись может позднее завершиться и затереть более новую; нужны revision/order/coalescing semantics.

H2. Worker готов по load callback, но init не маршрутизируется в listener

WebKit runtimeSendMessage после wake получает набор процессов для RuntimeOnMessage; если он пуст, завершает вызов пустым результатом. Выборка идёт по listener frames/content world, а не просто по живому PID background view.

bg_process_responsive проверяет WebKit verdict о процессе WKWebView. Он не проверяет, что нужный SW listener зарегистрирован и выполняется. Carrier page, SW thread, API bindings и listener routing — разные уровни.

Нужно поймать: content script точно начал init; worker ingress отсутствует при известной диагностической поддержке; альтернативный worker/native путь проверен. Одно отсутствие native progress mark недостаточно — сам мост может не работать.

Предыдущие проверки H1 Network crash и H2 skip-listener из заметок не подтвердили естественный persistent trigger на production 30-секундном unload. Быстрый TestWebKitAPI режим давал артефакты; его красный результат не переносится на production без повторения.

H3. Init дошёл, но native bridge / handler / dashboard correlation потеряли результат

Handler registered проверяет наличие native handler в словаре. Это не факт получения текущего сообщения. После message delivery есть отдельные parsing, URL/consentStatus validation и correlation по tab, document URL и extension generation.

Monitor сравнивает scheme/host/port/path/query, игнорируя fragment. Bundle берёт tab URL из sender.tab.url/pendingUrl; при redirects, URL rewrite и пересечении context reload возможны несовпадения. При известных mismatch до failure monitor часто отменяет недоказуемый замер, поэтому нельзя любой mismatch объявить false-positive. Но после первого failure отсутствие подходящего current-generation response может оставить episode открытым.

Нужны отдельные счётчики handler-entry, parse rejection, stale context, URL mismatch, zero candidates, ambiguous candidates и accepted attribution. Без них полученный, но отвергнутый ответ внешне равен отсутствующему.

H4. Script не выполнялся / ранний JS exception / неправильный sender

tab_has_ext_scripts=true означает зарегистрированный user script нужного content world, а не исполнение в конкретном документе. Перед обработкой init bundle проверяет sender.tab, frameId и tabId; часть неверных метаданных ведёт к раннему return.

Искать после independent cs-start evidence. Исследовать обычную navigation, восстановление/переиспользование web view, process swap и previews отдельно. Большая часть P6/controller mismatch к iOS wake-группе не относится.

H5. Idle wake может быть обычным состоянием большинства navigation

Большая доля wake среди failures не определяет риск wake. После 30-секундной выгрузки многие здоровые navigation тоже создают новый background.

Нужен знаменатель: failed wake / все eligible wake navigation против failed warm / все eligible warm navigation, одинаковая выборка и правила. Без него keep-warm может лечить действительно важный lifecycle race или просто сдвигать классификацию. До такого сравнения blanket keep-warm не является первым выбором.

4. Что найдено в WebKit и публичных обсуждениях

  • 317981: потеря sendMessage на первоначальном старте при ещё неизвестном listener set; исправление добавляет очередь для initial load. Первый load после очистки listener state — его scope, обычный повторный wake он полностью не объясняет.
  • 324743: readiness объявлялась до завершения registration; исправлено ожидание нужного состояния. Это показывает ограниченность прежнего loader-ready как messaging-ready, но не устанавливает наличие бага на нашей OS.
  • 324754, patch: после unload/reload worker мог оказаться в процессе, где отсутствует carrier page и не инжектируются extension bindings. Хорошее структурное сходство, однако требуется совпадение trigger/OS; прежняя наша Network-проверка это не подтвердила.
  • 322881 / patch: восстановление SW clients при повторном background load. Upstream regression проверяет offscreen client; обычные HTTPS-вкладки с content scripts не следует автоматически считать теми же SW clients.
  • Публичный iPhone reproduction: автор сообщает о сбое после lock/inactivity на iOS 18/26 и отсутствии проявления в Simulator/с Web Inspector. Исходники проверены: worker возвращает явный pong; manifest использует module worker, content script document_end и ping раз в секунду. У нас classic worker/document_start, явный native pre-wake и отдельный initResp. Это мотивирует device/без-inspector опыт, а не доказывает одинаковую причину.
  • Apple Forums 758346: исторические device-only wake failures; автор описывает улучшение при непостоянной background page вместо service worker. Apple сообщала о фиксе в 17.6.1, позже есть сообщения пользователей о сохранении проблемы. Это не единый доказанный баг всех новых OS.
  • WWDC26 описывает поддержку background page и service worker. Локальный WebKit manifest parser тоже поддерживает непостоянный document background при MV3. Поэтому SW ↔ nonpersistent background page — допустимый диагностический A/B-кандидат, а не keep-alive workaround.

Наличие этих upstream fixes не означает, что надо ждать очередного Safari release. Сначала нужны наши stages и совпадающее воспроизведение. Safari-hosted extension и WKWebExtension внутри DDG отличаются управлением и lifecycle.

5. Телеметрия, которая действительно различит эти ветки

Это предложение контракта, не добавленные пиксели. Значения — ограниченные enum/bool/buckets; URL, tab IDs, document IDs, worker IDs и nonce в аналитику не отправляются.

Минимальный приоритетный набор

A. Per-document trace с локальной корреляцией. Временный navigation/document token + extension generation только в памяти app/JS. Token проходит вместе с init/ответом, не сохраняется между сессиями и не уходит в ClickHouse. Он должен различать повторную загрузку того же URL и поколения после M4. Capability handshake: unsupported/unknown/supported; отсутствие marks у старого bundle/старого документа нельзя писать как init_not_received.

B. Раздельные положительные свидетельства на границах:

  • content script started;
  • init send attempted / rejected / settled;
  • worker init ingress;
  • dashboard handler entry / validation / attribution;
  • initResp send attempt / transport completion;
  • content script actually received initResp и завершил предусмотренную initialize-стадию.

Нельзя заменить последнюю точку успешным tabs.sendMessage: это транспортный результат, не прикладной ACK. И нельзя считать успешный runtime promise с undefined ACK принятия init.

C. Где ждёт worker. Отдельные bounded markers: config_session_get, native_config_request, config_session_set, user_settings, site_policy, state_queue_wait, state_session_get, state_session_set, dashboard_notify; возраст текущего ожидания и глубина очереди в buckets. Маркеры пишутся до await, collector не зависит от той же state queue/storage. Для параллельных dashboard/initResp нужны отдельные bits/statuses, а не один максимальный stage, который теряет информацию о второй ветке.

D. Native rejection reason. no_handler / malformed / stale_context / url_mismatch / no_candidate / ambiguous / attributed, с отдельным unknown если входящий вызов не наблюдался. В аналитику идёт классификация расхождения, не адрес страницы.

E. First и end evidence + recovery opportunity. Сохранить первый snapshot; дополнить bounded end-state и событиями, произошедшими после него:

  • view/context replaced, background ready/failure;
  • retry/M4 attempted либо suppression reason;
  • количество eligible regular measurements после recovery: 0 / 1 / 2+;
  • post-recovery timed out / dashboard attributed / page ACK / no opportunity;
  • initial-document recovery и ответ другой вкладки отдельно.

Пятиминутное отсутствие ответа при нуле post-reload opportunities не должно означать доказанный провал recovery.

Два обязательных условия дизайна

Независимость диагностического канала. Если все worker marks идут только через потенциально сломанный native messaging, отсутствие marks не различает «init не пришёл» и «worker не может отчитаться». Нужен дополнительный app↔content-script diagnostic bridge с проверенной привязкой content world и/или post-failure диагностический runtime reply, который content script передаст native независимо. App-owned probe в другом world доказывает работу того probe, а не исполнение CPM script; эти результаты нельзя объединять.

Positive marker надёжно показывает пройденную границу. Negative marker требует поддержки контракта и проверки канала; иначе результат остаётся unknown. Стопроцентно назвать внутренний баг WebKit только этими полями нельзя, но можно выбрать узкий участок для engine trace.

Healthy baseline и отсутствие изменения поведения до failure. Те же bounded признаки для небольшой случайной выборки здоровых eligible navigation: wake/warm, gate outcome, time since resume, readiness, page ACK. Измерение выбирается до результата, с известным sampling. Readiness-пробы не должны специально будить фон до контрольного snapshot; ping после обнаруженного failure отмечается как вмешательство. Никаких постоянных heartbeat/ports в основном диагностическом варианте.

Вспомогательные native поля: wake trigger navigation / retry / network recovery / context reload / WebKit-or-unknown, resume-age bucket, current measured navigation kind, diagnostic schema support; пересечения вызовов помечать как multiple/unknown, а не выдумывать один trigger.

6. Как превратить расследование в решение

  1. Первый небольшой диагностический change: per-document correlation, worker ingress, bounded pending-await/state queue, native attribution reason и настоящий page ACK. Добавить end-of-episode recovery opportunity. Адаптировать идею #7385 к нынешнему episode contract, а не публиковать старый stacked PR без переноса.
  2. Контролируемые fault cases: dropped init; config get never settles; state set never settles; native dashboard rejected; response URL/generation mismatch; initResp lost. Для каждого диагностический результат должен различаться. Уже выполненный storage control покрывает первые две storage-развилки на уровне JS.
  3. Физический iPhone, штатные 30 секунд, без debugger/Web Inspector: foreground idle control; lock/resume 1–5–30 минут; lock during startup; fresh navigation в A и B. Протокол требует и dashboard, и page ACK; после M4 нужна новая regular navigation. Нельзя ограничиваться старой страницей или одним успешным callback.
  4. Сильный причинный A/B: тот же CPM JS и непостоянный background, но service worker против document background. Сохранить idle unload, одинаковые профили/флаги и реально проверить unload/reload в обеих ветках. Если сбой воспроизводится в SW и исчезает в document при равной нагрузке, это сужает поиск до SW-specific registration/routing/bindings. Изменение сохраняет различия окружения и само по себе не идентифицирует одну строку WebKit.
  5. Второй A/B по нашему коду: убрать блокировку delivery/health-evidence на сохранении CPM state, сохранив корректность serial writes и настройки consent. Если page ACK здоровый, а старая dashboard-ветка висит, это отдельное исправление наблюдения/state pipeline; оно не должно маскировать реальные потери initResp.
  6. Только после совпадающего естественного fault решать о re-init, bounded restart или изменении background environment. Keep-warm оставлять отдельным causal experiment с расходом ресурсов и знаменателем wake, не первым безусловным лечением.

Практический следующий шаг: двигаться с двумя узкими гипотезами — init не дошёл до реально работающего worker; init дошёл, но общий config/state/native/attribution путь удержал наблюдаемый ответ. Новая телеметрия должна первым делом развести их и отдельно показать page ACK. Увеличение одного bg_events хвоста этого не сделает.

Артефакты

/Users/admin/DuckDuckGo/apple-browsers.git/cpm-ios-idle-wake-investigation-2026-10-30/:

  • shipped-app/, shipped-extension/, revisions.json — проверенные версии и извлечённый исходник.
  • webkit-commit-*.json — реальные upstream patches; source snapshot отдельно в /Users/admin/Downloads/WebKit_sparse.
  • storage-probes.cjs, storage-probe-results.json — три проверки shipped методов, включая healthy control и освобождение заблокированной операции.
  • public-repro/ — прочитанный публичный минимальный пример для сравнения scope.

Основание количественных выводов и SQL: cpm-ios-uncovered-groups-2026-10-30. История прошлых воспроизведений: cpm-post-release-analysis-2026-10-27.