diff --git a/demo/benchmark_led_strips.mjs b/demo/benchmark_led_strips.mjs index a14d9a82..8e2a8446 100644 --- a/demo/benchmark_led_strips.mjs +++ b/demo/benchmark_led_strips.mjs @@ -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); } diff --git a/demo/performance/README.md b/demo/performance/README.md index 0e8d0ec8..5cde5e30 100644 --- a/demo/performance/README.md +++ b/demo/performance/README.md @@ -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 diff --git a/demo/performance/budgets-led-strips.json b/demo/performance/budgets-led-strips.json index 01c86c48..e3a5ba08 100644 --- a/demo/performance/budgets-led-strips.json +++ b/demo/performance/budgets-led-strips.json @@ -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": {} } diff --git a/docs/LIGHT.md b/docs/LIGHT.md index b737f769..8b5e8fae 100644 --- a/docs/LIGHT.md +++ b/docs/LIGHT.md @@ -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. diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts index 159185ae..ae2261ab 100755 --- a/src/houseplan-card.ts +++ b/src/houseplan-card.ts @@ -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). */ diff --git a/src/led-strip-field.ts b/src/led-strip-field.ts index d116afcc..f796744c 100644 --- a/src/led-strip-field.ts +++ b/src/led-strip-field.ts @@ -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(); +/** 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) { diff --git a/src/led-strip-gate.ts b/src/led-strip-gate.ts index 847548eb..b2c51d17 100644 --- a/src/led-strip-gate.ts +++ b/src/led-strip-gate.ts @@ -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'), diff --git a/src/led-strip-runtime.ts b/src/led-strip-runtime.ts index 37669d3f..c7520774 100755 --- a/src/led-strip-runtime.ts +++ b/src/led-strip-runtime.ts @@ -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(); +/** 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, diff --git a/test/led-strip-runtime.test.mjs b/test/led-strip-runtime.test.mjs index 39b3e0f0..e10e8e8d 100644 --- a/test/led-strip-runtime.test.mjs +++ b/test/led-strip-runtime.test.mjs @@ -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'); +}); diff --git a/test/performance-workflow.test.mjs b/test/performance-workflow.test.mjs index 5863be9d..d7a5c7c5 100644 --- a/test/performance-workflow.test.mjs +++ b/test/performance-workflow.test.mjs @@ -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 }); });