Skip to content

Terminals render on xterm's DOM fallback renderer; sustained output costs 5-6x more per frame than it needs to #62

Description

@Sadykhzadeh

Measured against v0.1.2-alpha.4 (4abcf33), desktop/ frontend.

node_modules/@xterm/ contains addon-fit, addon-search and xterm. There is no WebglAddon or CanvasAddon anywhere in desktop/src or desktop/package.json, so every terminal runs on xterm's DOM renderer — the fallback path, which rebuilds one <span> per styled run per row every time the screen changes. TerminalPane.vue:307-314 constructs the terminal and loads FitAddon and SearchAddon, and nothing else.

Switching to @xterm/addon-webgl is worth doing, but it is not free, and two of its costs are the kind that produce a blank terminal rather than a slow one. Both are measured below.

What the renderer actually costs

All numbers measured on this machine (Windows 11, RTX 5060 Ti). Playwright's Chromium driven through ANGLE → D3D11 — the same graphics path WebView2 uses on Windows — with vsync and the frame-rate limit disabled so a frame takes as long as the work in it. The corpus is 4 MiB of realistic terminal output: a third heavily styled (eight colored runs per row, ls --color-shaped), a third plain bulk text, a third build-log lines with a few styled runs each.

Render-bound streaming — one 8 KiB chunk delivered per animation frame, so the renderer draws every frame. This is the case a user feels: output arriving steadily, fast enough to redraw but not fast enough to coalesce.

terminal DOM frame WebGL frame speedup DOM throughput WebGL throughput
120x40 4.54 ms 0.73 ms 6.2x 1.72 MiB/s 10.72 MiB/s
200x50 5.01 ms 0.90 ms 5.6x 1.56 MiB/s 8.68 MiB/s
80x24 1.85 ms 0.52 ms 3.6x 4.22 MiB/s 14.97 MiB/s

p95 frame time tracks the mean closely (6.4 → 0.9 ms at 120x40), so this is a shift in the whole distribution rather than a tail effect. The win scales with the number of cells, which is the expected shape: the DOM renderer's cost is per styled run per row.

Burst drain — the whole 4 MiB written as fast as term.write accepts it, timed until parsed and drawn:

terminal DOM WebGL speedup
120x40 37.7 MiB/s 52.9 MiB/s 1.40x
200x50 38.5 MiB/s 41.9 MiB/s 1.09x
80x24 43.5 MiB/s 41.1 MiB/s 0.94x

This is the honest counterpart and it should not be buried. When output arrives faster than the screen refreshes, xterm coalesces and draws only the final state, so the work is in the VT parser, not the renderer, and the renderer barely matters. cat bigfile is close to this case. The 5-6x figure applies to sustained interactive-rate output, not to everything.

A note on method: measuring renderRows() in isolation shows only ~2x, not ~6x. That number understates the DOM renderer, because most of its cost is the style, layout and paint of the swapped-in spans, which the browser charges to the frame after the JS call returns. The frame-interval figures above capture that and are the ones to trust.

Cost 1: the GPU context budget is a real constraint here

Chromium keeps a fixed number of WebGL contexts alive per renderer process and kills the oldest without warning past that. Measured directly — 64 webgl2 contexts created in one page, then counted:

created: 64   still alive: 16   lost: 48   first lost: the oldest

The cap is exactly 16, and eviction is oldest-first. This matters more in Clavyn than in most apps, because TerminalWorkspace.vue:101-102 deliberately mounts every pane of every tab at once — that is correct and prevents xterm remounts when panes move between tabs, but it means the number of live terminals is not bounded by what is on screen. A user with seventeen terminals open across tabs would silently kill the first one.

Attaching the addon to 24 terminals and waiting confirms it end to end: terminals 1-8 lost their contexts, onContextLoss fired for exactly those eight, and 16 stayed alive.

Cost 2: context loss is a three-second blank, not an instant fallback

Reading the installed addon rather than assuming: on webglcontextlost it calls preventDefault() and starts a 3000 ms timer, firing onContextLoss only if the context has not been restored by then. Measured: the event arrived at 3075 ms after the loss.

So an unhandled — or even a correctly handled — context loss leaves that pane frozen for three seconds. Handling it is still mandatory, because without the dispose() the pane never comes back at all. Verified on the installed version rather than taken on faith:

  • WebglAddon.activate() registers a disposable that calls renderService.setRenderer(terminal._core._createRenderer()) and handleResize(cols, rows).
  • _createRenderer() in @xterm/xterm 5.5.0 returns DomRenderer.
  • After addon.dispose() the live renderer's constructor is identical to a never-accelerated terminal's, output written afterwards appears in the DOM, and nothing throws.

Attach costs 19 ms warm and 57 ms cold (shader compile plus texture atlas); detach costs 6 ms. That rules out the obvious mitigation of disposing on hide and re-attaching on show — it would put a 19 ms hitch on every tab switch.

Cost 3: the transcript leaves the DOM

This one has no workaround and is worth stating plainly. The GPU renderer draws to a canvas, so .xterm-rows does not exist and the terminal's text is not in the DOM at all. Two consequences:

  • Five assertions across four browser regression suites (terminal-workspace.mjs:59, terminal-input-routing.mjs:163, terminal-keyboard-close.mjs:137,140, terminal-fault-isolation.mjs:74) read a pane's transcript from .xterm-rows. They do not fail loudly — they time out.
  • The app sets screenReaderMode: false, so xterm builds no accessibility layer under either renderer. Today a screen reader traverses the DOM rows incidentally; under WebGL there is nothing there to traverse. This is not xterm's intended accessibility path (that is screenReaderMode, which has its own cost), but it is a real change in what assistive technology can reach.

Bundle

The addon is 101.22 kB raw / 26.06 kB gzip. Imported on demand it becomes its own chunk and the boot-critical entry chunk grows by 1.33 kB raw / 0.43 kB gzip (653.47 → 654.80 kB), which is the budget module itself. Imported statically it would land the whole 101 kB in the entry chunk and undo a large part of #35's work.

Assessment

Worth doing, with the context budget and the loss handler treated as part of the feature rather than as hardening added later. A least-recently-used cap of eight GPU-backed panes keeps a heavy workspace at half the browser's limit and degrades to the DOM renderer — which is what every pane uses today — instead of to a blank pane. The accessibility change in cost 3 is the one that deserves a decision rather than a default.

PR to follow.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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