fix: stabilize prerelease signing checks

This commit is contained in:
Matysh
2026-08-11 03:14:56 +03:00
parent a41a75eba5
commit 5c5833d69a
7 changed files with 87 additions and 37 deletions
File diff suppressed because one or more lines are too long
+4 -6
View File
@@ -146,12 +146,10 @@ const out = await page.evaluate(async () => {
// SHOWS»): the marker indicates the entity its tap ACTS ON — the cover
// exactly when the owner has explicitly chosen «Открыть/закрыть».
const devEl = (dev) => {
const v = c._viewOr(c._baseVb());
const p = c._pos(dev);
const left = ((p.x - v.x) / v.w) * 100;
const top = ((p.y - v.y) / v.h) * 100;
return [...sr().querySelectorAll('.devlayer .dev')].find((e) =>
Math.abs(parseFloat(e.style.left) - left) < 0.4 && Math.abs(parseFloat(e.style.top) - top) < 0.4);
// The viewport may refit asynchronously after an editor transition. Use
// the stable public styling hook instead of racing rendered percentages
// against the next viewBox.
return dev ? sr().querySelector(`.devlayer .dev[data-id="${CSS.escape(dev.id)}"]`) : null;
};
const clsOf = (dev) => [...(devEl(dev)?.classList || [])];
const iconOf = (dev) => devEl(dev)?.querySelector('ha-icon')?.getAttribute('icon') || '';
+23 -5
View File
@@ -11,7 +11,10 @@ const res = await page.evaluate(async () => {
const batchSizes = [];
let round = 0;
c.hass = { ...c.hass, callWS: async (m) => {
// This probe owns its signer authority. Reusing the demo card's connection
// would intentionally share its cache and make the batch contract depend on
// whichever smoke content the main card has already rendered.
c.hass = { ...c.hass, connection: Object.create(c.hass.connection), callWS: async (m) => {
if (m.type === 'houseplan/content/sign') {
batchSizes.push(m.paths.length);
const urls = {};
@@ -25,7 +28,12 @@ const res = await page.evaluate(async () => {
// 201 вложение, разложенное по маркерам: столько же подписанных ссылок
const pdfs = [];
for (let i = 0; i < 201; i++) pdfs.push({ name: 'm' + i, url: '/api/houseplan/content/files/m/doc' + i + '.pdf' });
c._serverCfg = { ...c._serverCfg, markers: [{ id: 'mk1', pdfs }] };
c._serverCfg = {
...c._serverCfg,
markers: [{
id: 'mk1', binding: 'virtual', name: 'Attachment probe', space: c._space, pdfs,
}],
};
c._cfgEpoch++;
round = 1;
@@ -44,7 +52,13 @@ const res = await page.evaluate(async () => {
out.allRefreshed = vals.length === 201 && vals.every((u) => u.endsWith('authSig=R2'));
// ссылка, исчезнувшая из конфига, выбывает из кэша и не занимает слот
c._serverCfg = { ...c._serverCfg, markers: [{ id: 'mk1', pdfs: pdfs.slice(0, 5) }] };
c._serverCfg = {
...c._serverCfg,
markers: [{
id: 'mk1', binding: 'virtual', name: 'Attachment probe', space: c._space,
pdfs: pdfs.slice(0, 5),
}],
};
c._cfgEpoch++;
batchSizes.length = 0;
round = 3;
@@ -55,11 +69,15 @@ const res = await page.evaluate(async () => {
// протухшая подпись не отдаётся: она вернула бы 401 и «попытку входа»
const one = pdfs[0].url;
c._signer.entries[one] = { url: one + '?authSig=OLD', at: Date.now() - 25 * 3600 * 1000 };
c._signer.shared.signed[one] = {
url: one + '?authSig=OLD', at: Date.now() - 25 * 3600 * 1000, loaded: true,
};
out.expiredNotServed = c._display(one) === '';
out.expiredDropped = c._signer.entries[one] === undefined;
// а стареющая, но ещё живая — отдаётся, пока едет замена
c._signer.entries[one] = { url: one + '?authSig=AGING', at: Date.now() - 20 * 3600 * 1000 };
c._signer.shared.signed[one] = {
url: one + '?authSig=AGING', at: Date.now() - 20 * 3600 * 1000, loaded: true,
};
out.agingStillServed = c._display(one) === one + '?authSig=AGING';
return out;
});
+34 -16
View File
@@ -4,6 +4,11 @@
// каждом рендере получал 401. Проверяем весь контракт подписи для этой карточки.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
await page.route('**/api/houseplan/content/plans/_/f1.tok.svg?authSig=*', (route) => route.fulfill({
status: 200,
contentType: 'image/svg+xml',
body: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><rect width="16" height="16" fill="#789"/></svg>',
}));
const res = await page.evaluate(async () => {
const out = {};
await customElements.whenDefined('houseplan-space-card');
@@ -16,18 +21,25 @@ const res = await page.evaluate(async () => {
let signCalls = 0;
let failFirst = true;
const requestedHrefs = [];
const hass = { ...main.hass, callWS: async (m) => {
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 1 };
if (m.type === 'houseplan/layout/get') return { layout: {} };
if (m.type === 'houseplan/content/sign') {
signCalls++;
if (failFirst && signCalls === 1) throw new Error('ws down');
const urls = {};
for (const p of m.paths) urls[p] = p + '?authSig=SIG' + signCalls;
return { urls };
}
return { ok: true };
} };
// A separately mounted test card gets its own HA connection authority. In
// production cards sharing one connection deliberately share config/signing
// caches, which is not the behaviour this isolated failure probe exercises.
const hass = {
...main.hass,
connection: Object.create(main.hass.connection),
callWS: async (m) => {
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 1 };
if (m.type === 'houseplan/layout/get') return { layout: {} };
if (m.type === 'houseplan/content/sign') {
signCalls++;
if (failFirst && signCalls === 1) throw new Error('ws down');
const urls = {};
for (const p of m.paths) urls[p] = p + '?authSig=SIG' + signCalls;
return { urls };
}
return { ok: true };
},
};
const host = document.createElement('div');
document.body.appendChild(host);
@@ -35,6 +47,10 @@ const res = await page.evaluate(async () => {
card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
card.hass = hass;
host.appendChild(card);
// connectedCallback may synchronously adopt the module-level warm snapshot;
// force this isolated probe's server candidate after that warm paint.
await new Promise((r) => setTimeout(r, 80));
await card._load(true);
const stage = async () => {
const t0 = Date.now();
@@ -71,11 +87,11 @@ const res = await page.evaluate(async () => {
out.noExtraSignOnRerender = signCalls === before;
// 5) протухшая подпись не отдаётся, стареющая — отдаётся, пока едет замена
const ent = card._signer.entries;
ent[raw] = { url: raw + '?authSig=OLD', at: Date.now() - 25 * 3600 * 1000 };
const ent = card._signer.shared.signed;
ent[raw] = { url: raw + '?authSig=OLD', at: Date.now() - 25 * 3600 * 1000, loaded: true };
card.requestUpdate(); await card.updateComplete;
out.hrefWhenExpired = await href();
ent[raw] = { url: raw + '?authSig=AGING', at: Date.now() - 20 * 3600 * 1000 };
ent[raw] = { url: raw + '?authSig=AGING', at: Date.now() - 20 * 3600 * 1000, loaded: true };
card.requestUpdate(); await card.updateComplete;
out.hrefWhenAging = await href();
@@ -86,7 +102,9 @@ const res = await page.evaluate(async () => {
});
// зафиксировано прогоном на v1.45.1 и сверено с кодом
checkAll(res, {
hrefAfterFailedSign: null,
// #73 keeps the last complete frame until the replacement is signed and
// decoded, so a transient signing failure must not blank the background.
hrefAfterFailedSign: '/assets/f1.svg',
noRetryStorm: true,
hrefAfterRetry: '/api/houseplan/content/plans/_/f1.tok.svg?authSig=SIG2',
retried: true,
File diff suppressed because one or more lines are too long
+2 -2
View File
File diff suppressed because one or more lines are too long
+20 -4
View File
@@ -20,11 +20,17 @@ type SharedSignerRuntime = {
inFlight: Map<string, number>;
retry: Map<string, { notBefore: number; delay: number }>;
listeners: Set<() => void>;
/** Internal terminal-attempt listeners used by prepareImage(). */
settlers: Set<() => void>;
references: Map<object, Set<string>>;
};
const sharedSignerRuntimes = new WeakMap<object, SharedSignerRuntime>();
const SHARED_SIGNED_MAX = 96;
// A single marker may legitimately carry more than MAX_SIGN_PATHS attachments:
// signing is chunked at the transport boundary, while the shared cache must
// retain the complete referenced set. Keep it bounded, but comfortably above
// the 201-item regression contract (and ordinary multi-card plans).
const SHARED_SIGNED_MAX = 512;
const signerAuthority = (hass: any, fallback: object): object => {
const authority = hass?.connection || hass;
@@ -37,7 +43,7 @@ const signerRuntime = (authority: object): SharedSignerRuntime => {
if (!runtime) {
runtime = {
signed: {}, queued: new Set(), inFlight: new Map(), retry: new Map(),
listeners: new Set(), references: new Map(),
listeners: new Set(), settlers: new Set(), references: new Map(),
};
sharedSignerRuntimes.set(authority, runtime);
}
@@ -237,6 +243,11 @@ export class ContentSigner {
.finally(() => {
// release only our own attempt: a later one may have superseded it
for (const p of claimed) if (shared.inFlight.get(p) === sentAt) shared.inFlight.delete(p);
// `prepareImage()` also listens for terminal failures. Without this
// notification it waited for the full 15 s lost-request timeout even
// when the websocket had already rejected, delaying the card's
// bounded retry and keeping the previous frame far too long.
for (const settle of [...shared.settlers]) settle();
});
}
}
@@ -326,6 +337,11 @@ export class ContentSigner {
return this.preloadCurrentImage(shared, path, entry);
}
this.display(hass, path);
const retry = shared.retry.get(path);
if (retry && retry.notBefore > this.now()
&& !shared.inFlight.has(path) && !shared.queued.has(path)) {
return Promise.resolve(false);
}
return null;
};
const immediate = attempt();
@@ -337,7 +353,7 @@ export class ContentSigner {
if (done) return;
done = true;
clearTimeout(timer);
shared.listeners.delete(check);
shared.settlers.delete(check);
resolve(ready);
};
const check = (): void => {
@@ -347,7 +363,7 @@ export class ContentSigner {
active.then(finish).catch(() => finish(false));
};
const timer = setTimeout(() => finish(false), SIGN_INFLIGHT_MS);
shared.listeners.add(check);
shared.settlers.add(check);
check();
});
}