Skip to content

Commit 9d40712

Browse files
committed
test(OpenGLRenderWindow): regression for WebGL context leak
Mounts and unmounts the component 17 times - one past Chrome's per-process WebGL context limit of 16 - and asserts that the cleanup callback (a) calls WEBGL_lose_context.loseContext() once per cycle and (b) does so strictly before view.delete(), so the GPU slot is returned to the browser before vtk.js tears down the extension handle. Fails on this commit; the fix lands next.
1 parent c4640fa commit 9d40712

1 file changed

Lines changed: 130 additions & 0 deletions

File tree

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/**
2+
* Regression test: OpenGLRenderWindow must call WEBGL_lose_context.loseContext()
3+
* during cleanup so that GPU slots are freed before Chrome's per-process
4+
* WebGL context limit (~16) is reached.
5+
*
6+
* The test mounts and fully unmounts the component 17 times and asserts that
7+
* loseContext() was called exactly once per lifecycle, proving that the GPU
8+
* slot is returned to the browser on every destruction.
9+
*/
10+
11+
import { cleanup, render } from '@testing-library/react';
12+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
13+
14+
// Import the component before vi.mock() in source order.
15+
// Vitest hoists vi.mock() calls above all imports at transform time,
16+
// so the mock is always in place when the module is evaluated.
17+
import OpenGLRenderWindow from '../core/OpenGLRenderWindow';
18+
19+
// ---------------------------------------------------------------------------
20+
// Hoisted mock state
21+
// vi.mock() is hoisted above all imports by Vitest, so any values the factory
22+
// closes over must be initialised with vi.hoisted() to avoid a TDZ error.
23+
// ---------------------------------------------------------------------------
24+
25+
const { mockLoseContext, mockGetContext, mockNewInstance } = vi.hoisted(() => {
26+
const mockLoseContext = vi.fn();
27+
28+
const mockGetExtension = vi.fn((name: string) =>
29+
name === 'WEBGL_lose_context' ? { loseContext: mockLoseContext } : null
30+
);
31+
32+
const mockGetContext = vi.fn(() => ({
33+
getExtension: mockGetExtension,
34+
}));
35+
36+
let instanceId = 0;
37+
const mockNewInstance = vi.fn(() => ({
38+
__id: ++instanceId,
39+
setContainer: vi.fn(),
40+
getCanvas: vi.fn(() => ({ getContext: mockGetContext })),
41+
delete: vi.fn(),
42+
}));
43+
44+
return { mockLoseContext, mockGetContext, mockNewInstance };
45+
});
46+
47+
// ---------------------------------------------------------------------------
48+
// Module mock — must be declared at the top level for Vitest hoisting.
49+
// ---------------------------------------------------------------------------
50+
51+
vi.mock('@kitware/vtk.js/Rendering/OpenGL/RenderWindow', () => ({
52+
default: { newInstance: mockNewInstance },
53+
}));
54+
55+
// ---------------------------------------------------------------------------
56+
// Helper
57+
// ---------------------------------------------------------------------------
58+
59+
/**
60+
* Render one <OpenGLRenderWindow>, then immediately unmount it.
61+
* @testing-library wraps both calls in act(), so all useEffect cleanups
62+
* (including the DeletionRegistry callback) are flushed synchronously before
63+
* this function returns.
64+
*/
65+
function mountAndUnmount() {
66+
const { unmount } = render(<OpenGLRenderWindow />);
67+
unmount();
68+
}
69+
70+
// ---------------------------------------------------------------------------
71+
// Tests
72+
// ---------------------------------------------------------------------------
73+
74+
describe('OpenGLRenderWindow – WebGL context lifecycle', () => {
75+
beforeEach(() => {
76+
vi.clearAllMocks();
77+
});
78+
79+
afterEach(() => {
80+
cleanup();
81+
});
82+
83+
it('calls loseContext() once per mount-unmount cycle across 17 cycles', () => {
84+
// 17 is one past Chrome's hard per-process WebGL context limit of 16.
85+
// Before the fix, the 17th context was never released, blanking the viewport.
86+
const CYCLES = 17;
87+
88+
for (let i = 0; i < CYCLES; i++) {
89+
mountAndUnmount();
90+
}
91+
92+
expect(mockLoseContext).toHaveBeenCalledTimes(CYCLES);
93+
});
94+
95+
it('calls loseContext() strictly before view.delete()', () => {
96+
// Verify ordering: the GPU slot must be relinquished before vtk.js tears
97+
// down its internal state, otherwise the extension handle is invalid.
98+
const callOrder: string[] = [];
99+
100+
mockLoseContext.mockImplementationOnce(() => callOrder.push('loseContext'));
101+
102+
// Intercept delete() on whichever instance newInstance() creates next.
103+
const nextInstance = {
104+
__id: 999,
105+
setContainer: vi.fn(),
106+
getCanvas: vi.fn(() => ({ getContext: mockGetContext })),
107+
delete: vi.fn(() => callOrder.push('delete')),
108+
};
109+
mockNewInstance.mockReturnValueOnce(nextInstance);
110+
111+
mountAndUnmount();
112+
113+
expect(callOrder).toEqual(['loseContext', 'delete']);
114+
});
115+
116+
it('does not throw when getCanvas() is unavailable', () => {
117+
// Guard against vtk.js builds where getCanvas() does not exist, e.g.
118+
// server-side rendering stubs. Cleanup must still succeed.
119+
const instanceWithoutCanvas = {
120+
__id: 1000,
121+
setContainer: vi.fn(),
122+
getCanvas: undefined, // no canvas accessor
123+
delete: vi.fn(),
124+
};
125+
mockNewInstance.mockReturnValueOnce(instanceWithoutCanvas as never);
126+
127+
expect(() => mountAndUnmount()).not.toThrow();
128+
expect(instanceWithoutCanvas.delete).toHaveBeenCalledOnce();
129+
});
130+
});

0 commit comments

Comments
 (0)