mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 08:03:14 +00:00
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:
@@ -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
|
||||
|
||||
+30
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user