From 4bb732d9b91aac86e4b6f0ef9aa7925625797f8d Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Wed, 2 Sep 2026 22:06:11 +0100 Subject: [PATCH] perf(maestro): render terminals via WebGL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit xterm's DOM renderer builds a node per cell, which is CPU-bound — the wrong bottleneck on a phone, where an agent dumping output stutters visibly on a Pixel 7 despite plenty of GPU headroom. A WebGL context is scarce and revocable, so it follows the mounted view rather than the connection: acquired in onMount, released in onUnmount. ConnectionPool keeps MAX_PARKED connections alive behind the visible ones, which would otherwise exceed the browser's context cap (~16 on desktop Chrome, fewer on Android) and bring a parked terminal back blank. Context loss falls back to the DOM renderer and retries against a shared two-loss budget; a device that keeps dropping contexts stops being asked. A device pixel ratio change rebuilds the glyph atlas, which is otherwise left blurry. Co-Authored-By: Claude Opus 5 (1M context) --- web/package.json | 1 + web/pnpm-lock.yaml | 8 ++++ web/src/attach.ts | 77 +++++++++++++++++++++++++++++++++++ web/src/webglRenderer.test.ts | 55 +++++++++++++++++++++++++ web/src/webglRenderer.ts | 73 +++++++++++++++++++++++++++++++++ 5 files changed, 214 insertions(+) create mode 100644 web/src/webglRenderer.test.ts create mode 100644 web/src/webglRenderer.ts diff --git a/web/package.json b/web/package.json index 3d751e6..ef2765d 100644 --- a/web/package.json +++ b/web/package.json @@ -15,6 +15,7 @@ "dependencies": { "@fontsource/jetbrains-mono": "^5.3.0", "@xterm/addon-fit": "^0.10.0", + "@xterm/addon-webgl": "^0.19.0", "@xterm/xterm": "^5.5.0" }, "devDependencies": { diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 46f993a..5219b6d 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@xterm/addon-fit': specifier: ^0.10.0 version: 0.10.0(@xterm/xterm@5.5.0) + '@xterm/addon-webgl': + specifier: ^0.19.0 + version: 0.19.0 '@xterm/xterm': specifier: ^5.5.0 version: 5.5.0 @@ -348,6 +351,9 @@ packages: peerDependencies: '@xterm/xterm': ^5.0.0 + '@xterm/addon-webgl@0.19.0': + resolution: {integrity: sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==} + '@xterm/xterm@5.5.0': resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==} @@ -772,6 +778,8 @@ snapshots: dependencies: '@xterm/xterm': 5.5.0 + '@xterm/addon-webgl@0.19.0': {} + '@xterm/xterm@5.5.0': {} assertion-error@2.0.1: {} diff --git a/web/src/attach.ts b/web/src/attach.ts index 6ce6de1..d6618ff 100644 --- a/web/src/attach.ts +++ b/web/src/attach.ts @@ -1,4 +1,5 @@ import { FitAddon } from "@xterm/addon-fit"; +import { WebglAddon } from "@xterm/addon-webgl"; import { Terminal } from "@xterm/xterm"; import "@xterm/xterm/css/xterm.css"; @@ -10,6 +11,11 @@ import { applyModsToChar, cursorSeq, tildeSeq } from "./keys.js"; import { requestToggleDrawer } from "./drawer.js"; import { splitCommand, toolIconSvg } from "./toolIcon.js"; import type { ClientControl, Uuid } from "./types.js"; +import { WebglPolicy } from "./webglRenderer.js"; + +/** One budget for the whole page — see webglRenderer.ts for why contexts are + * rationed rather than handed to every connection. */ +const webglPolicy = new WebglPolicy(); /** * A single live attach: an xterm terminal bound to the daemon's attach WebSocket. @@ -135,6 +141,72 @@ export function createAttachConnection( term.loadAddon(fit); term.open(body); + // GPU rendering is scoped to the mounted view, not the connection: acquired + // in onMount, released in onUnmount. See webglRenderer.ts. + let webgl: WebglAddon | null = null; + let dprMedia: MediaQueryList | null = null; + + // The glyph atlas is rasterised for one device pixel ratio. Dragging the + // window to a display with a different one (or an Android browser-zoom + // change) leaves every cell blurry until the atlas is rebuilt. + const onDprChange = () => { + try { + term.clearTextureAtlas(); + } catch { + // ignore — nothing to rebuild if the renderer already went away + } + watchDpr(); + }; + + function watchDpr(): void { + dprMedia?.removeEventListener("change", onDprChange); + dprMedia = null; + try { + dprMedia = window.matchMedia?.(`(resolution: ${window.devicePixelRatio}dppx)`) ?? null; + dprMedia?.addEventListener("change", onDprChange); + } catch { + // ignore — a browser without `resolution` media queries just keeps the + // atlas it has, which is the pre-WebGL behaviour anyway + } + } + + function releaseWebgl(): void { + dprMedia?.removeEventListener("change", onDprChange); + dprMedia = null; + if (!webgl) return; + const addon = webgl; + webgl = null; + try { + addon.dispose(); // xterm falls back to the DOM renderer + } catch { + // ignore — a lost context can throw on the way out + } + } + + function acquireWebgl(): void { + if (webgl || disposed || !webglPolicy.enabled()) return; + try { + const addon = new WebglAddon(); + // Context loss is routine on Android — backgrounding the tab is enough. + // Drop back to the DOM renderer, then retry only while the shared budget + // allows and this view is still the one on screen. + addon.onContextLoss(() => { + webglPolicy.noteContextLoss(); + releaseWebgl(); + if (mounted) acquireWebgl(); + }); + term.loadAddon(addon); + webgl = addon; + watchDpr(); + } catch { + // Context creation failed outright (no GPU, or the browser is already at + // its context cap). Count it like a loss so we stop asking, and stay on + // the DOM renderer. + webglPolicy.noteContextLoss(); + webgl = null; + } + } + // Plain Escape hands the keyboard back to the session list instead of the // shell/app. `false` tells xterm to skip its own handling of this event. term.attachCustomKeyEventHandler((e) => { @@ -402,6 +474,7 @@ export function createAttachConnection( el, onMount() { mounted = true; + acquireWebgl(); // Defer a tick so the element has its content-pane dimensions before fit. requestAnimationFrame(() => { tryFit(); @@ -411,6 +484,9 @@ export function createAttachConnection( }, onUnmount() { mounted = false; + // Parked views hand their GPU context back: the browser's cap is small + // (smaller still on Android) and MAX_PARKED connections outlive the pane. + releaseWebgl(); }, focusTerminal() { term.focus(); @@ -427,6 +503,7 @@ export function createAttachConnection( visual?.removeEventListener("resize", onResize); dataDisp.dispose(); resizeDisp.dispose(); + releaseWebgl(); try { ws.close(); } catch { diff --git a/web/src/webglRenderer.test.ts b/web/src/webglRenderer.test.ts new file mode 100644 index 0000000..9485dfb --- /dev/null +++ b/web/src/webglRenderer.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from "vitest"; + +import { MAX_CONTEXT_LOSSES, WebglPolicy } from "./webglRenderer.js"; + +describe("WebglPolicy", () => { + it("is enabled when the browser supports WebGL2", () => { + expect(new WebglPolicy(() => true).enabled()).toBe(true); + }); + + it("is disabled when the browser has no WebGL2", () => { + expect(new WebglPolicy(() => false).enabled()).toBe(false); + }); + + it("probes only once, however often it is asked", () => { + const probe = vi.fn(() => true); + const policy = new WebglPolicy(probe); + policy.enabled(); + policy.enabled(); + policy.enabled(); + expect(probe).toHaveBeenCalledTimes(1); + }); + + it("tolerates a loss below the budget — a backgrounded tab is ordinary", () => { + const policy = new WebglPolicy(() => true); + policy.noteContextLoss(); + expect(policy.enabled()).toBe(true); + }); + + it("gives up for good once losses reach the budget", () => { + const policy = new WebglPolicy(() => true); + for (let i = 0; i < MAX_CONTEXT_LOSSES; i++) policy.noteContextLoss(); + expect(policy.enabled()).toBe(false); + expect(policy.contextLosses()).toBe(MAX_CONTEXT_LOSSES); + }); + + it("stays disabled past the budget, and never bothers probing", () => { + const probe = vi.fn(() => true); + const policy = new WebglPolicy(probe); + for (let i = 0; i < MAX_CONTEXT_LOSSES + 3; i++) policy.noteContextLoss(); + expect(policy.enabled()).toBe(false); + expect(probe).not.toHaveBeenCalled(); + }); + + it("treats a throwing probe as unsupported", () => { + const policy = new WebglPolicy(() => { + throw new Error("no gl for you"); + }); + expect(() => policy.enabled()).not.toThrow(); + expect(policy.enabled()).toBe(false); + }); + + it("reports no WebGL2 under the test DOM, which has no GPU", () => { + expect(new WebglPolicy().enabled()).toBe(false); + }); +}); diff --git a/web/src/webglRenderer.ts b/web/src/webglRenderer.ts new file mode 100644 index 0000000..b7e7c43 --- /dev/null +++ b/web/src/webglRenderer.ts @@ -0,0 +1,73 @@ +// WebGL renderer policy for attached terminals. Split out of attach.ts so it +// can be tested without a DOM or an xterm instance (same reasoning as +// reconnect.ts). +// +// xterm's default renderer builds DOM nodes per cell, which is CPU-bound — +// exactly the wrong bottleneck on a phone, where a full-screen repaint from an +// agent dumping output visibly stutters. The WebGL addon moves glyph drawing to +// a GPU texture atlas instead. +// +// The cost is that a WebGL context is a scarce, revocable resource: +// +// * Browsers cap live contexts (~16 on desktop Chrome, fewer on Android) and +// silently kill the oldest past the cap. `ConnectionPool` keeps MAX_PARKED +// parked connections alive on top of the mounted ones, so handing every +// connection its own context would blow that budget on its own and bring a +// parked terminal back blank. +// * Android drops contexts as routine housekeeping — backgrounding the tab, +// locking the screen, memory pressure. +// +// So the context follows the *view*, not the connection: acquired on mount, +// released on park. Losses count against a shared budget, because a device that +// keeps dropping contexts is telling us it cannot sustain one. + +/** Context losses tolerated before the page gives up and stays on the DOM + * renderer. One loss is ordinary (a tab switch); repeated losses mean the + * device is thrashing, and re-acquiring only makes it worse. */ +export const MAX_CONTEXT_LOSSES = 2; + +/** Can this browser hand out a WebGL2 context at all? */ +export function probeWebgl2(): boolean { + return document.createElement("canvas").getContext("webgl2") !== null; +} + +/** + * Whether an attached terminal should try the WebGL renderer right now. + * + * Shared by every connection on the page: the context budget and the "this + * device cannot cope" signal are both global, not per-terminal. + */ +export class WebglPolicy { + private supported: boolean | null = null; + private losses = 0; + + constructor(private readonly probe: () => boolean = probeWebgl2) {} + + /** True while WebGL is both available and still worth attempting. The probe + * runs at most once — support does not change under us, and creating + * throwaway contexts to re-ask would itself consume the budget. */ + enabled(): boolean { + if (this.losses >= MAX_CONTEXT_LOSSES) return false; + if (this.supported === null) { + try { + this.supported = this.probe(); + } catch { + // A browser that throws rather than returning null has no usable + // context either way. + this.supported = false; + } + } + return this.supported; + } + + /** Record a lost context. Past the budget, `enabled()` stays false for the + * life of the page. */ + noteContextLoss(): void { + this.losses += 1; + } + + /** Losses recorded so far — for the debug HUD and tests. */ + contextLosses(): number { + return this.losses; + } +}