Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions src/core/CoreShaderManager.contextLoss.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> }).shTypes = {
TestShader: { props: undefined },
};
(mgr as unknown as { shCache: Map<string, unknown> }).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);
});
});
17 changes: 15 additions & 2 deletions src/core/CoreShaderManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
16 changes: 16 additions & 0 deletions src/core/lib/WebGlContextWrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
49 changes: 49 additions & 0 deletions src/core/renderers/webgl/WebGlRenderer.contextLoss.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
14 changes: 13 additions & 1 deletion src/core/renderers/webgl/WebGlRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,7 +385,19 @@ export class WebGlRenderer extends CoreRenderer {
shaderType: WebGlShaderType,
props: Record<string, unknown>,
): 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(
Expand Down
Loading