mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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
This commit is contained in:
@@ -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<string, unknown>): void
|
||||
|
||||
/** `terminal-fit-scale`: applies a theme the host sent to the live terminal. */
|
||||
export declare function applyTerminalTheme(input: unknown): void
|
||||
|
||||
@@ -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<typeof setTimeout> | 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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
Reference in New Issue
Block a user