From 1100cdcff5536cecf43ebd966105eec6d756685f Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 4 Nov 2026 17:48:46 +0400 Subject: [PATCH] Let the flights' fix history fill before the long soak measures the heap, and say why a context-loss case drew no frame The long soak's heap slope counted the per-aircraft dead-reckoning history (POSITION_HISTORY_LIMIT fixes for 10 000 aircraft, about 14 MiB over the first 2.5 min of polls) as switch growth: a run with the same workload and no switches grows as much and then levels off. With --assert the soak now waits until the flights feed has been read more times than the history keeps before it switches; over 500 switches the heap then grows 1.07 KiB per switch. qa-strict-context-loss records, only when no non-blank frame came, how many frames the running and the watched engine drew in 3 s and how much of the last one differs from its first pixel. --- scripts/qa-strict-context-loss.mjs | 46 ++++++++++++++++++++++++++++++ scripts/qa-strict-soak.mjs | 18 +++++++++++- 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/scripts/qa-strict-context-loss.mjs b/scripts/qa-strict-context-loss.mjs index 0bf428d8c..fcc78151c 100644 --- a/scripts/qa-strict-context-loss.mjs +++ b/scripts/qa-strict-context-loss.mjs @@ -164,6 +164,51 @@ await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { const outcome = after.engine === to ? 'switched' : 'rolled back'; if (after.running) await rememberEngine(p); const drawn = after.running ? await draws(after.engine) : false; + // Why no frame passed, when none did: frames drawn in 3 s by the engine + // the page runs and by the one the probe watched, and how many of 48x48 + // samples of the last frame differ from the first one. + const noFrame = + after.running && !drawn + ? await p.evaluate(async (kind) => { + const running = window.__godsEyeView.engine; + const watched = window.__qaEngines.get(kind); + const probe = document.createElement('canvas'); + probe.width = probe.height = 48; + const context = probe.getContext('2d', { + willReadFrequently: true, + }); + const count = { running: 0, watched: 0, differing: null }; + const offs = [ + running.frames.postRender.addEventListener(() => { + count.running++; + context.drawImage(running.host.canvas, 0, 0, 48, 48); + const d = context.getImageData(0, 0, 48, 48).data; + let differing = 0; + for (let i = 4; i < d.length; i += 4) + if ( + Math.abs(d[i] - d[0]) + + Math.abs(d[i + 1] - d[1]) + + Math.abs(d[i + 2] - d[2]) > + 24 + ) + differing++; + count.differing = differing; + }), + watched === running + ? () => {} + : watched?.frames.postRender.addEventListener( + () => count.watched++, + ), + ]; + await new Promise((resolve) => setTimeout(resolve, 3000)); + offs.forEach((off) => off?.()); + return { + ...count, + sameEngine: watched === running, + connected: running.host.canvas.isConnected, + }; + }, after.engine) + : null; console.log( 'after', label, @@ -205,6 +250,7 @@ await strict.run({ args: ['--use-angle=metal', '--enable-gpu'] }, async (b) => { sessionIntact: intact(after), history: after.history - before.history, drawn, + ...(noFrame ? { noFrame } : {}), back, backEngine: backState?.engine ?? null, backSessionIntact: backState ? intact(backState) : false, diff --git a/scripts/qa-strict-soak.mjs b/scripts/qa-strict-soak.mjs index d5d3ffbd9..9bda34ce9 100644 --- a/scripts/qa-strict-soak.mjs +++ b/scripts/qa-strict-soak.mjs @@ -14,7 +14,8 @@ // // Checked on every switch: the same session (manager and every layer // model), the radio still playing the same station with one play() call. -// With --assert (the long run), after the warm-up (switch 20): live WebGL +// With --assert (the long run), the flights' fix history fills first (see +// below), then after the warm-up (switch 20): live WebGL // objects per engine context grow by at most 0.1 per switch (least-squares // slope over the checkpoints) and by at most 64 overall; no new WebGL // context; JS heap slope at most 10 KiB per switch; no new document; the @@ -42,6 +43,7 @@ import { } from './qa-strict-session.mjs'; import { installRadioFixture, stations } from './fixtures/w2/radio.mjs'; import { trafficFixtureResponse } from './traffic-fixtures.mjs'; +import { POSITION_HISTORY_LIMIT } from '../src/layers/flights/recordPolicy.js'; const base = localPreview(process.argv[2]); const option = (name, fallback) => { @@ -193,6 +195,20 @@ for (let run = 1; run <= runs; run++) { window.__godsEyeView.dataManager.layers.get('radio').module.getUIState() .audioState === 'playing', ); + // Each aircraft keeps its last POSITION_HISTORY_LIMIT fixes for dead + // reckoning, so the first polls of the flights feed grow the heap by + // itself (about 14 MiB here, over 2.5 min; a run without switches then + // levels off). The long run lets that history fill before it switches, + // so the heap slope it asserts is the switches' own. + if (strictMode) { + const filling = Date.now(); + await p.waitForFunction( + (polls) => window.__qaReads.flights > polls, + { timeout: 300000, polling: 1000 }, + POSITION_HISTORY_LIMIT, + ); + row.historyFillSeconds = Math.round((Date.now() - filling) / 1000); + } row.workload = await p.evaluate(() => { const layers = window.__godsEyeView.dataManager.layers; return Object.fromEntries(