fix(terminal): bound fullscreen atlas recovery (#12061)

This commit is contained in:
OrcaWin 2026-08-01 23:38:46 -07:00 committed by GitHub
parent 5c7fba5bb5
commit 2f73775ffc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 222 additions and 40 deletions

View File

@ -7476,6 +7476,129 @@
],
"demotionRule": "Disable the probe if it exceeds its per-reveal budget or produces false-positive anomaly noise."
},
{
"id": "terminal-render.atlas-recovery-fanout",
"title": "Terminal atlas recovery stays bounded to visible renderers",
"maturity": "experimental",
"protection": "partial",
"owner": "terminal-rendering",
"layer": "renderer-unit-and-electron",
"surfaces": [
"WebGL rendering",
"document visibility",
"hidden terminal output",
"paired terminal traffic"
],
"platforms": ["macos", "linux", "windows"],
"providers": ["local", "daemon", "ssh", "remote-runtime"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["local"],
"coverageNotes": "A deterministic renderer contract models one visible manager plus 64 mounted hidden managers and hidden synchronized/TUI output. A macOS Electron run verifies that a document visibility cycle preserves real WebGL atlases and terminal pixels. Production v1.4.163 evidence linked the same 49-manager fanout to paired traffic, but the candidate has not been rerun against an isolated live paired server.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/7054",
"https://github.com/stablyai/orca/pull/7604"
],
"invariant": "Ordinary document visibility transitions and hidden terminal output must not clear the shared WebGL glyph atlas. Heavy reset-and-refresh recovery may touch only managers with visible terminal surfaces; hidden managers recover when revealed. Genuine OS resume remains a heavy recovery trigger.",
"oracle": "Dispatch a visible document visibilitychange and require atlas-preserving wake recovery; register one visible manager and 64 hidden managers and require exactly one reset and one refresh; parse hidden synchronized and high-confidence TUI output and require zero global atlas-recovery schedules. Separately, drive an Electron visibility cycle with two real WebGL panes, require zero atlas clears, and retain at least 85% of each pane's baseline ink pixels.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts",
"pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/terminal-document-visibility-webgl-recovery.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/terminal-document-visibility-webgl-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1"
],
"testFiles": [
"src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts",
"src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts",
"src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts",
"src/renderer/src/components/terminal-pane/pty-connection.test.ts",
"tests/e2e/terminal-document-visibility-webgl-recovery.spec.ts"
],
"assertionRefs": [
{
"file": "src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts",
"assertions": ["preserves the glyph atlas when a fullscreen Space becomes visible"]
},
{
"file": "src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts",
"assertions": [
"preserves WebGL texture atlases when the active terminal document becomes visible"
]
},
{
"file": "src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts",
"assertions": ["bounds atlas recovery to visible managers"]
},
{
"file": "src/renderer/src/components/terminal-pane/pty-connection.test.ts",
"assertions": [
"defers hidden synchronized-output atlas recovery until reveal",
"defers hidden high-confidence TUI redraw recovery until reveal",
"advances hidden rewrite state without scheduling atlas recovery"
]
},
{
"file": "tests/e2e/terminal-document-visibility-webgl-recovery.spec.ts",
"assertions": [
"preserves the WebGL atlas and keeps terminal text painted after document visibility resumes"
]
}
],
"evidenceRuns": [
{
"date": "2026-08-01",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts",
"result": "passed",
"durationSeconds": 26,
"summary": "All 577 focused renderer contracts passed, including atlas-preserving visibility, visible-only recovery fanout, and hidden-output rewrite-state coverage."
},
{
"date": "2026-08-01",
"runner": "local",
"platform": "macos",
"command": "pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/terminal-document-visibility-webgl-recovery.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"result": "passed",
"durationSeconds": 18,
"summary": "The changed-spec CI topology retained both real WebGL terminal panes with zero atlas clears after a deterministic document visibility cycle."
},
{
"date": "2026-08-01",
"runner": "local",
"platform": "macos",
"command": "pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/terminal-document-visibility-webgl-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
"result": "passed",
"durationSeconds": 23,
"summary": "Two real WebGL terminal panes retained painted glyphs with zero atlas clears after a deterministic document visibility cycle. BrowserWindow.hide did not change document visibility in the harness, so the test used its explicit visibility-event fallback."
}
],
"runtimeBudget": {
"p95Seconds": 60,
"scope": "focused renderer contracts plus one prebuilt Electron visibility test"
},
"flakeHistory": {
"status": "unknown",
"evidence": "The focused unit oracle and one local Electron run passed; CI soak history is not yet available."
},
"redGreenEvidence": {
"status": "complete",
"evidence": "With the fix disabled, the byte-identical unit oracle observed clearGlyphAtlases=true, reset/refreshed all 65 managers, and scheduled hidden synchronized/TUI recovery one to three times. Restoring the fix made every assertion pass."
},
"performanceBudget": {
"required": true,
"evidence": "A recovery with one visible and 64 hidden managers performs one reset and one refresh instead of 65 of each. Hidden synchronized/TUI output schedules zero global recovery work; no polling, cooldown, provider call, or parking change is added."
},
"promotionCriteria": [
"Accumulate stable macOS Electron runs with real BrowserWindow visibility transitions.",
"Run an isolated headed paired-server terminal flood and verify bounded renderer CPU and atlas diagnostics.",
"Add Linux and Windows WebGL visibility evidence before claiming cross-platform visual coverage."
],
"knownGaps": [
"The Electron harness used a deterministic visibility-event fallback because BrowserWindow.hide did not change document.visibilityState.",
"No isolated live paired-server candidate run is recorded; remote-runtime coverage is a provider-agnostic renderer contract plus production incident evidence.",
"The gate counts recovery fanout and pixel retention but does not impose an end-to-end renderer frame-latency threshold."
],
"demotionRule": "Demote if hidden managers re-enter reset/refresh recovery, ordinary visibility clears the atlas, real WebGL pixels regress, or the focused Electron test cannot remain deterministic."
},
{
"id": "terminal-render.atlas-identity-invalidation",
"title": "A WebGL atlas identity change rebuilds cached glyph vertices",

View File

@ -12072,7 +12072,7 @@ describe('connectPanePty', () => {
})
})
it('schedules WebGL atlas recovery after hidden synchronized output parses', async () => {
it('defers hidden synchronized-output atlas recovery until reveal', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-id')
const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null }
@ -12112,13 +12112,13 @@ describe('connectPanePty', () => {
parseCallbacks[0]?.()
expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(3)
expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('recognizes hidden synchronized output markers split across PTY chunks', async () => {
it('defers split hidden synchronized-output markers until reveal', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-id')
const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null }
@ -12154,7 +12154,7 @@ describe('connectPanePty', () => {
parseCallbacks[0]?.()
expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(3)
expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
@ -12199,7 +12199,7 @@ describe('connectPanePty', () => {
}
})
it('schedules hidden atlas recovery for high-confidence TUI redraw controls', async () => {
it('defers hidden high-confidence TUI redraw recovery until reveal', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-id')
const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null }
@ -12230,13 +12230,13 @@ describe('connectPanePty', () => {
parseCallbacks[0]?.()
expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1)
expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('advances hidden rewrite state when synchronized output already requests recovery', async () => {
it('advances hidden rewrite state without scheduling atlas recovery', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-id')
const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null }
@ -12270,7 +12270,7 @@ describe('connectPanePty', () => {
vi.advanceTimersByTime(50)
expect(writes).toEqual(['prompt rewrite\r', '\x1b[?2026hredraw frame\x1b[?2026l'])
parseCallbacks.shift()?.()
expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1)
expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled()
scheduleTerminalWebglAtlasRecovery.mockClear()
capturedDataCallback.current?.('plain after frame')
@ -12313,7 +12313,7 @@ describe('connectPanePty', () => {
capturedDataCallback.current?.('\x1b[?2026h')
vi.advanceTimersByTime(50)
parseCallbacks.shift()?.()
expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1)
expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled()
scheduleTerminalWebglAtlasRecovery.mockClear()
writes.length = 0

View File

@ -6250,16 +6250,20 @@ export function connectPanePty(
const renderRefreshDecision = foregroundOutput
? shouldForceForegroundRenderRefresh(data)
: { refresh: false, inPlaceRewrite: false, recoverWebglAtlasAfterParse: false }
const recoverHiddenWebglAtlasAfterParse =
!foregroundOutput && hiddenOutputNeedsAtlasRecoveryAfterParse(data)
if (!foregroundOutput) {
// Advance hidden rewrite state; reveal owns atlas recovery.
void hiddenOutputNeedsAtlasRecoveryAfterParse(data)
}
const recoverWebglAtlasAfterParse =
renderRefreshDecision.recoverWebglAtlasAfterParse || recoverHiddenWebglAtlasAfterParse
foreground && renderRefreshDecision.recoverWebglAtlasAfterParse
// Why: atlas recovery must repaint from the parsed xterm buffer, not a pre-write snapshot a late TUI redraw can stale.
const onParsedAtlasRecovery = recoverWebglAtlasAfterParse
? scheduleTerminalWebglAtlasRecovery
: renderRefreshDecision.inPlaceRewrite
? alternateScreenRewriteAtlasRecoveryOnParsed()
: undefined
const onParsedAtlasRecovery = foreground
? recoverWebglAtlasAfterParse
? scheduleTerminalWebglAtlasRecovery
: renderRefreshDecision.inPlaceRewrite
? alternateScreenRewriteAtlasRecoveryOnParsed()
: undefined
: undefined
const foregroundRenderRefreshNeeded = renderRefreshDecision.refresh
// Why: Claude Code's in-place prompt redraws on Windows ConPTY can paint one frame late; a follow-up repaint fixes the column desync without a resize.
const nativeWindowsInPlaceRewriteFollowup = nativeWindowsRewriteNeedsFollowupRenderRefresh({

View File

@ -974,7 +974,7 @@ describe('useTerminalPaneGlobalEffects', () => {
expect(manager.refreshAllPanes).toHaveBeenCalledTimes(1)
})
it('clears WebGL texture atlases when the active visible terminal document becomes visible', () => {
it('preserves WebGL texture atlases when the active terminal document becomes visible', () => {
let visibilityState: DocumentVisibilityState = 'hidden'
const documentListeners = new Map<string, EventListenerOrEventListenerObject>()
vi.stubGlobal('document', {
@ -1024,6 +1024,7 @@ describe('useTerminalPaneGlobalEffects', () => {
throw new Error('expected visibilitychange listener')
}
manager.resetWebglTextureAtlases.mockClear()
manager.scheduleRevealPresent.mockClear()
siblingManager.resetWebglTextureAtlases.mockClear()
listener(new Event('visibilitychange'))
expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled()
@ -1032,8 +1033,9 @@ describe('useTerminalPaneGlobalEffects', () => {
visibilityState = 'visible'
listener(new Event('visibilitychange'))
expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1)
expect(siblingManager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1)
expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled()
expect(siblingManager.resetWebglTextureAtlases).not.toHaveBeenCalled()
expect(manager.scheduleRevealPresent).toHaveBeenCalledTimes(1)
})
it('registers document visibility recovery for visible inactive terminals but not hidden ones', () => {

View File

@ -142,6 +142,7 @@ export function useTerminalPaneGlobalEffects({
if (!manager) {
return
}
manager.setAtlasRecoveryVisible?.(rendererVisible)
const wasVisible = wasVisibleRef.current
const wasWorktreeActive = wasWorktreeActiveRef.current
isActiveRef.current = isActive

View File

@ -79,6 +79,22 @@ describe('useTerminalWindowWakeRecovery', () => {
})
})
it('preserves the glyph atlas when a fullscreen Space becomes visible', () => {
renderWakeRecoveryHook()
Object.defineProperty(document, 'visibilityState', {
configurable: true,
value: 'visible'
})
document.dispatchEvent(new Event('visibilitychange'))
expect(recoverVisibleTerminalWindowWakeMock).toHaveBeenLastCalledWith({
manager,
isActive: true,
clearGlyphAtlases: false
})
})
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

View File

@ -101,7 +101,7 @@ export function useTerminalWindowWakeRecovery({
const onFocus = (): void => recoverVisibleWake(false, 'focus')
const onVisibilityChange = (): void => {
if (typeof document !== 'undefined' && document.visibilityState === 'visible') {
recoverVisibleWake(true, 'visibilitychange')
recoverVisibleWake(false, 'visibilitychange')
}
}
// Why: Linux has no window-occlusion tracking, so visibilitychange never

View File

@ -82,6 +82,34 @@ describe('pane manager registry', () => {
expect(order).toEqual(['first-reset', 'second-reset', 'first-refresh', 'second-refresh'])
})
it('bounds atlas recovery to visible managers', () => {
const visible = {
resetWebglTextureAtlases: vi.fn<() => void>(),
refreshAllPanes: vi.fn<() => void>(),
isVisibleForAtlasRecovery: () => true
}
registerLivePaneManager(visible)
registeredManagers.push(visible)
const hidden = Array.from({ length: 64 }, () => ({
resetWebglTextureAtlases: vi.fn<() => void>(),
refreshAllPanes: vi.fn<() => void>(),
isVisibleForAtlasRecovery: () => false
}))
for (const manager of hidden) {
registerLivePaneManager(manager)
registeredManagers.push(manager)
}
resetAndRefreshAllTerminalWebglAtlases()
expect(visible.resetWebglTextureAtlases).toHaveBeenCalledOnce()
expect(visible.refreshAllPanes).toHaveBeenCalledOnce()
expect(
hidden.every((manager) => manager.resetWebglTextureAtlases.mock.calls.length === 0)
).toBe(true)
expect(hidden.every((manager) => manager.refreshAllPanes.mock.calls.length === 0)).toBe(true)
})
it('continues reset-and-refresh recovery when one manager throws', () => {
const broken = {
resetWebglTextureAtlases: vi.fn<() => void>(() => {

View File

@ -8,6 +8,7 @@ type RegisteredPaneManager = {
getRenderingDiagnostics?: () => PaneRenderingDiagnostics[]
getPanes?: () => { id: number; terminal: unknown }[]
getPaneCount?: () => number
isVisibleForAtlasRecovery?: () => boolean
}
const liveManagers = new Set<RegisteredPaneManager>()
@ -48,9 +49,15 @@ 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 recoveryManagers = Array.from(liveManagers).filter(
(manager) => manager.isVisibleForAtlasRecovery?.() !== false
)
recordTerminalWebglDiagnostic('webgl-atlas-reset', {
managers: recoveryManagers.length,
mountedManagers: liveManagers.size
})
const resetManagers: RegisteredPaneManager[] = []
for (const manager of liveManagers) {
for (const manager of recoveryManagers) {
try {
manager.resetWebglTextureAtlases()
resetManagers.push(manager)

View File

@ -72,6 +72,7 @@ export class PaneManager {
private styleOptions: PaneStyleOptions = {}
private destroyed = false
private renderingSuspended: boolean
private atlasRecoveryVisible: boolean
private identities = new PaneIdentityRegistry()
private pendingPaneReparentFrameIds = new Set<number>()
@ -82,6 +83,7 @@ export class PaneManager {
this.root = root
this.options = options
this.renderingSuspended = options.initialRenderingSuspended === true
this.atlasRecoveryVisible = !this.renderingSuspended
// Why: atlas recovery must reach every live manager — see
// resetAllTerminalWebglAtlases for the shared-atlas rationale.
registerLivePaneManager(this)
@ -358,6 +360,14 @@ export class PaneManager {
resetPaneWebglTextureAtlases(this.panes.values())
}
setAtlasRecoveryVisible(visible: boolean): void {
this.atlasRecoveryVisible = visible
}
isVisibleForAtlasRecovery(): boolean {
return this.atlasRecoveryVisible && !this.destroyed
}
scheduleRevealRepaint(): void {
// Why: the settled-frame callback can fire after destroy(); repainting
// disposed panes could throw in attach and latch the global WebGL

View File

@ -280,8 +280,8 @@ async function dispatchDocumentVisibilityCycle(page: Page): Promise<void> {
})
}
test.describe('terminal document visibility WebGL recovery @headful', () => {
test('clears the WebGL atlas and keeps terminal text painted after document visibility resumes', async ({
test.describe('terminal document visibility WebGL recovery', () => {
test('preserves the WebGL atlas and keeps terminal text painted after document visibility resumes', async ({
electronApp,
orcaPage
}, testInfo) => {
@ -293,7 +293,7 @@ test.describe('terminal document visibility WebGL recovery @headful', () => {
await waitForPaneCount(orcaPage, 2)
const webglActive = await forceWebgl(orcaPage)
test.skip(!webglActive, 'WebGL was not active in this headful environment')
test.skip(!webglActive, 'WebGL was not active in this Electron environment')
await writeStableTerminalContent(orcaPage)
expect(await patchAtlasCounter(orcaPage)).toBe(true)
@ -317,25 +317,16 @@ test.describe('terminal document visibility WebGL recovery @headful', () => {
console.log(
`[visibility-webgl] browserWindowVisibilityWorked=${browserWindowVisibilityWorked}`
)
if (browserWindowVisibilityWorked) {
await expect
.poll(() => readAtlasResetCount(orcaPage), {
timeout: 2_000,
message: 'BrowserWindow visibility resume did not clear the WebGL atlas'
})
.toBeGreaterThan(0)
} else {
if (!browserWindowVisibilityWorked) {
await resetAtlasResetCount(orcaPage)
await dispatchDocumentVisibilityCycle(orcaPage)
await expect
.poll(() => readAtlasResetCount(orcaPage), {
timeout: 2_000,
message: 'document visibility resume did not clear the WebGL atlas'
})
.toBeGreaterThan(0)
}
await waitForTerminalPaint(orcaPage)
expect(
await readAtlasResetCount(orcaPage),
'ordinary document visibility resume cleared the shared WebGL atlas'
).toBe(0)
const afterResume = await terminalScreenshots(orcaPage)
for (const [index, baselineShot] of baseline.entries()) {