diff --git a/src/core/CoreShaderManager.contextLoss.test.ts b/src/core/CoreShaderManager.contextLoss.test.ts new file mode 100644 index 0000000..ccd93f7 --- /dev/null +++ b/src/core/CoreShaderManager.contextLoss.test.ts @@ -0,0 +1,72 @@ +/** + * Tests for CoreShaderManager's behavior when shader creation fails. + * + * A lost GL context makes shader creation throw synchronously (see + * WebGlRenderer.createShaderProgram, which trips `stage.isContextLost`). On a + * lost context we must fail soft — returning the default shader node so the + * throw does not propagate through the consumer's reactive layer (recovery is + * an app reload via the `contextLost` event). A genuine compile error (context + * not lost) must still surface loudly. + */ +import { describe, expect, it, vi } from 'vitest'; +import { CoreShaderManager } from './CoreShaderManager.js'; +import type { Stage } from './Stage.js'; + +function makeManager(opts: { isContextLost: boolean; throwOnCreate: Error }) { + const defShaderNode = { __default: true }; + const createShaderProgram = vi.fn(() => { + throw opts.throwOnCreate; + }); + const stage = { + isContextLost: opts.isContextLost, + defShaderNode, + renderer: { + mode: 'webgl', + createShaderProgram, + }, + } as unknown as Stage; + + const mgr = Object.create(CoreShaderManager.prototype) as CoreShaderManager; + (mgr as unknown as { stage: Stage }).stage = stage; + (mgr as unknown as { shTypes: Record }).shTypes = { + TestShader: { props: undefined }, + }; + (mgr as unknown as { shCache: Map }).shCache = new Map(); + + return { mgr, defShaderNode, createShaderProgram }; +} + +describe('CoreShaderManager.createShader — context loss', () => { + it('returns the default shader node when the context is lost', () => { + const { mgr, defShaderNode } = makeManager({ + isContextLost: true, + throwOnCreate: new Error( + 'Unable to create the shader: VERTEX_SHADER. WebGlContext Error: 37442', + ), + }); + + const result = mgr.createShader('TestShader' as never); + + expect(result).toBe(defShaderNode); + }); + + it('rethrows when the failure is not a lost context (genuine compile error)', () => { + const err = new Error('Vertex shader creation failed'); + const { mgr } = makeManager({ isContextLost: false, throwOnCreate: err }); + + expect(() => mgr.createShader('TestShader' as never)).toThrow(err); + }); + + it('does not cache a failed program', () => { + const { mgr, createShaderProgram } = makeManager({ + isContextLost: true, + throwOnCreate: new Error('boom'), + }); + + mgr.createShader('TestShader' as never); + mgr.createShader('TestShader' as never); + + // Cache miss both times — the failure was never stored. + expect(createShaderProgram).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/core/CoreShaderManager.ts b/src/core/CoreShaderManager.ts index 3050346..1b92bd0 100644 --- a/src/core/CoreShaderManager.ts +++ b/src/core/CoreShaderManager.ts @@ -103,8 +103,21 @@ export class CoreShaderManager { * if shaderProgram was not found create a new one */ if (shProgram === undefined) { - shProgram = this.stage.renderer.createShaderProgram(shType, props)!; - this.shCache.set(shaderKey, shProgram); + try { + shProgram = this.stage.renderer.createShaderProgram(shType, props)!; + this.shCache.set(shaderKey, shProgram); + } catch (e) { + // The renderer trips `isContextLost` when shader creation fails due to + // a lost GL context. That is unrecoverable in-place (the consumer + // reloads on the `contextLost` event), so fall back to the default + // shader node instead of letting the throw propagate through the app's + // reactive layer. A genuine GLSL compile error (context not lost) is a + // real bug and is rethrown so it surfaces loudly. + if (this.stage.isContextLost === true) { + return this.stage.defShaderNode; + } + throw e; + } } return this.stage.renderer.createShaderNode( diff --git a/src/core/lib/WebGlContextWrapper.ts b/src/core/lib/WebGlContextWrapper.ts index c15477e..c0896f2 100644 --- a/src/core/lib/WebGlContextWrapper.ts +++ b/src/core/lib/WebGlContextWrapper.ts @@ -1190,6 +1190,22 @@ export class WebGlContextWrapper { return this.gl.getError(); } + /** + * ``` + * gl.isContextLost(); + * ``` + * + * @remarks + * Reports the current lost state directly and is not consumed like + * `getError()` (whose `CONTEXT_LOST_WEBGL` flag is cleared on first read). + * Use this to detect a lost context after a GL call has already failed. + * + * @returns + */ + isContextLost() { + return this.gl.isContextLost(); + } + /** * ``` * gl.getAttribLocation(program, name); diff --git a/src/core/renderers/webgl/WebGlRenderer.contextLoss.test.ts b/src/core/renderers/webgl/WebGlRenderer.contextLoss.test.ts new file mode 100644 index 0000000..993070a --- /dev/null +++ b/src/core/renderers/webgl/WebGlRenderer.contextLoss.test.ts @@ -0,0 +1,49 @@ +/** + * Tests for WebGlRenderer.createShaderProgram's context-loss handling. + * + * When the GL context is lost, gl.createShader() returns null and getError() + * reports CONTEXT_LOST_WEBGL (0x9242 / 37442). That can happen on the app's + * reactive stack before the async `webglcontextlost` event fires, so the + * renderer trips `stage.setContextLost()` itself when it detects a lost + * context — but only then; genuine compile errors are rethrown untouched. + */ +import { describe, expect, it, vi } from 'vitest'; +import { WebGlRenderer } from './WebGlRenderer.js'; +import type { WebGlShaderType } from './WebGlShaderNode.js'; + +function makeRenderer(isContextLost: boolean) { + const setContextLost = vi.fn(); + // Minimal glw that drives the WebGlShaderProgram constructor straight to the + // "Unable to create the shader" throw (createShader returns null). + const glw = { + VERTEX_SHADER: 0x8b31, + getExtension: () => ({}), + createShader: () => null, + getError: () => 37442, + isContextLost: () => isContextLost, + }; + const renderer = Object.create(WebGlRenderer.prototype) as WebGlRenderer; + (renderer as unknown as { glw: unknown }).glw = glw; + (renderer as unknown as { stage: unknown }).stage = { setContextLost }; + + const config = { vertex: 'v', fragment: 'f' } as unknown as WebGlShaderType; + return { renderer, config, setContextLost }; +} + +describe('WebGlRenderer.createShaderProgram — context loss', () => { + it('trips setContextLost and rethrows when the context is lost', () => { + const { renderer, config, setContextLost } = makeRenderer(true); + + expect(() => renderer.createShaderProgram(config, {})).toThrow( + /Unable to create the shader/, + ); + expect(setContextLost).toHaveBeenCalledTimes(1); + }); + + it('rethrows without tripping setContextLost when the context is healthy', () => { + const { renderer, config, setContextLost } = makeRenderer(false); + + expect(() => renderer.createShaderProgram(config, {})).toThrow(); + expect(setContextLost).not.toHaveBeenCalled(); + }); +}); diff --git a/src/core/renderers/webgl/WebGlRenderer.ts b/src/core/renderers/webgl/WebGlRenderer.ts index 8fbaccc..393bf2d 100644 --- a/src/core/renderers/webgl/WebGlRenderer.ts +++ b/src/core/renderers/webgl/WebGlRenderer.ts @@ -385,7 +385,19 @@ export class WebGlRenderer extends CoreRenderer { shaderType: WebGlShaderType, props: Record, ): WebGlShaderProgram { - return new WebGlShaderProgram(this, shaderType, props); + try { + return new WebGlShaderProgram(this, shaderType, props); + } catch (e) { + // A lost GL context makes shader creation/compilation fail synchronously + // (gl.createShader returns null -> CONTEXT_LOST_WEBGL). This can run on + // the app's reactive stack before the async `webglcontextlost` event is + // processed, so trip the flag here too. setContextLost() is idempotent + // and emits `contextLost` for consumers (recovery is an app reload). + if (this.glw.isContextLost() === true) { + this.stage.setContextLost(); + } + throw e; + } } createShaderNode(