From 9fff12a3fcb9c6f04332d7fbbe72030781370a6f Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Fri, 28 Aug 2026 11:10:01 -0400 Subject: [PATCH 1/2] fix(OpenGL): release poly data mapper buffers when the view node is deleted vtkOpenGLPolyDataMapper, vtkOpenGLPolyDataMapper2D and vtkOpenGLGlyph3DMapper had no way to free the VBOs, VAOs and color texture they own on the GL context. Deleting a view node, which is what happens when an actor is removed or a render window is closed, left them allocated. Fifty addActor/removeActor cycles orphan 150 WebGL objects on a live context, and getGraphicsMemoryInfo does not account for them. The leak is not recoverable for a render window that shares a context with a parent: releaseGraphicsResources is proxied to the root render window, so calling it from a child frees resources its siblings still draw with. Add releaseGraphicsResources to those mappers and chain it into delete, so a view frees its own buffers and nothing else. The buffers are rebuilt on the next render. The release logic is shared between the two poly data mappers, which own the same buffer-holding model shape. The tests drive the public API and count live WebGL objects by wrapping the create/delete pairs on the rendering context prototypes, added to testUtils as trackWebGLObjects alongside createRenderContainer. The tracked-view setup and the same-image-after-release assertion they share live in renderTestUtils. --- .../Rendering/OpenGL/Glyph3DMapper/index.js | 22 +++ .../test/testReleaseGraphicsResources.js | 49 +++++++ Sources/Rendering/OpenGL/Helper/index.js | 13 ++ .../Rendering/OpenGL/PolyDataMapper/api.md | 7 +- .../Rendering/OpenGL/PolyDataMapper/index.js | 12 +- .../test/testReleaseGraphicsResources.js | 135 ++++++++++++++++++ .../Rendering/OpenGL/PolyDataMapper2D/api.md | 7 +- .../OpenGL/PolyDataMapper2D/index.js | 12 +- .../test/testReleaseGraphicsResources.js | 46 ++++++ Sources/Testing/renderTestUtils.js | 48 +++++++ Sources/Testing/testUtils.js | 70 +++++++++ 11 files changed, 417 insertions(+), 4 deletions(-) create mode 100644 Sources/Rendering/OpenGL/Glyph3DMapper/test/testReleaseGraphicsResources.js create mode 100644 Sources/Rendering/OpenGL/PolyDataMapper/test/testReleaseGraphicsResources.js create mode 100644 Sources/Rendering/OpenGL/PolyDataMapper2D/test/testReleaseGraphicsResources.js create mode 100644 Sources/Testing/renderTestUtils.js diff --git a/Sources/Rendering/OpenGL/Glyph3DMapper/index.js b/Sources/Rendering/OpenGL/Glyph3DMapper/index.js index 49456d56706..75ac2e591f5 100644 --- a/Sources/Rendering/OpenGL/Glyph3DMapper/index.js +++ b/Sources/Rendering/OpenGL/Glyph3DMapper/index.js @@ -546,6 +546,28 @@ function vtkOpenGLGlyph3DMapper(publicAPI, model) { ); } }; + + const glyphBuffers = () => + [ + model.matrixBuffer, + model.normalBuffer, + model.colorBuffer, + model.pickBuffer, + ].filter(Boolean); + + publicAPI.getAllocatedGPUMemoryInBytes = () => + superClass.getAllocatedGPUMemoryInBytes() + + glyphBuffers().reduce( + (memUsed, buffer) => memUsed + buffer.getAllocatedGPUMemoryInBytes(), + 0 + ); + + publicAPI.releaseGraphicsResources = (renderWindow) => { + glyphBuffers().forEach((buffer) => buffer.releaseGraphicsResources()); + // glyph buffers re-upload only when the renderable is newer than this + model.glyphBOBuildTime.set({ mtime: 0 }); + superClass.releaseGraphicsResources(renderWindow); + }; } // ---------------------------------------------------------------------------- diff --git a/Sources/Rendering/OpenGL/Glyph3DMapper/test/testReleaseGraphicsResources.js b/Sources/Rendering/OpenGL/Glyph3DMapper/test/testReleaseGraphicsResources.js new file mode 100644 index 00000000000..771cfedc7a7 --- /dev/null +++ b/Sources/Rendering/OpenGL/Glyph3DMapper/test/testReleaseGraphicsResources.js @@ -0,0 +1,49 @@ +import { it, expect } from 'vitest'; +import testUtils from 'vtk.js/Sources/Testing/testUtils'; +import { + createTrackedRenderView, + expectSameImageAfterRelease, +} from 'vtk.js/Sources/Testing/renderTestUtils'; + +import vtkActor from 'vtk.js/Sources/Rendering/Core/Actor'; +import vtkConeSource from 'vtk.js/Sources/Filters/Sources/ConeSource'; +import vtkGlyph3DMapper from 'vtk.js/Sources/Rendering/Core/Glyph3DMapper'; +import vtkPlaneSource from 'vtk.js/Sources/Filters/Sources/PlaneSource'; + +function createGlyphActor(gc) { + const planeSource = gc.registerResource(vtkPlaneSource.newInstance()); + const coneSource = gc.registerResource(vtkConeSource.newInstance()); + const mapper = gc.registerResource(vtkGlyph3DMapper.newInstance()); + mapper.setInputConnection(planeSource.getOutputPort(), 0); + mapper.setInputConnection(coneSource.getOutputPort(), 1); + const actor = gc.registerResource(vtkActor.newInstance()); + actor.setMapper(mapper); + return actor; +} + +// Glyphs add per-instance matrix, normal, color and pick buffers. +it.skipIf(__VTK_TEST_NO_WEBGL__)( + 'frees the GPU objects of a glyph actor removed from a view', + () => { + const gc = testUtils.createGarbageCollector(); + const { tracker, renderer, renderWindow, emptySceneObjects } = + createTrackedRenderView(gc); + + const actor = createGlyphActor(gc); + renderer.addActor(actor); + renderer.resetCamera(); + renderWindow.render(); + expect(tracker.count()).toBeGreaterThan(emptySceneObjects); + + renderer.removeActor(actor); + renderWindow.render(); + expect(tracker.count()).toBe(emptySceneObjects); + + gc.releaseResources(); + } +); + +it.skipIf(__VTK_TEST_NO_WEBGL__)( + 'rebuilds the same image after releaseGraphicsResources', + () => expectSameImageAfterRelease(createGlyphActor) +); diff --git a/Sources/Rendering/OpenGL/Helper/index.js b/Sources/Rendering/OpenGL/Helper/index.js index 46f48cf57a4..f025fdddb58 100644 --- a/Sources/Rendering/OpenGL/Helper/index.js +++ b/Sources/Rendering/OpenGL/Helper/index.js @@ -340,4 +340,17 @@ export const newInstance = macro.newInstance(extend); // ---------------------------------------------------------------------------- +// Shared by the poly data mappers, whose primitives are vtkHelper instances. +export function releasePolyDataMapperResources(publicAPI, model, renderWindow) { + model.primitives.forEach((prim) => prim.releaseGraphicsResources()); + model.internalColorTexture?.releaseGraphicsResources( + renderWindow ?? model._openGLRenderWindow + ); + // Force the buffers to be rebuilt on the next render + model.VBOBuildString = null; + publicAPI.modified(); +} + +// ---------------------------------------------------------------------------- + export default { newInstance, extend, primTypes }; diff --git a/Sources/Rendering/OpenGL/PolyDataMapper/api.md b/Sources/Rendering/OpenGL/PolyDataMapper/api.md index c7684583756..e93b1d85268 100644 --- a/Sources/Rendering/OpenGL/PolyDataMapper/api.md +++ b/Sources/Rendering/OpenGL/PolyDataMapper/api.md @@ -10,6 +10,12 @@ preparefor rendering do the rendering +### releaseGraphicsResources(openGLRenderWindow) + +Release the GPU resources owned by this OpenGL mapper view node. If the view +node remains active, the resources are recreated as needed on a subsequent +render. + ## Shader customization In order to provide specific properties for rendering, you will have to add @@ -144,4 +150,3 @@ mapperSpecificProp.ShaderCallbacks.push({ Defined 'userData' will be the first parameters which will be passed to the callback. These callbacks will be executed when updateShaders() of OpenGLPolyDataMapper is called. - diff --git a/Sources/Rendering/OpenGL/PolyDataMapper/index.js b/Sources/Rendering/OpenGL/PolyDataMapper/index.js index 2fa9850a396..2a28fe99a5d 100755 --- a/Sources/Rendering/OpenGL/PolyDataMapper/index.js +++ b/Sources/Rendering/OpenGL/PolyDataMapper/index.js @@ -1,7 +1,9 @@ import { mat3, mat4, vec3 } from 'gl-matrix'; import * as macro from 'vtk.js/Sources/macros'; -import vtkHelper from 'vtk.js/Sources/Rendering/OpenGL/Helper'; +import vtkHelper, { + releasePolyDataMapperResources, +} from 'vtk.js/Sources/Rendering/OpenGL/Helper'; import vtkMapper from 'vtk.js/Sources/Rendering/Core/Mapper'; import * as vtkMath from 'vtk.js/Sources/Common/Core/Math'; import vtkOpenGLTexture from 'vtk.js/Sources/Rendering/OpenGL/Texture'; @@ -2025,6 +2027,14 @@ function vtkOpenGLPolyDataMapper(publicAPI, model) { // Return in MB return memUsed; }; + + publicAPI.releaseGraphicsResources = (renderWindow) => + releasePolyDataMapperResources(publicAPI, model, renderWindow); + + publicAPI.delete = macro.chain( + () => publicAPI.releaseGraphicsResources(), + publicAPI.delete + ); } // ---------------------------------------------------------------------------- diff --git a/Sources/Rendering/OpenGL/PolyDataMapper/test/testReleaseGraphicsResources.js b/Sources/Rendering/OpenGL/PolyDataMapper/test/testReleaseGraphicsResources.js new file mode 100644 index 00000000000..2e2374693da --- /dev/null +++ b/Sources/Rendering/OpenGL/PolyDataMapper/test/testReleaseGraphicsResources.js @@ -0,0 +1,135 @@ +import { it, expect } from 'vitest'; +import testUtils from 'vtk.js/Sources/Testing/testUtils'; +import { + createTrackedRenderView, + expectSameImageAfterRelease, +} from 'vtk.js/Sources/Testing/renderTestUtils'; + +import vtkActor from 'vtk.js/Sources/Rendering/Core/Actor'; +import vtkColorTransferFunction from 'vtk.js/Sources/Rendering/Core/ColorTransferFunction'; +import vtkConeSource from 'vtk.js/Sources/Filters/Sources/ConeSource'; +import vtkDataArray from 'vtk.js/Sources/Common/Core/DataArray'; +import vtkMapper from 'vtk.js/Sources/Rendering/Core/Mapper'; +import vtkOpenGLRenderWindow from 'vtk.js/Sources/Rendering/OpenGL/RenderWindow'; +import vtkRenderer from 'vtk.js/Sources/Rendering/Core/Renderer'; +import vtkRenderWindow from 'vtk.js/Sources/Rendering/Core/RenderWindow'; + +function createConeActor(gc) { + const cone = gc.registerResource(vtkConeSource.newInstance()); + const mapper = gc.registerResource(vtkMapper.newInstance()); + mapper.setInputConnection(cone.getOutputPort()); + const actor = gc.registerResource(vtkActor.newInstance()); + actor.setMapper(mapper); + return actor; +} + +// A lookup table makes the mapper own a color texture. +function createScalarColoredConeActor(gc) { + const cone = gc.registerResource(vtkConeSource.newInstance()); + cone.update(); + const polyData = cone.getOutputData(); + const pointCount = polyData.getPoints().getNumberOfPoints(); + polyData.getPointData().setScalars( + vtkDataArray.newInstance({ + name: 'scalars', + values: Float32Array.from({ length: pointCount }, (_, i) => i), + }) + ); + + const lookupTable = gc.registerResource( + vtkColorTransferFunction.newInstance() + ); + lookupTable.addRGBPoint(0, 0, 0, 1); + lookupTable.addRGBPoint(pointCount, 1, 0, 0); + + const mapper = gc.registerResource(vtkMapper.newInstance()); + mapper.setInputData(polyData); + mapper.setLookupTable(lookupTable); + mapper.setUseLookupTableScalarRange(true); + mapper.setInterpolateScalarsBeforeMapping(true); + const actor = gc.registerResource(vtkActor.newInstance()); + actor.setMapper(mapper); + return actor; +} + +it.skipIf(__VTK_TEST_NO_WEBGL__)( + 'frees the GPU objects of actors removed from a view', + () => { + const gc = testUtils.createGarbageCollector(); + const { tracker, renderer, renderWindow, emptySceneObjects } = + createTrackedRenderView(gc); + + const actors = [ + createConeActor(gc), + createConeActor(gc), + createScalarColoredConeActor(gc), + ]; + actors.forEach((actor) => renderer.addActor(actor)); + renderer.resetCamera(); + renderWindow.render(); + expect(tracker.count()).toBeGreaterThan(emptySceneObjects); + + actors.forEach((actor) => renderer.removeActor(actor)); + renderWindow.render(); + expect(tracker.count()).toBe(emptySceneObjects); + + gc.releaseResources(); + } +); + +it.skipIf(__VTK_TEST_NO_WEBGL__)( + 'frees only the closed view GPU objects on a shared context', + async () => { + const gc = testUtils.createGarbageCollector(); + const tracker = testUtils.trackWebGLObjects(); + + const rootRenderWindow = gc.registerResource(vtkRenderWindow.newInstance()); + const rootView = gc.registerResource(vtkOpenGLRenderWindow.newInstance()); + rootRenderWindow.addView(rootView); + rootView.initialize(); + + // The child render windows, their view nodes and their renderers are not + // gc-registered: the closing one is deleted explicitly below. + const addChildView = () => { + const childRenderWindow = vtkRenderWindow.newInstance(); + rootRenderWindow.addRenderWindow(childRenderWindow); + const childView = rootView.addMissingNode(childRenderWindow); + childRenderWindow.addView(childView); + childView.setContainer(testUtils.createRenderContainer(gc)); + childView.setSize(200, 200); + + const renderer = vtkRenderer.newInstance(); + childRenderWindow.addRenderer(renderer); + renderer.addActor(createConeActor(gc)); + renderer.resetCamera(); + + return { childRenderWindow, childView }; + }; + + const closing = addChildView(); + const surviving = addChildView(); + rootRenderWindow.render(); + + const bothViewsObjects = tracker.count(); + const survivingBefore = surviving.childView.captureNextImage(); + rootRenderWindow.render(); + expect(tracker.count()).toBe(bothViewsObjects); + + rootRenderWindow.removeRenderWindow(closing.childRenderWindow); + closing.childRenderWindow.delete(); + + const survivingAfter = surviving.childView.captureNextImage(); + rootRenderWindow.render(); + + expect(tracker.count()).toBeLessThan(bothViewsObjects); + expect(await survivingAfter).toBe(await survivingBefore); + + gc.releaseResources(); + } +); + +// The scalar-colored actor makes the released color texture come back too. +it.skipIf(__VTK_TEST_NO_WEBGL__)( + 'rebuilds the same image after releaseGraphicsResources', + () => expectSameImageAfterRelease(createScalarColoredConeActor) +); diff --git a/Sources/Rendering/OpenGL/PolyDataMapper2D/api.md b/Sources/Rendering/OpenGL/PolyDataMapper2D/api.md index bfc9680a259..8e67dc527d7 100644 --- a/Sources/Rendering/OpenGL/PolyDataMapper2D/api.md +++ b/Sources/Rendering/OpenGL/PolyDataMapper2D/api.md @@ -10,6 +10,12 @@ Prepare for rendering. Do the rendering. +### releaseGraphicsResources(openGLRenderWindow) + +Release the GPU resources owned by this OpenGL mapper view node. If the view +node remains active, the resources are recreated as needed on a subsequent +render. + ## Shader customization In order to provide specific properties for rendering, you will have to add @@ -144,4 +150,3 @@ mapperSpecificProp.ShaderCallbacks.push({ Defined 'userData' will be the first parameters which will be passed to the callback. These callbacks will be executed when updateShaders() of OpenGLPolyDataMapper is called. - diff --git a/Sources/Rendering/OpenGL/PolyDataMapper2D/index.js b/Sources/Rendering/OpenGL/PolyDataMapper2D/index.js index d8e9315fac6..4ea0c6cc018 100644 --- a/Sources/Rendering/OpenGL/PolyDataMapper2D/index.js +++ b/Sources/Rendering/OpenGL/PolyDataMapper2D/index.js @@ -2,7 +2,9 @@ import { mat4 } from 'gl-matrix'; import * as macro from 'vtk.js/Sources/macros'; -import vtkHelper from 'vtk.js/Sources/Rendering/OpenGL/Helper'; +import vtkHelper, { + releasePolyDataMapperResources, +} from 'vtk.js/Sources/Rendering/OpenGL/Helper'; import vtkPoints from 'vtk.js/Sources/Common/Core/Points'; import vtkPolyData2DFS from 'vtk.js/Sources/Rendering/OpenGL/glsl/vtkPolyData2DFS.glsl'; import vtkPolyData2DVS from 'vtk.js/Sources/Rendering/OpenGL/glsl/vtkPolyData2DVS.glsl'; @@ -755,6 +757,14 @@ function vtkOpenGLPolyDataMapper2D(publicAPI, model) { // Return in MB return memUsed; }; + + publicAPI.releaseGraphicsResources = (renderWindow) => + releasePolyDataMapperResources(publicAPI, model, renderWindow); + + publicAPI.delete = macro.chain( + () => publicAPI.releaseGraphicsResources(), + publicAPI.delete + ); } // ---------------------------------------------------------------------------- diff --git a/Sources/Rendering/OpenGL/PolyDataMapper2D/test/testReleaseGraphicsResources.js b/Sources/Rendering/OpenGL/PolyDataMapper2D/test/testReleaseGraphicsResources.js new file mode 100644 index 00000000000..70a269c8855 --- /dev/null +++ b/Sources/Rendering/OpenGL/PolyDataMapper2D/test/testReleaseGraphicsResources.js @@ -0,0 +1,46 @@ +import { it, expect } from 'vitest'; +import testUtils from 'vtk.js/Sources/Testing/testUtils'; +import { createTrackedRenderView } from 'vtk.js/Sources/Testing/renderTestUtils'; + +import vtkActor2D from 'vtk.js/Sources/Rendering/Core/Actor2D'; +import vtkCoordinate from 'vtk.js/Sources/Rendering/Core/Coordinate'; +import vtkLineSource from 'vtk.js/Sources/Filters/Sources/LineSource'; +import vtkMapper2D from 'vtk.js/Sources/Rendering/Core/Mapper2D'; + +function createLineActor2D(gc) { + const line = gc.registerResource( + vtkLineSource.newInstance({ point1: [2, 2, 0], point2: [14, 14, 0] }) + ); + const coordinate = gc.registerResource(vtkCoordinate.newInstance()); + coordinate.setCoordinateSystemToWorld(); + + const mapper = gc.registerResource(vtkMapper2D.newInstance()); + mapper.setInputConnection(line.getOutputPort()); + mapper.setTransformCoordinate(coordinate); + mapper.setScalarVisibility(false); + + const actor = gc.registerResource(vtkActor2D.newInstance()); + actor.setMapper(mapper); + return actor; +} + +it.skipIf(__VTK_TEST_NO_WEBGL__)( + 'frees the GPU objects of a 2D actor removed from a view', + () => { + const gc = testUtils.createGarbageCollector(); + const { tracker, renderer, renderWindow, emptySceneObjects } = + createTrackedRenderView(gc); + + const actor = createLineActor2D(gc); + renderer.addActor2D(actor); + renderer.resetCamera(); + renderWindow.render(); + expect(tracker.count()).toBeGreaterThan(emptySceneObjects); + + renderer.removeActor2D(actor); + renderWindow.render(); + expect(tracker.count()).toBe(emptySceneObjects); + + gc.releaseResources(); + } +); diff --git a/Sources/Testing/renderTestUtils.js b/Sources/Testing/renderTestUtils.js new file mode 100644 index 00000000000..5f20018e17c --- /dev/null +++ b/Sources/Testing/renderTestUtils.js @@ -0,0 +1,48 @@ +import { expect } from 'vitest'; +import vtkGenericRenderWindow from 'vtk.js/Sources/Rendering/Misc/GenericRenderWindow'; +import testUtils from 'vtk.js/Sources/Testing/testUtils'; + +// A tracked view that has rendered an empty scene: any count beyond +// emptySceneObjects belongs to the code under test. +export function createTrackedRenderView(gc, initialValues = {}) { + const tracker = testUtils.trackWebGLObjects(); + const genericRenderWindow = gc.registerResource( + vtkGenericRenderWindow.newInstance({ + listenWindowResize: false, + ...initialValues, + }) + ); + genericRenderWindow.setContainer(testUtils.createRenderContainer(gc)); + genericRenderWindow.resize(); + const renderer = genericRenderWindow.getRenderer(); + const renderWindow = genericRenderWindow.getRenderWindow(); + const view = genericRenderWindow.getApiSpecificRenderWindow(); + renderWindow.render(); + const emptySceneObjects = tracker.count(); + return { tracker, renderer, renderWindow, view, emptySceneObjects }; +} + +// Releasing a mapper's resources must leave no GPU object behind and must not +// change what the next render draws. +export async function expectSameImageAfterRelease(createActor) { + const gc = testUtils.createGarbageCollector(); + const { tracker, renderer, renderWindow, view, emptySceneObjects } = + createTrackedRenderView(gc); + + const actor = createActor(gc); + renderer.addActor(actor); + renderer.resetCamera(); + const beforeRelease = view.captureNextImage(); + renderWindow.render(); + expect(tracker.count()).toBeGreaterThan(emptySceneObjects); + + view.getViewNodeFor(actor.getMapper()).releaseGraphicsResources(view); + expect(tracker.count()).toBe(emptySceneObjects); + + const afterRelease = view.captureNextImage(); + renderWindow.render(); + expect(tracker.count()).toBeGreaterThan(emptySceneObjects); + expect(await afterRelease).toBe(await beforeRelease); + + gc.releaseResources(); +} diff --git a/Sources/Testing/testUtils.js b/Sources/Testing/testUtils.js index 056a3c2488d..38df511a01a 100644 --- a/Sources/Testing/testUtils.js +++ b/Sources/Testing/testUtils.js @@ -197,6 +197,74 @@ function alignTo256(value) { return Math.ceil(value / 256) * 256; } +// Counts live WebGL objects by wrapping the create/delete pairs on the +// rendering context prototypes. +function trackWebGLObjects() { + const pairs = [ + ['createBuffer', 'deleteBuffer'], + ['createFramebuffer', 'deleteFramebuffer'], + ['createRenderbuffer', 'deleteRenderbuffer'], + ['createTexture', 'deleteTexture'], + ['createVertexArray', 'deleteVertexArray'], + ]; + const prototypes = [ + globalThis.WebGL2RenderingContext?.prototype, + globalThis.WebGLRenderingContext?.prototype, + ].filter(Boolean); + + const live = new Set(); + + function trackObjectType(prototype, createName, deleteName) { + const create = prototype[createName]; + const remove = prototype[deleteName]; + function trackedCreate(...args) { + const glObject = create.apply(this, args); + live.add(glObject); + return glObject; + } + function trackedDelete(glObject, ...args) { + live.delete(glObject); + return remove.call(this, glObject, ...args); + } + prototype[createName] = trackedCreate; + prototype[deleteName] = trackedDelete; + return () => { + // Unwrap only our own wrappers, so overlapping trackers cannot + // reinstall a dead one on the prototype + if (prototype[createName] === trackedCreate) { + prototype[createName] = create; + } + if (prototype[deleteName] === trackedDelete) { + prototype[deleteName] = remove; + } + }; + } + + const restoreCallbacks = prototypes.flatMap((prototype) => + pairs + // WebGL 1 has no vertex array objects + .filter(([create, remove]) => prototype[create] && prototype[remove]) + .map(([create, remove]) => trackObjectType(prototype, create, remove)) + ); + + function stop() { + restoreCallbacks.forEach((restore) => restore()); + // Stop pinning the objects this tracker exists to detect + live.clear(); + } + onTestFinished(stop); + + return { count: () => live.size, stop }; +} + +function createRenderContainer(gc, size = 200) { + const container = gc.registerDOMElement(document.createElement('div')); + container.style.width = `${size}px`; + container.style.height = `${size}px`; + document.querySelector('body').appendChild(container); + return container; +} + async function createWebGPUTestDevice() { const adapter = await navigator.gpu.requestAdapter({ powerPreference: 'high-performance', @@ -270,10 +338,12 @@ export default { compareImages, createGarbageCollector, createImage, + createRenderContainer, createWebGPUTestDevice, getImageDataFromURI, readWebGPUTexture2D, keepDOM, objEquals, removeDOM, + trackWebGLObjects, }; From 42b5d9983a9a98c2b816763f608ceb1204928877 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Sun, 30 Aug 2026 12:58:50 -0400 Subject: [PATCH 2/2] test: release garbage collector resources on test teardown A failing assertion skips the trailing gc.releaseResources() call in a test body, leaving the registered vtk objects and DOM containers alive for the rest of the file. Register releaseResources with onTestFinished so teardown runs it either way, the same mechanism createWebGPUTestDevice and trackWebGLObjects use. Releasing twice is a no-op: releaseResources drains both registries before returning, so the existing explicit calls are unaffected. --- Sources/Testing/testUtils.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/Testing/testUtils.js b/Sources/Testing/testUtils.js index 38df511a01a..f1b60c8ad1c 100644 --- a/Sources/Testing/testUtils.js +++ b/Sources/Testing/testUtils.js @@ -134,6 +134,10 @@ function createGarbageCollector() { } } + // A failing assertion skips the releaseResources() call at the end of a test + // body, so run it on test teardown too. Releasing twice is a no-op. + onTestFinished(releaseResources); + return { registerResource, registerDOMElement,