fix(zigbee): allow explicit retry after transient reload failure (#800)

Issue: #800
User-Visible: yes
This commit is contained in:
Matysh
2026-10-05 20:12:25 +03:00
parent 4a7abf531b
commit 67af859e8c
8 changed files with 181 additions and 5 deletions
+10 -1
View File
@@ -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);
+2 -1
View File
@@ -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
+2 -1
View File
@@ -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
+116
View File
@@ -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 сценарии и обязательные дешёвые гейты.
+2 -1
View File
@@ -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 | — | — |
+11
View File
@@ -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 '
+9 -1
View File
@@ -148,7 +148,15 @@ async function capability(cache: Cache, hass: ZigbeeTopologyHass): Promise<void>
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;
@@ -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();
});