Skip to content

WebglAddon.dispose() does not release the underlying WebGL2 context #6068

Description

@openwong2kim

Details

  • Browser and browser version: Chromium (reproduced on Electron 30+/Chromium 124+ and stock Chrome); driver-dependent
  • OS version: Windows 11 (also seen on macOS)
  • xterm.js version: 5.5 / current master, @xterm/addon-webgl

Steps to reproduce

  1. Create a Terminal, open() it, and load a WebglAddon.

  2. Dispose the addon (or the terminal), then repeat: create + dispose many WebGL-backed terminals over the app's lifetime (tab/split churn, session restore, etc.).

  3. Once the number of contexts created this way passes Chromium's per-document live-context cap (~16, driver/build dependent), the console logs:

    WARNING: Too many active WebGL contexts. Oldest context will be lost.
    

    and a still-live terminal loses its renderer (webglcontextlost) and renders blank / as an "X box" / garbled.

A self-contained repro is attached (repro.html): it keeps one live terminal and then creates and disposes 30 more WebGL terminals.

Root cause

WebglRenderer's dispose (registered in the constructor) tears down the render layers and removes the <canvas> from the DOM, but never releases the WebGL2 context:

https://github.com/xtermjs/xterm.js/blob/master/addons/addon-webgl/src/WebglRenderer.ts#L160-L168

this._register(toDisposable(() => {
  clearTimeout(this._contextRestorationTimeout);
  this._contextRestorationTimeout = undefined;
  for (const l of this._renderLayers) {
    l.dispose();
  }
  this._canvas.parentElement?.removeChild(this._canvas);
  removeTerminalFromCache(this._terminal);
}));

Detaching the canvas does not free the GPU-side context — it lingers until the canvas is garbage collected, which is non-deterministic and can be arbitrarily delayed. Every disposed addon leaves a "zombie" context that still counts toward Chromium's cap. When the cap is exceeded the browser force-evicts the oldest context, which may belong to a terminal that is still on screen, so a live terminal goes blank rather than the disposed one being cleaned up.

Impact

Any embedder that creates and disposes many terminals in one document hits this — VS Code-style multi-terminal UIs, terminal multiplexers, IDEs with per-tab terminals. The failure is confusing because the terminal that breaks is not the one that was disposed, and it only appears after enough churn to cross the (driver-dependent) cap.

We currently work around it downstream by reaching into private addon internals (addon._renderer._gl) after dispose() and calling WEBGL_lose_context.loseContext() ourselves — which is exactly the kind of thing that should live in the addon.

Suggested fix

Call this._gl.getExtension('WEBGL_lose_context')?.loseContext() as part of the renderer's dispose so the context is released immediately and the live count stays bounded. PR to follow.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions