perf(led): the led-strips-v1 profile proves caches, disconnect and a late import (#780)

r1 M5: the runtime and field chunks release a card's frame and field cache
on disconnect (ledRelease from disconnectedCallback), never cache for a
disconnected card, and a chunk that lands after disconnect renders nothing
(the card's LED hook is connected-only). The profile now reports the three
caches of the shown space separately (shapes ≤ 50, visibility ≤ 50, retained
per-emitter fans ≤ 2500) through the runtime's ledStats, asserts 0 retained
entries and 0 live LED timers/frames/observers after every disconnect,
judges the Long Tasks of a 100-step camera series as well as the interaction
profile's camera scenario, runs one extra cold mount whose runtime response
is held while the card is removed, and enforces ≥ 7 samples after ≥ 1
warm-up — also on reports merged from parts (--warmup-only, --merge).

Issue: #780
User-Visible: no
This commit is contained in:
Codex
2026-10-02 20:54:50 +03:00
parent b40e8ff58d
commit e447364636
10 changed files with 375 additions and 97 deletions
+289 -79
View File
@@ -8,17 +8,33 @@
* (50 strips × 50 points); `--size=none` is the same build and plan without
* strips. No icon is added: the converted devices keep their ids, only their
* entity becomes a light. Light radius 50 cm (the strip default), Glow on,
* viewport 1440×1000, DPR 1, reduced motion. Every sample is cold: a new
* browser with an empty cache mounts a new card.
* viewport 1440×1000, DPR 1, reduced motion. `--warmups` (≥1) samples are
* discarded, then `--samples` (≥7) are judged. Every sample is cold: a new
* browser with an empty cache mounts a new card; the warm metrics run in the
* same page after the module has loaded.
*
* Metrics (judged on median and p95 against budgets-led-strips.json):
* firstStableRenderMs (mount → stripes and fields of every visible strip),
* warmSpaceReadyMs (space switch with the module loaded → stable LED frame),
* stateUpdateMs (all strip sources switched → every stripe in its new state),
* panZoomMs and its longest Long Task, retained heap after 20 A→B→C→A cycles.
* Counters (must be exact): field geometry recomputes over 100 unrelated HA
* ticks, 100 pan/zoom steps and a colour-only change (0), cache entries ≤ 50
* and equal after every cycle, LED chunk requests (none without strips).
* Metrics (judged on median AND p95 against budgets-led-strips.json):
* firstStableRenderMs (mount → stripes and fields of every visible strip,
* cold import included), warmSpaceReadyMs (space switch with the module
* loaded → stable LED frame), stateUpdateMs (the interaction profile's method:
* hass update → every stripe in its new state), panZoomMs (the interaction
* profile's camera scenario, benchmark_large_house.mjs) and the longest Long
* Task of the camera — the scenario and the 100-step series, retained heap
* after 20 A→B→C→A cycles over a warm cycle (same GC protocol).
*
* Counters (exact, every sample): geometry/visibility recomputes over 100
* unrelated HA ticks, 100 pan/zoom steps and a colour-only change (0); the
* three LED caches of the shown space — shapes ≤ 50, visibility ≤ 50,
* retained per-emitter fans ≤ 2500 — identical after every cycle (growth 0);
* after disconnect 0 retained entries and 0 live LED timers/observers; a
* late import after disconnect restores nothing (one extra cold run with the
* runtime response delayed); LED chunk requests (none without strips, never
* the editor chunk in the View).
*
* `--merge=a.json,b.json` judges the rows of several partial reports of the
* same size as one run — the samples are the same cold samples, only the
* process that collected them differs (a runner with a short step limit);
* `--warmup-only` collects the discarded warm-up of such a run.
*/
import { execFileSync } from 'node:child_process';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
@@ -27,15 +43,19 @@ import { launch } from './serve.mjs';
import { makeLedStripsFixture } from './performance/led-strips-fixture.mjs';
const valueArg = (name) => process.argv.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
const samples = Math.max(1, Math.min(20, Number(valueArg('samples')) || 7));
const warmups = Math.max(0, Math.min(5, Number(valueArg('warmups')) || 1));
const warmupOnly = process.argv.includes('--warmup-only');
const samples = warmupOnly ? 0 : Math.max(1, Math.min(20, Number(valueArg('samples')) || 7));
const warmups = warmupOnly ? 1 : Math.max(0, Math.min(5, Number(valueArg('warmups') ?? 1)));
const size = valueArg('size') ?? '10x5';
const output = valueArg('output') ? resolve(valueArg('output')) : null;
const merge = valueArg('merge')?.split(',').filter(Boolean).map((file) => resolve(file)) ?? null;
const skipLateImport = process.argv.includes('--no-late-import');
const SIZES = { '10x5': [10, 5], '50x50': [50, 50], none: [0, 0] };
if (!SIZES[size]) throw new Error(`unknown size: ${size}`);
const [STRIPS, POINTS] = SIZES[size];
const PROFILE = 'led-strips-v1';
const CYCLES = Math.max(1, Math.min(20, Number(valueArg('cycles')) || 20));
const MIN_SAMPLES = 7;
const fixture = makeLedStripsFixture(STRIPS, POINTS);
const sourceSha = (() => {
@@ -43,43 +63,59 @@ const sourceSha = (() => {
})();
const budgets = JSON.parse(readFileSync(new URL('./performance/budgets-led-strips.json', import.meta.url), 'utf8'));
const rows = [];
let chromium = null;
for (let iteration = 0; iteration < warmups + samples; iteration++) {
const { page, browser } = await launch({ width: 1440, height: 1000 }, 1,
['--enable-precise-memory-info', '--js-flags=--expose-gc']);
chromium ??= await browser.version();
await page.emulateMedia({ reducedMotion: 'reduce' });
const requests = [];
page.on('request', (request) => {
const name = request.url().replace(/.*\//, '').replace(/\?.*$/, '');
if (/^led-strip-(runtime|field|editor)-/.test(name)) requests.push(name.replace(/-[^-]+\.js$/, ''));
});
try {
const row = await page.evaluate(async ({ fixture, strips, cycles }) => {
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
const until = async (predicate, timeout = 20000) => {
const started = performance.now();
while (!predicate()) {
if (performance.now() - started > timeout) throw new Error('led-strips-v1 timed out');
await sleep(5);
}
};
const longTasks = () => {
const entries = [];
const observer = new PerformanceObserver((list) => entries.push(...list.getEntries()));
observer.observe({ type: 'longtask', buffered: false });
return async () => {
await sleep(0);
entries.push(...observer.takeRecords());
observer.disconnect();
return Number(Math.max(0, ...entries.map((entry) => entry.duration)).toFixed(2));
};
};
const gc = async () => { if (globalThis.gc) { globalThis.gc(); await frame(); globalThis.gc(); await frame(); } };
window.__card?.remove?.();
localStorage.clear();
/**
* Installed in the page before the card mounts anything: timers, frames and
* observers created from an LED chunk (its file name is in the creation
* stack) are tracked until they fire, are cleared or disconnected.
*/
function installLedLifecycleTracker() {
if (window.__ledLive) return;
const live = { timers: new Set(), frames: new Set(), observers: new Set() };
window.__ledLive = live;
const fromLed = () => /led-strip-/.test(new Error().stack || '');
const nativeTimeout = window.setTimeout, nativeClearTimeout = window.clearTimeout;
window.setTimeout = function setTimeout(fn, ms, ...rest) {
const mine = fromLed();
let id = 0;
id = nativeTimeout.call(window, typeof fn === 'function'
? (...args) => { live.timers.delete(id); return fn(...args); } : fn, ms, ...rest);
if (mine) live.timers.add(id);
return id;
};
window.clearTimeout = function clearTimeout(id) { live.timers.delete(id); return nativeClearTimeout.call(window, id); };
const nativeInterval = window.setInterval, nativeClearInterval = window.clearInterval;
window.setInterval = function setInterval(fn, ms, ...rest) {
const id = nativeInterval.call(window, fn, ms, ...rest);
if (fromLed()) live.timers.add(id);
return id;
};
window.clearInterval = function clearInterval(id) { live.timers.delete(id); return nativeClearInterval.call(window, id); };
const nativeFrame = window.requestAnimationFrame, nativeCancelFrame = window.cancelAnimationFrame;
window.requestAnimationFrame = function requestAnimationFrame(fn) {
const mine = fromLed();
let id = 0;
id = nativeFrame.call(window, (time) => { live.frames.delete(id); return fn(time); });
if (mine) live.frames.add(id);
return id;
};
window.cancelAnimationFrame = function cancelAnimationFrame(id) { live.frames.delete(id); return nativeCancelFrame.call(window, id); };
for (const name of ['ResizeObserver', 'MutationObserver', 'IntersectionObserver']) {
const Native = window[name];
if (!Native) continue;
window[name] = class extends Native {
constructor(callback) {
super(callback);
if (fromLed()) live.observers.add(this);
}
disconnect() { live.observers.delete(this); return super.disconnect(); }
};
}
}
/** The page side of one mount: the same fixture host as before, shared by the sample and the late-import run. */
function pageHost() {
return `
window.__ledHost = (fixture) => {
const card = document.createElement('houseplan-card');
card.setConfig({ type: 'custom:houseplan-card', title: 'LED strips', icon_size: 3.4 });
let states = fixture.states;
@@ -100,19 +136,74 @@ for (let iteration = 0; iteration < warmups + samples; iteration++) {
formatEntityState: (state) => state.state,
config: { unit_system: { length: 'km' } },
};
const setStates = (next) => { states = next; card.hass = { ...hassBase, states }; };
return {
card,
get states() { return states; },
setStates(next) { states = next; card.hass = { ...hassBase, states }; },
};
};
window.__ledStats = async (card) => {
const url = performance.getEntriesByType('resource').map((entry) => entry.name)
.find((name) => /led-strip-runtime-[^/]+\\.js/.test(name));
if (!url) return { shapes: 0, visibility: 0, sources: 0, recomputes: 0, loaded: false };
const runtime = await import(url);
return { ...runtime.ledStats(card), loaded: true };
};
window.__ledLiveCounts = () => ({
timers: window.__ledLive.timers.size, frames: window.__ledLive.frames.size,
observers: window.__ledLive.observers.size,
});
`;
}
async function sample() {
const { page, browser } = await launch({ width: 1440, height: 1000 }, 1,
['--enable-precise-memory-info', '--js-flags=--expose-gc']);
const chromium = await browser.version();
await page.emulateMedia({ reducedMotion: 'reduce' });
const requests = [];
page.on('request', (request) => {
const name = request.url().replace(/.*\//, '').replace(/\?.*$/, '');
if (/^led-strip-(runtime|field|editor)-/.test(name)) requests.push(name.replace(/-[^-]+\.js$/, ''));
});
try {
await page.evaluate(installLedLifecycleTracker);
await page.evaluate(pageHost());
const row = await page.evaluate(async ({ fixture, strips, cycles }) => {
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
const until = async (predicate, timeout = 30000) => {
const started = performance.now();
while (!predicate()) {
if (performance.now() - started > timeout) throw new Error('led-strips-v1 timed out');
await sleep(5);
}
};
const longTasks = () => {
const entries = [];
const observer = new PerformanceObserver((list) => entries.push(...list.getEntries()));
observer.observe({ type: 'longtask', buffered: false });
return async () => {
await sleep(0);
entries.push(...observer.takeRecords());
observer.disconnect();
return Number(Math.max(0, ...entries.map((entry) => entry.duration)).toFixed(2));
};
};
const gc = async () => { if (globalThis.gc) { globalThis.gc(); await frame(); globalThis.gc(); await frame(); } };
window.__card?.remove?.();
localStorage.clear();
const host = window.__ledHost(fixture);
const { card } = host;
const root = () => card.renderRoot;
const count = (selector) => root()?.querySelectorAll(selector).length ?? 0;
const ledStable = (state = 'on') => count(`[data-led-strip][data-state="${state}"]`) === strips
&& (state !== 'on' || count('.led-fields [data-led-field]') === strips);
const cache = () => {
const group = root().querySelector('.led-fields');
return group ? { size: Number(group.dataset.ledCache), recomputes: Number(group.dataset.ledRecomputes) } : null;
};
const stats = () => window.__ledStats(card);
const started = performance.now();
document.getElementById('host').replaceChildren(card);
setStates(states);
host.setStates(host.states);
await until(() => card._loadOk && card._model?.length === 3);
if (strips) await until(() => ledStable());
else { await until(() => card._booting === false); await frame(); }
@@ -126,28 +217,31 @@ for (let iteration = 0; iteration < warmups + samples; iteration++) {
await frame();
const warmSpaceReadyMs = Number((performance.now() - warm).toFixed(2));
const states = host.states;
const floorLights = Object.keys(states).filter((id) => id.startsWith('light.perf_led_1_'));
const stateStarted = performance.now();
setStates({ ...states, ...Object.fromEntries(floorLights.map((id) => [id, { ...states[id], state: 'off' }])) });
host.setStates({ ...states, ...Object.fromEntries(floorLights.map((id) => [id, { ...states[id], state: 'off' }])) });
await card.updateComplete;
if (strips) await until(() => ledStable('off'));
await frame();
const stateUpdateMs = Number((performance.now() - stateStarted).toFixed(2));
setStates({ ...states, ...Object.fromEntries(floorLights.map((id) => [id, { ...states[id], state: 'on' }])) });
host.setStates({ ...states, ...Object.fromEntries(floorLights.map((id) => [id, { ...states[id], state: 'on' }])) });
await card.updateComplete;
if (strips) await until(() => ledStable());
await frame();
const before = strips ? cache() : null;
const before = await stats();
// 100 unrelated HA ticks.
const sensor = Object.keys(states).find((id) => id.startsWith('sensor.'));
const sensor = Object.keys(host.states).find((id) => id.startsWith('sensor.'));
for (let tick = 0; tick < 100; tick++) {
setStates({ ...states, [sensor]: { ...states[sensor], state: String(20 + (tick % 10) / 10) } });
host.setStates({ ...host.states, [sensor]: { ...host.states[sensor], state: String(20 + (tick % 10) / 10) } });
await card.updateComplete;
}
await frame();
const afterTicks = strips ? cache() : null;
// Pan/zoom: the camera scenario, then 100 steps for the counter.
const afterTicks = await stats();
// The camera: the interaction profile's scenario (one wheel step, its
// render and frame — benchmark_large_house.mjs), then 100 steps whose
// Long Tasks are judged too and whose recomputes are counted.
const stage = root().querySelector('.stage');
const rect = stage.getBoundingClientRect();
const wheel = (deltaY) => stage.dispatchEvent(new WheelEvent('wheel', {
@@ -160,21 +254,24 @@ for (let iteration = 0; iteration < warmups + samples; iteration++) {
await frame();
const panZoomMs = Number((performance.now() - panStarted).toFixed(2));
const panZoomLongTaskMaxMs = await stopPan();
// 100 more camera steps for the counters only (not a timing window).
const stopSeries = longTasks();
// One wheel step per frame, as a real wheel or trackpad delivers them.
for (let step = 0; step < 100; step++) {
wheel(step % 2 ? 60 : -60);
await card.updateComplete;
await frame();
}
await frame();
const afterCamera = strips ? cache() : null;
const cameraSeriesLongTaskMaxMs = await stopSeries();
const afterCamera = await stats();
// A colour-only change rebuilds no geometry or visibility.
setStates({ ...states, ...Object.fromEntries(floorLights.map((id) => [id,
{ ...states[id], attributes: { ...states[id].attributes, rgb_color: [255, 120, 80] } }])) });
host.setStates({ ...host.states, ...Object.fromEntries(floorLights.map((id) => [id,
{ ...host.states[id], attributes: { ...host.states[id].attributes, rgb_color: [255, 120, 80] } }])) });
await card.updateComplete;
await frame();
const afterColour = strips ? cache() : null;
const afterColour = await stats();
// 20 cycles A→B→C→A after one warm cycle; cache size equal after each.
// 20 cycles A→B→C→A after one warm cycle; the three caches identical after each.
const cycle = async () => {
for (const id of ['perf-floor-1', 'perf-floor-2', 'perf-floor-3', 'perf-floor-1']) {
card._pickSpace(id);
@@ -182,51 +279,149 @@ for (let iteration = 0; iteration < warmups + samples; iteration++) {
if (strips) await until(() => ledStable());
}
await frame();
return strips ? cache().size : 0;
const { shapes, visibility, sources } = await stats();
return { shapes, visibility, sources };
};
await cycle();
await gc();
const heapBefore = performance.memory?.usedJSHeapSize ?? null;
const sizes = [];
for (let index = 0; index < cycles; index++) sizes.push(await cycle());
const cycleStats = [];
for (let index = 0; index < cycles; index++) cycleStats.push(await cycle());
await gc();
const heapAfter = performance.memory?.usedJSHeapSize ?? null;
const liveBeforeDisconnect = window.__ledLiveCounts();
card.remove();
await frame();
await sleep(600);
const afterDisconnect = await stats();
const liveAfterDisconnect = window.__ledLiveCounts();
const key = (entry) => `${entry.shapes}/${entry.visibility}/${entry.sources}`;
return {
firstStableRenderMs, warmSpaceReadyMs, stateUpdateMs, panZoomMs, panZoomLongTaskMaxMs,
firstStableRenderMs, warmSpaceReadyMs, stateUpdateMs, panZoomMs, panZoomLongTaskMaxMs, cameraSeriesLongTaskMaxMs,
retainedHeapBytes: heapBefore == null || heapAfter == null ? null : Math.max(0, heapAfter - heapBefore),
counters: strips ? {
recomputesOnHaTicks: afterTicks.recomputes - before.recomputes,
recomputesOnCamera: afterCamera.recomputes - afterTicks.recomputes,
recomputesOnColour: afterColour.recomputes - afterCamera.recomputes,
cacheEntries: Math.max(...sizes),
cacheGrowthOverCycles: Math.max(...sizes) - Math.min(...sizes),
shapes: Math.max(...cycleStats.map((entry) => entry.shapes)),
visibility: Math.max(...cycleStats.map((entry) => entry.visibility)),
sources: Math.max(...cycleStats.map((entry) => entry.sources)),
cacheGrowthOverCycles: new Set(cycleStats.map(key)).size - 1,
} : null,
disconnect: {
retained: afterDisconnect.shapes + afterDisconnect.visibility + afterDisconnect.sources,
liveBefore: liveBeforeDisconnect,
live: liveAfterDisconnect.timers + liveAfterDisconnect.frames + liveAfterDisconnect.observers,
},
};
}, { fixture, strips: STRIPS, cycles: CYCLES });
row.ledRequests = [...new Set(requests)].sort();
if (iteration >= warmups) rows.push(row);
return { row, chromium };
} finally {
await browser.close();
}
}
/**
* The late import: the card is removed while the runtime chunk is still on
* the wire (its response is held back); when it lands nothing is rendered,
* cached or scheduled for the gone card.
*/
async function lateImport() {
const { page, browser } = await launch({ width: 1440, height: 1000 }, 1, []);
try {
let held = false;
let runtimeUrl = null;
await page.route(/led-strip-runtime-[^/]+\.js/, async (route) => {
held = true;
runtimeUrl = route.request().url();
await new Promise((done) => setTimeout(done, 1500));
await route.fallback();
});
await page.evaluate(installLedLifecycleTracker);
await page.evaluate(pageHost());
const result = await page.evaluate(async ({ fixture }) => {
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
window.__card?.remove?.();
const host = window.__ledHost(fixture);
const { card } = host;
document.getElementById('host').replaceChildren(card);
host.setStates(host.states);
const started = performance.now();
while (!(card._loadOk && card._model?.length === 3) && performance.now() - started < 20000) await sleep(10);
await sleep(300);
const before = card.renderRoot.querySelectorAll('[data-led-strip]').length;
card.remove();
window.__lateCard = card;
await sleep(3000);
return { stripesBeforeLoad: before, stripesAfterLoad: card.renderRoot.querySelectorAll('[data-led-strip]').length };
}, { fixture });
// The chunk the card asked for, by its URL: the same module instance.
const late = await page.evaluate(async (url) => {
const runtime = url ? await import(url) : null;
return { stats: runtime ? { ...runtime.ledStats(window.__lateCard), loaded: true } : { shapes: 0, visibility: 0, sources: 0, loaded: false },
live: window.__ledLiveCounts() };
}, runtimeUrl);
return { ...result, ...late, held };
} finally {
await browser.close();
}
}
let rows = [];
let chromium = null;
let late = null;
let discarded = 0;
if (merge) {
for (const file of merge) {
const part = JSON.parse(readFileSync(file, 'utf8'));
if (part.profile !== PROFILE || part.size !== size) throw new Error(`${file} is not ${PROFILE} ${size}`);
rows.push(...part.rows);
discarded += part.warmups;
chromium ??= part.chromium;
late ??= part.lateImport ?? null;
}
} else {
for (let iteration = 0; iteration < warmups + samples; iteration++) {
const result = await sample();
chromium ??= result.chromium;
if (iteration >= warmups) rows.push(result.row);
}
discarded = warmups;
if (STRIPS && !skipLateImport && !warmupOnly) late = await lateImport();
}
if (warmupOnly) {
// A discarded warm-up sample for a later --merge: nothing is judged here.
const text = `${JSON.stringify({ profile: PROFILE, size, sourceSha, chromium, samples: 0, warmups: 1, rows: [], partial: true }, null, 2)}\n`;
if (output) { mkdirSync(dirname(output), { recursive: true }); writeFileSync(output, text); }
console.log(text);
process.exit(0);
}
const metric = (name) => {
const values = rows.map((row) => row[name]).filter((value) => Number.isFinite(value)).sort((a, b) => a - b);
const at = (q) => values[Math.min(values.length - 1, Math.ceil(q * values.length) - 1)];
return { median: values.length ? at(0.5) : null, p95: values.length ? at(0.95) : null, samples: values };
};
const METRICS = ['firstStableRenderMs', 'warmSpaceReadyMs', 'stateUpdateMs', 'panZoomMs', 'panZoomLongTaskMaxMs', 'retainedHeapBytes'];
const METRICS = ['firstStableRenderMs', 'warmSpaceReadyMs', 'stateUpdateMs', 'panZoomMs', 'panZoomLongTaskMaxMs',
'cameraSeriesLongTaskMaxMs', 'retainedHeapBytes'];
const report = {
profile: PROFILE, size, strips: STRIPS, points: POINTS, sourceSha, chromium,
samples, warmups, cycles: CYCLES, viewport: { width: 1440, height: 1000 }, dpr: 1,
samples: rows.length, warmups: discarded, cycles: CYCLES, viewport: { width: 1440, height: 1000 }, dpr: 1,
merged: merge ? merge.length : undefined,
metrics: Object.fromEntries(METRICS.map((name) => [name, metric(name)])),
counters: rows[0]?.counters ?? null,
counterSamples: rows.map((row) => row.counters),
disconnectSamples: rows.map((row) => row.disconnect),
lateImport: late,
ledRequests: [...new Set(rows.flatMap((row) => row.ledRequests))].sort(),
rows,
};
const failures = [];
const partial = !merge && (samples < MIN_SAMPLES || warmups < 1);
if (!partial && (report.samples < MIN_SAMPLES || report.warmups < 1)) {
failures.push(`${report.samples} samples after ${report.warmups} warmups: the profile needs ≥ ${MIN_SAMPLES} after ≥ 1`);
}
const limits = budgets.sizes[size] || {};
for (const [name, limit] of Object.entries(limits)) {
for (const stat of ['median', 'p95']) {
@@ -235,19 +430,34 @@ for (const [name, limit] of Object.entries(limits)) {
if (value > limit) failures.push(`${name} ${stat} ${value} > ${limit}`);
}
}
for (const [index, disconnect] of report.disconnectSamples.entries()) {
if (disconnect.retained !== 0) failures.push(`sample ${index}: ${disconnect.retained} LED cache entries retained after disconnect`);
if (disconnect.live !== 0) failures.push(`sample ${index}: ${disconnect.live} LED timers/frames/observers alive after disconnect`);
}
if (STRIPS) {
const caches = budgets.caches;
for (const counters of report.counterSamples) {
for (const key of ['recomputesOnHaTicks', 'recomputesOnCamera', 'recomputesOnColour', 'cacheGrowthOverCycles']) {
if (counters[key] !== 0) failures.push(`${key} = ${counters[key]}, expected 0`);
}
if (counters.cacheEntries > budgets.cacheEntries) failures.push(`cache entries ${counters.cacheEntries} > ${budgets.cacheEntries}`);
for (const key of ['shapes', 'visibility', 'sources']) {
if (counters[key] > caches[key]) failures.push(`${key} cache ${counters[key]} > ${caches[key]}`);
}
}
if (late) {
if (!late.held || !late.stats.loaded) failures.push('late import: the runtime response was never held or never landed');
if (late.stripesAfterLoad !== late.stripesBeforeLoad) failures.push('late import rendered the gone card');
const retained = late.stats.shapes + late.stats.visibility + late.stats.sources;
if (retained) failures.push(`late import restored ${retained} cache entries`);
if (late.live.timers + late.live.frames + late.live.observers) failures.push('late import left LED timers/observers');
} else if (!skipLateImport) failures.push('late import not measured');
if (!report.ledRequests.includes('led-strip-runtime') || !report.ledRequests.includes('led-strip-field'))
failures.push(`LED chunks not loaded: ${report.ledRequests.join(', ')}`);
} else if (report.ledRequests.length) failures.push(`no strips but LED chunks requested: ${report.ledRequests.join(', ')}`);
if (report.ledRequests.includes('led-strip-editor')) failures.push('the View loaded the LED editor chunk');
report.failures = failures;
report.partial = partial || undefined;
const text = `${JSON.stringify(report, null, 2)}\n`;
if (output) { mkdirSync(dirname(output), { recursive: true }); writeFileSync(output, text); }
console.log(text);
console.log(JSON.stringify({ ...report, rows: undefined }, null, 2));
if (failures.length) { console.error(`led-strips-v1 ${size}: ${failures.length} failure(s)`); process.exit(1); }
+15 -5
View File
@@ -437,11 +437,21 @@ reduced motion; every sample is cold (a new browser and card), seven samples
after one warm-up. The runner fails on its own against
`budgets-led-strips.json` — the ТЗ table, median and p95: `firstStableRenderMs`
3400/5000, `warmSpaceReadyMs` 1500, `stateUpdateMs` 1000/1500, `panZoomMs` 500,
the longest pan/zoom Long Task 150 ms, retained heap after 20 A→B→C→A cycles
64 MiB — and on exact counters: zero field geometry recomputes over 100
unrelated HA ticks, 100 camera steps and a colour-only change, at most 50 cache
entries, no cache growth over the cycles, the LED chunks loaded with strips
and none without them, never the editor chunk in the View. Base predates the
the longest pan/zoom Long Task 150 ms — of the interaction profile's camera
scenario and of a 100-step series, one wheel step per frame — retained heap
after 20 A→B→C→A cycles 64 MiB — and on exact counters: zero field geometry
recomputes over 100 unrelated HA ticks, 100 camera steps and a colour-only
change; the three caches of the shown space (`ledStats` of the runtime chunk)
— shapes ≤ 50, visibility ≤ 50, retained per-emitter fans ≤ 2500 — identical
after every cycle; after disconnect 0 retained entries and 0 live LED
timers/frames/observers (tracked by creation stack in the page); one extra
cold run holds the runtime response, removes the card meanwhile and requires
that the landed chunk renders, caches and schedules nothing for it; the LED
chunks loaded with strips and none without them, never the editor chunk in
the View. A runner with a short step limit may collect the samples in parts
(`--warmup-only`, `--samples=N --no-late-import`) and judge them with
`--merge=…` — the same cold samples, the same gates, the minimum of seven
samples after one warm-up enforced on the merged set. Base predates the
strips, so there is no relative comparison; the zero-LED View remains judged
by the relative `large-house-interaction-v1` profile. Exact-SHA Linux output
of the full performance workflow (`led-strips` matrix entry) is the gate
+10 -4
View File
@@ -1,8 +1,12 @@
{
"profile": "led-strips-v1",
"issue": 780,
"note": "ТЗ #780 §13.2: absolute limits applied to median and p95; 10x5 rests on the interaction profile's absolute ceilings, 50x50 are the acceptance bounds set in the ТЗ. The no-strip control is judged by the existing interaction profile against base.",
"cacheEntries": 50,
"note": "ТЗ #780 §13.2: absolute limits applied to median and p95; 10x5 rests on the interaction profile's absolute ceilings, 50x50 are the acceptance bounds set in the ТЗ. The no-strip control is judged by the existing interaction profile against base. Caches of the shown space (ТЗ §13.2): 50 shapes, 50 visibility entries, 2500 retained per-emitter fans; 0 after disconnect.",
"caches": {
"shapes": 50,
"visibility": 50,
"sources": 2500
},
"sizes": {
"10x5": {
"firstStableRenderMs": 3400,
@@ -10,7 +14,8 @@
"stateUpdateMs": 1000,
"panZoomMs": 500,
"panZoomLongTaskMaxMs": 150,
"retainedHeapBytes": 67108864
"retainedHeapBytes": 67108864,
"cameraSeriesLongTaskMaxMs": 150
},
"50x50": {
"firstStableRenderMs": 5000,
@@ -18,7 +23,8 @@
"stateUpdateMs": 1500,
"panZoomMs": 500,
"panZoomLongTaskMaxMs": 150,
"retainedHeapBytes": 67108864
"retainedHeapBytes": 67108864,
"cameraSeriesLongTaskMaxMs": 150
},
"none": {}
}
+2 -1
View File
@@ -451,4 +451,5 @@ only the geometry differs:
- **Laziness.** The stripe/hit/2.5D code (`led-strip-runtime`) and the field
(`led-strip-field`) are separate lazy chunks; the initial graph holds only
the presence check and the loader (`led-strip-gate`). Caches are bounded per
space (50 shapes, 50 visibilities) and released on space change.
space (50 shapes, 50 visibilities, 2500 retained fans) and released on space
change and on disconnect; a chunk that lands after disconnect applies nothing.
+5 -3
View File
@@ -6,7 +6,7 @@
* 2) LEGACY fallback — baked-in country-house data (src/data/*), coordinates in a 1489×1053 canvas.
* The icon layout is stored on the server (houseplan/layout/*), fallback — localStorage.
*/
import { ledAnchor, ledRuntime, ledStripsByMarker, ledVisible } from './led-strip-gate';
import { ledAnchor, ledRelease, ledRuntime, ledStripsByMarker, ledVisible } from './led-strip-gate';
import { ledButton, ledEditorFor, ledHistory, ledImportNote, ledSection, ledWallsNote } from './led-strip-card';
import { LitElement, html, svg, nothing, noChange, TemplateResult, PropertyValues, type PropertyDeclaration } from 'lit';
import { cache as litCache } from 'lit/directives/cache.js';
@@ -2772,6 +2772,7 @@ export class HouseplanCard extends LitElement {
this._continuityHistory = [...this._continuityHistory, ...this._continuity.trace].slice(-80);
this._continuity.dispose();
this._continuityDisposed = true;
ledRelease(this); // #780 r1 M5: no retained LED cache after disconnect
super.disconnectedCallback();
}
@@ -10509,8 +10510,9 @@ export class HouseplanCard extends LitElement {
/** #780: the lazy LED chunk when the space shows a strip (ТЗ §13.1). */
private _ledRt(space: SpaceModel) {
return ledVisible(space, this._renderDevices, this._mode === 'devices' && this._showAll, this._serverCfg?.markers)
? ledRuntime(space.id, () => this.requestUpdate()) : null;
// A disconnected card never applies the chunk (a late import, r1 M5).
return this.isConnected && ledVisible(space, this._renderDevices, this._mode === 'devices' && this._showAll, this._serverCfg?.markers)
? ledRuntime(space.id, () => this.isConnected && this.requestUpdate()) : null;
}
/** #780: the Devices-editor LED tool, its own lazy chunk (src/led-strip-card.ts). */
+16
View File
@@ -96,6 +96,12 @@ export class LedFieldCache {
return value;
}
clear(): void { this.entries.clear(); this.ids.clear(); this.space = ''; }
/** Retained visibility fans (the per-emitter source cache, ТЗ §13.2: ≤ 2500). */
get sources(): number {
let n = 0;
for (const value of this.entries.values()) for (const piece of value?.pieces || []) n += piece.clip?.length ?? 0;
return n;
}
}
const pointsKey = (points: readonly number[][]): string =>
@@ -234,6 +240,16 @@ export interface LedFieldInput {
}
const fieldCaches = new WeakMap<object, LedFieldCache>();
/** Disconnect (ТЗ §13.2): no retained entry of this owner. */
export function releaseLedField(owner: object): void {
fieldCaches.get(owner)?.clear();
fieldCaches.delete(owner);
}
/** The performance witness: what this owner retains right now. */
export function ledFieldStats(owner: object): { visibility: number; sources: number; recomputes: number } {
const cache = fieldCaches.get(owner);
return { visibility: cache?.size ?? 0, sources: cache?.sources ?? 0, recomputes: cache?.recomputes ?? 0 };
}
export function ledFieldCache(owner: object): LedFieldCache {
let cache = fieldCaches.get(owner);
if (!cache) {
+3
View File
@@ -89,6 +89,9 @@ export function ledRuntime(entry: string, ready: () => void): LedRuntime | null
'__HOUSEPLAN_LED_RETRY_ASSET__', (module) => module.LED_RUNTIME_FINGERPRINT);
}
/** Disconnect: release this card's LED caches if the chunk is here (ТЗ §13.2). */
export function ledRelease(owner: object): void { runtimeSlot.module?.releaseLed(owner); }
/** The Devices-editor LED tool chunk (ТЗ §13.1): only on an explicit entry. */
export function ledEditorModule(entry: string, ready: () => void): LedEditorModule | null {
return lazy(editorSlot, entry, ready, () => import('./led-strip-editor'),
+18 -2
View File
@@ -413,6 +413,8 @@ export function ledFrame(input: LedFrameInput): LedFrame {
// (ТЗ §13.1). The host is the card itself, read structurally.
export interface LedCardHost {
/** A late chunk never re-renders (and re-fills caches of) a disconnected card. */
isConnected?: boolean;
_renderDevices: readonly DevItem[];
_renderPlanHass: any; // any-ok: the card's HA snapshot type is internal to the card
_virtualLights: VirtualLightSnapshot;
@@ -443,6 +445,18 @@ export interface LedCardHost {
const frames = new WeakMap<object, { key: unknown[]; frame: LedFrame }>();
/** Disconnect (ТЗ §13.2, r1 M5): the frame and the field caches of this card are released. */
export function releaseLed(owner: object): void {
frames.delete(owner);
field?.releaseLedField(owner);
}
/** The performance witness: shapes (frame), visibility entries and retained fans of this card. */
export function ledStats(owner: object): { shapes: number; visibility: number; sources: number; recomputes: number } {
return { shapes: frames.get(owner)?.frame.views.length ?? 0,
...(field?.ledFieldStats(owner) ?? { visibility: 0, sources: 0, recomputes: 0 }) };
}
/** The LED frame of a space for this card, rebuilt only when an input changed. */
export function ledFrameFor(host: LedCardHost, space: SpaceModel, spaceGlow: boolean): LedFrame {
const polygons = space.rooms.flatMap((room) => {
@@ -472,7 +486,8 @@ export function ledFrameFor(host: LedCardHost, space: SpaceModel, spaceGlow: boo
inRoom: (point, room) => host._pointInRoom(point, room),
showHidden: host._mode === 'devices' && host._showAll,
});
frames.set(host, { key, frame });
// A disconnected card keeps nothing (a pending update after disconnect, r1 M5).
if (host.isConnected !== false) frames.set(host, { key, frame });
return frame;
}
@@ -524,8 +539,9 @@ export function renderLedLayerFor(
}
export function renderLedFieldFor(host: LedCardHost, space: SpaceModel, spaceGlow: boolean): TemplateResult {
if (host.isConnected === false) return svg`` as unknown as TemplateResult;
const frame = ledFrameFor(host, space, spaceGlow);
const module = frame.scene && fieldWanted(frame.views) ? ledField(space.id, () => host.requestUpdate()) : null;
const module = frame.scene && fieldWanted(frame.views) ? ledField(space.id, () => host.isConnected !== false && host.requestUpdate()) : null;
if (!module) return svg`` as unknown as TemplateResult;
return module.renderLedField({
views: frame.views,
+13
View File
@@ -158,3 +158,16 @@ test('AC2/r1 M2: an explicit valid room_id wins over the anchor room; a stale on
assert.equal(stripRoom(rooms, null, [250, 100], inA)?.id, 'A');
assert.equal(stripRoom(rooms, null, null, inA), undefined);
});
test('AC17/r1 M5: a released owner retains nothing; the stats count visibility entries and fans', async () => {
const { ledFieldCache, ledFieldStats, releaseLedField } = await import('../test-build/led-strip-field.js');
const owner = {};
const cache = ledFieldCache(owner);
cache.forSpace('a');
cache.read('k1', () => ({ pieces: [{ d: 'M0 0', clip: ['M0 0 Z', 'M1 1 Z'] }, { d: 'M1 1', clip: null }], box: { x: 0, y: 0, w: 1, h: 1 } }));
cache.read('k2', () => null);
assert.deepEqual(ledFieldStats(owner), { visibility: 2, sources: 2, recomputes: 2 });
releaseLedField(owner);
assert.deepEqual(ledFieldStats(owner), { visibility: 0, sources: 0, recomputes: 0 });
assert.notEqual(ledFieldCache(owner), cache, 'a new mount starts a new cache');
});
+4 -3
View File
@@ -305,8 +305,9 @@ test('#780 led-strips-v1: the derived fixture converts devices without adding ic
}
const budgets = JSON.parse(readFileSync(new URL('../demo/performance/budgets-led-strips.json', import.meta.url), 'utf8'));
assert.deepEqual(budgets.sizes['10x5'], { firstStableRenderMs: 3400, warmSpaceReadyMs: 1500, stateUpdateMs: 1000,
panZoomMs: 500, panZoomLongTaskMaxMs: 150, retainedHeapBytes: 64 * 1024 * 1024 });
panZoomMs: 500, panZoomLongTaskMaxMs: 150, retainedHeapBytes: 64 * 1024 * 1024, cameraSeriesLongTaskMaxMs: 150 });
assert.deepEqual(budgets.sizes['50x50'], { firstStableRenderMs: 5000, warmSpaceReadyMs: 1500, stateUpdateMs: 1500,
panZoomMs: 500, panZoomLongTaskMaxMs: 150, retainedHeapBytes: 64 * 1024 * 1024 });
assert.equal(budgets.cacheEntries, 50);
panZoomMs: 500, panZoomLongTaskMaxMs: 150, retainedHeapBytes: 64 * 1024 * 1024, cameraSeriesLongTaskMaxMs: 150 });
// ТЗ §13.2: the three caches of the shown space, judged separately (r1 M5).
assert.deepEqual(budgets.caches, { shapes: 50, visibility: 50, sources: 2500 });
});