From 1a34d36cc425475870444aa1dca19f16c6a8d227 Mon Sep 17 00:00:00 2001 From: Chris Lorenzo Date: Thu, 25 Jun 2026 13:20:38 -0500 Subject: [PATCH] fix(webgl): cache support probe so it creates a context at most once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `getWebglSupportedVersions` creates a real WebGL context (`getContext`) to test support, but only cached the result when called with the *default* id list (reference equality against the internal `WEBGL_CONTEXT_IDS`). Any caller passing its own array bypassed the cache and created a brand-new context on every call. On embedded TV boxes (Apollo/Sunrise, Chrome 38+) the live-context budget is tiny. Re-probing repeatedly blows that budget; the browser evicts the oldest context — the live render context — and the renderer then fails every `createTexture()`, spamming "Could not create WebGL Texture" each frame. The device console showed both "too many active WebGL contexts" and "INVALID_OPERATION: loseContext: context already lost" originating here. Changes: - Cache per distinct id list (Map keyed on the joined ids) so a probe context is created at most once per page, regardless of how the caller invokes it. - Release the probe context via WEBGL_lose_context, guarded by isContextLost() so we never call loseContext on a context the browser already evicted (the source of the "context already lost" console spam). Detection output is unchanged for any given argument — only the number of contexts created changes (now: one per arg list, ever). Co-Authored-By: Claude Opus 4.8 --- src/utils.ts | 44 ++++++++++++++++++++--- tests/webgl-support.test.ts | 72 +++++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 5 deletions(-) create mode 100644 tests/webgl-support.test.ts diff --git a/src/utils.ts b/src/utils.ts index a502aab..ae3c1f2 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -7,7 +7,14 @@ const WEBGL_CONTEXT_IDS = [ 'experimental-webgl2', 'experimental-webgl', ]; -let supportedWebglVersions: string[] | undefined; +// WebGL support never changes during a page's lifetime, so each distinct id +// list is probed exactly once and the result is cached forever. This is +// load-bearing on embedded TV boxes: every probe call creates a real WebGL +// context, and those boxes cap live contexts very low. Probing repeatedly (the +// old behavior bypassed the cache for any non-default argument) creates a new +// context each time, blows the page's context budget, and evicts the oldest +// context — the live render context — which then fails every createTexture. +const supportedWebglVersions = new Map(); /** * Converts a color string to a color number value. @@ -90,14 +97,26 @@ export function mod(n: number, m: number): number { export function getWebglSupportedVersions( webglContextIds: string[] = WEBGL_CONTEXT_IDS, ): string[] { - if (supportedWebglVersions && webglContextIds === WEBGL_CONTEXT_IDS) { - return supportedWebglVersions; + // Cache per distinct id list. The previous version only cached the default + // list, so any caller passing its own array re-probed (and re-created a + // context) on every call — see the comment on `supportedWebglVersions`. + const cacheKey = webglContextIds.join('|'); + const cached = supportedWebglVersions.get(cacheKey); + if (cached !== undefined) { + return cached; } const cv = document.createElement('canvas'); + // A canvas locks to the first context type it hands out, so probing several + // ids on one canvas yields at most one live context. Capture it so we can + // release it below. + let probeContext: RenderingContext | null = null; const supports = webglContextIds.filter((id) => { try { const context = cv.getContext(id); + if (context !== null && probeContext === null) { + probeContext = context; + } return !!( context && (context instanceof WebGLRenderingContext || @@ -110,10 +129,25 @@ export function getWebglSupportedVersions( } }); - if (webglContextIds === WEBGL_CONTEXT_IDS) { - supportedWebglVersions = supports; + // Free the probe context immediately instead of leaking it until GC. Embedded + // TV browsers cap live WebGL contexts very low; a lingering probe burns one of + // those scarce slots and can trigger "too many active webgl contexts" — which + // evicts the oldest context (potentially the live render context). Guard with + // isContextLost(): if the browser already evicted this probe, loseContext() + // throws INVALID_OPERATION ("context already lost") and spams the console. + const probe = probeContext as RenderingContext | null; + if ( + probe !== null && + 'getExtension' in probe && + (probe as WebGLRenderingContext).isContextLost() === false + ) { + (probe as WebGLRenderingContext) + .getExtension('WEBGL_lose_context') + ?.loseContext(); } + supportedWebglVersions.set(cacheKey, supports); + return supports; } diff --git a/tests/webgl-support.test.ts b/tests/webgl-support.test.ts new file mode 100644 index 0000000..09ad1aa --- /dev/null +++ b/tests/webgl-support.test.ts @@ -0,0 +1,72 @@ +import { vi, describe, it, expect, afterEach } from 'vitest'; +import { getWebglSupportedVersions } from '../src/utils.ts'; + +// jsdom has no real WebGL, so stub the globals the detection branch checks and +// hand back a fake context that reports support + exposes the lifecycle methods +// the probe-cleanup path calls. +function fakeGl(opts: { contextLost?: boolean } = {}) { + return { + getParameter: vi.fn(), + isContextLost: vi.fn(() => opts.contextLost === true), + getExtension: vi.fn((name: string) => + name === 'WEBGL_lose_context' ? { loseContext: vi.fn() } : null, + ), + }; +} + +function stubCanvas(getContext: (id: string) => unknown) { + vi.stubGlobal('WebGLRenderingContext', class {}); + vi.stubGlobal('WebGL2RenderingContext', class {}); + const createElement = vi.spyOn(document, 'createElement').mockReturnValue({ + getContext: vi.fn(getContext), + } as unknown as HTMLElement); + return createElement; +} + +describe('getWebglSupportedVersions', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('releases the probe context instead of leaking a GL slot', () => { + const gl = fakeGl(); + // Unique id list per test so the module-level cache never collides across + // tests (vitest runs this file with isolate:false). + stubCanvas((id) => (id === 'webgl-a' ? gl : null)); + + const versions = getWebglSupportedVersions(['webgl-a']); + + expect(versions).toEqual(['webgl-a']); + expect(gl.getExtension).toHaveBeenCalledWith('WEBGL_lose_context'); + }); + + it('creates a probe context at most once across repeated calls', () => { + const gl = fakeGl(); + const createElement = stubCanvas((id) => (id === 'webgl-b' ? gl : null)); + + getWebglSupportedVersions(['webgl-b']); + getWebglSupportedVersions(['webgl-b']); + getWebglSupportedVersions(['webgl-b']); + + // Only the first call probes; the rest hit the cache and create nothing. + expect(createElement).toHaveBeenCalledTimes(1); + }); + + it('does not call loseContext when the probe context is already lost', () => { + const gl = fakeGl({ contextLost: true }); + const ext = { loseContext: vi.fn() }; + gl.getExtension = vi.fn(() => ext); + stubCanvas((id) => (id === 'webgl-c' ? gl : null)); + + getWebglSupportedVersions(['webgl-c']); + + expect(ext.loseContext).not.toHaveBeenCalled(); + }); + + it('does not throw when no WebGL context is available', () => { + stubCanvas(() => null); + expect(() => getWebglSupportedVersions(['webgl-d'])).not.toThrow(); + expect(getWebglSupportedVersions(['webgl-d'])).toEqual([]); + }); +});