Skip to content

fix(terminal): lazy WebGL per visible pane + deterministic activation repair - #105

Merged
GOODBOY008 merged 2 commits into
mainfrom
fix/issue-87-renderer-resilience
Aug 23, 2026
Merged

fix(terminal): lazy WebGL per visible pane + deterministic activation repair#105
GOODBOY008 merged 2 commits into
mainfrom
fix/issue-87-renderer-resilience

Conversation

@GOODBOY008

Copy link
Copy Markdown
Owner

Root cause (from the #87 symptom set)

Blank + input-dead panes with the badge still Connected, SFTP working, System Monitor updating, and tab switching never recovering localizes the failure to the xterm render layer (the WS is loopback and can't silently die; the status badge is React state driven only by WS events). Two defects combined:

  1. One WebGL context per mounted terminal, held for life — including hidden panes. 10+ tabs exceeded Chromium's live-context budget; the oldest contexts got evicted overnight. Under WebGL, text exists only as pixels on the GL canvas, so an evicted pane renders black forever while keystrokes still flow into the dead context — which is exactly why Ctrl+L showed no visible response while everything else stayed alive. (Same failure class as Webgl: Handle context loss when there are too many contexts xtermjs/xterm.js#2253 / #6015.)
  2. One-shot activation. The latch was consumed before a single rAF that bailed permanently on a 0×0 container, and the ResizeObserver path only ever called fit() — never refresh() — so tab switching repainted through the still-registered dead renderer: black on black.

Changes (pty-terminal.tsx, frontend-only)

  • Lazy WebGL: the addon loads when a pane becomes visible and is disposed when it hides. Live contexts drop from mounted panes to visible panes (1–4), below eviction pressure. Hidden panes keep their text via the DOM renderer.
  • Hardened context loss: dispose wrapped in try/catch, then forced fit() + refresh(0, rows-1) so the fallback renderer repaints immediately instead of relying on the addon's best-effort dispose path.
  • Deterministic activation: retries every animation frame until the container has a real size (~2s budget), and only then consumes the latch, ensures a working renderer, and runs fit + full refresh + focus. wasActiveRef now starts false, so mount-active panes pass through the same measured path — previously a terminal that mounted active behind a 0×0 container had no retry at all (a hole the new regression test exposed during development).
  • The ResizeObserver completes a still-pending activation on a 0×0 → non-zero transition (wake-from-display-sleep coverage).

Tests (5 new, pty-terminal-renderer-lifecycle.test.tsx)

Full suite: 54 files / 594 tests pass. tsc --noEmit clean, eslint 0 errors on touched files.

Notes

🤖 Generated with ZCode

Copilot AI lite review requested due to automatic review settings August 23, 2026 07:25

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Activation completion via ResizeObserver is currently gated behind a > 100px size check, which can prevent activation from ever completing when the pane becomes non-zero but small or becomes visible after the rAF retry budget expires.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR addresses terminal panes becoming blank/input-dead after long idle periods by making the xterm WebGL renderer lifecycle depend on visibility (not just mount), and by making activation deterministic even when the container is temporarily 0×0 (e.g., after sleep/wake or delayed layout).

Changes:

  • Lazily load/dispose WebglAddon per visible pane to avoid Chromium WebGL context eviction across many mounted tabs.
  • Replace one-shot activation with an rAF retry loop that only “consumes” activation after a successful measured fit + refresh + focus.
  • Add a new test suite covering WebGL lifecycle, context-loss fallback repaint, and 0×0 → sized activation retry.
File summaries
File Description
src/components/pty-terminal.tsx Adds lazy WebGL renderer controls and deterministic activation retry/repair paths.
src/tests/pty-terminal-renderer-lifecycle.test.tsx New regression coverage for renderer lifecycle, context loss recovery, and 0×0 activation retries.
Review details

Suppressed comments (1)

src/components/pty-terminal.tsx:927

  • This comment again refers to hidden panes keeping text via a "DOM renderer", but the implementation is actually releasing the WebGL addon and falling back to xterm’s default non-WebGL renderer. Updating the wording will avoid confusion when debugging renderer state.
      // Release this pane's WebGL context while hidden — visible panes get
      // the GPU; hidden panes keep their text via the DOM renderer.
      webglControlsRef.current?.release();
  • Files reviewed: 2/2 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/components/pty-terminal.tsx Outdated
Comment on lines +191 to +193
// panes: under WebGL, text exists only as pixels on the GL canvas. The
// addon is now loaded only while the pane is visible; hidden panes use
// the DOM renderer, whose text survives in the DOM.
Comment on lines 805 to 815
// Only refit if the container has a reasonable size
if (entry.contentRect.width > 100 && entry.contentRect.height > 100) {
debouncedFit();
// A 0×0 → non-zero transition can be the first reliable visibility
// signal (e.g. after display sleep/wake). If an activation is still
// pending — its rAF retry budget may have expired while the pane
// was hidden — finish it now.
if (isActiveStateRef.current && !wasActiveRef.current) {
activateTerminalRef.current?.();
}
}
@GOODBOY008

Copy link
Copy Markdown
Owner Author

Thanks @copilot-pull-request-reviewer — one comment adopted, one corrected with evidence (423290b):

Adopted — >100px gate on activation completion. Correct catch: the ResizeObserver backstop inherited debouncedFit's threshold, so a pane that exhausted its rAF budget at 0×0 and then appeared at a sub-100px size (deep splits are legitimately that narrow) would never activate — the same permanently-blank class this PR fixes. Activation now triggers on any non-zero size while debouncedFit keeps its 100px threshold, and a new regression test covers the exact scenario (budget exhausted → 30px transition → fit + full refresh + focus must land).

Corrected — "fallback is not DOM-based". Verified against the installed @xterm/xterm 6.0.0: core's _createRenderer() constructs c.DomRenderer — the DOM renderer is built into v6 core and is what the WebglAddon restores on dispose (its dispose handler calls setRenderer(terminal._core._createRenderer()) + handleResize, which triggers a full refresh). So hidden panes do keep their text as DOM rows; that's the load-bearing property this PR relies on. I've sharpened both comments to cite that mechanism so the next reader doesn't have to re-derive it.

🤖 Generated with ZCode

… repair

Long-lived SSH tabs could become permanently blank and appear input-dead
after being left overnight (#87): every mounted terminal held a WebGL
context for its whole life — including hidden panes — so 10+ tabs
exceeded Chromium's context budget and the oldest contexts were evicted.
Under WebGL, text exists only as pixels on the GL canvas, so an evicted
pane renders black forever while keystrokes still flow into the dead
context ('no visible response'). Recovery was impossible because
activation was one-shot: the latch was consumed before a single rAF
that bailed permanently on a 0x0 container, and tab switching only
repainted through the still-registered dead renderer.

- WebGL addon now loads only while a pane is visible and is disposed
  when the pane is hidden (hidden panes keep their text via the DOM
  renderer; the live-context count drops to the number of visible
  panes, below eviction pressure).
- Context-loss handling disposes defensively (try/catch) and forces a
  fit + full refresh so the fallback renderer repaints immediately.
- Activation retries on every animation frame until the container has a
  real size (~2s budget), and only then consumes the latch, loads the
  renderer, and runs fit + full refresh + focus. Mount-active panes now
  pass through the same measured path (wasActiveRef starts false).
- The ResizeObserver completes a still-pending activation on a 0x0 ->
  non-zero transition, covering wake-from-display-sleep cases.

Fixes the renderer root cause of #87.

🤖 Generated with [ZCode](https://github.com/ZhipuAI/ZCode)
Address Copilot review:
- The ResizeObserver backstop inherited debouncedFit's ">100px" gate, so
  a pane that stayed 0x0 past the rAF retry budget and then appeared at
  a small size (deep splits can be sub-100px) would never complete its
  activation — permanently blank again. Activation now triggers on any
  non-zero size; debouncedFit keeps its threshold.
- New regression test: budget exhausted at 0x0, then a 30px ResizeObserver
  transition must still land fit + full refresh + focus.
- Renderer-fallback comments now cite the verified mechanism (xterm v6
  core _createRenderer() builds DomRenderer — the same renderer the
  WebglAddon restores on dispose).

🤖 Generated with [ZCode](https://github.com/ZhipuAI/ZCode)
@GOODBOY008
GOODBOY008 force-pushed the fix/issue-87-renderer-resilience branch from 423290b to 618f415 Compare August 23, 2026 07:57
@GOODBOY008
GOODBOY008 merged commit d849570 into main Aug 23, 2026
4 checks passed
@GOODBOY008
GOODBOY008 deleted the fix/issue-87-renderer-resilience branch August 23, 2026 08:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants