diff --git a/scripts/qa-radio-engine-switch.mjs b/scripts/qa-radio-engine-switch.mjs index 32b8b288a..9e0bbaad9 100644 --- a/scripts/qa-radio-engine-switch.mjs +++ b/scripts/qa-radio-engine-switch.mjs @@ -4,6 +4,7 @@ import { stations, installRadioFixture } from './fixtures/w2/radio.mjs'; import { blockPaidQaMaps } from './qa-map-policy.mjs'; import { switchGlobe } from './qa-switch-globe.mjs'; const url = new URL(process.argv[2]); +const soak = process.argv.includes('--soak'); if ( !['localhost', '127.0.0.1'].includes(url.hostname) || ['', '4173', '4185', '4186', '4187', '4188', '4189', '4194'].includes( @@ -23,6 +24,20 @@ try { const errors = []; page.on('pageerror', (e) => errors.push(e.message)); await installRadioFixture(page); + if (soak) await page.evaluateOnNewDocument(() => { + const fetch = window.fetch.bind(window); + window.qaQuakeReads = 0; + window.fetch = (input, init) => { + const url = new URL(input?.url ?? input, location.href); + if (url.hostname !== 'earthquake.usgs.gov') return fetch(input, init); + window.qaQuakeReads++; + return Promise.resolve(Response.json({ + type: 'FeatureCollection', + features: [{ type: 'Feature', id: 'qa-austin', properties: { mag: 4.1, time: Date.now(), place: 'Austin' }, + geometry: { type: 'Point', coordinates: [-97.74, 30.27, 8] } }], + })); + }; + }); await page.goto(url.href); await page.waitForFunction( () => @@ -56,8 +71,53 @@ try { window.qaSnapshot = window.qaLayer.getAcceptedCatalogSnapshot(); return window.__qaRadio.reads; }); - for (const kind of ['cesium', 'navara']) { + const client = soak ? await page.createCDPSession() : null; + if (soak) await page.evaluate(async () => { + const { dataManager, engine } = window.__godsEyeView; + await dataManager.setEnabled('earthquakes', true, { origin: 'user' }); + window.qaModels = new Map([...dataManager.layers].map(([id, entry]) => [id, entry.module])); + window.qaEngines = new Map([[engine.kind, engine]]); + }); + const swaps = soak ? 50 : 2, measurements = [], memory = []; + let contextListeners; + for (let i = 1; i <= swaps; i++) { + const kind = i % 2 ? 'cesium' : 'navara'; + const start = await page.evaluate(() => performance.now()); await switchGlobe(page, kind); + const elapsed = await page.evaluate((start) => performance.now() - start, start); + measurements.push({ kind, ms: Math.round(elapsed) }); + if (soak) { + assert.ok(await page.evaluate(() => { + const { dataManager, engine } = window.__godsEyeView; + const previous = window.qaEngines.get(engine.kind); + window.qaEngines.set(engine.kind, engine); + return (!previous || previous === engine) && window.qaEngines.size === 2 && + [...window.qaModels].every(([id, model]) => dataManager.layers.get(id)?.module === model) && + window.qaModels.size === 33 && window.qaQuakeReads >= 1 && + dataManager.getEnabledLayerIds().has('earthquakes'); + })); + if ([2, 10, 50].includes(i)) { + const { result } = await client.send('Runtime.evaluate', { + includeCommandLineAPI: true, + returnByValue: true, + expression: `(() => { + const events = getEventListeners(window.__godsEyeView.engine.host.canvas); + return [events.webglcontextlost.length, events.webglcontextrestored.length]; + })()`, + }); + assert.ok( + Array.isArray(result.value) && result.value.length === 2 && + result.value.every((count) => Number.isInteger(count) && count > 0), + 'the active canvas must expose both WebGL event subscriptions', + ); + contextListeners ??= result.value; + assert.deepEqual(result.value, contextListeners, 'warm Navara must release old context listeners'); + await client.send('HeapProfiler.collectGarbage'); + const heap = await client.send('Runtime.getHeapUsage'); + const dom = await client.send('Memory.getDOMCounters'); + memory.push({ swap: i, contextListeners, jsMiB: Math.round(heap.usedSize / 1048576), ...dom }); + } + } const state = await page.evaluate(() => ({ same: window.qaLayer === @@ -86,6 +146,14 @@ try { assert.equal(state.state.selected.id, stations[10].id); assert.equal(state.state.volume, 0.36); } + if (soak) { + const warm = {}; + for (const kind of ['navara', 'cesium']) { + const ms = measurements.slice(2).filter(x => x.kind === kind).map(x => x.ms).sort((a, b) => a - b); + warm[kind] = { p50: ms[Math.ceil(ms.length * 0.50) - 1], p95: ms[Math.ceil(ms.length * 0.95) - 1] }; + } + console.log('SOAK', JSON.stringify({ swaps, first: measurements.slice(0, 2), warm, memory })); + } assert.deepEqual(errors, []); console.log( 'PASS one live radio player, one station directory and the same selection', diff --git a/src/globe/navara/movers/moverMesh.js b/src/globe/navara/movers/moverMesh.js index 1822cf3a6..b6b0b4f92 100644 --- a/src/globe/navara/movers/moverMesh.js +++ b/src/globe/navara/movers/moverMesh.js @@ -217,7 +217,14 @@ function atlasTexture(atlas, encoded, onLoad) { const texture = cesiumSampling(new Texture(), encoded); const image = new Image(); image.crossOrigin = 'anonymous'; + const release = () => { + image.onload = null; + texture.image = null; + texture.removeEventListener('dispose', release); + }; + texture.addEventListener('dispose', release); image.onload = () => { + image.onload = null; texture.image = image; texture.needsUpdate = true; onLoad(); diff --git a/src/globe/navara/movers/moverMesh.test.mjs b/src/globe/navara/movers/moverMesh.test.mjs index 510d5d708..d8a5990d8 100644 --- a/src/globe/navara/movers/moverMesh.test.mjs +++ b/src/globe/navara/movers/moverMesh.test.mjs @@ -23,6 +23,37 @@ const spriteOptions = normalizeMoverSetOptions({ }, }); +test('disposing a sprite atlas drops decoded images and detaches pending load callbacks', (t) => { + const previous = globalThis.Image; + let image; + globalThis.Image = class { + constructor() { image = this; } + }; + t.after(() => { + if (previous) globalThis.Image = previous; + else delete globalThis.Image; + }); + const options = normalizeMoverSetOptions({ + ...spriteOptions, sprite: { + sizePx: 24, atlas: { url: '/bracket.svg', cells: { a: [0, 0, 1, 1] } }, + }, + }); + for (const loaded of [false, true]) { + let frames = 0; + const mover = createMoverMesh(options, createMoverStore(options), { + onTextureLoad: () => frames++, + }); + if (loaded) { + image.onload(); + assert.equal(mover.mesh.material.map.image, image); + } + mover.dispose(); + assert.equal(image.onload, null); + assert.equal(mover.mesh.material.map.image, null); + assert.equal(frames, Number(loaded)); + } +}); + const basicShader = () => ({ uniforms: UniformsUtils.clone(ShaderLib.basic.uniforms), vertexShader: ShaderLib.basic.vertexShader, diff --git a/src/globe/navara/overlay.js b/src/globe/navara/overlay.js index 1b3ce9796..919017608 100644 --- a/src/globe/navara/overlay.js +++ b/src/globe/navara/overlay.js @@ -43,6 +43,17 @@ class GevOverlayEffectDesc extends EffectDesc { ); } + onDestroy() { + // Navara removes the pass from its composer; our pass owns its GPU target + // and context event handlers until it is explicitly disposed. + const pass = this.raw?.rawPass; + try { + super.onDestroy(); + } finally { + pass?.dispose(); + } + } + // Each drawn frame: the scene depth the overlays test against, then each // overlay's own per-frame work (pixel footprint, origin, viewport). update(time) {