Release the cockpit cloud WebGL context when its controller is destroyed

This commit is contained in:
German Khokhlov committed 2026-11-03 14:47:36 +04:00
1 parent 12cd73bed9
commit 64759c552e
2 files changed
+199 -34

No files matched your search

+23 -2
View File
@@ -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) {
+176 -32
View File
@@ -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;
}
}
});