mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-30 08:46:03 +00:00
v1.45.2: hardening from the v1.45.1 review — R4-1, R4-2
R4-1: collecting superseded plan files runs after the configuration is already durable, but an error listing the directory propagated out of config/set. The client saw a failure for a revision the server had committed, and its retry came back as a conflict. collect_plans now reports 0 instead of raising, and config/set logs and proceeds — the event fires, the revision is returned. R4-2: the pending set was cleared when a batch went out, not when it came back, so every render during an in-flight content/sign queued another request: six calls where one was needed, and unbounded on a socket that is slow rather than busy. Queued and in-flight are separate states now; a failure backs off (2 s doubling to 60 s) instead of retrying on the next frame; an in-flight entry expires after 15 s so a promise that never settles cannot wedge retries; a late answer after dispose() no longer renders. Tests: test/signing.test.mjs — eight cases with hand-settled promises, verified against a v1.45.1 checkout where four of them fail (2 sign calls instead of 1, no backoff, a late answer rendering after teardown). Backend: a broken collector still yields a successful save whose revision the next CAS accepts. Pure collector: a disappearing directory returns 0. Docs: CHANGELOG.md + CHANGELOG.ru.md + ARCHITECTURE.md + TESTING.md + STATUS.md.
This commit is contained in:
Binary file not shown.
@@ -24,7 +24,7 @@ MAX_SIGN_PATHS = 200
|
||||
PLAN_ORPHAN_TTL_S = 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.45.1"
|
||||
VERSION = "1.45.2"
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.45.1"
|
||||
"version": "1.45.2"
|
||||
}
|
||||
|
||||
@@ -63,14 +63,23 @@ def collect_plans(
|
||||
* any other unreferenced plan file is a rejected or abandoned upload, and
|
||||
is removed only once PLAN_ORPHAN_TTL_S has passed: a fresh one may
|
||||
belong to a transaction that has not committed yet.
|
||||
|
||||
Never raises: the configuration is already stored by the time this runs, so
|
||||
a file-system problem must not turn a durable commit into a failed call.
|
||||
"""
|
||||
if not plans_dir.is_dir():
|
||||
return 0
|
||||
new_refs = plan_refs(new_cfg)
|
||||
old_refs = plan_refs(old_cfg)
|
||||
cutoff = (time.time() if now is None else now) - PLAN_ORPHAN_TTL_S
|
||||
removed = 0
|
||||
for item in sorted(plans_dir.iterdir()):
|
||||
try:
|
||||
items = sorted(plans_dir.iterdir()) if plans_dir.is_dir() else []
|
||||
except OSError as err:
|
||||
# The directory can vanish or turn unreadable between the check and the
|
||||
# walk. This is housekeeping running behind a commit that is already
|
||||
# durable, so it reports "nothing collected" instead of failing (R4-1).
|
||||
_LOGGER.warning("House Plan: could not list %s: %s", plans_dir, err)
|
||||
return 0
|
||||
for item in items:
|
||||
if not item.is_file() or item.name in new_refs or not is_plan_file(item.name):
|
||||
continue
|
||||
superseded = item.name in old_refs
|
||||
|
||||
@@ -363,11 +363,18 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
||||
return
|
||||
new_rev = current_rev + 1
|
||||
await rt.config_store.async_save({"config": msg["config"], "rev": new_rev})
|
||||
# still holding the lock: the file system is not part of the store's
|
||||
# transaction, so collection has to be pinned to this commit (R3-1)
|
||||
await hass.async_add_executor_job(
|
||||
collect_plans, Path(hass.config.path(PLANS_DIR)), data.get("config"), msg["config"]
|
||||
)
|
||||
# Still holding the lock: the file system is not part of the store's
|
||||
# transaction, so collection has to be pinned to this commit (R3-1).
|
||||
# It is best-effort housekeeping behind an already durable write — a
|
||||
# failure here must not withhold the event and the success response,
|
||||
# or the client retries an edit the server has already accepted and
|
||||
# gets a conflict for its trouble (R4-1).
|
||||
try:
|
||||
await hass.async_add_executor_job(
|
||||
collect_plans, Path(hass.config.path(PLANS_DIR)), data.get("config"), msg["config"]
|
||||
)
|
||||
except Exception: # noqa: BLE001 — see above: the commit stands regardless
|
||||
_LOGGER.exception("House Plan: collecting superseded plan files failed")
|
||||
hass.bus.async_fire("houseplan_config_updated", {"rev": new_rev})
|
||||
# refresh repair issues (broken plan references) without waiting for a restart
|
||||
entry = get_entry(hass)
|
||||
|
||||
@@ -51,19 +51,26 @@ const res = await page.evaluate(async () => {
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
out.hrefAfterFailedSign = await href();
|
||||
|
||||
// 2) повтор после ошибки: pending освобождён, вторая попытка проходит
|
||||
// 2) сразу повтора нет: после ошибки подпись уходит в backoff (ревью R4-2),
|
||||
// иначе нестабильный сокет получал бы по запросу на каждый рендер
|
||||
for (let i = 0; i < 5; i++) { card.requestUpdate(); await card.updateComplete; }
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
out.noRetryStorm = signCalls === 1;
|
||||
|
||||
// 3) после выдержки повтор проходит
|
||||
await new Promise((r) => setTimeout(r, 2100));
|
||||
card.requestUpdate(); await card.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
out.hrefAfterRetry = await href();
|
||||
out.retried = signCalls >= 2;
|
||||
out.retried = signCalls === 2;
|
||||
|
||||
// 3) повторный рендер не теряет подпись и не просит её заново
|
||||
// 4) повторный рендер не теряет подпись и не просит её заново
|
||||
const before = signCalls;
|
||||
card.requestUpdate(); await card.updateComplete;
|
||||
out.hrefStable = await href();
|
||||
out.noExtraSignOnRerender = signCalls === before;
|
||||
|
||||
// 4) протухшая подпись не отдаётся, стареющая — отдаётся, пока едет замена
|
||||
// 5) протухшая подпись не отдаётся, стареющая — отдаётся, пока едет замена
|
||||
const ent = card._signer.entries;
|
||||
ent[raw] = { url: raw + '?authSig=OLD', at: Date.now() - 25 * 3600 * 1000 };
|
||||
card.requestUpdate(); await card.updateComplete;
|
||||
@@ -80,6 +87,7 @@ const res = await page.evaluate(async () => {
|
||||
// зафиксировано прогоном на v1.45.1 и сверено с кодом
|
||||
checkAll(res, {
|
||||
hrefAfterFailedSign: null,
|
||||
noRetryStorm: true,
|
||||
hrefAfterRetry: '/api/houseplan/content/plans/_/f1.tok.svg?authSig=SIG2',
|
||||
retried: true,
|
||||
hrefStable: '/api/houseplan/content/plans/_/f1.tok.svg?authSig=SIG2',
|
||||
|
||||
File diff suppressed because one or more lines are too long
Vendored
+13
-13
File diff suppressed because one or more lines are too long
@@ -194,7 +194,7 @@ to a transaction still in flight. The `.` between id and token is load-bearing
|
||||
a space id cannot contain one, so `<space>.<token>.<ext>` can never be confused
|
||||
with the files of a space whose name merely starts the same way.
|
||||
|
||||
**Signed content urls are batched and aged** (reviews R2-2, R3-2). `ContentSigner`
|
||||
**Signed content urls are batched, aged and deduplicated** (reviews R2-2, R3-2, R4-2). `ContentSigner`
|
||||
in `src/signing.ts` is the single implementation, used by both cards; the
|
||||
duplicate inside houseplan-space-card signed correctly and never handed the
|
||||
result to its renderer, which is the failure mode a second copy invites. `MAX_SIGN_PATHS`
|
||||
@@ -204,6 +204,10 @@ signatures carry the time they were issued — an aging one keeps rendering whil
|
||||
its replacement is fetched, an expired one is dropped rather than served (it
|
||||
would 401 and raise a failed-login warning). The cache is pruned to the urls the
|
||||
live config references, so it cannot grow past the cap through history alone.
|
||||
Queued and in-flight are distinct states: a render happening while a request is
|
||||
out must not queue the same url again, a failure backs off rather than retrying
|
||||
on the next frame, and an in-flight entry expires after `SIGN_INFLIGHT_MS` so a
|
||||
promise that never settles cannot block retries forever.
|
||||
|
||||
**Room climate is one pass per hass snapshot** (review R2-3). `areaClimateMap()`
|
||||
classifies the whole registry once and returns `Map<area, {temp, hum}>`; the
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
# Changelog
|
||||
|
||||
## v1.45.2 — 2026-07-27 (hardening from the v1.45.1 review: R4-1, R4-2)
|
||||
- **A failed cleanup no longer reports an accepted save as an error (R4-1).**
|
||||
Collecting superseded plan files runs after the configuration is already
|
||||
stored, but an error while listing the directory — it can vanish or turn
|
||||
unreadable between the check and the walk — propagated out of `config/set`.
|
||||
The client then saw a failure for a revision the server had committed, and its
|
||||
retry came back as a conflict. The collector now reports "nothing collected"
|
||||
instead of raising, and `config/set` logs and proceeds: the event fires and
|
||||
the new revision is returned.
|
||||
- **One signing request per url instead of one per render (R4-2).** The pending
|
||||
set was cleared when the batch went out rather than when it came back, so
|
||||
while a `content/sign` call was in flight every re-render queued another one —
|
||||
six calls where one was needed, and far worse on a socket that is slow rather
|
||||
than merely busy. Queued and in-flight are now separate states, a failure
|
||||
backs off (2 s doubling to 60 s) instead of retrying on the next frame, and a
|
||||
request that never settles stops blocking retries after 15 s. A late answer
|
||||
arriving after the card was torn down no longer triggers a render.
|
||||
- Tests: eight unit tests for the signer with hand-settled promises (four fail
|
||||
on v1.45.1), a backend test asserting a broken collector still yields a
|
||||
successful save with a usable revision, and the pure-collector test extended
|
||||
to a disappearing directory.
|
||||
|
||||
## v1.45.1 — 2026-07-27 (follow-up review of v1.45.0: R3-1, R3-2)
|
||||
- **Collecting old plan files moved into the config transaction (R3-1, high).**
|
||||
v1.45.0 made the upload safe but handed the deletion to the client: after a
|
||||
|
||||
@@ -6,6 +6,29 @@
|
||||
> **Правило проекта:** оба файла пополняются в одном коммите с самим
|
||||
> изменением — как и остальная документация (см. docs/STATUS.md).
|
||||
|
||||
## v1.45.2 — 2026-07-27 (закалка по ревью v1.45.1: R4-1, R4-2)
|
||||
- **Сбой уборки больше не превращает принятое сохранение в ошибку (R4-1).**
|
||||
Сборка вытесненных файлов плана идёт уже после того, как конфигурация
|
||||
сохранена, но ошибка при обходе каталога — он может исчезнуть или стать
|
||||
недоступным между проверкой и обходом — вылетала наружу из `config/set`.
|
||||
Клиент видел неудачу для ревизии, которую сервер закоммитил, а его повтор
|
||||
возвращался с конфликтом. Теперь сборщик сообщает «ничего не убрано» вместо
|
||||
исключения, а `config/set` пишет в лог и продолжает: событие уходит, новая
|
||||
ревизия возвращается.
|
||||
- **Один запрос подписи на ссылку вместо одного на рендер (R4-2).** Множество
|
||||
ожидающих очищалось в момент отправки батча, а не по возвращении, поэтому
|
||||
пока запрос `content/sign` был в полёте, каждая перерисовка ставила ещё
|
||||
один — шесть вызовов там, где нужен один, и куда хуже на медленном (а не
|
||||
просто занятом) сокете. Состояния «в очереди» и «в полёте» теперь разделены,
|
||||
после ошибки включается выдержка (2 с с удвоением до 60 с) вместо повтора на
|
||||
следующем кадре, а запрос, который так и не завершился, перестаёт блокировать
|
||||
повторы через 15 с. Поздний ответ, пришедший после размонтирования карточки,
|
||||
больше не вызывает перерисовку.
|
||||
- Тесты: восемь юнит-тестов подписывателя с ручным разрешением promise (четыре
|
||||
падают на v1.45.1), backend-тест на то, что сломанный сборщик оставляет
|
||||
сохранение успешным с рабочей ревизией, и проверка исчезнувшего каталога в
|
||||
тестах чистого сборщика.
|
||||
|
||||
## v1.45.1 — 2026-07-27 (повторное ревью v1.45.0: R3-1, R3-2)
|
||||
- **Уборка старых файлов плана перенесена внутрь транзакции конфига (R3-1,
|
||||
high).** v1.45.0 сделала загрузку безопасной, но отдала удаление клиенту:
|
||||
|
||||
+2
-2
@@ -15,12 +15,12 @@
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Version | **v1.45.1** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
|
||||
| Version | **v1.45.2** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
|
||||
| Workflow | Since 2026-07-22: minor changes go to branch **`dev`** (build + smokes → deploy home → commit → push, NO release); releases are batched on the owner's command (merge dev→main, one tag, one release with a summary changelog, CI checked on dev beforehand) |
|
||||
| GitHub | https://github.com/Matysh/houseplan-card — `main` = releases up to **v1.40.1**; `dev` ahead with v1.40.2+ (speaker icons, kiosk). Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
|
||||
| CI | validate.yml (hacs + hassfest + frontend + backend) green; release.yml attaches the bundle on release publish |
|
||||
| HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled; ~864 older open PRs but merge rate ≈180/mo; realistic ETA 1–3 months (checked 2026-07-24) |
|
||||
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.45.1** via direct copy (HACS custom repo also installed) |
|
||||
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.45.2** via direct copy (HACS custom repo also installed) |
|
||||
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
|
||||
| Tests | 121 frontend (node:test) + 12 pure backend + 12 HA-harness (CI, py3.13); ~30 demo smoke suites (headless chromium) |
|
||||
| Community | **Telegram chat: https://t.me/ha_houseplan** (created 2026-07-27) — the primary user-facing support channel; GitHub issues stay for bugs/features. Link it from any new release notes and posts |
|
||||
|
||||
@@ -234,6 +234,15 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
are only reachable through /api/houseplan/content/… with a session; the
|
||||
old /houseplan_files/plans|files paths return 404 after a restart; old
|
||||
stored URLs keep working (rewritten on read) [auto+manual]
|
||||
- [ ] Signing does not amplify on a bad connection (v1.45.2, review R4-2): with
|
||||
the WebSocket slow or refusing, the card issues ONE sign request per url
|
||||
and backs off after a failure instead of asking again on every render; a
|
||||
request that never answers stops blocking retries after 15 s
|
||||
[auto: unit: signing.test + smoke_space_card_bg]
|
||||
- [ ] A broken plans directory does not fail a save (v1.45.2, review R4-1): make
|
||||
the plans folder unreadable and save the configuration — the save
|
||||
succeeds, the revision is usable, and the next save does not conflict
|
||||
[auto: backend test_a_failing_collector_does_not_undo_an_accepted_save]
|
||||
- [ ] Two editors, one plan (v1.45.1, review R3-1): with the same space open in
|
||||
two tabs, attach a background in each in turn — the plan last saved is the
|
||||
one served, and neither commit deletes the other's file. A rejected upload
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.45.1",
|
||||
"version": "1.45.2",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -34,7 +34,7 @@ import './space-card';
|
||||
import { cardStyles } from './styles';
|
||||
import { langOf, t, type I18nKey } from './i18n';
|
||||
|
||||
const CARD_VERSION = '1.45.1';
|
||||
const CARD_VERSION = '1.45.2';
|
||||
const LS_KEY = 'houseplan_card_layout_v1';
|
||||
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
|
||||
const LS_ZOOM = 'houseplan_card_zoom_v1';
|
||||
|
||||
+53
-13
@@ -1,5 +1,11 @@
|
||||
import { contentUrl, chunk, MAX_SIGN_PATHS, SIGN_TTL_MS, SIGN_REFRESH_MS } from './logic';
|
||||
|
||||
/** A request older than this is presumed lost, and the url may be asked again. */
|
||||
export const SIGN_INFLIGHT_MS = 15000;
|
||||
/** After a failure, wait before retrying; doubles up to the cap. */
|
||||
export const SIGN_BACKOFF_MIN_MS = 2000;
|
||||
export const SIGN_BACKOFF_MAX_MS = 60000;
|
||||
|
||||
/**
|
||||
* Signed urls for the authenticated content endpoint, shared by both cards.
|
||||
*
|
||||
@@ -19,14 +25,23 @@ import { contentUrl, chunk, MAX_SIGN_PATHS, SIGN_TTL_MS, SIGN_REFRESH_MS } from
|
||||
* replacement is fetched while the old url keeps rendering, past SIGN_TTL_MS
|
||||
* the entry is dropped rather than served (it would 401 and raise a
|
||||
* failed-login warning for the viewer's own IP);
|
||||
* - `pending` is always released, so one failed request does not wedge a url
|
||||
* forever.
|
||||
* - a url that is queued or already in flight is not asked for again: renders
|
||||
* are frequent and a slow socket used to turn every one of them into another
|
||||
* `content/sign` call (review R4-2). An in-flight entry expires after
|
||||
* SIGN_INFLIGHT_MS so a promise that never settles cannot block retries
|
||||
* forever, and a failure backs off instead of retrying on the next frame.
|
||||
*/
|
||||
export class ContentSigner {
|
||||
private signed: Record<string, { url: string; at: number }> = {};
|
||||
private pending = new Set<string>();
|
||||
/** Waiting for the batch timer to fire. */
|
||||
private queued = new Set<string>();
|
||||
/** Sent and not settled yet: url -> when the request went out. */
|
||||
private inFlight = new Map<string, number>();
|
||||
/** After a failure: when this url may be asked again, and the current delay. */
|
||||
private retry = new Map<string, { notBefore: number; delay: number }>();
|
||||
private batchTimer?: ReturnType<typeof setTimeout>;
|
||||
private resignTimer?: ReturnType<typeof setInterval>;
|
||||
private disposed = false;
|
||||
|
||||
/**
|
||||
* @param onUpdate schedule a re-render (a signature arriving changes the DOM)
|
||||
@@ -36,15 +51,18 @@ export class ContentSigner {
|
||||
|
||||
/** Start the periodic re-sign. `referenced` prunes the cache on each tick. */
|
||||
start(hass: () => any, referenced: () => Set<string>): void {
|
||||
this.disposed = false; // an element can be reconnected after a disconnect
|
||||
this.stopTimer();
|
||||
this.resignTimer = setInterval(() => this.resign(hass(), referenced()), SIGN_REFRESH_MS / 2);
|
||||
}
|
||||
|
||||
/** Release every timer; the cache survives a reconnect, the timers must not. */
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
this.stopTimer();
|
||||
clearTimeout(this.batchTimer);
|
||||
this.pending.clear();
|
||||
this.queued.clear();
|
||||
this.inFlight.clear();
|
||||
}
|
||||
|
||||
private stopTimer(): void {
|
||||
@@ -72,13 +90,18 @@ export class ContentSigner {
|
||||
}
|
||||
|
||||
private request(hass: any, url: string): void {
|
||||
if (this.pending.has(url) || !hass?.callWS) return;
|
||||
this.pending.add(url);
|
||||
if (!hass?.callWS || this.queued.has(url)) return;
|
||||
const now = this.now();
|
||||
const sent = this.inFlight.get(url);
|
||||
if (sent !== undefined && now - sent < SIGN_INFLIGHT_MS) return; // already asking
|
||||
const back = this.retry.get(url);
|
||||
if (back && now < back.notBefore) return; // still backing off
|
||||
this.queued.add(url);
|
||||
clearTimeout(this.batchTimer);
|
||||
// batch: switching space asks for several urls in the same tick
|
||||
this.batchTimer = setTimeout(() => {
|
||||
const paths = [...this.pending];
|
||||
this.pending.clear();
|
||||
const paths = [...this.queued];
|
||||
this.queued.clear();
|
||||
this.sign(hass, paths);
|
||||
}, 30);
|
||||
}
|
||||
@@ -86,21 +109,32 @@ export class ContentSigner {
|
||||
private sign(hass: any, paths: string[]): void {
|
||||
if (!paths.length || !hass?.callWS) return;
|
||||
for (const batch of chunk(paths, MAX_SIGN_PATHS)) {
|
||||
const sentAt = this.now();
|
||||
for (const p of batch) this.inFlight.set(p, sentAt);
|
||||
hass
|
||||
.callWS({ type: 'houseplan/content/sign', paths: batch })
|
||||
.then((r: any) => {
|
||||
if (!r?.urls) return;
|
||||
for (const p of batch) this.retry.delete(p);
|
||||
if (!r?.urls || this.disposed) return;
|
||||
const at = this.now();
|
||||
const next = { ...this.signed };
|
||||
for (const [k, v] of Object.entries<string>(r.urls)) next[k] = { url: v, at };
|
||||
this.signed = next;
|
||||
this.onUpdate();
|
||||
})
|
||||
.catch(() => undefined) // a retry happens on the next render
|
||||
.catch(() => {
|
||||
// back off rather than retry on the very next frame: a socket that
|
||||
// is refusing sign requests would otherwise be hammered per render
|
||||
const now = this.now();
|
||||
for (const p of batch) {
|
||||
const prev = this.retry.get(p)?.delay || 0;
|
||||
const delay = Math.min(SIGN_BACKOFF_MAX_MS, prev ? prev * 2 : SIGN_BACKOFF_MIN_MS);
|
||||
this.retry.set(p, { notBefore: now + delay, delay });
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
// never leave a url wedged in `pending`: the first failure would
|
||||
// otherwise make it unrequestable for the life of the page (R3-2)
|
||||
for (const p of batch) this.pending.delete(p);
|
||||
// release only our own attempt: a later one may have superseded it
|
||||
for (const p of batch) if (this.inFlight.get(p) === sentAt) this.inFlight.delete(p);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -117,6 +151,7 @@ export class ContentSigner {
|
||||
if (referenced.has(k) && now - v.at < SIGN_TTL_MS) kept[k] = v;
|
||||
}
|
||||
this.signed = kept;
|
||||
this.retry.clear(); // a scheduled refresh is a fresh chance for everything
|
||||
this.sign(hass, Object.keys(kept));
|
||||
}
|
||||
|
||||
@@ -124,4 +159,9 @@ export class ContentSigner {
|
||||
get entries(): Record<string, { url: string; at: number }> {
|
||||
return this.signed;
|
||||
}
|
||||
|
||||
/** Test/debug view of what is currently being asked for. */
|
||||
get inFlightUrls(): string[] {
|
||||
return [...this.inFlight.keys()];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { ContentSigner, SIGN_INFLIGHT_MS, SIGN_BACKOFF_MIN_MS } from '../test-build/signing.js';
|
||||
import { SIGN_TTL_MS, SIGN_REFRESH_MS } from '../test-build/logic.js';
|
||||
|
||||
const URL_A = '/api/houseplan/content/plans/_/f1.tok.svg';
|
||||
const URL_B = '/api/houseplan/content/files/m1/manual.pdf';
|
||||
const tick = () => new Promise((r) => setTimeout(r, 45)); // > the 30 ms batch timer
|
||||
|
||||
/** hass whose sign call is resolved/rejected by hand. */
|
||||
function makeHass() {
|
||||
const calls = [];
|
||||
const hass = {
|
||||
callWS(m) {
|
||||
let settle;
|
||||
const p = new Promise((res, rej) => { settle = { res, rej }; });
|
||||
calls.push({ paths: m.paths, ...settle });
|
||||
return p;
|
||||
},
|
||||
};
|
||||
return { hass, calls };
|
||||
}
|
||||
|
||||
function signer(now = () => Date.now()) {
|
||||
let updates = 0;
|
||||
const s = new ContentSigner(() => { updates++; }, now);
|
||||
return { s, updates: () => updates };
|
||||
}
|
||||
|
||||
test('R4-2: renders during one unresolved request do not multiply it', async () => {
|
||||
const { hass, calls } = makeHass();
|
||||
const { s } = signer();
|
||||
for (let i = 0; i < 6; i++) assert.equal(s.display(hass, URL_A), '');
|
||||
await tick();
|
||||
assert.equal(calls.length, 1, 'one batched request');
|
||||
// more renders while it is still in flight
|
||||
for (let i = 0; i < 5; i++) s.display(hass, URL_A);
|
||||
await tick();
|
||||
assert.equal(calls.length, 1, 'still one: the url is in flight');
|
||||
assert.deepEqual(s.inFlightUrls, [URL_A]);
|
||||
|
||||
calls[0].res({ urls: { [URL_A]: URL_A + '?authSig=OK' } });
|
||||
await tick();
|
||||
assert.equal(s.display(hass, URL_A), URL_A + '?authSig=OK');
|
||||
assert.equal(calls.length, 1, 'a resolved signature starts no extra request');
|
||||
assert.deepEqual(s.inFlightUrls, []);
|
||||
});
|
||||
|
||||
test('R4-2: a rejection frees the url but backs off before retrying', async () => {
|
||||
let t = 1_000_000;
|
||||
const { hass, calls } = makeHass();
|
||||
const { s } = signer(() => t);
|
||||
s.display(hass, URL_A);
|
||||
await tick();
|
||||
calls[0].rej(new Error('socket'));
|
||||
await tick();
|
||||
assert.deepEqual(s.inFlightUrls, [], 'released, not wedged');
|
||||
|
||||
s.display(hass, URL_A);
|
||||
await tick();
|
||||
assert.equal(calls.length, 1, 'the very next render must not retry');
|
||||
|
||||
t += SIGN_BACKOFF_MIN_MS + 1;
|
||||
s.display(hass, URL_A);
|
||||
await tick();
|
||||
assert.equal(calls.length, 2, 'retried once the backoff has passed');
|
||||
|
||||
// the second failure waits longer than the first
|
||||
calls[1].rej(new Error('socket'));
|
||||
await tick();
|
||||
t += SIGN_BACKOFF_MIN_MS + 1;
|
||||
s.display(hass, URL_A);
|
||||
await tick();
|
||||
assert.equal(calls.length, 2, 'backoff doubled');
|
||||
t += SIGN_BACKOFF_MIN_MS * 2;
|
||||
s.display(hass, URL_A);
|
||||
await tick();
|
||||
assert.equal(calls.length, 3);
|
||||
|
||||
calls[2].res({ urls: { [URL_A]: URL_A + '?authSig=OK' } });
|
||||
await tick();
|
||||
assert.equal(s.display(hass, URL_A), URL_A + '?authSig=OK');
|
||||
});
|
||||
|
||||
test('R4-2: a request that never settles stops blocking after the timeout', async () => {
|
||||
let t = 1_000_000;
|
||||
const { hass, calls } = makeHass();
|
||||
const { s } = signer(() => t);
|
||||
s.display(hass, URL_A);
|
||||
await tick();
|
||||
assert.equal(calls.length, 1);
|
||||
|
||||
t += SIGN_INFLIGHT_MS - 1;
|
||||
s.display(hass, URL_A);
|
||||
await tick();
|
||||
assert.equal(calls.length, 1, 'still presumed in flight');
|
||||
|
||||
t += 2;
|
||||
s.display(hass, URL_A);
|
||||
await tick();
|
||||
assert.equal(calls.length, 2, 'presumed lost — asked again');
|
||||
|
||||
// the FIRST promise finally answers: it must not resurrect a stale in-flight
|
||||
calls[0].res({ urls: { [URL_A]: URL_A + '?authSig=LATE' } });
|
||||
await tick();
|
||||
assert.deepEqual(s.inFlightUrls, [URL_A], 'only the second attempt is in flight');
|
||||
});
|
||||
|
||||
test('signatures age: fresh, aging, expired', async () => {
|
||||
let t = 1_000_000;
|
||||
const { hass, calls } = makeHass();
|
||||
const { s } = signer(() => t);
|
||||
s.display(hass, URL_A);
|
||||
await tick();
|
||||
calls[0].res({ urls: { [URL_A]: URL_A + '?authSig=ONE' } });
|
||||
await tick();
|
||||
|
||||
assert.equal(s.display(hass, URL_A), URL_A + '?authSig=ONE');
|
||||
assert.equal(calls.length, 1, 'a fresh signature asks for nothing');
|
||||
|
||||
t += SIGN_REFRESH_MS + 1; // aging: keep rendering, fetch a replacement
|
||||
assert.equal(s.display(hass, URL_A), URL_A + '?authSig=ONE');
|
||||
await tick();
|
||||
assert.equal(calls.length, 2);
|
||||
|
||||
t += SIGN_TTL_MS; // expired: rendering it would 401
|
||||
assert.equal(s.display(hass, URL_A), '');
|
||||
assert.equal(s.entries[URL_A], undefined, 'the dead entry is dropped');
|
||||
});
|
||||
|
||||
test('a non-content url is passed straight through, nothing is signed', async () => {
|
||||
const { hass, calls } = makeHass();
|
||||
const { s } = signer();
|
||||
assert.equal(s.display(hass, '/local/plan.png'), '/local/plan.png');
|
||||
assert.equal(s.display(hass, ''), '');
|
||||
assert.equal(s.display(hass, null), '');
|
||||
await tick();
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test('legacy urls are normalised before signing', async () => {
|
||||
const { hass, calls } = makeHass();
|
||||
const { s } = signer();
|
||||
s.display(hass, '/houseplan_files/plans/f1.svg');
|
||||
await tick();
|
||||
assert.deepEqual(calls[0].paths, ['/api/houseplan/content/plans/_/f1.svg']);
|
||||
});
|
||||
|
||||
test('resign prunes to the referenced set and gives everything a fresh chance', async () => {
|
||||
let t = 1_000_000;
|
||||
const { hass, calls } = makeHass();
|
||||
const { s } = signer(() => t);
|
||||
s.display(hass, URL_A);
|
||||
s.display(hass, URL_B);
|
||||
await tick();
|
||||
calls[0].res({ urls: { [URL_A]: URL_A + '?s=1', [URL_B]: URL_B + '?s=1' } });
|
||||
await tick();
|
||||
assert.equal(Object.keys(s.entries).length, 2);
|
||||
|
||||
s.resign(hass, new Set([URL_A]));
|
||||
await tick();
|
||||
assert.deepEqual(Object.keys(s.entries), [URL_A], 'the unreferenced url is dropped');
|
||||
assert.deepEqual(calls[1].paths, [URL_A]);
|
||||
});
|
||||
|
||||
test('dispose(): a late answer neither renders nor throws, start() revives it', async () => {
|
||||
const { hass, calls } = makeHass();
|
||||
const { s, updates } = signer();
|
||||
s.display(hass, URL_A);
|
||||
await tick();
|
||||
s.dispose();
|
||||
calls[0].res({ urls: { [URL_A]: URL_A + '?authSig=LATE' } });
|
||||
await tick();
|
||||
assert.equal(updates(), 0, 'no re-render after teardown');
|
||||
|
||||
s.start(() => hass, () => new Set([URL_A]));
|
||||
s.display(hass, URL_A);
|
||||
await tick();
|
||||
assert.equal(calls.length, 2, 'a reconnected card asks again');
|
||||
calls[1].res({ urls: { [URL_A]: URL_A + '?authSig=NEW' } });
|
||||
await tick();
|
||||
assert.equal(updates(), 1);
|
||||
assert.equal(s.display(hass, URL_A), URL_A + '?authSig=NEW');
|
||||
s.dispose();
|
||||
});
|
||||
@@ -396,6 +396,47 @@ async def test_collection_ignores_files_that_are_not_plans(
|
||||
assert (plans / "readme").is_file()
|
||||
|
||||
|
||||
async def test_a_failing_collector_does_not_undo_an_accepted_save(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, monkeypatch
|
||||
) -> None:
|
||||
"""review R4-1: garbage collection runs behind an already durable write.
|
||||
|
||||
If it raised, the client got an error for a revision the store had already
|
||||
accepted — and its retry then failed with `conflict`, because the server had
|
||||
moved on. The commit stands and the event fires regardless.
|
||||
"""
|
||||
from custom_components.houseplan import websocket_api as wsapi
|
||||
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
events = []
|
||||
hass.bus.async_listen("houseplan_config_updated", lambda ev: events.append(ev.data))
|
||||
|
||||
def _boom(*_a, **_k):
|
||||
raise OSError("the plans directory is on fire")
|
||||
|
||||
monkeypatch.setattr(wsapi, "collect_plans", _boom)
|
||||
|
||||
cfg = await _cfg([{"id": "r5", "plan_url": None}])
|
||||
ok = await _save(client, cfg, 0)
|
||||
assert ok["success"], "an accepted revision must be reported as accepted"
|
||||
rev = ok["result"]["rev"]
|
||||
|
||||
await hass.async_block_till_done()
|
||||
assert events and events[-1]["rev"] == rev, "the update event still fires"
|
||||
|
||||
# the store really holds the new revision, and the reported rev is usable
|
||||
await client.send_json_auto_id({"type": "houseplan/config/get"})
|
||||
got = await client.receive_json()
|
||||
assert got["result"]["rev"] == rev
|
||||
assert [sp["id"] for sp in got["result"]["config"]["spaces"]] == ["r5"]
|
||||
|
||||
monkeypatch.undo()
|
||||
again = await _save(client, cfg, rev)
|
||||
assert again["success"], "the next CAS on the reported revision goes through"
|
||||
|
||||
|
||||
async def test_content_signed_path_opens_without_a_bearer_header(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, hass_client_no_auth
|
||||
) -> None:
|
||||
|
||||
@@ -292,3 +292,16 @@ def test_collect_plans_survives_a_missing_directory(tmp_path):
|
||||
collect_plans = plans.collect_plans
|
||||
|
||||
assert collect_plans(tmp_path / "nope", _cfg(), _cfg()) == 0
|
||||
|
||||
|
||||
def test_collect_plans_never_raises_when_the_directory_disappears(tmp_path, monkeypatch):
|
||||
"""review R4-1: it runs behind a durable commit, so it may only report 0."""
|
||||
collect_plans = plans.collect_plans
|
||||
d = tmp_path / "plans"
|
||||
d.mkdir()
|
||||
|
||||
def _boom(self):
|
||||
raise OSError("gone")
|
||||
|
||||
monkeypatch.setattr(type(d), "iterdir", _boom, raising=False)
|
||||
assert collect_plans(d, _cfg("/p/a.png"), _cfg("/p/b.png")) == 0
|
||||
|
||||
+2
-1
@@ -12,6 +12,7 @@
|
||||
"src/rules.ts",
|
||||
"src/devices.ts",
|
||||
"src/types.ts",
|
||||
"src/space-geometry.ts"
|
||||
"src/space-geometry.ts",
|
||||
"src/signing.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user