mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +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:
@@ -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()];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user