fix(terminal): lazy WebGL per visible pane + deterministic activation repair - #105
Conversation
There was a problem hiding this comment.
🟡 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
WebglAddonper 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.
| // 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. |
| // 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?.(); | ||
| } | ||
| } |
|
Thanks @copilot-pull-request-reviewer — one comment adopted, one corrected with evidence (423290b): Adopted — Corrected — "fallback is not DOM-based". Verified against the installed 🤖 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)
423290b to
618f415
Compare
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:
fit()— neverrefresh()— so tab switching repainted through the still-registered dead renderer: black on black.Changes (
pty-terminal.tsx, frontend-only)fit()+refresh(0, rows-1)so the fallback renderer repaints immediately instead of relying on the addon's best-effort dispose path.wasActiveRefnow startsfalse, 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).Tests (5 new,
pty-terminal-renderer-lifecycle.test.tsx)refresh(0, rows-1)+ focus land — the exact regression [Bug] Multiple long-lived SSH tabs can become blank and input-dead after being left overnight #87 asked forFull suite: 54 files / 594 tests pass.
tsc --noEmitclean, eslint 0 errors on touched files.Notes
main(93d37c9). Overlaps trivially with fix(terminal): recover SSH-dead sessions via typed errors and auto re-auth #104'spty-terminal.tsxedits (different regions); will rebase if fix(terminal): recover SSH-dead sessions via typed errors and auto re-auth #104 merges first.🤖 Generated with ZCode