From f6ea1e44f176dbf17e973c49bc5831175e77f48a Mon Sep 17 00:00:00 2001 From: Christopher Pruijsen Date: Fri, 9 Oct 2026 11:39:39 +0100 Subject: [PATCH 1/2] fix(OpenGLHelper): restore empty program cleared on shader failure --- Sources/Rendering/OpenGL/Helper/index.js | 6 ++++ .../Helper/test/testNullProgramRecovery.js | 35 +++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 Sources/Rendering/OpenGL/Helper/test/testNullProgramRecovery.js diff --git a/Sources/Rendering/OpenGL/Helper/index.js b/Sources/Rendering/OpenGL/Helper/index.js index f025fdddb58..96dc8fccc26 100644 --- a/Sources/Rendering/OpenGL/Helper/index.js +++ b/Sources/Rendering/OpenGL/Helper/index.js @@ -26,6 +26,12 @@ function vtkOpenGLHelper(publicAPI, model) { publicAPI.setOpenGLRenderWindow = (win) => { model.context = win.getContext(); + if (!model.program) { + // A mapper clears the program on shader compile/bind failure; restore an + // empty one (handle 0) so getNeedToRebuildShaders re-arms instead of + // dereferencing null on the next pass. + model.program = vtkShaderProgram.newInstance(); + } model.program.setContext(model.context); model.VAO.setOpenGLRenderWindow(win); model.CABO.setOpenGLRenderWindow(win); diff --git a/Sources/Rendering/OpenGL/Helper/test/testNullProgramRecovery.js b/Sources/Rendering/OpenGL/Helper/test/testNullProgramRecovery.js new file mode 100644 index 00000000000..19824ad02f8 --- /dev/null +++ b/Sources/Rendering/OpenGL/Helper/test/testNullProgramRecovery.js @@ -0,0 +1,35 @@ +import { it, expect } from 'vitest'; + +import vtkHelper from 'vtk.js/Sources/Rendering/OpenGL/Helper'; + +it('restores an empty program after a mapper clears it on shader failure', () => { + const helper = vtkHelper.newInstance(); + const gl = {}; + const win = { getContext: () => gl }; + + helper.setOpenGLRenderWindow(win); + // updateShaders clears the program when a shader fails to compile or bind + helper.setProgram(null); + + // the next render pass must not dereference the null program + expect(() => helper.setOpenGLRenderWindow(win)).not.toThrow(); + + // an empty program (handle 0) re-arms getNeedToRebuildShaders so the + // mapper retries the shader instead of leaving the viewport dead + const program = helper.getProgram(); + expect(program.getHandle()).toBe(0); + expect(program.getCompiled()).toBe(false); + expect(program.getVertexShader().getContext()).toBe(gl); +}); + +it('keeps the existing program when it was not cleared', () => { + const helper = vtkHelper.newInstance(); + const win = { getContext: () => ({}) }; + const program = { setContext() {} }; + + helper.setProgram(program); + helper.setOpenGLRenderWindow(win); + + // healthy frames must not discard a compiled program every pass + expect(helper.getProgram()).toBe(program); +}); From 9338071fe08ae29f77fdc103fdfb16cdd69ebfc5 Mon Sep 17 00:00:00 2001 From: Christopher Pruijsen Date: Sun, 18 Oct 2026 09:43:00 +0100 Subject: [PATCH 2/2] fix(ShaderProgram): delete the program when its link fails A failed link cleared the program handle without deleting the program or resetting the recorded shader attachment handles. The shader cache keeps the failed program, so the next build creates a new program and detaches the old shaders from it, which raises INVALID_OPERATION. link now deletes the failed program, which detaches its shaders, and clears the attachment handles so a retry starts clean. Co-authored-by: Paul Elliott --- .../Rendering/OpenGL/ShaderProgram/index.js | 4 ++ .../ShaderProgram/test/testFailedLink.js | 45 +++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 Sources/Rendering/OpenGL/ShaderProgram/test/testFailedLink.js diff --git a/Sources/Rendering/OpenGL/ShaderProgram/index.js b/Sources/Rendering/OpenGL/ShaderProgram/index.js index 83d27aa9539..607aa9e5047 100644 --- a/Sources/Rendering/OpenGL/ShaderProgram/index.js +++ b/Sources/Rendering/OpenGL/ShaderProgram/index.js @@ -138,7 +138,11 @@ function vtkShaderProgram(publicAPI, model) { if (!isCompiled) { const lastError = model.context.getProgramInfoLog(model.handle); vtkErrorMacro(`Error linking shader ${lastError}`); + // Deleting the program detaches its shaders + model.context.deleteProgram(model.handle); model.handle = 0; + model.vertexShaderHandle = 0; + model.fragmentShaderHandle = 0; return false; } diff --git a/Sources/Rendering/OpenGL/ShaderProgram/test/testFailedLink.js b/Sources/Rendering/OpenGL/ShaderProgram/test/testFailedLink.js new file mode 100644 index 00000000000..c0aac03d8ee --- /dev/null +++ b/Sources/Rendering/OpenGL/ShaderProgram/test/testFailedLink.js @@ -0,0 +1,45 @@ +import { it, expect, vi } from 'vitest'; + +import vtkShaderProgram from 'vtk.js/Sources/Rendering/OpenGL/ShaderProgram'; + +function createContext() { + let nextHandle = 1; + return { + VERTEX_SHADER: 'VERTEX_SHADER', + FRAGMENT_SHADER: 'FRAGMENT_SHADER', + createShader: () => nextHandle++, + shaderSource() {}, + compileShader() {}, + getShaderParameter: () => true, + deleteShader() {}, + createProgram: () => nextHandle++, + attachShader() {}, + detachShader: vi.fn(), + linkProgram() {}, + getProgramParameter: vi.fn(() => true), + getProgramInfoLog: () => 'link failed', + deleteProgram: vi.fn(), + }; +} + +it('deletes the program when its link fails so a retry starts clean', () => { + const gl = createContext(); + const program = vtkShaderProgram.newInstance(); + program.setContext(gl); + program.getVertexShader().setSource('void main() {}'); + program.getFragmentShader().setSource('void main() {}'); + + vi.spyOn(console, 'error').mockImplementation(() => {}); + gl.getProgramParameter.mockReturnValueOnce(false); + expect(program.compileShader()).toBe(0); + const failedHandle = gl.deleteProgram.mock.calls[0]?.[0]; + expect(failedHandle).toBeTruthy(); + expect(program.getHandle()).toBe(0); + + // the retry creates a new program; detaching the old shaders from it is + // INVALID_OPERATION, so the stale attachment handles must be cleared + expect(program.compileShader()).toBe(1); + expect(program.getHandle()).not.toBe(failedHandle); + expect(gl.detachShader).not.toHaveBeenCalled(); + vi.restoreAllMocks(); +});