diff --git a/src/cockpitCloudEffects.js b/src/cockpitCloudEffects.js index 36f8a327a..95d65a249 100644 --- a/src/cockpitCloudEffects.js +++ b/src/cockpitCloudEffects.js @@ -199,6 +199,7 @@ class CockpitCloudEffectsController { this.gl = null; this.program = null; + this.buffer = null; this.locations = null; this.frame = null; this.refreshTimer = null; @@ -275,8 +276,9 @@ class CockpitCloudEffectsController { } initializeRenderer() { + let gl = null; try { - const gl = this.canvas.getContext('webgl', { + gl = this.canvas.getContext('webgl', { alpha: true, antialias: false, depth: false, @@ -318,6 +320,7 @@ class CockpitCloudEffectsController { this.gl = gl; this.program = program; + this.buffer = buffer; this.locations = { resolution: gl.getUniformLocation(program, 'uResolution'), time: gl.getUniformLocation(program, 'uTime'), @@ -327,6 +330,7 @@ class CockpitCloudEffectsController { } catch (error) { console.warn('[Cockpit clouds] Renderer unavailable:', error); this.canvas.dataset.status = 'unavailable'; + if (gl && !this.gl) gl.getExtension('WEBGL_lose_context')?.loseContext(); } } @@ -605,9 +609,26 @@ class CockpitCloudEffectsController { 'gev:cockpit-weather-toggle', this.onEnabledChange, ); - if (this.program && this.gl) this.gl.deleteProgram(this.program); + this.releaseRenderer(); this.canvas.remove(); } + + /** + * Free the GPU objects and the canvas's WebGL context. The application is + * rebuilt on every globe switch, and browsers cap live contexts, so a + * destroyed controller must not leave its context for garbage collection. + */ + releaseRenderer() { + const gl = this.gl; + this.gl = null; + this.locations = null; + if (!gl) return; + if (this.buffer) gl.deleteBuffer(this.buffer); + if (this.program) gl.deleteProgram(this.program); + this.buffer = null; + this.program = null; + gl.getExtension('WEBGL_lose_context')?.loseContext(); + } } export function initCockpitCloudEffects(viewer, options) { diff --git a/src/cockpitCloudEffects.test.mjs b/src/cockpitCloudEffects.test.mjs index 18a0ed394..5c8a565fa 100644 --- a/src/cockpitCloudEffects.test.mjs +++ b/src/cockpitCloudEffects.test.mjs @@ -1,19 +1,32 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { + initCockpitCloudEffects, cockpitCloudRenderSize, cockpitWeatherRefreshDue, cockpitWeatherEnabledFromStoredValue, } from './cockpitCloudEffects.js'; test('cockpit cloud framebuffer stays low resolution on large displays', () => { - assert.deepEqual(cockpitCloudRenderSize(2048, 1152), { width: 520, height: 293 }); - assert.deepEqual(cockpitCloudRenderSize(1280, 720), { width: 520, height: 293 }); + assert.deepEqual(cockpitCloudRenderSize(2048, 1152), { + width: 520, + height: 293, + }); + assert.deepEqual(cockpitCloudRenderSize(1280, 720), { + width: 520, + height: 293, + }); }); test('cockpit cloud framebuffer never upscales or collapses to zero', () => { - assert.deepEqual(cockpitCloudRenderSize(640, 360), { width: 269, height: 151 }); - assert.deepEqual(cockpitCloudRenderSize(0, Number.NaN), { width: 1, height: 1 }); + assert.deepEqual(cockpitCloudRenderSize(640, 360), { + width: 269, + height: 151, + }); + assert.deepEqual(cockpitCloudRenderSize(0, Number.NaN), { + width: 1, + height: 1, + }); }); test('cockpit weather defaults off and enables only from an explicit saved opt-in', () => { @@ -25,32 +38,163 @@ test('cockpit weather defaults off and enables only from an explicit saved opt-i test('cockpit weather refreshes after time or meaningful movement', () => { const anchor = { latitude: 30, longitude: -97 }; - assert.equal(cockpitWeatherRefreshDue({ - nowMs: 1000, - fetchedAt: 500, - anchor, - point: anchor, - hasWeather: false, - }), true); - assert.equal(cockpitWeatherRefreshDue({ - nowMs: 60_000, - fetchedAt: 0, - anchor, - point: { latitude: 30.01, longitude: -97 }, - hasWeather: true, - }), false); - assert.equal(cockpitWeatherRefreshDue({ - nowMs: 5 * 60_000, - fetchedAt: 0, - anchor, - point: anchor, - hasWeather: true, - }), true); - assert.equal(cockpitWeatherRefreshDue({ - nowMs: 60_000, - fetchedAt: 0, - anchor, - point: { latitude: 30.3, longitude: -97 }, - hasWeather: true, - }), true); + assert.equal( + cockpitWeatherRefreshDue({ + nowMs: 1000, + fetchedAt: 500, + anchor, + point: anchor, + hasWeather: false, + }), + true, + ); + assert.equal( + cockpitWeatherRefreshDue({ + nowMs: 60_000, + fetchedAt: 0, + anchor, + point: { latitude: 30.01, longitude: -97 }, + hasWeather: true, + }), + false, + ); + assert.equal( + cockpitWeatherRefreshDue({ + nowMs: 5 * 60_000, + fetchedAt: 0, + anchor, + point: anchor, + hasWeather: true, + }), + true, + ); + assert.equal( + cockpitWeatherRefreshDue({ + nowMs: 60_000, + fetchedAt: 0, + anchor, + point: { latitude: 30.3, longitude: -97 }, + hasWeather: true, + }), + true, + ); +}); + +// A WebGL context that records what is allocated, freed and lost. +function fakeWebGl() { + const calls = []; + const gl = { + VERTEX_SHADER: 1, + FRAGMENT_SHADER: 2, + ARRAY_BUFFER: 3, + STATIC_DRAW: 4, + FLOAT: 5, + COLOR_BUFFER_BIT: 6, + LINK_STATUS: 7, + COMPILE_STATUS: 8, + calls, + createShader: () => ({}), + shaderSource() {}, + compileShader() {}, + getShaderParameter: () => true, + getShaderInfoLog: () => '', + deleteShader() {}, + createProgram: () => ({ program: true }), + attachShader() {}, + linkProgram() {}, + getProgramParameter: () => true, + getProgramInfoLog: () => '', + deleteProgram: (program) => calls.push(['deleteProgram', program]), + createBuffer: () => ({ buffer: true }), + bindBuffer() {}, + bufferData() {}, + deleteBuffer: (buffer) => calls.push(['deleteBuffer', buffer]), + getAttribLocation: () => 0, + enableVertexAttribArray() {}, + vertexAttribPointer() {}, + getUniformLocation: () => ({}), + viewport() {}, + clearColor() {}, + clear() {}, + getExtension: (name) => + name === 'WEBGL_lose_context' + ? { loseContext: () => calls.push(['loseContext']) } + : null, + }; + return gl; +} + +test('a destroyed cockpit cloud controller frees its GPU objects and WebGL context', () => { + const saved = Object.fromEntries( + [ + 'document', + 'window', + 'localStorage', + 'CustomEvent', + 'requestAnimationFrame', + 'cancelAnimationFrame', + ].map((name) => [name, globalThis[name]]), + ); + const gl = fakeWebGl(); + let contexts = 0; + const canvas = { + dataset: {}, + classList: { add() {}, remove() {} }, + setAttribute() {}, + remove() { + canvas.removed = true; + }, + getContext() { + contexts += 1; + return gl; + }, + }; + globalThis.document = { + createElement: () => canvas, + body: { appendChild() {}, classList: { contains: () => true } }, + }; + globalThis.window = { + innerWidth: 1280, + innerHeight: 720, + matchMedia: () => ({ matches: false }), + addEventListener() {}, + removeEventListener() {}, + dispatchEvent() {}, + setTimeout: () => 1, + clearTimeout() {}, + }; + globalThis.localStorage = { getItem: () => '1', setItem() {} }; + globalThis.CustomEvent = class { + constructor(type, init) { + this.type = type; + this.detail = init?.detail; + } + }; + globalThis.requestAnimationFrame = () => 7; + globalThis.cancelAnimationFrame = () => {}; + try { + const clouds = initCockpitCloudEffects(null, { weatherService: {} }); + clouds.start(); + clouds.stop(); + clouds.start(); + assert.equal( + contexts, + 1, + 'one context per controller, reused across starts', + ); + const { program, buffer } = clouds; + clouds.destroy(); + assert.deepEqual(gl.calls, [ + ['deleteBuffer', buffer], + ['deleteProgram', program], + ['loseContext'], + ]); + assert.equal(clouds.gl, null); + assert.equal(canvas.removed, true); + } finally { + for (const [name, value] of Object.entries(saved)) { + if (value === undefined) delete globalThis[name]; + else globalThis[name] = value; + } + } });