diff --git a/web/src/lib/pty-reconnect.test.ts b/web/src/lib/pty-reconnect.test.ts index 56202eb4b3..995076d41b 100644 --- a/web/src/lib/pty-reconnect.test.ts +++ b/web/src/lib/pty-reconnect.test.ts @@ -2,12 +2,10 @@ import { describe, expect, it } from 'vitest' import { PTY_RECONNECT_BASE_MS, - PTY_KEEPALIVE_INTERVAL_MS, PTY_RECONNECT_MAX_ATTEMPTS, PTY_RECONNECT_MAX_MS, ptyReconnectDelayMs, shouldBlockPtyInput, - shouldSendPtyKeepalive, shouldReconnectPtyOnPageResume } from './pty-reconnect' @@ -141,36 +139,6 @@ describe('shouldBlockPtyInput', () => { }) }) -describe('shouldSendPtyKeepalive', () => { - it('keeps an active, visible PTY socket alive every 20 seconds', () => { - expect(PTY_KEEPALIVE_INTERVAL_MS).toBe(20_000) - expect( - shouldSendPtyKeepalive({ - isActive: true, - visibilityState: 'visible', - socketReadyState: 1 - }) - ).toBe(true) - }) - - it('does not send control traffic from hidden tabs or closed sockets', () => { - expect( - shouldSendPtyKeepalive({ - isActive: true, - visibilityState: 'hidden', - socketReadyState: 1 - }) - ).toBe(false) - expect( - shouldSendPtyKeepalive({ - isActive: true, - visibilityState: 'visible', - socketReadyState: 3 - }) - ).toBe(false) - }) -}) - describe('ptyReconnectDelayMs', () => { it('doubles from the base on each attempt and clamps at the cap', () => { expect(Array.from({ length: PTY_RECONNECT_MAX_ATTEMPTS }, (_, i) => ptyReconnectDelayMs(i + 1))).toEqual([ diff --git a/web/src/lib/pty-reconnect.ts b/web/src/lib/pty-reconnect.ts index da592461ba..61f205d0f4 100644 --- a/web/src/lib/pty-reconnect.ts +++ b/web/src/lib/pty-reconnect.ts @@ -69,16 +69,6 @@ const WS_OPEN = 1 const WS_CLOSING = 2 const WS_CLOSED = 3 -export interface PtyKeepaliveInput { - isActive: boolean - visibilityState?: DocumentVisibilityState - socketReadyState?: number | null -} - -export function shouldSendPtyKeepalive({ isActive, visibilityState, socketReadyState }: PtyKeepaliveInput): boolean { - return isActive && visibilityState !== 'hidden' && socketReadyState === WS_OPEN -} - export function shouldReconnectPtyOnPageResume({ isActive, visibilityState, diff --git a/web/src/lib/xterm-webgl-release.test.ts b/web/src/lib/xterm-webgl-release.test.ts new file mode 100644 index 0000000000..9d0ddc3379 --- /dev/null +++ b/web/src/lib/xterm-webgl-release.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from 'vitest' + +import { loseWebglContexts } from './xterm-webgl-release' + +function canvasWith(gl: unknown) { + return { + getContext: vi.fn((type: string) => (type === 'webgl2' ? gl : null)) + } +} + +describe('loseWebglContexts', () => { + it('loses every WebGL context under the host and leaves 2D canvases alone', () => { + // Dashboard reconnect churn (#111909): xterm disposes its WebGL canvas + // without losing the context, so the browser's context budget fills up. + const loseContext = vi.fn() + const gl = { getExtension: vi.fn((name: string) => (name === 'WEBGL_lose_context' ? { loseContext } : null)) } + const twoD = canvasWith(null) + const host = { querySelectorAll: () => [canvasWith(gl), twoD, canvasWith(gl)] } as unknown as ParentNode + + expect(loseWebglContexts(host)).toBe(2) + expect(loseContext).toHaveBeenCalledTimes(2) + // A canvas that holds no WebGL context is never handed one. + expect(twoD.getContext).toHaveBeenCalledWith('webgl2') + expect(twoD.getContext).toHaveBeenCalledWith('webgl') + }) +}) diff --git a/web/src/lib/xterm-webgl-release.ts b/web/src/lib/xterm-webgl-release.ts new file mode 100644 index 0000000000..6a357f427f --- /dev/null +++ b/web/src/lib/xterm-webgl-release.ts @@ -0,0 +1,27 @@ +/** + * Release the WebGL contexts an xterm WebglAddon left behind. + * + * `@xterm/addon-webgl` (0.19) removes its canvas on dispose but never calls + * `WEBGL_lose_context.loseContext()`, so the GL context stays alive until the + * browser garbage-collects the canvas. Browsers cap live contexts (~16) and + * force-lose the oldest when the cap is hit; ChatPage rebuilds the terminal on + * every PTY reconnect, so a reconnect storm walks straight into that cap and + * eventually loses the context of the terminal the user is looking at. + * + * Call it on the terminal host BEFORE `term.dispose()` removes the canvases. + * Returns the number of contexts released. + */ +export function loseWebglContexts(host: ParentNode): number { + let released = 0 + for (const canvas of Array.from(host.querySelectorAll('canvas'))) { + // getContext() with the type a canvas already holds returns that context; + // for a 2D canvas both WebGL lookups return null without creating one. + const gl = canvas.getContext('webgl2') ?? canvas.getContext('webgl') + const lose = gl?.getExtension('WEBGL_lose_context') + if (lose) { + lose.loseContext() + released += 1 + } + } + return released +} diff --git a/web/src/pages/ChatPage.test.tsx b/web/src/pages/ChatPage.test.tsx index 6fc28d2166..9c080dae69 100644 --- a/web/src/pages/ChatPage.test.tsx +++ b/web/src/pages/ChatPage.test.tsx @@ -15,10 +15,6 @@ class FakeFitAddon { } class FakeWebglAddon { - constructor() { - webglAddonConstructed += 1; - } - onContextLoss() { return { dispose() {} }; } @@ -165,7 +161,6 @@ type CloseEventLike = { let container: HTMLDivElement; let root: Root; -let webglAddonConstructed = 0; // jsdom runs without an origin here (per-file @vitest-environment jsdom on a // node-default config), so localStorage is undefined. Stub it so components @@ -200,7 +195,6 @@ async function render(ui: ReactNode) { beforeEach(() => { FakeWebSocket.instances = []; - webglAddonConstructed = 0; maybeReloadForLoopbackWsAuthFailure.mockClear(); apiMocks.buildWsUrl.mockReset(); apiMocks.buildWsUrl.mockResolvedValue("ws://localhost/api/pty?channel=chat-1"); @@ -265,7 +259,7 @@ afterEach(async () => { }); describe("ChatPage", () => { - it("uses the canvas renderer and sends a visible PTY keepalive", async () => { + it("sends a PTY keepalive frame every 20 seconds while the socket is open", async () => { vi.useFakeTimers(); try { const { default: ChatPage } = await import("./ChatPage"); @@ -278,7 +272,6 @@ describe("ChatPage", () => { await Promise.resolve(); }); expect(FakeWebSocket.instances).toHaveLength(1); - expect(webglAddonConstructed).toBe(0); const socket = FakeWebSocket.instances[0]; await act(async () => socket.onopen?.()); diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index ca74a9eac9..039621169a 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -4,7 +4,7 @@ *