diag(terminal): breadcrumb WebGL context-loss/atlas + wake triggers into freeze report

Silent instrumentation (memory ring only, no new console lines) so the next
post-wake garble report attributes itself. Adds:
- shared/terminal-webgl-diagnostics.ts: lib-safe sink so pane-webgl-renderer
  (lib) can record without importing the components-layer ring; wired to the
  ring in terminal-freeze-breadcrumbs.
- webgl-context-loss crumb at onContextLoss, webgl-atlas-reset crumb at the
  atlas registry reset — the pair that distinguishes 'atlas corrupted' from
  'missed repaint'.
- wake-recovery:<source> crumb (focus/visibilitychange/system-resumed) with the
  clearGlyphAtlases decision; source in the kind so distinct triggers don't
  coalesce.
- per-pane WebGL state (getAllPaneRenderingDiagnostics) in the freeze report.

Gates: typecheck 0 errors; terminal suites 328 files pass; oxlint clean.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-07-08 17:42:26 -04:00
co-authored by Orca
parent 6f5585551b
commit 33256e557d
8 changed files with 143 additions and 4 deletions
@@ -6,6 +6,7 @@ import {
type PtyDeliveryBreadcrumb,
createPtyDeliveryBreadcrumbRing
} from '../../../../shared/pty-delivery-diagnostics'
import { setTerminalWebglDiagnosticRecorder } from '../../../../shared/terminal-webgl-diagnostics'
const rendererDeliveryBreadcrumbs = createPtyDeliveryBreadcrumbRing()
@@ -16,6 +17,14 @@ export function recordTerminalFreezeBreadcrumb(
rendererDeliveryBreadcrumbs.record(kind, detail)
}
// Why: lib-layer WebGL code (pane-webgl-renderer, the atlas registry) can't
// import this components-layer ring directly, so it records through a shared
// sink. Point that sink at the same ring here so context-loss and atlas-reset
// crumbs land in the one-paste report alongside delivery/visibility history.
setTerminalWebglDiagnosticRecorder((kind, detail) =>
rendererDeliveryBreadcrumbs.record(kind, detail)
)
export function getTerminalFreezeBreadcrumbs(): PtyDeliveryBreadcrumb[] {
return rendererDeliveryBreadcrumbs.snapshot()
}
@@ -7,6 +7,7 @@
import { getTerminalFreezeBreadcrumbs } from './terminal-freeze-breadcrumbs'
import { getTerminalDeliveryWatchdogDiagnostics } from './terminal-delivery-watchdog'
import { isDocumentVisibilityProvenStale } from './stale-document-visibility'
import { getAllPaneRenderingDiagnostics } from '@/lib/pane-manager/pane-manager-registry'
export type TerminalFreezeReport = {
capturedAt: string
@@ -16,6 +17,9 @@ export type TerminalFreezeReport = {
documentVisibilityProvenStale: boolean
ptyDataListenerCount: number | null
watchdog: ReturnType<typeof getTerminalDeliveryWatchdogDiagnostics>
// Why: per-pane WebGL state distinguishes a stale post-wake surface from a
// context-loss fallback — the missing signal for the garble-after-sleep class.
paneRendering: ReturnType<typeof getAllPaneRenderingDiagnostics>
breadcrumbs: ReturnType<typeof getTerminalFreezeBreadcrumbs>
}
main: unknown
@@ -36,6 +40,7 @@ export async function buildTerminalFreezeReport(): Promise<TerminalFreezeReport>
documentVisibilityProvenStale: isDocumentVisibilityProvenStale(),
ptyDataListenerCount: window.api?.pty?.getPtyDataListenerCount?.() ?? null,
watchdog: getTerminalDeliveryWatchdogDiagnostics(),
paneRendering: getAllPaneRenderingDiagnostics(),
breadcrumbs: getTerminalFreezeBreadcrumbs()
},
main: main ?? null
@@ -0,0 +1,30 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { recordTerminalWebglDiagnostic } from '../../../../shared/terminal-webgl-diagnostics'
import {
getTerminalFreezeBreadcrumbs,
resetTerminalFreezeBreadcrumbsForTesting
} from './terminal-freeze-breadcrumbs'
// Why: lib-layer WebGL code records through the shared sink because it may not
// import the components-layer ring. Importing terminal-freeze-breadcrumbs wires
// that sink to the ring at module load; this pins that the WebGL crumbs land in
// the same one-paste report as delivery/visibility history.
describe('WebGL diagnostics → freeze breadcrumb ring', () => {
beforeEach(() => {
resetTerminalFreezeBreadcrumbsForTesting()
})
afterEach(() => {
resetTerminalFreezeBreadcrumbsForTesting()
})
it('routes context-loss and atlas-reset crumbs into the freeze report ring', () => {
recordTerminalWebglDiagnostic('webgl-context-loss', { paneId: 3 })
recordTerminalWebglDiagnostic('webgl-atlas-reset', { managers: 1 })
const crumbs = getTerminalFreezeBreadcrumbs()
expect(crumbs.map((crumb) => crumb.kind)).toEqual(['webgl-context-loss', 'webgl-atlas-reset'])
expect(crumbs[0]?.detail).toEqual({ paneId: 3 })
expect(crumbs[1]?.detail).toEqual({ managers: 1 })
})
})
@@ -12,6 +12,10 @@ vi.mock('./terminal-visibility-resume', () => ({
}))
import { useTerminalWindowWakeRecovery } from './use-terminal-window-wake-recovery'
import {
getTerminalFreezeBreadcrumbs,
resetTerminalFreezeBreadcrumbsForTesting
} from './terminal-freeze-breadcrumbs'
describe('useTerminalWindowWakeRecovery', () => {
const manager = {} as PaneManager
@@ -27,6 +31,7 @@ describe('useTerminalWindowWakeRecovery', () => {
recoverVisibleTerminalWindowWakeMock.mockClear()
unsubscribeSystemResumed.mockClear()
onSystemResumed.mockClear()
resetTerminalFreezeBreadcrumbsForTesting()
// Why: without requestAnimationFrame the hook skips its settled-frame
// follow-up, so every trigger maps to exactly one synchronous recovery.
vi.stubGlobal('requestAnimationFrame', undefined)
@@ -74,6 +79,24 @@ describe('useTerminalWindowWakeRecovery', () => {
})
})
it('records a wake-recovery breadcrumb with the trigger source and atlas decision', () => {
// Why: a post-wake garble report attributes to the trigger that ran (or its
// absence). Pin that focus records source=focus/atlas=false and system
// resume records source=system-resumed/atlas=true.
renderWakeRecoveryHook()
window.dispatchEvent(new Event('focus'))
systemResumedCallback?.()
const wakeCrumbs = getTerminalFreezeBreadcrumbs().filter((crumb) =>
crumb.kind.startsWith('wake-recovery:')
)
expect(wakeCrumbs.map((crumb) => [crumb.kind, crumb.detail])).toEqual([
['wake-recovery:focus', { clearGlyphAtlases: false }],
['wake-recovery:system-resumed', { clearGlyphAtlases: true }]
])
})
it('unsubscribes from the system resume event on cleanup', () => {
const { unmount } = renderWakeRecoveryHook()
expect(onSystemResumed).toHaveBeenCalledTimes(1)
@@ -1,6 +1,7 @@
import { useEffect } from 'react'
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import { recoverVisibleTerminalWindowWake } from './terminal-visibility-resume'
import { recordTerminalFreezeBreadcrumb } from './terminal-freeze-breadcrumbs'
type UseTerminalWindowWakeRecoveryArgs = {
isVisible: boolean
@@ -29,7 +30,19 @@ export function useTerminalWindowWakeRecovery({
cancelAnimationFrame(wakeRecoveryFrameId)
wakeRecoveryFrameId = null
}
const recoverVisibleWake = (clearGlyphAtlases: boolean): void => {
const recoverVisibleWake = (
clearGlyphAtlases: boolean,
source: 'focus' | 'visibilitychange' | 'system-resumed'
): void => {
// Why: the decisive crumb for a post-wake garble report — which trigger
// fired and whether it wiped the atlas. If the report shows a stale pane
// but NO wake crumb near the unlock time, the trigger never fired (the
// unlock-screen gap); a crumb with clearGlyphAtlases=false means the light
// path ran but may not have healed a corrupted atlas. Silent (memory ring).
// Source is in the kind so distinct triggers don't coalesce into one
// entry (focus and resume often fire together); repeats of the same
// source still fold, which is the noise control we want.
recordTerminalFreezeBreadcrumb(`wake-recovery:${source}`, { clearGlyphAtlases })
// Focus and visibility often fire together; keep one immediate recovery and one settled RAF pass.
if (wakeRecoveryFrameId !== null) {
// Why: a pending settled pass may only upgrade in strength — a plain
@@ -69,17 +82,17 @@ export function useTerminalWindowWakeRecovery({
// an agent streams; wiping the shared glyph atlas then provokes xterm's
// page-merge race and paints garbled glyphs. Focus recovery keeps the warm
// atlas: it only retries WebGL attach, refits, and repaints pane-scoped.
const onFocus = (): void => recoverVisibleWake(false)
const onFocus = (): void => recoverVisibleWake(false, 'focus')
const onVisibilityChange = (): void => {
if (typeof document !== 'undefined' && document.visibilityState === 'visible') {
recoverVisibleWake(true)
recoverVisibleWake(true, 'visibilitychange')
}
}
// Why: Linux has no window-occlusion tracking, so visibilitychange never
// fires around system suspend; the main process broadcasts OS resume.
const onSystemResumed = (): void => {
if (typeof document === 'undefined' || document.visibilityState === 'visible') {
recoverVisibleWake(true)
recoverVisibleWake(true, 'system-resumed')
}
}
window.addEventListener('focus', onFocus)
@@ -1,7 +1,11 @@
import { recordTerminalWebglDiagnostic } from '../../../../shared/terminal-webgl-diagnostics'
import type { PaneRenderingDiagnostics } from './pane-manager-types'
type RegisteredPaneManager = {
resetWebglTextureAtlases(): void
fitAllPanes?: () => void
refreshAllPanes?: () => void
getRenderingDiagnostics?: () => PaneRenderingDiagnostics[]
}
const liveManagers = new Set<RegisteredPaneManager>()
@@ -35,6 +39,9 @@ export function resetAllTerminalWebglAtlases(): void {
}
export function resetAndRefreshAllTerminalWebglAtlases(): void {
// Why: the atlas wipe is the heavy recovery path; recording it lets a freeze
// report show whether a post-wake repaint actually ran. Silent breadcrumb.
recordTerminalWebglDiagnostic('webgl-atlas-reset', { managers: liveManagers.size })
const resetManagers: RegisteredPaneManager[] = []
for (const manager of liveManagers) {
try {
@@ -55,6 +62,27 @@ export function resetAndRefreshAllTerminalWebglAtlases(): void {
}
}
/**
* Per-pane WebGL renderer state across all live managers, for the one-paste
* freeze report. Lets a post-wake garble report show, per pane, whether it
* held a live WebGL addon or had fallen back after a context loss — the state
* that distinguishes "missed repaint" from "atlas corrupted".
*/
export function getAllPaneRenderingDiagnostics(): PaneRenderingDiagnostics[] {
const all: PaneRenderingDiagnostics[] = []
for (const manager of liveManagers) {
try {
const diagnostics = manager.getRenderingDiagnostics?.()
if (diagnostics) {
all.push(...diagnostics)
}
} catch {
// Why: best-effort during teardown; one manager must not sink the report.
}
}
return all
}
export function refitAndRefreshAllTerminalPanes(): void {
for (const manager of liveManagers) {
try {
@@ -1,5 +1,6 @@
import { WebglAddon } from '@xterm/addon-webgl'
import type { ManagedPaneInternal } from './pane-manager-types'
import { recordTerminalWebglDiagnostic } from '../../../../shared/terminal-webgl-diagnostics'
import { forceRepaintThroughRenderPause } from './terminal-render-pause-release'
import {
getTerminalWebglAutoDecision,
@@ -170,6 +171,10 @@ export function attachWebgl(pane: ManagedPaneInternal): void {
pane.id,
'— falling back to DOM renderer'
)
// Why: a lost context is the decisive signal for a post-wake garble
// report — it means the glyph atlas was wiped (needs a full reset), not
// just a missed repaint. Silent breadcrumb; the console.warn stays.
recordTerminalWebglDiagnostic('webgl-context-loss', { paneId: pane.id })
// Why: Chromium starts reclaiming terminal contexts under pressure.
// Recreating WebGL for this pane can loop context loss and leave xterm
// visually blank, so keep the pane on the DOM renderer until the next
+26
View File
@@ -0,0 +1,26 @@
/**
* Lib-safe sink for WebGL renderer breadcrumbs (context loss/restore, atlas
* resets). The renderer breadcrumb ring lives in the components layer
* (terminal-freeze-breadcrumbs.ts), which may import from lib/pane-manager but
* not the reverse. This indirection lets lib-layer WebGL code record a crumb
* without a backward import: components registers the recorder at startup;
* until then (and in non-renderer contexts) recording is a silent no-op.
*/
export type WebglDiagnosticRecorder = (
kind: string,
detail?: Record<string, string | number | boolean | null>
) => void
let recorder: WebglDiagnosticRecorder | null = null
export function setTerminalWebglDiagnosticRecorder(next: WebglDiagnosticRecorder | null): void {
recorder = next
}
export function recordTerminalWebglDiagnostic(
kind: string,
detail?: Record<string, string | number | boolean | null>
): void {
recorder?.(kind, detail)
}