From 33256e557d7d9363abfdb3e176df7dd9af4b8cfa Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 8 Jul 2026 17:42:26 -0400 Subject: [PATCH] diag(terminal): breadcrumb WebGL context-loss/atlas + wake triggers into freeze report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: 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 --- .../terminal-freeze-breadcrumbs.ts | 9 ++++++ .../terminal-pane/terminal-freeze-report.ts | 5 ++++ ...inal-webgl-diagnostics-breadcrumbs.test.ts | 30 +++++++++++++++++++ .../use-terminal-window-wake-recovery.test.ts | 23 ++++++++++++++ .../use-terminal-window-wake-recovery.ts | 21 ++++++++++--- .../lib/pane-manager/pane-manager-registry.ts | 28 +++++++++++++++++ .../lib/pane-manager/pane-webgl-renderer.ts | 5 ++++ src/shared/terminal-webgl-diagnostics.ts | 26 ++++++++++++++++ 8 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-webgl-diagnostics-breadcrumbs.test.ts create mode 100644 src/shared/terminal-webgl-diagnostics.ts diff --git a/src/renderer/src/components/terminal-pane/terminal-freeze-breadcrumbs.ts b/src/renderer/src/components/terminal-pane/terminal-freeze-breadcrumbs.ts index 8417137108e..042d261450f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-freeze-breadcrumbs.ts +++ b/src/renderer/src/components/terminal-pane/terminal-freeze-breadcrumbs.ts @@ -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() } diff --git a/src/renderer/src/components/terminal-pane/terminal-freeze-report.ts b/src/renderer/src/components/terminal-pane/terminal-freeze-report.ts index f507dea9c0d..5c087827f9f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-freeze-report.ts +++ b/src/renderer/src/components/terminal-pane/terminal-freeze-report.ts @@ -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 + // 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 breadcrumbs: ReturnType } main: unknown @@ -36,6 +40,7 @@ export async function buildTerminalFreezeReport(): Promise documentVisibilityProvenStale: isDocumentVisibilityProvenStale(), ptyDataListenerCount: window.api?.pty?.getPtyDataListenerCount?.() ?? null, watchdog: getTerminalDeliveryWatchdogDiagnostics(), + paneRendering: getAllPaneRenderingDiagnostics(), breadcrumbs: getTerminalFreezeBreadcrumbs() }, main: main ?? null diff --git a/src/renderer/src/components/terminal-pane/terminal-webgl-diagnostics-breadcrumbs.test.ts b/src/renderer/src/components/terminal-pane/terminal-webgl-diagnostics-breadcrumbs.test.ts new file mode 100644 index 00000000000..8fbb426ade8 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-webgl-diagnostics-breadcrumbs.test.ts @@ -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 }) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts index f70a85dcf39..c83cdde9288 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts @@ -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) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts b/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts index 5168afc6847..839b6184696 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts @@ -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) diff --git a/src/renderer/src/lib/pane-manager/pane-manager-registry.ts b/src/renderer/src/lib/pane-manager/pane-manager-registry.ts index 5fa2be90a20..2a52b31ba78 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-registry.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-registry.ts @@ -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() @@ -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 { diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts index e0ca805801e..7ae295b4a9d 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts @@ -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 diff --git a/src/shared/terminal-webgl-diagnostics.ts b/src/shared/terminal-webgl-diagnostics.ts new file mode 100644 index 00000000000..f63c34f1807 --- /dev/null +++ b/src/shared/terminal-webgl-diagnostics.ts @@ -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 +) => void + +let recorder: WebglDiagnosticRecorder | null = null + +export function setTerminalWebglDiagnosticRecorder(next: WebglDiagnosticRecorder | null): void { + recorder = next +} + +export function recordTerminalWebglDiagnostic( + kind: string, + detail?: Record +): void { + recorder?.(kind, detail) +}