From 67af859e8c28bb80cb294bdbb2b0062c0aad0099 Mon Sep 17 00:00:00 2001 From: Matysh Date: Mon, 5 Oct 2026 20:12:25 +0300 Subject: [PATCH] fix(zigbee): allow explicit retry after transient reload failure (#800) Issue: #800 User-Visible: yes --- demo/smoke_zigbee_topology_job.mjs | 11 +- docs/CHANGELOG.md | 3 +- docs/CHANGELOG.ru.md | 3 +- docs/reviews/CODE-REVIEW-800-r2.md | 116 +++++++++++++++++++ docs/reviews/INDEX.md | 3 +- scripts/mutation-registry.mjs | 11 ++ src/zigbee-topology-runtime.ts | 10 +- test/zigbee-topology-runtime-routes.test.mjs | 29 +++++ 8 files changed, 181 insertions(+), 5 deletions(-) create mode 100644 docs/reviews/CODE-REVIEW-800-r2.md diff --git a/demo/smoke_zigbee_topology_job.mjs b/demo/smoke_zigbee_topology_job.mjs index e55c4c22..2835efe7 100644 --- a/demo/smoke_zigbee_topology_job.mjs +++ b/demo/smoke_zigbee_topology_job.mjs @@ -10,12 +10,13 @@ const output = {}; const record = (name, actual) => { output[name] = actual; check(name, actual); }; const artifacts = new URL('../artifacts/zigbee-topology-800/', import.meta.url); mkdirSync(artifacts, { recursive: true }); -const server = { session: 'server-800', revision: 0, sequence: 0, publishes: 0, cancels: 0, provider: null }; +const server = { available: true, session: 'server-800', revision: 0, sequence: 0, publishes: 0, cancels: 0, provider: null }; const state = () => ({ kind: 'state', session_id: server.session, revision: server.revision, provider: structuredClone(server.provider) }); const initial = () => [{ kind: 'reset', session_id: server.session, revision: server.revision, topics: server.provider ? [server.provider.topic] : [] }, ...(server.provider ? [state()] : [])]; await page.exposeFunction('__scanRpc', async (message) => { + if (message.type === 'houseplan/config/get') return { available: server.available }; if (message.type === 'houseplan/zigbee/subscribe') return initial(); if (message.type === 'houseplan/zigbee/start') { if (server.provider?.phase !== 'loading') { @@ -73,6 +74,9 @@ const install = async (embedded = false) => page.evaluate(async (isEmbedded) => const hass = { ...original, language: 'en', user: { ...original.user, id: 'admin-800', is_admin: true }, connection, callWS: async (message) => { if (message.type.startsWith('houseplan/zigbee/')) return window.__scanRpc(message); + if (message.type === 'houseplan/config/get' && !(await window.__scanRpc(message)).available) { + throw { code: 'not_ready' }; + } const result = await original.callWS(message); if (message.type === 'houseplan/config/get') return { ...result, zigbee_scan_api: 1, @@ -193,6 +197,11 @@ try { await page.waitForFunction(() => !document.getElementById('scan-settings')._snapshot.states['z2m:zigbee2mqtt']); record('integrationUnloadClearsUiWithoutClosingHaSocket', await refresh().isEnabled() && await page.evaluate(() => window.__scanStats.active === 0)); + server.available = false; + await refresh().click(); await waitState('error'); + record('retryDuringReloadDoesNotStartRadioOrDisableRetry', server.publishes === 2 + && await refresh().isEnabled()); + server.available = true; await refresh().click(); await waitState('loading'); await page.evaluate((event) => { for (const callback of window.__scanCallbacks) callback(event); diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index fdda74f7..82abb18a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,7 +8,8 @@ or losing the last successful map. Update and restart the integration too ([#800](https://github.com/Matysh/houseplan-card/issues/800)). - Reloading the integration also clears an old scan from an already-open page, - so its next explicit scan can attach to the new session ([#800](https://github.com/Matysh/houseplan-card/issues/800)). + so its next explicit scan can attach to the new session, including a retry + after a temporary reload error ([#800](https://github.com/Matysh/houseplan-card/issues/800)). ## v1.80.0-beta.2 — 2026-10-05 diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index c321fd58..a7582f45 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -14,7 +14,8 @@ и не теряя последнюю полученную карту. Обновите и перезапустите также интеграцию ([#800](https://github.com/Matysh/houseplan-card/issues/800)). - После перезагрузки интеграции старое ожидание сбрасывается и на уже открытой - странице: следующий явный запуск подключается к новой сессии ([#800](https://github.com/Matysh/houseplan-card/issues/800)). + странице: следующий явный запуск подключается к новой сессии, в том числе + после временной ошибки во время reload ([#800](https://github.com/Matysh/houseplan-card/issues/800)). ## v1.80.0-beta.2 — 2026-10-05 diff --git a/docs/reviews/CODE-REVIEW-800-r2.md b/docs/reviews/CODE-REVIEW-800-r2.md new file mode 100644 index 00000000..5748539d --- /dev/null +++ b/docs/reviews/CODE-REVIEW-800-r2.md @@ -0,0 +1,116 @@ +# Независимое локальное код-ревью #800 — r2 + +Вердикт: жёлтый · заход r2 · блокирующих циклов 2/4 · High: 0 · Medium: 1 → в задаче · Low: 0 · маршрут: fix. + +## Материал раунда и дельта + +- Дата: 2026-10-05; тот же независимый код-ревьюер `/root/code_review_800`. +- Предыдущий отчёт: [CODE-REVIEW-800-r1.md](CODE-REVIEW-800-r1.md), материал + `379de706d29f4b1547c3b162c1c205654487c6b8`. +- Замороженный материал r2: `4a7abf531bc9bf750073fb7ac7f4a1d9777e83ed`; + дерево `311dad4c7c6ce782fcc7c8d516b53df0ea8afc6a`. +- Проверяемый диапазон: `git diff 379de706d..4a7abf531` — 19 файлов, + 443 добавленные и 25 удалённых строк, включая сам r1 и перенос preflight. +- HEAD повторно сверён перед вердиктом; продуктовые пути чисты. + Коммит имеет `Issue: #800`, `User-Visible: yes` и оба changelog. + +ТЗ не менялось. Дельта локальна: инвалидирование runtime при reload, +свидетели этого сценария и длительного ожидания, закрытие отказов гейтов и +документации. Нового продуктового скоупа и новой подсистемы нет; применён +PROCESS §2.10. Разрешение локального независимого порядка остаётся +[исключением владельца для #800](https://github.com/Matysh/houseplan-card/issues/800#issuecomment-5998860326). +Ревьюер продукт не правил, мутанты не исполнял, GitHub/ветки/метки не менял. + +## Закрытие находок r1 + +| Находка | Конкретное изменение | Результат | +| --- | --- | --- | +| M1: старый job после reload на живом WS | `zigbee_topology.py:427–431` перед очисткой посылает `closed` со своей сессией и новой revision. `zigbee-topology-runtime.ts:93–103` инвалидирует старые observer/команды/Z2M-cache; явный start повторно открывает feed. HA test теперь подписывается до unload и использует тот же socket после нового setup; четыре unit и новый browser сценарий защищают stale callback/ACK/command | Обычный reload исправлен, но соседний случай раннего повторного нажатия остаётся дефектным — M1 этого раунда | +| M2: виртуальные 15 минут не исполняли asyncio deadline | Backend witness теперь подменяет `hass.loop.time`, продвигает 600 и затем 300 секунд, пропускает callbacks через loop. Мутант `zigbee-background-scan-restores-ten-minute-deadline` возвращает точный `asyncio.timeout(600)` вокруг финального ожидания | Закрыта чтением механизма witness и зелёным HA-прогоном; мутант ждёт ночного исполнения, как требует процесс | +| M3: новые ошибки gate:small | Шесть hooks внесены в `docs/data-hp-contract.json`; `SMOKE_LINKS` снова содержит только frontend `.ts`; preflight сохранён в `docs/analysis/800-spec-preflight.md`, ссылки и generated INDEX согласованы; измеренная база размера обновлена с обоснованием | Закрыта полным зелёным WSL gate на точном SHA | +| L1: устаревшие English guide/STATUS | `USER-GUIDE.md:347` теперь описывает фоновое ожидание, восстановление, отмену после 10 минут и сброс при reload; STATUS описывает #800 вместо прежнего deadline #799 | Закрыта | + +Рост `bundleBytes` до 2 697 519 (+11 331 Б, 0,42%) объяснён +[в issue](https://github.com/Matysh/houseplan-card/issues/800#issuecomment-5999301563). +Изменена только измеренная база; пять остальных метрик и допустимые полосы +не менялись. Gzip budgets и lint:unused прошли. Это не ослабление потолков +ради зелёного результата. + +## Проверки r2 + +| Команда / проверка | Результат | +| --- | --- | +| `git diff --check 379de706d..4a7abf531` | PASS, ревьюер | +| `node --test test/zigbee-topology-runtime-routes.test.mjs test/zigbee-topology.test.mjs test/zigbee-provider-routes.test.mjs test/zigbee-topology-style.test.mjs` | 56/56 PASS, ревьюер | +| `node --test test/data-hp-contract.test.mjs test/smoke-select.test.mjs test/reviews-index.test.mjs` | 33/33 PASS, ревьюер | +| `node scripts/mutation-gate.mjs --check` | PASS, ревьюер; прежние 4 предупреждения inventory, мутанты не запускались | +| `npm run gate:small` в новой чистой WSL-копии `/home/matysh/hp800-final-oct5` на `4a7abf531` | PASS, все 9 шагов, 82 с; лог автора `C:/Temp/hp800-gate-small-wsl-r2.log` прочитан ревьюером. Включает build/typecheck, npm test, bundle integrity/budget, no-new-any/private-writes, smoke-select, lint:unused | +| `python -m pytest tests_backend -q --tb=short` в WSL HA harness | 1007 PASS, 1 SKIP, 28 с по результату автора; не pure-only Windows | +| `python -m pytest tests_backend/test_ha_zigbee_topology.py -q --tb=short` | 32 PASS после backend-дельты по результату backend-автора | +| `ruff check custom_components/houseplan`, strict mypy разрешённых 7 модулей | PASS по результатам исполнителей | +| `node demo/smoke_zigbee_topology_job.mjs` | 17/17 PASS по результату автора; теперь включает closed/new-session на том же соединении | +| `node demo/smoke_zigbee_topology_hover.mjs` | PASS по результату автора, fixture wait из r1 теперь в замороженном коммите | +| Production-runtime probe: retry в окне reload | FAIL ожидаемого восстановления, исполнено ревьюером; M1 ниже | + +Повторный smoke-select выдаёт те же 14 имён; все решения по каждой строке +унаследованы из r1. Два Zigbee smoke повторены; прочие не стали затронутыми +от добавления `closed` в локальный runtime. Полная предрелизная матрица +этим не объявляется исполненной. Backend/smoke прогоны автора не выдаются +за собственные прогоны ревьюера. + +## M1 — временный not_ready во время reload навсегда блокирует явный retry + +**Medium, в скоупе AC2/AC4/AC6.** После события `closed` кнопка Update снова +доступна, но новый coordinator может ещё не закончить setup. Если нажать +Update в этом окне, `houseplan/config/get` законно отвечает `not_ready`. +`capability()` (`src/zigbee-topology-runtime.ts:144–151`) сохраняет отвергнутый +Promise в `cache.capability`. Следующее явное нажатие после успешного setup +снова await-ит тот же отказ и вообще не спрашивает backend. События WS `ready` +при reload только интеграции нет, поэтому закрытия одних настроек недостаточно, +если overlay удерживает общий runtime. + +Ревьюер исполнил Node-пробу, транспилировав текущий production TypeScript в +памяти без изменения файлов: + +1. Подписка и первый start на старой сессии успешны. +2. Принят `closed`; backend временно возвращает `not_ready` на config/get. +3. Явный start даёт `phase:error`, `error:backend_required`. +4. Backend становится доступен с новой сессией; повторён явный start. + +Фактический результат после шага 3 **и после шага 4** одинаков: +`config reads = 2`, `server starts = 1`, состояние +`{phase: "error", error: "backend_required"}`. После setup не произошло даже +повторного чтения capability. Пользователь снова вынужден перезагружать страницу. + +Нужно разрешить новое чтение capability после временного отказа при следующем +явном действии/восстановлении наблюдения, сохранив отсутствие автоматических +публикаций. Witness должен включать именно промежуточный отказ: +closed → explicit start/not_ready → setup завершён → explicit retry → новая +подписка, один новый scan и успешное принятие результата. Нынешний happy-path +тест делает backend доступным до первого повторного start и этого не ловит. + +## Повторно проверенные защиты + +| Контракт | Свидетель | Чем краснеет | +| --- | --- | --- | +| AC1: 600 секунд не scan deadline | `test_issue_800_background_15_minutes_two_clients_and_reopen` с продвижением настоящего loop clock | Зарегистрированный `zigbee-background-scan-restores-ten-minute-deadline` | +| AC4/AC6: unload инвалидирует существующий feed | HA same-socket unload/setup; unit `integration closed invalidates mounted jobs`; smoke 17 | `zigbee-background-unload-leaves-ui-waiting`; assertions пустых states/maps и очищенного observer | +| Старый async callback не оживляет сессию | `closed while subscribe ACK is pending`, `only current-session non-stale closed event`, `late command completion from closed session` | Foreign/stale closed игнорируется; старый ACK не закрывает новый feed; старый finally не стирает дедупликацию новой команды | +| Повтор после временного отказа | Независимая production-runtime проба выше | Красный пользовательский исход — единственная Medium этого раунда | + +## Унаследовано из r1 + +Без полного повторного разбора приняты выводы +[r1](CODE-REVIEW-800-r1.md) на `379de706d29f4b1547c3b162c1c205654487c6b8`: +J7 и границы скоупа; дедупликация topic, admin-only, нормализация/пределы, +MQTT transaction и retained guards, last-good/stale, cancel threshold/id/race, +немедленные provider errors, MQTT disconnect, optional MQTT и API floor, +непостоянный runtime без config/export/diagnostics, отсутствие радио retry, +неизменность ZHA/маршрутов/LQI, локали, elapsed formatter и narrow visual QA. +Полные backend и Zigbee unit прогоны повторены, но это не повод выдавать +всё перечисленное за заново проведённое независимое исследование. + +Реальная радиосеть, настоящий touch/скринридер, полный golden/performance и +CI кандидата/релизные артефакты не проверялись. M2/M3/L1 закрыты; до устранения +единственной M1 зелёного вердикта нет. Следующий раунд — узкая retry-дельта +плюс затронутые async/ownership сценарии и обязательные дешёвые гейты. diff --git a/docs/reviews/INDEX.md b/docs/reviews/INDEX.md index 74cce9a2..1d3e8bd3 100644 --- a/docs/reviews/INDEX.md +++ b/docs/reviews/INDEX.md @@ -1,6 +1,6 @@ # Индекс ревью -Генерируется `node scripts/reviews-index.mjs` (#635) — не редактировать руками. Документов: 303, issue: 152. Вердикт: 🟢 зелёный · 🟡 жёлтый · 🔴 красный · ⚪ не распознан (свободная форма старых документов). H/M — число High/Medium по строке вердикта или заголовкам находок. Файлы — пути, названные в находках; ищите по имени файла: `grep form-kit INDEX.md`. +Генерируется `node scripts/reviews-index.mjs` (#635) — не редактировать руками. Документов: 304, issue: 152. Вердикт: 🟢 зелёный · 🟡 жёлтый · 🔴 красный · ⚪ не распознан (свободная форма старых документов). H/M — число High/Medium по строке вердикта или заголовкам находок. Файлы — пути, названные в находках; ищите по имени файла: `grep form-kit INDEX.md`. | Issue | Документ | Этап · раунд | Вердикт | H | M | Находки | Файлы | |---|---|---|---|---:|---:|---|---| @@ -9,6 +9,7 @@ | бета v1.79.0-beta.1 | [SHIP-REVIEW-v1.79.0-beta.1.md](SHIP-REVIEW-v1.79.0-beta.1.md) | пакетное ревью ship · — | ⚪ — | 0 | 0 | — | — | | #800 | [SPEC-REVIEW-800-r1.md](SPEC-REVIEW-800-r1.md) | spec · r1 | 🟢 зелёный | 0 | 0 | — | — | | #800 | [CODE-REVIEW-800-r1.md](CODE-REVIEW-800-r1.md) | code · r1 | 🟡 жёлтый | 0 | 3 | живой браузер остаётся на умершем job после reload интеграции; AC1 не имеет свидетеля, чувствительного к прежнему 600-секундному deadline; материал не проходит обязательный gate:small; English guide всё ещё обещает прекращение ожидания через 10 минут | `custom_components/houseplan/zigbee_topology.py` `scripts/mutation-registry.mjs` `test/data-hp-contract.test.mjs` `test/reviews-index.test.mjs` `PREFLIGHT-SPEC-800.md` `test/smoke-select.test.mjs` `docs/USER-GUIDE.md` | +| #800 | [CODE-REVIEW-800-r2.md](CODE-REVIEW-800-r2.md) | code · r2 | 🟡 жёлтый | 0 | 1 | временный not_ready во время reload навсегда блокирует явный retry | `src/zigbee-topology-runtime.ts` | | #799 | [CODE-REVIEW-799-r1.md](CODE-REVIEW-799-r1.md) | code · r1 | 🟢 зелёный | 0 | 0 | — | — | | #798 | [SPEC-REVIEW-798-r1.md](SPEC-REVIEW-798-r1.md) | spec · r1 | 🟢 зелёный | 0 | 0 | — | — | | #798 | [CODE-REVIEW-798-r1.md](CODE-REVIEW-798-r1.md) | code · r1 | 🟢 зелёный | 0 | 0 | — | — | diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index 34f428a7..464a8a9c 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -9878,6 +9878,17 @@ const MUTANT_DEFINITIONS = [ replace: ' async with asyncio.timeout(600):\n await self.hass.loop.create_future()', }], }, + { + id: 'zigbee-background-reload-error-poisons-explicit-retry', + guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + + '&& node --test --test-name-pattern="explicit retry recovers" test/zigbee-topology-runtime-routes.test.mjs', + because: '#800: a temporary config/get not_ready during integration reload must not cache a rejected promise forever', + patches: [{ + file: 'src/zigbee-topology-runtime.ts', + find: ' if (cache.capability === pending) cache.capability = undefined;', + replace: ' void pending; // mutant: retain the rejected capability forever', + }], + }, { id: 'zigbee-background-unload-leaves-ui-waiting', guard: 'node scripts/backend-test-guard.mjs issue_800_entry_unload_clears_jobs_and_new_entry_session_is_idle ' diff --git a/src/zigbee-topology-runtime.ts b/src/zigbee-topology-runtime.ts index 7c1a459d..28837366 100644 --- a/src/zigbee-topology-runtime.ts +++ b/src/zigbee-topology-runtime.ts @@ -148,7 +148,15 @@ async function capability(cache: Cache, hass: ZigbeeTopologyHass): Promise const result = recordOf(await hass.callWS({ type: 'houseplan/config/get' })); if (result?.zigbee_scan_api !== 1) throw fail('backend_required'); })(); - await cache.capability; + const pending = cache.capability; + try { + await pending; + } catch (error) { + // A transient reload failure must not poison later explicit actions. Do not + // retry here, or let an old rejection clear a newer owner's capability check. + if (cache.capability === pending) cache.capability = undefined; + throw error; + } } function startFeed(cache: Cache, hass: ZigbeeTopologyHass): void { if (!cache.listeners.size || cache.feedPending || cache.unsubscribe) return; diff --git a/test/zigbee-topology-runtime-routes.test.mjs b/test/zigbee-topology-runtime-routes.test.mjs index 9891c5dc..4b2f4bd3 100644 --- a/test/zigbee-topology-runtime-routes.test.mjs +++ b/test/zigbee-topology-runtime-routes.test.mjs @@ -354,3 +354,32 @@ test('#800 late command completion from closed session cannot erase new command assert.equal(b.calls.filter(c => c.type.endsWith('/start')).length, 2); assert.equal(b.callbacks.length, 2); off(); }); + +test('#800 explicit retry recovers after config not_ready during same-WS integration reload', async () => { + const b = backend(), off = b.observe(); await refreshZ2mTopology(b.hass, TOPIC); + const oldJob = b.snapshot().states[KEY].jobId; + b.emit({ kind: 'closed', session_id: b.session, revision: ++b.revision }); + b.capabilityGate = deferred(); + const duringUnload = refreshZ2mTopology(b.hass, TOPIC); + await turn(); b.capabilityGate.reject({ code: 'not_ready' }); await duringUnload; + assert.equal(b.snapshot().states[KEY].phase, 'error'); + assert.equal(b.snapshot().backendError, 'backend_required'); + assert.equal(b.publications, 1, 'failed capability must never publish'); + const reads = b.calls.filter(c => c.type === 'houseplan/config/get').length; + assert.equal(reads, 2); + b.session = 'replacement'; b.revision = 0; b.jobs.clear(); b.capabilityGate = null; + await turn(); + assert.equal(b.calls.filter(c => c.type === 'houseplan/config/get').length, reads, 'setup alone does not auto-retry'); + assert.equal(b.publications, 1); + const retry = refreshZ2mTopology(b.hass, TOPIC); + assert.equal(refreshZ2mTopology({ ...b.hass }, TOPIC), retry, 'simultaneous explicit retry remains deduplicated'); + await retry; + assert.equal(b.calls.filter(c => c.type === 'houseplan/config/get').length, 3, 'new explicit action must retry failed capability'); + assert.equal(b.publications, 2); + assert.equal(b.callbacks.length, 2); assert.equal(b.live.size, 1); + assert.equal(b.snapshot().states[KEY].phase, 'loading'); + assert.notEqual(b.snapshot().states[KEY].jobId, oldJob); + assert.equal(b.snapshot().backendError, undefined); + b.update(TOPIC, { phase: 'ready', result: map, obtained_at: 101 }); + assert.equal(b.snapshot().states[KEY].obtainedAt, 101); off(); +});