diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 12b937b187f..9a94d74c2cf 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -12284,7 +12284,7 @@ "id": "terminal-render.pixel-refresh-repair", "title": "Forced repaint does not change revealed pane pixels", "maturity": "experimental", - "protection": "none", + "protection": "partial", "owner": "terminal-rendering", "layer": "electron-visual-golden", "surfaces": [ @@ -12296,44 +12296,71 @@ ], "platforms": ["macos", "linux", "windows"], "providers": ["local", "daemon"], - "coveredPlatforms": [], - "coveredProviders": [], - "coverageNotes": "Registered gap only; the live repro harness with a refresh-repair oracle exists but is not productized into the release-blocking terminal-rendering-golden suite.", + "coveredPlatforms": ["macos"], + "coveredProviders": ["local"], + "coverageNotes": "A headed macOS Electron test drives a real WebGL terminal through a tab hide/reveal while DEC 2026 output is held. It verifies the serialized buffer contains the new frame, then compares compositor screenshots before and after a direct full-buffer present. The same renderer choreography is provider-local, but daemon, SSH, remote-runtime, Linux, and Windows GPU runs remain gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/7133", "https://github.com/stablyai/orca/pull/7054" ], "invariant": "After any reveal, restore, or wake, the pixels a pane displays must equal what a forced full model-invalidating repaint would draw from the buffer; a material refresh-repair diff means stale rendering escaped, whatever the mechanism.", "oracle": "Screenshot the revealed pane, force a model-invalidating full repaint, screenshot again, and assert the bounded pixel diff is immaterial; pair with a JS-level render-model-vs-buffer comparison. Compositor screenshots only, because WebGL canvas readback is blank with preserveDrawingBuffer disabled.", - "commands": [], - "testFiles": [], - "assertionRefs": [], - "evidenceRuns": [], + "commands": [ + "pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/terminal-reveal-paused-render-repro.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", + "ORCA_E2E_RECORD_VIDEO=1 pnpm exec playwright test tests/e2e/terminal-reveal-paused-render-repro.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --repeat-each=3" + ], + "testFiles": ["tests/e2e/terminal-reveal-paused-render-repro.spec.ts"], + "assertionRefs": [ + { + "file": "tests/e2e/terminal-reveal-paused-render-repro.spec.ts", + "assertions": ["@headful tab reveal presents a synchronized-output WebGL frame"] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-22", + "runner": "local", + "platform": "macos", + "command": "pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/terminal-reveal-paused-render-repro.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", + "result": "passed", + "durationSeconds": 16, + "summary": "A real WebGL tab reveal matched the current serialized synchronized-output frame with zero changed pixels after a direct renderer present. The test persists and attaches the exact before/after screenshots and pixel-diff metadata." + }, + { + "date": "2026-08-22", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_RECORD_VIDEO=1 pnpm exec playwright test tests/e2e/terminal-reveal-paused-render-repro.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --repeat-each=3", + "result": "passed", + "durationSeconds": 48, + "summary": "Three consecutive headed real-WebGL runs passed in 15.0-15.7 seconds each and emitted a 1920x1080 video per BrowserWindow." + } + ], "runtimeBudget": { - "p95Seconds": 300, - "scope": "release-blocking golden Electron visual test" + "p95Seconds": 120, + "scope": "focused prebuilt headed Electron visual test" }, "flakeHistory": { - "status": "not-started", - "evidence": "Runs nightly only while the per-platform diff threshold stabilizes; the destination is the release-blocking terminal-rendering-golden suite in release-cut.yml." + "status": "unknown", + "evidence": "Four local macOS headed runs passed, including three consecutive video-recorded repeats; CI soak history and cross-platform GPU runs are not yet available." }, "redGreenEvidence": { - "status": "missing", - "evidence": "Needs an intentionally fossilized render model (skip the model invalidation on reveal) proving the refresh-repair diff fires." + "status": "complete", + "evidence": "On unmodified main, the revealed screenshot differed from a direct renderer present across 87.68% of pixels even though serialization already contained the new frame. With synchronized-output presentation enabled, the same test passes the 1.5% material-diff threshold." }, "performanceBudget": { "required": true, - "evidence": "Soak-only; must not add product-path repaints beyond the existing reveal hardening." + "evidence": "Recovery remains event-driven and limited to visible managers. The deterministic 64-hidden-manager count test requires zero hidden atlas clears or presents. A display:none retry is deduped per pane and capped at 16 animation frames; no polling, provider calls, PTY scans, or background output work was added." }, "promotionCriteria": [ - "Productize the existing stale-render repro harness with the refresh-repair oracle and failure artifacts.", "Establish a stable material-diff threshold across platforms via nightly runs.", "Promotion means membership in the release-blocking terminal-rendering-golden suite that release-cut.yml runs against every release tag; never PR-blocking.", - "Register the gate command as a direct spec-file invocation; the checker rejects --grep tag selectors, so do not reuse the existing --grep-based golden script as the command." + "Add Linux and Windows GPU evidence or retain those platform gaps explicitly." ], "knownGaps": [ - "No manifest command yet.", - "Buffer-clean, pixels-stale variants are otherwise invisible to every buffer oracle in the manifest." + "The deterministic test holds synchronized output past xterm's one-second safety timeout so the reveal race is stable.", + "The renderer-level frame injection isolates the paint failure; provider delivery and hidden-output snapshot restore have separate gates.", + "Linux, Windows, daemon, SSH, and remote-runtime live GPU evidence is not yet recorded." ], "demotionRule": "Drop from the golden suite back to nightly diagnostic if diff thresholds cannot be kept stable across CI runners." }, diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts index 8714adbd84b..7820542dc48 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts @@ -11,6 +11,7 @@ import { writeTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler' import { recordTerminalOutput } from '@/lib/pane-manager/pane-scroll' +import { presentPaneViewport } from '@/lib/pane-manager/pane-webgl-renderer' import { buildMainModelSnapshotReplayWrites, hasPositiveTerminalDimensions, @@ -22,6 +23,7 @@ import { HIDDEN_OUTPUT_RESTORE_UNAVAILABLE_WARNING } from './hidden-output-resto import { shouldWritePtyOutputForeground } from './foreground-output-scan' import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore' import { restoredSnapshotPaintsPrintableContent } from '../restored-snapshot-coverage' +import { recordTerminalFreezeBreadcrumb } from '../terminal-freeze-breadcrumbs' import type { ConnectPanePtySession } from './connect-pane-pty-session' @@ -152,6 +154,12 @@ export function bindHiddenOutputRestoreSnapshot(session: ConnectPanePtySession): session.recordRendererOrderedSeq(snapshot) recordTerminalOutput(session.pane.terminal) await waitForTerminalReplayWritesParsed(session.pane.terminal) + if (session.deps.isVisibleRef.current) { + presentPaneViewport(session.pane) + recordTerminalFreezeBreadcrumb('stale-pixel-restore-present', { + paneId: session.pane.id + }) + } }, { shouldRestore: () => diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts index 3b198054861..557ef20ecb1 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts @@ -87,6 +87,7 @@ describe('resumeTerminalVisibility reveal repaint', () => { resumeTerminalVisibility(resumeArgs(manager, true)) expect(manager.scheduleRevealRepaint).toHaveBeenCalledTimes(1) + expect(manager.scheduleRevealPresent).toHaveBeenCalledTimes(1) expect(manager.resumeRendering).not.toHaveBeenCalled() expect(scheduleTabRevealWebglAtlasRecovery).toHaveBeenCalledTimes(1) }) @@ -193,6 +194,25 @@ describe('resumeTerminalVisibility reveal repaint', () => { expect(manager.fitAllPanes).not.toHaveBeenCalled() }) + it('repairs WebGL canvas backing-store dpr on window wake', () => { + // Clamshell undock: dpr changes while the pane stayed "visible" with a + // stale backing store; tab-reveal is not in the path. + const first = { terminal: { name: 'pane-a' } } + const second = { terminal: { name: 'pane-b' } } + const manager = createManager() + manager.getPanes.mockReturnValue([first, second]) + + recoverVisibleTerminalWindowWake({ + manager: manager as never as PaneManager, + isActive: true, + clearGlyphAtlases: false + }) + + expect(repairPaneWebglCanvasDprMismatch).toHaveBeenCalledTimes(2) + expect(repairPaneWebglCanvasDprMismatch).toHaveBeenNthCalledWith(1, first) + expect(repairPaneWebglCanvasDprMismatch).toHaveBeenNthCalledWith(2, second) + }) + it('latches viewport intent before refocus recovery flushes streaming output', async () => { const terminal = { name: 'streaming-terminal' } const manager = createManager() diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts index ee86edeba18..97590a2e328 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts @@ -17,6 +17,7 @@ import { focusActivePane } from './pane-helpers' import { scheduleTabRevealWebglAtlasRecovery } from './terminal-webgl-atlas-recovery' import { flushDeferredPaneMetricOptionsIfMeasurable } from '@/lib/pane-manager/pane-fit' import { repairPaneWebglCanvasDprMismatch } from '@/lib/pane-manager/terminal-canvas-dpr-repair' +import { presentPaneViewport } from '@/lib/pane-manager/pane-webgl-renderer' const VISIBLE_RESUME_FLUSH_CHARS = 256 * 1024 const WINDOW_WAKE_FLUSH_CHARS = 64 * 1024 @@ -90,6 +91,10 @@ export function resumeTerminalVisibility({ requestLightTabBacklogRecovery(manager) // Why: reveal is the lifecycle boundary that owns hidden renderer repair. scheduleTabRevealWebglAtlasRecovery() + // Why: the atlas burst often runs while this tab is still display:none + // (paused=true needFull=true in the field trace). A present-only pass after + // overlay layout rebuilds the WebGL model without a second atlas wipe. + manager.scheduleRevealPresent() if (flushedDeferredMetrics) { // Why: the light path normally skips fitting, but flushed metrics changed // cell size — refit so cols/rows match before the overlay settles. @@ -161,6 +166,13 @@ export function recoverVisibleTerminalWindowWake({ // Why: backlog writes can expose transient viewport geometry while parsing. syncTerminalViewportIntents(manager) for (const pane of manager.getPanes()) { + // Why: clamshell undock / monitor move changes devicePixelRatio while the + // pane can stay "visible" with a stale WebGL backing store. The addon's + // device-pixel observer misses that (no CSS-box change, or no box while + // the lid was closed). Repair here — not only on tab reveal. + if (repairPaneWebglCanvasDprMismatch(pane)) { + presentPaneViewport(pane) + } requestTerminalBacklogRecovery(pane.terminal) flushTerminalOutput(pane.terminal, { maxChars: WINDOW_WAKE_FLUSH_CHARS }) // Why: window blur fires mouseleave, clearing xterm's current link but not 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 b866c90af42..bed8aa76c8f 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 @@ -3,14 +3,28 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { renderHook } from '@testing-library/react' import type { PaneManager } from '@/lib/pane-manager/pane-manager' -const { recoverVisibleTerminalWindowWakeMock } = vi.hoisted(() => ({ - recoverVisibleTerminalWindowWakeMock: vi.fn() +const { + recoverVisibleTerminalWindowWakeMock, + repairPaneWebglCanvasDprMismatchMock, + presentPaneViewportMock +} = vi.hoisted(() => ({ + recoverVisibleTerminalWindowWakeMock: vi.fn(), + repairPaneWebglCanvasDprMismatchMock: vi.fn(() => false), + presentPaneViewportMock: vi.fn() })) vi.mock('./terminal-visibility-resume', () => ({ recoverVisibleTerminalWindowWake: recoverVisibleTerminalWindowWakeMock })) +vi.mock('@/lib/pane-manager/terminal-canvas-dpr-repair', () => ({ + repairPaneWebglCanvasDprMismatch: repairPaneWebglCanvasDprMismatchMock +})) + +vi.mock('@/lib/pane-manager/pane-webgl-renderer', () => ({ + presentPaneViewport: presentPaneViewportMock +})) + import { useTerminalWindowWakeRecovery } from './use-terminal-window-wake-recovery' import { getTerminalFreezeBreadcrumbs, @@ -29,6 +43,9 @@ describe('useTerminalWindowWakeRecovery', () => { beforeEach(() => { systemResumedCallback = null recoverVisibleTerminalWindowWakeMock.mockClear() + repairPaneWebglCanvasDprMismatchMock.mockClear() + presentPaneViewportMock.mockClear() + repairPaneWebglCanvasDprMismatchMock.mockReturnValue(false) unsubscribeSystemResumed.mockClear() onSystemResumed.mockClear() resetTerminalFreezeBreadcrumbsForTesting() @@ -180,4 +197,30 @@ describe('useTerminalWindowWakeRecovery', () => { expect(onSystemResumed).not.toHaveBeenCalled() }) + + it('repairs WebGL canvas dpr on window resize without wiping the glyph atlas', () => { + // Chromium emits resize when devicePixelRatio changes (undock / monitor + // move) even if the CSS box is unchanged. + const pane = { id: 1, terminal: {} } + const resizeManager = { getPanes: () => [pane] } as unknown as PaneManager + repairPaneWebglCanvasDprMismatchMock.mockReturnValue(true) + const { unmount } = renderHook(() => + useTerminalWindowWakeRecovery({ + isVisible: true, + managerRef: { current: resizeManager }, + isActiveRef: { current: true }, + isVisibleRef: { current: true } + }) + ) + + window.dispatchEvent(new Event('resize')) + + expect(repairPaneWebglCanvasDprMismatchMock).toHaveBeenCalledWith(pane) + expect(presentPaneViewportMock).toHaveBeenCalledWith(pane) + expect(recoverVisibleTerminalWindowWakeMock).not.toHaveBeenCalled() + + unmount() + window.dispatchEvent(new Event('resize')) + expect(repairPaneWebglCanvasDprMismatchMock).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 ba8102a5fb1..1afc70377a2 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,8 @@ import { useEffect } from 'react' import type { PaneManager } from '@/lib/pane-manager/pane-manager' import { recoverVisibleTerminalWindowWake } from './terminal-visibility-resume' +import { repairPaneWebglCanvasDprMismatch } from '@/lib/pane-manager/terminal-canvas-dpr-repair' +import { presentPaneViewport } from '@/lib/pane-manager/pane-webgl-renderer' import { recordTerminalFreezeBreadcrumb } from './terminal-freeze-breadcrumbs' import type { IDisposable } from '@xterm/xterm' @@ -111,7 +113,22 @@ export function useTerminalWindowWakeRecovery({ recoverVisibleWake(true, 'system-resumed') } } + const onWindowResize = (): void => { + // Why: Chromium emits window resize on devicePixelRatio changes even when + // the CSS box is unchanged (monitor move / undock). xterm's own observer + // misses that while the canvas had no box (laptop lid closed). + const manager = managerRef.current + if (!manager || !isVisibleRef.current) { + return + } + for (const pane of manager.getPanes?.() ?? []) { + if (repairPaneWebglCanvasDprMismatch(pane)) { + presentPaneViewport(pane) + } + } + } window.addEventListener('focus', onFocus) + window.addEventListener('resize', onWindowResize) if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') { document.addEventListener('visibilitychange', onVisibilityChange) } @@ -126,6 +143,7 @@ export function useTerminalWindowWakeRecovery({ return () => { cancelScheduledWakeRecovery() window.removeEventListener('focus', onFocus) + window.removeEventListener('resize', onWindowResize) if (typeof document !== 'undefined' && typeof document.removeEventListener === 'function') { document.removeEventListener('visibilitychange', onVisibilityChange) } diff --git a/src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts b/src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts index 10beddbf8b9..9320e0410b4 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts @@ -85,9 +85,43 @@ describe('pane manager registry', () => { expect(order).toEqual(['first-reset', 'second-reset', 'first-refresh', 'second-refresh']) }) + it('clears every recovered atlas before presenting any pane', () => { + // Why: per-pane clear+present interleaves a present against atlas generation + // N with the next pane's wipe to N+1. The first synchronized-output column + // then keeps pre-hide footer pixels. Wipe first, present once the generation + // is final. + const order: string[] = [] + const first = { + resetWebglTextureAtlases: vi.fn<() => void>(() => order.push('first-reset')), + clearWebglTextureAtlases: vi.fn<() => void>(() => order.push('first-clear')), + presentForcedViewports: vi.fn<() => void>(() => order.push('first-present')), + refreshAllPanes: vi.fn<() => void>(() => order.push('first-refresh')), + isVisibleForAtlasRecovery: () => true + } + const second = { + resetWebglTextureAtlases: vi.fn<() => void>(() => order.push('second-reset')), + clearWebglTextureAtlases: vi.fn<() => void>(() => order.push('second-clear')), + presentForcedViewports: vi.fn<() => void>(() => order.push('second-present')), + refreshAllPanes: vi.fn<() => void>(() => order.push('second-refresh')), + isVisibleForAtlasRecovery: () => true + } + registerLivePaneManager(first) + registeredManagers.push(first) + registerLivePaneManager(second) + registeredManagers.push(second) + + resetAndRefreshAllTerminalWebglAtlases() + + expect(order).toEqual(['first-clear', 'second-clear', 'first-present', 'second-present']) + expect(first.resetWebglTextureAtlases).not.toHaveBeenCalled() + expect(second.refreshAllPanes).not.toHaveBeenCalled() + }) + it('bounds atlas recovery to visible managers', () => { const visible = { resetWebglTextureAtlases: vi.fn<() => void>(), + clearWebglTextureAtlases: vi.fn<() => void>(), + presentForcedViewports: vi.fn<() => void>(), refreshAllPanes: vi.fn<() => void>(), isVisibleForAtlasRecovery: () => true } @@ -95,6 +129,8 @@ describe('pane manager registry', () => { registeredManagers.push(visible) const hidden = Array.from({ length: 64 }, () => ({ resetWebglTextureAtlases: vi.fn<() => void>(), + clearWebglTextureAtlases: vi.fn<() => void>(), + presentForcedViewports: vi.fn<() => void>(), refreshAllPanes: vi.fn<() => void>(), isVisibleForAtlasRecovery: () => false })) @@ -105,12 +141,14 @@ describe('pane manager registry', () => { resetAndRefreshAllTerminalWebglAtlases() - expect(visible.resetWebglTextureAtlases).toHaveBeenCalledOnce() - expect(visible.refreshAllPanes).toHaveBeenCalledOnce() + expect(visible.clearWebglTextureAtlases).toHaveBeenCalledOnce() + expect(visible.presentForcedViewports).toHaveBeenCalledOnce() expect( - hidden.every((manager) => manager.resetWebglTextureAtlases.mock.calls.length === 0) + hidden.every((manager) => manager.clearWebglTextureAtlases.mock.calls.length === 0) ).toBe(true) - expect(hidden.every((manager) => manager.refreshAllPanes.mock.calls.length === 0)).toBe(true) + expect(hidden.every((manager) => manager.presentForcedViewports.mock.calls.length === 0)).toBe( + true + ) }) it('continues reset-and-refresh recovery when one manager throws', () => { 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 588970436de..34492cd508e 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-registry.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-registry.ts @@ -4,6 +4,8 @@ import type { PaneRenderingDiagnostics } from './pane-manager-types' type RegisteredPaneManager = { resetWebglTextureAtlases(): void + clearWebglTextureAtlases?: () => void + presentForcedViewports?: () => void fitAllPanes?: () => void refreshAllPanes?: () => void getRenderingDiagnostics?: () => PaneRenderingDiagnostics[] @@ -60,9 +62,17 @@ export function resetAndRefreshAllTerminalWebglAtlases(reason?: string): void { ...(reason ? { reason } : {}) }) const resetManagers: RegisteredPaneManager[] = [] + // Why: clearTextureAtlas() is module-global. Clearing then presenting per + // pane interleaves a present against generation N with the next pane's wipe + // to N+1, so the first synchronized-output column keeps pre-hide footer pixels. + // Wipe every recovered atlas first; present only once the generation is final. for (const manager of recoveryManagers) { try { - manager.resetWebglTextureAtlases() + if (manager.clearWebglTextureAtlases) { + manager.clearWebglTextureAtlases() + } else { + manager.resetWebglTextureAtlases() + } resetManagers.push(manager) } catch { // Why: recovery is best-effort during pane teardown; a disposed manager @@ -71,7 +81,11 @@ export function resetAndRefreshAllTerminalWebglAtlases(reason?: string): void { } for (const manager of resetManagers) { try { - manager.refreshAllPanes?.() + if (manager.presentForcedViewports) { + manager.presentForcedViewports() + } else { + manager.refreshAllPanes?.() + } } catch { // Why: a pane can unmount between atlas reset and repaint; later // managers still need to repaint from their xterm buffers. diff --git a/src/renderer/src/lib/pane-manager/pane-manager.ts b/src/renderer/src/lib/pane-manager/pane-manager.ts index 8a99a00563e..0962df890dc 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager.ts @@ -26,6 +26,8 @@ import { applyTerminalGpuAcceleration } from './pane-terminal-gpu-acceleration' import { rebuildAttachedWebgl } from './pane-webgl-reattach' import { markPaneComplexScriptOutput, + clearPaneWebglTextureAtlases, + presentPaneViewports, resetPaneWebglTextureAtlases, resumePaneRendering, setPaneGpuRenderingState, @@ -280,6 +282,14 @@ export class PaneManager { resetPaneWebglTextureAtlases(this.panes.values()) } + clearWebglTextureAtlases(): void { + clearPaneWebglTextureAtlases(this.panes.values()) + } + + presentForcedViewports(): void { + presentPaneViewports(this.panes.values()) + } + setAtlasRecoveryVisible(visible: boolean): void { this.atlasRecoveryVisible = visible } diff --git a/src/renderer/src/lib/pane-manager/pane-rendering-control.ts b/src/renderer/src/lib/pane-manager/pane-rendering-control.ts index 2c005f0ff05..1de4621a60f 100644 --- a/src/renderer/src/lib/pane-manager/pane-rendering-control.ts +++ b/src/renderer/src/lib/pane-manager/pane-rendering-control.ts @@ -6,6 +6,8 @@ import { disposeWebgl, isPaneWebglContextLost, markComplexScriptOutput, + clearWebglTextureAtlas, + presentPaneViewport, resetWebglTextureAtlas } from './pane-webgl-renderer' import { rebuildAttachedWebgl, reattachWebglIfNeeded } from './pane-webgl-reattach' @@ -101,3 +103,15 @@ export function resetPaneWebglTextureAtlases(panes: Iterable): void { + for (const pane of panes) { + clearWebglTextureAtlas(pane) + } +} + +export function presentPaneViewports(panes: Iterable): void { + for (const pane of panes) { + presentPaneViewport(pane) + } +} diff --git a/src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts b/src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts index bfc4797aa52..1560a579b98 100644 --- a/src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts +++ b/src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts @@ -1,5 +1,6 @@ import type { ManagedPaneInternal } from './pane-manager-types' import { reattachWebglIfNeeded } from './pane-webgl-reattach' +import { presentPaneViewport } from './pane-webgl-renderer' import { resetAndRefreshAllTerminalWebglAtlases } from './pane-manager-registry' type PaneGetter = () => Iterable @@ -96,8 +97,6 @@ export function schedulePaneRevealRepaint(getPanes: () => Iterable Iterable): void { forEachPaneOnSettledFrame(getPanes, (pane) => { reattachWebglIfNeeded(pane) - if (pane.terminal.rows > 0) { - pane.terminal.refresh(0, pane.terminal.rows - 1) - } + presentPaneViewport(pane) }) } diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts index 11076608027..c3392d6a81a 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts @@ -4,6 +4,7 @@ import type { ManagedPaneInternal } from './pane-manager-types' import { attachWebgl, clearTerminalWebglAttachBackoff, + presentPaneViewport, resetTerminalWebglSuggestion, resetWebglTextureAtlas } from './pane-webgl-renderer' @@ -75,6 +76,7 @@ type FakeRenderService = { function createPausedPane(display: 'block' | 'none'): { pane: ManagedPaneInternal renderService: FakeRenderService + setDisplay: (next: 'block' | 'none') => void } { const pane = createPane() const renderService: FakeRenderService = { @@ -87,7 +89,8 @@ function createPausedPane(display: 'block' | 'none'): { } }) } - const view = { getComputedStyle: () => ({ display }) } + let currentDisplay = display + const view = { getComputedStyle: () => ({ display: currentDisplay }) } const element = { ownerDocument: { defaultView: view }, parentElement: null } pane.container = element as never pane.xtermContainer = element as never @@ -96,7 +99,13 @@ function createPausedPane(display: 'block' | 'none'): { refresh: vi.fn(() => renderService.refreshRows(0, pane.terminal.rows - 1)), _core: { _renderService: renderService } } as never - return { pane, renderService } + return { + pane, + renderService, + setDisplay: (next) => { + currentDisplay = next + } + } } describe('terminal WebGL addon lifecycle', () => { @@ -168,6 +177,33 @@ describe('terminal WebGL addon lifecycle', () => { expect(pane.terminal.refresh).toHaveBeenCalledWith(0, 23) }) + it('falls back to terminal.refresh on an unpaused, unsynchronized pane', () => { + // Restore-after-replay and other callers use presentPaneViewport even when + // forceFullViewportPresent is a no-op (not paused, no DEC 2026). + const refreshRows = vi.fn() + const renderRows = vi.fn() + const pane = createPane() + pane.terminal = { + ...pane.terminal, + refresh: vi.fn(), + _core: { + _renderService: { + _isPaused: false, + _needsFullRefresh: false, + refreshRows, + _renderer: { value: { renderRows } } + }, + coreService: { decPrivateModes: { synchronizedOutput: false } } + } + } as never + + presentPaneViewport(pane) + + expect(pane.terminal.refresh).toHaveBeenCalledWith(0, 23) + expect(refreshRows).not.toHaveBeenCalled() + expect(renderRows).not.toHaveBeenCalled() + }) + it('keeps the render pause latched when resetting a pane that has no layout box', () => { // Regression: the atlas reset released xterm's pause for every pane of a // visible manager, including a collapsed sibling of an expanded pane. That @@ -185,6 +221,50 @@ describe('terminal WebGL addon lifecycle', () => { expect(pane.terminal.refresh).toHaveBeenCalledWith(0, 23) }) + it('retries a display:none present after the overlay actually shows a box', () => { + // Field traces show paused=true needFull=true because the + // first present ran while the overlay was still display:none. Selection + // later healed the inner rows; only a resize cleared the sides. Flush the + // retry after the box exists so the full present actually runs. + const queued: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + queued.push(callback) + return queued.length + }) + const { pane, renderService, setDisplay } = createPausedPane('none') + + presentPaneViewport(pane) + + expect(renderService._isPaused).toBe(true) + expect(queued).toHaveLength(1) + + setDisplay('block') + queued.shift()?.(16) + + expect(renderService._isPaused).toBe(false) + expect(renderService.refreshRows).toHaveBeenCalledWith(0, 23, true) + }) + + it('caps display:none present retries', () => { + const queued: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + queued.push(callback) + return queued.length + }) + const { pane, renderService } = createPausedPane('none') + + presentPaneViewport(pane) + let callbacks = 0 + while (queued.length > 0) { + queued.shift()?.(callbacks * 16) + callbacks += 1 + } + + expect(callbacks).toBe(17) + expect(renderService._isPaused).toBe(true) + expect(renderService.refreshRows).toHaveBeenCalledTimes(1) + }) + it('still forces the paused render through for a pane that has a layout box', () => { // The reveal case the release exists for: DOM-visible, but xterm's observer // has not caught up, so a plain refresh() would be swallowed. 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 9d05e6b876b..c3b13c186af 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts @@ -1,9 +1,9 @@ import { WebglAddon } from '@xterm/addon-webgl' -import type { ManagedPaneInternal } from './pane-manager-types' +import type { ManagedPane, ManagedPaneInternal } from './pane-manager-types' import { recordTerminalWebglDiagnostic } from '../../../../shared/terminal-webgl-diagnostics' import { getLivePaneCensus } from './pane-manager-registry' import { isManagedPaneDisplayNone } from './pane-display-visibility' -import { forceRepaintThroughRenderPause } from './terminal-render-pause-release' +import { forceFullViewportPresent } from './terminal-render-pause-release' import { getTerminalWebglAutoDecision, resetTerminalWebglAutoDecision @@ -138,7 +138,7 @@ export function markComplexScriptOutput(pane: ManagedPaneInternal): void { pane.hasComplexScriptOutput = true } -export function resetWebglTextureAtlas(pane: ManagedPaneInternal): void { +export function clearWebglTextureAtlas(pane: ManagedPaneInternal): void { if (pane.webglDisabledAfterContextLoss) { return } @@ -147,11 +147,51 @@ export function resetWebglTextureAtlas(pane: ManagedPaneInternal): void { // context-loss event. Clearing the atlas preserves GPU rendering and forces // a fresh paint when the pane becomes visible/focused again. pane.webglAddon?.clearTextureAtlas() + } catch { + /* ignore — pane may have been disposed in the meantime */ + } +} + +const DISPLAYED_PRESENT_RETRY_FRAMES = 16 +const pendingDisplayedPresentRetries = new WeakMap() + +function schedulePresentWhenDisplayed(pane: ManagedPaneInternal): void { + if (typeof globalThis.requestAnimationFrame !== 'function') { + return + } + if (pendingDisplayedPresentRetries.has(pane)) { + return + } + pendingDisplayedPresentRetries.set(pane, DISPLAYED_PRESENT_RETRY_FRAMES) + const tick = (): void => { + const remaining = pendingDisplayedPresentRetries.get(pane) ?? 0 + if (remaining <= 0 || !pane.terminal) { + pendingDisplayedPresentRetries.delete(pane) + return + } + if (isManagedPaneDisplayNone(pane)) { + pendingDisplayedPresentRetries.set(pane, remaining - 1) + globalThis.requestAnimationFrame(tick) + return + } + pendingDisplayedPresentRetries.delete(pane) + presentPaneViewport(pane) + } + globalThis.requestAnimationFrame(tick) +} + +export function presentPaneViewport(pane: ManagedPane): void { + const internal = pane as ManagedPaneInternal + if (internal.webglDisabledAfterContextLoss) { + return + } + try { // Why: on reveal xterm's IntersectionObserver can still report the pane as // not intersecting, so a plain refresh() is swallowed by RenderService's // paused-render gate and the cleared model never repaints (stale bottom rows - // until a drag-select forces a redraw). Force the paused render through - // first; only fall back to refresh() when the terminal was not gated. + // until a drag-select forces a redraw). Drive one synchronous full present + // even if the observer already unpaused; only fall back to refresh() when + // internals are unavailable. // // Why the display check: that release is only right for a pane that is // DOM-visible. A pane with no box at all (collapsed sibling of an expanded @@ -162,7 +202,17 @@ export function resetWebglTextureAtlas(pane: ManagedPaneInternal): void { // it also drops the full repaint the observer owes the pane on reveal, and // the deferred _pausedResizeTask that flushes alongside it. Latching is what // xterm's own gate does, and the reveal repaints from the latch. - if (isManagedPaneDisplayNone(pane) || !forceRepaintThroughRenderPause(pane.terminal)) { + if (isManagedPaneDisplayNone(pane)) { + pane.terminal.refresh(0, pane.terminal.rows - 1) + // Why: light tab reveal runs while the overlay is still display:none + // (field trace: paused=true needFull=true at click). A plain refresh only + // latches _needsFullRefresh; if IntersectionObserver never fires, the + // canvas keeps pre-hide pixels until a user resize. Retry once the box + // exists so the full present actually runs. + schedulePresentWhenDisplayed(internal) + return + } + if (!forceFullViewportPresent(pane.terminal)) { // Why: refresh even without a WebGL addon so recovery never silently // no-ops — a DOM-rendered pane can hold stale pixels after reveal too. pane.terminal.refresh(0, pane.terminal.rows - 1) @@ -172,6 +222,11 @@ export function resetWebglTextureAtlas(pane: ManagedPaneInternal): void { } } +export function resetWebglTextureAtlas(pane: ManagedPaneInternal): void { + clearWebglTextureAtlas(pane) + presentPaneViewport(pane) +} + function refitAfterFitAnchoredWebglAttach(pane: ManagedPaneInternal): void { // Why: the fit that triggered this attach measured DOM cell metrics, but WebGL // floors the device cell width — keeping that grid leaves an unpainted right diff --git a/src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts b/src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts index e3d84677424..a3c9cb30f06 100644 --- a/src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts +++ b/src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts @@ -1,24 +1,34 @@ import { describe, expect, it, vi } from 'vitest' -import { forceRepaintThroughRenderPause } from './terminal-render-pause-release' +import { + forceFullViewportPresent, + forceRepaintThroughRenderPause +} from './terminal-render-pause-release' type FakeRenderService = { _isPaused?: boolean _needsFullRefresh?: boolean refreshRows?: ReturnType + _renderer?: { + value?: { clear?: ReturnType; renderRows?: ReturnType } + } } function createTerminal(options: { rows?: number renderService?: FakeRenderService | null withoutCore?: boolean + synchronizedOutput?: boolean }): unknown { - const { rows = 24, renderService, withoutCore } = options + const { rows = 24, renderService, withoutCore, synchronizedOutput } = options if (withoutCore) { return { rows } } return { rows, - _core: { _renderService: renderService ?? null } + _core: { + _renderService: renderService ?? null, + coreService: { decPrivateModes: { synchronizedOutput: synchronizedOutput === true } } + } } } @@ -85,3 +95,78 @@ describe('forceRepaintThroughRenderPause', () => { expect(renderService._isPaused).toBe(false) }) }) + +describe('forceFullViewportPresent', () => { + it('leaves an unpaused, unsynchronized terminal to the normal refresh path', () => { + // A forced sync renderRows on first splash paints before cell metrics + // settle and shows a 1px black gutter under the TUI composer. + const refreshRows = vi.fn() + const renderRows = vi.fn() + const terminal = createTerminal({ + rows: 24, + renderService: { + _isPaused: false, + _needsFullRefresh: false, + refreshRows, + _renderer: { value: { renderRows } } + } + }) + + expect(forceFullViewportPresent(terminal)).toBe(false) + expect(renderRows).not.toHaveBeenCalled() + expect(refreshRows).not.toHaveBeenCalled() + }) + + it('paints through the renderer so DEC 2026 cannot swallow the reveal present', () => { + const refreshRows = vi.fn() + const renderRows = vi.fn() + const renderService = { + _isPaused: false, + _needsFullRefresh: false, + refreshRows, + _renderer: { value: { renderRows } } + } + const terminal = createTerminal({ + rows: 24, + renderService, + synchronizedOutput: true + }) + + expect(forceFullViewportPresent(terminal)).toBe(true) + expect(renderRows).toHaveBeenCalledWith(0, 23) + expect(refreshRows).not.toHaveBeenCalled() + }) + + it('uses RenderService refreshRows when paused without DEC 2026, matching production splash', () => { + const refreshRows = vi.fn() + const renderRows = vi.fn() + const renderService = { + _isPaused: true, + _needsFullRefresh: true, + refreshRows, + _renderer: { value: { renderRows } } + } + const terminal = createTerminal({ rows: 24, renderService }) + + expect(forceFullViewportPresent(terminal)).toBe(true) + expect(refreshRows).toHaveBeenCalledWith(0, 23, true) + expect(renderRows).not.toHaveBeenCalled() + expect(renderService._isPaused).toBe(false) + }) + + it('leaves pause cleared when the forced render throws so callers can refresh()', () => { + const renderService = { + _isPaused: true, + _needsFullRefresh: true, + refreshRows: vi.fn(() => { + throw new Error('terminal disposed') + }), + _renderer: { value: { renderRows: vi.fn() } } + } + const terminal = createTerminal({ rows: 24, renderService }) + + expect(forceFullViewportPresent(terminal)).toBe(false) + expect(renderService._isPaused).toBe(false) + expect(renderService._needsFullRefresh).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-render-pause-release.ts b/src/renderer/src/lib/pane-manager/terminal-render-pause-release.ts index 88b53129d87..299ce6f68cb 100644 --- a/src/renderer/src/lib/pane-manager/terminal-render-pause-release.ts +++ b/src/renderer/src/lib/pane-manager/terminal-render-pause-release.ts @@ -17,10 +17,15 @@ * never throws into a render frame. */ +type MaybeWebglRenderer = { + renderRows?: (start: number, end: number) => void +} + type MaybePausableRenderService = { _isPaused?: boolean _needsFullRefresh?: boolean refreshRows?: (start: number, end: number, sync?: boolean) => void + _renderer?: { value?: MaybeWebglRenderer | null } | MaybeWebglRenderer | null } type PausableRenderService = MaybePausableRenderService & { @@ -31,6 +36,8 @@ type TerminalWithRenderService = { rows?: number _core?: { _renderService?: MaybePausableRenderService + coreService?: { decPrivateModes?: { synchronizedOutput?: boolean } } + _coreService?: { decPrivateModes?: { synchronizedOutput?: boolean } } } } @@ -71,3 +78,70 @@ export function forceRepaintThroughRenderPause(terminal: unknown): boolean { return false } } + +function getRenderer(service: MaybePausableRenderService): MaybeWebglRenderer | null { + const holder = service._renderer + if (!holder) { + return null + } + if (typeof (holder as MaybeWebglRenderer).renderRows === 'function') { + return holder as MaybeWebglRenderer + } + const wrapped = (holder as { value?: MaybeWebglRenderer | null }).value + return wrapped ?? null +} + +function isSynchronizedOutputHeld(terminal: unknown): boolean { + const core = (terminal as TerminalWithRenderService)._core + return ( + (core?.coreService?.decPrivateModes ?? core?._coreService?.decPrivateModes) + ?.synchronizedOutput === true + ) +} + +/** + * One synchronous full-viewport present when xterm would otherwise swallow it: + * IntersectionObserver pause, or DEC 2026 synchronized output. + * + * Why not on every paint: a forced renderer.renderRows on a fresh, unpaused + * splash paints before cell metrics settle and leaves a 1px black gutter under + * a TUI composer — production never does that. Callers fall back to + * terminal.refresh() for the normal path. + */ +export function forceFullViewportPresent(terminal: unknown): boolean { + const service = getRenderService(terminal) + const rows = (terminal as TerminalWithRenderService).rows + if (!service || typeof rows !== 'number' || rows < 1) { + return false + } + + const paused = service._isPaused === true + const syncHeld = isSynchronizedOutputHeld(terminal) + if (!paused && !syncHeld) { + return false + } + + if (paused) { + service._isPaused = false + service._needsFullRefresh = false + } + + const renderer = getRenderer(service) + try { + // Why: a new TUI tab is often still paused (observer lag). Painting + // via renderer.renderRows skips RenderService's dimension clamp and draws + // 1px short of the composer box. Production uses refreshRows here. + // renderer.renderRows is only for DEC 2026, which swallows refreshRows. + if (syncHeld && typeof renderer?.renderRows === 'function') { + renderer.renderRows(0, rows - 1) + return true + } + service.refreshRows(0, rows - 1, true) + return true + } catch { + // Why: same as forceRepaintThroughRenderPause — leave the latch cleared so + // the caller's terminal.refresh() fallback can still paint. Restoring + // _isPaused would swallow that refresh until IntersectionObserver fires. + return false + } +} diff --git a/tests/e2e/terminal-reveal-paused-render-repro.spec.ts b/tests/e2e/terminal-reveal-paused-render-repro.spec.ts index 5b280037da4..202c6c3a4b3 100644 --- a/tests/e2e/terminal-reveal-paused-render-repro.spec.ts +++ b/tests/e2e/terminal-reveal-paused-render-repro.spec.ts @@ -1,6 +1,7 @@ +import { writeFile } from 'node:fs/promises' import type { Page } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' -import { getActiveTabId, waitForSessionReady } from './helpers/store' +import { getActiveTabId, getActiveWorktreeId, waitForSessionReady } from './helpers/store' import { execInTerminal, waitForActivePanePtyId, @@ -50,6 +51,11 @@ type RevealRenderDebug = { type RevealProbeWindow = Window & { __revealRenderProbe?: RevealRenderDebug + __syncRevealProbe?: { + paintFrame: (marker: string, background: number, release: boolean) => Promise + forceRendererPresent: () => void + read: () => { atlasClears: number; screen: string; synchronizedOutput: boolean } + } } /** @@ -182,6 +188,139 @@ async function forceWebglOn(page: Page, tabId: string): Promise { }, tabId) } +async function installSynchronizedRevealProbe(page: Page, tabId: string): Promise { + return page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + const pane = [ + ...((manager as unknown as { panes?: Map } | undefined)?.panes?.values() ?? + []) + ][0] as + | { + serializeAddon?: { serialize?: () => string } + terminal: unknown + webglAddon?: unknown + } + | undefined + const addon = pane?.webglAddon as { clearTextureAtlas: () => void } | null | undefined + type SyncRenderService = { + _renderer?: + | { renderRows?: (start: number, end: number) => void } + | { value?: { renderRows?: (start: number, end: number) => void } | null } + _syncOutputHandler?: { _timeout?: number } + } + const terminal = pane?.terminal as + | { + cols: number + rows: number + write: (data: string, callback: () => void) => void + _core?: { + coreService?: { decPrivateModes?: { synchronizedOutput?: boolean } } + _coreService?: { decPrivateModes?: { synchronizedOutput?: boolean } } + _renderService?: SyncRenderService + } + } + | undefined + const service = terminal?._core?._renderService + const modes = + terminal?._core?.coreService?.decPrivateModes ?? + terminal?._core?._coreService?.decPrivateModes + const rendererHolder = service?._renderer + const renderer = + rendererHolder && 'renderRows' in rendererHolder + ? rendererHolder + : rendererHolder && 'value' in rendererHolder + ? rendererHolder.value + : null + if (!pane || !addon || !terminal || !service || !modes || !renderer?.renderRows) { + throw new Error( + JSON.stringify({ + addon: Boolean(addon), + modes: Boolean(modes), + pane: Boolean(pane), + renderer: Boolean(renderer?.renderRows), + rendererKeys: rendererHolder ? Object.keys(rendererHolder) : [], + service: Boolean(service), + terminal: Boolean(terminal) + }) + ) + } + + let atlasClears = 0 + const originalClear = addon.clearTextureAtlas.bind(addon) + addon.clearTextureAtlas = () => { + atlasClears += 1 + originalClear() + } + + const paintFrame = (marker: string, background: number, release: boolean): Promise => { + const width = Math.max(1, terminal.cols) + const rows = Math.max(1, terminal.rows) + const line = ` ${marker} `.padEnd(width, marker[0] ?? '#').slice(0, width) + const frame = Array.from( + { length: rows }, + (_, row) => `\x1b[${row + 1};1H\x1b[48;5;${background}m\x1b[38;5;231m${line}` + ).join('') + return new Promise((resolve) => { + terminal.write( + `\x1b[?2026h\x1b[?1049h\x1b[2J${frame}\x1b[0m${release ? '\x1b[?2026l' : ''}`, + () => { + if (!release && service._syncOutputHandler?._timeout !== undefined) { + window.clearTimeout(service._syncOutputHandler._timeout) + service._syncOutputHandler._timeout = undefined + } + resolve() + } + ) + }) + } + + ;(window as RevealProbeWindow).__syncRevealProbe = { + paintFrame, + forceRendererPresent: () => renderer.renderRows?.(0, terminal.rows - 1), + read: () => ({ + atlasClears, + screen: pane.serializeAddon?.serialize?.() ?? '', + synchronizedOutput: modes.synchronizedOutput === true + }) + } + return true + }, tabId) +} + +async function callSynchronizedRevealProbe( + page: Page, + action: 'forceRendererPresent' | 'paintFrame', + frame?: { marker: string; background: number; release: boolean } +): Promise { + await page.evaluate( + async ({ action, frame }) => { + const probe = (window as RevealProbeWindow).__syncRevealProbe + if (!probe) { + throw new Error('Synchronized reveal probe not installed') + } + if (action === 'paintFrame') { + if (!frame) { + throw new Error('Synchronized frame missing') + } + await probe.paintFrame(frame.marker, frame.background, frame.release) + return + } + probe.forceRendererPresent() + }, + { action, frame } + ) +} + +async function readSynchronizedRevealProbe(page: Page) { + return page.evaluate(() => { + const probe = (window as RevealProbeWindow).__syncRevealProbe + if (!probe) { + throw new Error('Synchronized reveal probe not installed') + } + return probe.read() + }) +} + test.describe('terminal reveal paused-render recovery', () => { test("reveal repaint forces a render through xterm's paused gate", async ({ orcaPage }) => { // Why: __store / __paneManagers live on the main Orca renderer window @@ -265,4 +404,95 @@ test.describe('terminal reveal paused-render recovery', () => { `recovered surface matches the revealed content (diffRatio=${diff.diffRatio})` ).toBe(true) }) + + test('@headful tab reveal presents a synchronized-output WebGL frame', async ({ + orcaPage + }, testInfo) => { + await waitForSessionReady(orcaPage) + await waitForActiveTerminalManager(orcaPage) + const tabId = (await getActiveTabId(orcaPage))! + const worktreeId = (await getActiveWorktreeId(orcaPage))! + await forceWebglOn(orcaPage, tabId) + const webglAttached = await orcaPage + .waitForFunction( + (tabId) => + (window.__paneManagers?.get(tabId)?.getRenderingDiagnostics?.() ?? []).some( + (diagnostic) => diagnostic.hasWebgl + ), + tabId, + { timeout: 15_000 } + ) + .then(() => true) + .catch(() => false) + test.skip(!webglAttached, 'WebGL renderer is unavailable') + if (!webglAttached) { + return + } + const installed = await installSynchronizedRevealProbe(orcaPage, tabId) + expect(installed, 'WebGL renderer internals are available').toBe(true) + + await callSynchronizedRevealProbe(orcaPage, 'paintFrame', { + marker: 'BASELINE_FRAME', + background: 17, + release: true + }) + const siblingTabId = await orcaPage.evaluate((worktreeId) => { + const state = window.__store?.getState() + if (!state) { + throw new Error('Renderer store unavailable') + } + return state.createTab(worktreeId, undefined, undefined, { activate: false }).id + }, worktreeId) + await orcaPage.evaluate( + (siblingTabId) => window.__store?.getState().setActiveTab(siblingTabId), + siblingTabId + ) + await expect(orcaPage.locator(`[data-terminal-tab-id="${tabId}"]`)).toBeHidden() + + await callSynchronizedRevealProbe(orcaPage, 'paintFrame', { + marker: 'REVEALED_FRAME', + background: 52, + release: false + }) + const held = await readSynchronizedRevealProbe(orcaPage) + expect(held.synchronizedOutput).toBe(true) + expect(held.screen).toContain('REVEALED_FRAME') + + await orcaPage.evaluate((tabId) => window.__store?.getState().setActiveTab(tabId), tabId) + await expect.poll(() => getActiveTabId(orcaPage)).toBe(tabId) + await expect + .poll(async () => (await readSynchronizedRevealProbe(orcaPage)).atlasClears) + .toBeGreaterThan(0) + const afterReveal = await captureStableTabScreenshot(orcaPage, tabId) + + await callSynchronizedRevealProbe(orcaPage, 'forceRendererPresent') + const afterForcedPresent = await captureStableTabScreenshot(orcaPage, tabId) + const diff = compareTerminalScreenshots(afterReveal, afterForcedPresent) + const afterRevealPath = testInfo.outputPath('synchronized-frame-after-reveal.png') + const afterForcedPresentPath = testInfo.outputPath( + 'synchronized-frame-after-forced-present.png' + ) + const pixelDiffPath = testInfo.outputPath('synchronized-frame-pixel-diff.json') + await Promise.all([ + writeFile(afterRevealPath, afterReveal), + writeFile(afterForcedPresentPath, afterForcedPresent), + writeFile(pixelDiffPath, JSON.stringify(diff, null, 2)) + ]) + await testInfo.attach('synchronized-frame-after-reveal.png', { + path: afterRevealPath, + contentType: 'image/png' + }) + await testInfo.attach('synchronized-frame-after-forced-present.png', { + path: afterForcedPresentPath, + contentType: 'image/png' + }) + await testInfo.attach('synchronized-frame-pixel-diff.json', { + path: pixelDiffPath, + contentType: 'application/json' + }) + expect( + diff.matches, + `revealed pixels already match a forced buffer present (diffRatio=${diff.diffRatio})` + ).toBe(true) + }) })