From e8072835fc1698dd8106bdbf7b70ab75e337a1fd Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Sun, 20 Sep 2026 08:58:03 -0400 Subject: [PATCH] refactor(mobile): make WebGL loss recovery a module The fifth group, and the first carrying a top-level statement rather than only declarations: the visibility listener it registers. In the document that runs when the IIFE reaches it; as a module it runs on import, which is the same single registration. The context-loss listener disposes the addon it is registered on, so it cannot run before that addon exists, but the assignment is to a `let` a closure captures and TypeScript will not carry the narrowing across it. A non-null assertion, erased by the compiler, keeps the emitted script identical and puts the invariant where the reader is. Counts: twenty-three qualified references across the terminal, the addon, its retry timer and the theme the host last sent; three locals rebound; twelve one-statement bodies braced; five of the six catch clauses unbound, the sixth keeping its binding because the attach failure reads the error into its diagnostic. The document is untouched, so the byte pin is still green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../terminal/document/document-externals.ts | 6 + .../src/terminal/document/document-scope.ts | 20 +++- .../terminal/document/webgl-recovery.test.ts | 24 ++++ .../src/terminal/document/webgl-recovery.ts | 105 ++++++++++++++++++ 4 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 mobile/src/terminal/document/webgl-recovery.test.ts create mode 100644 mobile/src/terminal/document/webgl-recovery.ts diff --git a/mobile/src/terminal/document/document-externals.ts b/mobile/src/terminal/document/document-externals.ts index 34811d1ecd2..797a119c187 100644 --- a/mobile/src/terminal/document/document-externals.ts +++ b/mobile/src/terminal/document/document-externals.ts @@ -32,3 +32,9 @@ export declare function applyFitScale(reason: string): void /** `smooth-scroll-and-cell-geometry`: shows or hides the scroll indicator. */ export declare function updateScrollIndicator(visible: boolean): void + +/** `runtime-state-and-text-scaling`: one diagnostic line, forwarded to the host. */ +export declare function flog(name: string, detail: Record): void + +/** `terminal-fit-scale`: applies a theme the host sent to the live terminal. */ +export declare function applyTerminalTheme(input: unknown): void diff --git a/mobile/src/terminal/document/document-scope.ts b/mobile/src/terminal/document/document-scope.ts index 0325dab3136..1116205fecb 100644 --- a/mobile/src/terminal/document/document-scope.ts +++ b/mobile/src/terminal/document/document-scope.ts @@ -53,6 +53,8 @@ export type TerminalDocumentTerminal = { readonly rows: number readonly buffer: { readonly active: TerminalDocumentBuffer } resize: (cols: number, rows: number) => void + refresh: (start: number, end: number) => void + loadAddon: (addon: TerminalDocumentWebglAddon) => void scrollToBottom: () => void scrollLines: (amount: number) => void } @@ -69,11 +71,24 @@ export type TerminalDocumentScope = { termObserverDisposables: TerminalDocumentDisposable[] /** `terminal-init-and-write`: the row count the last init or reflow settled on. */ initRows: number + /** `webgl-recovery`: the loaded WebGL addon, or null on the DOM renderer. */ + webglAddon: TerminalDocumentWebglAddon | null + /** `webgl-recovery`: the pending single retry after a context loss. */ + webglRecoveryTimer: ReturnType | null + /** `runtime-state-and-text-scaling`: the theme the host last sent, replayed on visibility. */ + terminalThemeInput: unknown } /** An xterm listener handle, as the document disposes of one. */ export type TerminalDocumentDisposable = { dispose?: () => void } +/** xterm's WebGL addon, as the document loads, repaints and disposes of it. */ +export type TerminalDocumentWebglAddon = { + onContextLoss?: (listener: () => void) => void + clearTextureAtlas?: () => void + dispose: () => void +} + /** * The initial values, which are the ones the document's own declarations carried. * @@ -87,7 +102,10 @@ export function createTerminalDocumentScope(): TerminalDocumentScope { panY: 0, terminalGeneration: 0, termObserverDisposables: [], - initRows: 24 + initRows: 24, + webglAddon: null, + webglRecoveryTimer: null, + terminalThemeInput: null } } diff --git a/mobile/src/terminal/document/webgl-recovery.test.ts b/mobile/src/terminal/document/webgl-recovery.test.ts new file mode 100644 index 00000000000..b2c1e41d060 --- /dev/null +++ b/mobile/src/terminal/document/webgl-recovery.test.ts @@ -0,0 +1,24 @@ +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { emitTerminalDocumentModule } from '../../../scripts/build-terminal-document-script.mjs' +import { TERMINAL_WEBGL_RECOVERY_JS } from '../terminal-webview-webgl-recovery-injected' +import { compareTerminalDocumentScripts } from './terminal-document-equivalence.test-support' + +const modulePath = fileURLToPath(new URL('./webgl-recovery.ts', import.meta.url)) + +describe('the WebGL recovery module', () => { + it('emits the script the document carries, modulo the four normalisations', async () => { + const emitted = await emitTerminalDocumentModule(modulePath) + expect(compareTerminalDocumentScripts(TERMINAL_WEBGL_RECOVERY_JS, emitted, 'scope')).toEqual({ + equivalent: true, + normalisations: { + qualifiedReferences: 23, + scopeFieldDeclarations: 0, + rebindings: 3, + bracedBodies: 12, + // Five of the six catch clauses; the attach failure reads its error and keeps its binding. + unboundCatches: 5 + } + }) + }) +}) diff --git a/mobile/src/terminal/document/webgl-recovery.ts b/mobile/src/terminal/document/webgl-recovery.ts new file mode 100644 index 00000000000..0c9258c366b --- /dev/null +++ b/mobile/src/terminal/document/webgl-recovery.ts @@ -0,0 +1,105 @@ +import { applyTerminalTheme, flog } from './document-externals' +import { scope, type TerminalDocumentWebglAddon } from './document-scope' + +/** xterm's WebGL addon constructor, as the engine bundle puts it on `window`. */ +type WebglAddonGlobal = { WebglAddon?: new () => TerminalDocumentWebglAddon } + +declare global { + interface Window { + WebglAddon?: WebglAddonGlobal + } +} + +export function refreshTerminalSurface() { + if (!scope.term) { + return + } + try { + scope.term.refresh(0, Math.max(0, scope.term.rows - 1)) + } catch {} +} + +export function cancelWebglContextRecovery() { + if (!scope.webglRecoveryTimer) { + return + } + clearTimeout(scope.webglRecoveryTimer) + scope.webglRecoveryTimer = null +} + +export function attachWebglAddon(allowRecovery: boolean) { + if (!scope.term || !window.WebglAddon || !window.WebglAddon.WebglAddon) { + return false + } + let addon: TerminalDocumentWebglAddon | null = null + try { + addon = new window.WebglAddon.WebglAddon() + scope.webglAddon = addon + if (addon.onContextLoss) { + addon.onContextLoss(function () { + if (scope.webglAddon !== addon) { + return + } + flog('webgl-context-loss', { retry: allowRecovery }) + scope.webglAddon = null + try { + // oxlint-disable-next-line typescript/no-non-null-assertion -- SAFETY: this listener is registered on the addon, so it cannot run before the assignment above. + addon!.dispose() + } catch {} + refreshTerminalSurface() + if (!allowRecovery) { + return + } + // Why: one delayed retry handles transient iOS context loss without + // entering a GPU crash loop; a second loss stays on the DOM renderer. + cancelWebglContextRecovery() + const recoveryTerm = scope.term + const recoveryGeneration = scope.terminalGeneration + scope.webglRecoveryTimer = setTimeout(function () { + scope.webglRecoveryTimer = null + if (scope.term !== recoveryTerm || scope.terminalGeneration !== recoveryGeneration) { + return + } + attachWebglAddon(false) + }, 100) + }) + } + scope.term.loadAddon(addon) + if (!allowRecovery) { + try { + if (addon.clearTextureAtlas) { + addon.clearTextureAtlas() + } + } catch {} + refreshTerminalSurface() + } + return true + } catch (e) { + flog('webgl-attach-failed', { retry: !allowRecovery, message: String(e) }) + if (scope.webglAddon === addon) { + scope.webglAddon = null + } + try { + if (addon) { + addon.dispose() + } + } catch {} + refreshTerminalSurface() + return false + } +} + +document.addEventListener('visibilitychange', function () { + if (document.visibilityState !== 'visible') { + return + } + // Why: iOS may restore the xterm model while discarding GPU pixels/theme + // paint state, so visibility must rebuild the atlas and repaint every row. + applyTerminalTheme(scope.terminalThemeInput) + try { + if (scope.webglAddon && scope.webglAddon.clearTextureAtlas) { + scope.webglAddon.clearTextureAtlas() + } + } catch {} + refreshTerminalSurface() +})