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:
Matysh
2026-07-28 00:13:45 +03:00
parent c749b52a0d
commit 2e2d353b04
21 changed files with 434 additions and 72 deletions
+185
View File
@@ -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();
});