From d6f89e3cfd9b27e21c2ee5ad0307107ed46c079f Mon Sep 17 00:00:00 2001 From: Said Date: Tue, 20 Oct 2026 14:01:22 +0100 Subject: [PATCH] fix: release weather camera listeners while disabled --- changelog.d/1275-forecast-issue-fix.md | 1 + docs/CURRENT-STATE.md | 1 + scripts/qa-weather-teardown.mjs | 28 ++++++++++++++++++++++---- src/layers/weather/index.js | 12 +++++++---- src/layers/weather/weather.test.mjs | 15 ++++++++++++++ 5 files changed, 49 insertions(+), 8 deletions(-) diff --git a/changelog.d/1275-forecast-issue-fix.md b/changelog.d/1275-forecast-issue-fix.md index 9308249e2..6168e7f1f 100644 --- a/changelog.d/1275-forecast-issue-fix.md +++ b/changelog.d/1275-forecast-issue-fix.md @@ -1 +1,2 @@ - Preserve enable during first-load replacement (asturksever, #1275). +- Release disabled weather camera listeners and make teardown resource leaks fail the browser probe (asturksever, #1275). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 2309269b8..00e533540 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -253,6 +253,7 @@ catalog construction owns each instance. Wind animates one forecast without advancing forecast time. Separate Weather observation layers provide radar and satellite history; none claims measured cloud volume. +Weather imagery releases its camera move listener on disable and registers it once on re-enable. Disabling also clears active imagery work. Weather imagery drapes onto the globe basemap. On photorealistic 3D Tiles each observed product is instead a raised, translucent shell: one rectangle over the product bounds at a fixed height (global infrared 5.5 km, regional infrared diff --git a/scripts/qa-weather-teardown.mjs b/scripts/qa-weather-teardown.mjs index b64d1d59c..c81a7bec1 100644 --- a/scripts/qa-weather-teardown.mjs +++ b/scripts/qa-weather-teardown.mjs @@ -141,7 +141,7 @@ const snapshot = (page) => ), }, weather, - windCells: wind.gpu?.pathCount ?? wind.paths ?? null, + windCells: wind.gpu?.pathCount ?? wind.paths ?? 0, enabled: [...dm.layers].filter(([, e]) => e.enabled).map(([id]) => id), }; }); @@ -220,7 +220,7 @@ try { const baseline = await snapshot(a.page); console.log('baseline', JSON.stringify(baseline)); for (const id of WEATHER) await setLayer(a.page, id, true); - await a.page + const weatherReady = await a.page .waitForFunction( () => ['weather-radar', 'weather-satellite', 'weather-lightning'].every( @@ -233,7 +233,13 @@ try { ), { timeout: 120_000 }, ) - .catch(() => console.log(' !! weather not ready')); + .then( + () => true, + () => false, + ); + results.weatherReady = weatherReady; + if (!weatherReady) + console.log(' !! weather not ready — feature coverage incomplete'); await sleep(2000); // exercise: history playback, wind reading, storm selection, product switch await a.page.evaluate(() => { @@ -331,11 +337,25 @@ try { 'errors (control run):', results.errorsB.length ? results.errorsB : 'none', ); + // Resource ownership is an acceptance check; provider availability remains + // separately reported diagnostic evidence in both runs. + if ( + !weatherReady || + Object.keys(leak).length || + [...a.errors, ...b.errors].some((e) => e.startsWith('pageerror:')) + ) { + process.exitCode = 1; + } +} catch (error) { + results.failure = { name: error.name, message: error.message }; + throw error; +} finally { + // Preserve completed resource evidence even if a later provider or browser + // phase aborts. A partial run still exits nonzero. writeFileSync( path.join(outDir, 'results.json'), JSON.stringify(results, null, 2), ); console.log(`wrote ${path.join(outDir, 'results.json')}`); -} finally { await browser.close(); } diff --git a/src/layers/weather/index.js b/src/layers/weather/index.js index 188955090..757764fe6 100644 --- a/src/layers/weather/index.js +++ b/src/layers/weather/index.js @@ -296,10 +296,6 @@ export function createWeatherLayer({ motion?.addEventListener?.('change', onVisibility); documentRef?.addEventListener?.('visibilitychange', onVisibility); registerClock(); - removeCamera = viewer.camera.moveEnd?.addEventListener(() => { - checkHost(); - if (enabled) notify(); - }); }, attachShellServices(services) { imageryHost = @@ -315,10 +311,18 @@ export function createWeatherLayer({ }, enable() { enabled = true; + if (!removeCamera) { + removeCamera = viewer.camera.moveEnd?.addEventListener(() => { + checkHost(); + notify(); + }); + } registerClock(); }, disable() { enabled = false; + removeCamera?.(); + removeCamera = null; unregisterClock?.(); unregisterClock = null; ++generation; diff --git a/src/layers/weather/weather.test.mjs b/src/layers/weather/weather.test.mjs index 38668909a..486d42520 100644 --- a/src/layers/weather/weather.test.mjs +++ b/src/layers/weather/weather.test.mjs @@ -2341,3 +2341,18 @@ test('a product replacement accepts supersession but never paints the obsolete f assert.equal(h.layer.getParams().product, 'clouds'); h.layer.destroy(); }); + +test('disabled weather releases its camera listener and re-enabling binds only once', () => { + const h = layerHarness(); + h.layer.disable(); + assert.equal(h.moveEnd.size, 0, 'disabled layers own no active camera work'); + h.layer.enable(); + h.layer.enable(); + assert.equal(h.moveEnd.size, 1); + h.layer.disable(); + assert.equal(h.moveEnd.size, 0); + h.layer.enable(); + assert.equal(h.moveEnd.size, 1); + h.layer.destroy(); + assert.equal(h.moveEnd.size, 0); +});