diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 12b937b187f..97711a43088 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -2674,7 +2674,7 @@ "platform": "macos", "command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-browser-link.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "result": "failed", - "durationSeconds": 60, + "durationSeconds": 53, "summary": "The byte-identical final Electron oracle failed at its exact host/client identity barrier when the sole source delta replaced src/renderer/src/store/index.ts registration lines 110-113 with registerRuntimeHttpLinkBrowserOpener(null); no host-owned browser appeared within the deterministic 60-second bound." } ], @@ -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,47 +12296,207 @@ ], "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 explicit destructive atlas recovery while DEC 2026 output is held, 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": [], + "invariant": "After proven renderer damage triggers destructive recovery, visible pixels equal a full model-invalidating present of the current buffer.", + "oracle": "Hold DEC 2026, invoke explicit atlas recovery, require an atlas clear, screenshot the pane, force a direct renderer present, and assert the bounded pixel diff is immaterial. Compositor screenshots only, because WebGL canvas readback is blank with preserveDrawingBuffer disabled.", + "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", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.test.ts src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts", + "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": [ + "src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts", + "src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts", + "src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts", + "src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts", + "src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts", + "src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.test.ts", + "src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts", + "tests/e2e/terminal-reveal-paused-render-repro.spec.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts", + "assertions": [ + "deduplicates a deferred dpr retry and consumes it after repair succeeds", + "caps a deferred dpr transition at sixteen animation-frame retries" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts", + "assertions": ["defers a heavy-reveal dpr present until the shared atlas recovery"] + }, + { + "file": "src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.test.ts", + "assertions": [ + "repairs when the canvas and renderer dimensions share a stale dpr cache", + "defers an unmeasurable WebGL canvas but accepts a renderer-less pane" + ] + }, + { + "file": "tests/e2e/terminal-reveal-paused-render-repro.spec.ts", + "assertions": ["@headful atlas recovery presents a synchronized-output WebGL frame"] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-23", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.test.ts src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts", + "result": "passed", + "durationSeconds": 2, + "summary": "All 98 renderer contracts passed, including same-DPR zero-scan behavior, visible-only fanout, tri-state DPR repair, deduplicated bounded retry, heavy-reveal repair, and listener cleanup." + }, + { + "date": "2026-08-23", + "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": 60, + "summary": "Both headed oracles passed together. Explicit destructive atlas recovery presented the held synchronized-output buffer with zero pixels different from a direct renderer present; the test attached before/after screenshots plus 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": "Five local macOS headed runs passed, including one on the final branch state and 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": "Without the forced synchronized-output present, the damaged compositor differed from a direct renderer present across 87.68% of pixels even though serialization already contained the new frame. With explicit destructive recovery, the same oracle 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": "Destructive recovery remains event-driven and limited to confirmed renderer-lifecycle boundaries. Gate-local contracts prove ordinary same-DPR resize performs zero pane scans; a true transition scans only the visible manager; unavailable canvas retry is deduplicated and capped at 16 frames; and heavy reveal adds one bounded visible-pane scan. The shared terminal-render.atlas-recovery-fanout contracts separately prove zero hidden-manager atlas clears or presents and a per-pane display:none retry capped at 16 frames." }, "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 recovery test disables xterm's one-second safety timeout so the held-frame damage remains stable until recovery.", + "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." }, + { + "id": "terminal-render.synchronized-reveal-coherence", + "title": "Ordinary reveal preserves synchronized-output frame coherence", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-rendering", + "layer": "electron-visual-golden", + "surfaces": ["WebGL rendering", "tab reveal", "snapshot restore", "DEC 2026"], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "ssh", "remote-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local"], + "coverageNotes": "A headed macOS Electron test uses a real xterm WebGL renderer and production tab reveal. It proves the coherent pre-hide canvas remains visible while DEC 2026 holds a newer buffer, then proves normal release and xterm's one-second watchdog publish complete frames. Renderer behavior is provider-neutral; live Linux, Windows, daemon, SSH, and remote-runtime GPU runs remain gaps.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/pull/7133", + "https://github.com/stablyai/orca/pull/7054" + ], + "invariant": "terminal-render.synchronized-reveal-coherence: an ordinary reveal never clears a healthy shared atlas or publishes a partially built DEC 2026 frame; it retains the last coherent canvas until release or xterm's bounded watchdog.", + "oracle": "Paint and capture a coherent frame, hide the real terminal tab, build a distinct full-screen frame while DEC 2026 is held, reveal through production, and require zero atlas clears plus pixel-equivalence with the coherent frame. Then release normally and through the one-second watchdog, requiring both compositor frames to match a direct renderer present.", + "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", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts" + ], + "testFiles": [ + "src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts", + "src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts", + "src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts", + "src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts", + "src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts", + "tests/e2e/terminal-reveal-paused-render-repro.spec.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts", + "assertions": [ + "schedules an atlas-preserving present on a light tab reveal", + "keeps heavy atlas recovery off a plain window refocus" + ] + }, + { + "file": "src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts", + "assertions": ["preserves a synchronized frame on an ordinary reveal present"] + }, + { + "file": "src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts", + "assertions": [ + "routes synchronized output through RenderService instead of the renderer", + "releases observer pause before requesting the synchronized frame" + ] + }, + { + "file": "tests/e2e/terminal-reveal-paused-render-repro.spec.ts", + "assertions": [ + "@headful reveal preserves the coherent frame until synchronized output releases" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-23", + "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": 53, + "summary": "Both final-head headed oracles passed together. The coherent held frame differed by 73 of 1,404,288 pixels (0.0052%), ordinary reveal performed zero atlas clears, normal release differed from the direct present by 95 pixels (0.0068%), and watchdog release matched exactly. All three ratios passed the 1.5% compositor threshold." + } + ], + "runtimeBudget": { + "p95Seconds": 120, + "scope": "focused prebuilt headed Electron prevention and watchdog test" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "One final watchdog-inclusive macOS headed run passed; CI soak history and cross-platform GPU runs are not yet available." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Before the prevention change, the ordinary reveal oracle failed deterministically because it observed three atlas clears. After the change, reveal performs zero clears and the first visible frame remains pixel-equivalent to the coherent pre-hide frame." + }, + "performanceBudget": { + "required": true, + "evidence": "Ordinary reveal removes three shared-atlas clears and their global visible-manager presents. It adds one settled, event-driven full-viewport refresh request per revealed pane; DEC 2026 coalesces it and uses xterm's existing one-second watchdog. No polling, subprocess, provider call, PTY inventory, background-output work, listener, or unbounded retry was added; the display-none retry remains deduplicated and capped at 16 frames." + }, + "promotionCriteria": [ + "Accumulate stable macOS runs for both normal release and watchdog release.", + "Add Linux and Windows GPU evidence or retain those platform gaps explicitly.", + "Keep the destructive atlas-recovery oracle separate and passing." + ], + "knownGaps": [ + "The deterministic test injects full-screen marker frames through xterm rather than invoking a live model; genuine OpenCode full-window evidence separately proves the original corruption.", + "Linux, Windows, daemon, SSH, and remote-runtime live GPU evidence is not yet recorded.", + "The 1.5% compositor threshold tolerates cursor and shell-prompt timing noise; exact buffer content and atlas-clear counts are asserted separately." + ], + "demotionRule": "Demote if ordinary reveal clears the shared atlas, the held frame stops matching its coherent baseline, release/watchdog output diverges from a direct present, or the headed oracle cannot remain deterministic." + }, { "id": "pty-delivery.renderer-lifecycle-accounting-reset", "title": "Renderer lifecycle reset clears leaked PTY delivery accounting so surviving PTYs cannot freeze", diff --git a/src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts index c1df93dca25..a54c78f87de 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts @@ -33,14 +33,16 @@ const { scheduleRuntimeGraphSync, shouldSeedCacheTimerOnInitialTitle, toastInfo, - notifyCodexPaneBoundForStaleSweep + notifyCodexPaneBoundForStaleSweep, + presentPaneViewportPreservingSynchronizedOutput } = vi.hoisted(() => ({ resetAndRefreshAllTerminalWebglAtlases: vi.fn(), scheduleTerminalWebglAtlasRecovery: vi.fn(), scheduleRuntimeGraphSync: vi.fn(), shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), toastInfo: vi.fn(), - notifyCodexPaneBoundForStaleSweep: vi.fn() + notifyCodexPaneBoundForStaleSweep: vi.fn(), + presentPaneViewportPreservingSynchronizedOutput: vi.fn() })) let mockStoreState: StoreState @@ -57,6 +59,11 @@ vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ resetAndRefreshAllTerminalWebglAtlases })) +vi.mock('@/lib/pane-manager/pane-webgl-renderer', async (importOriginal) => ({ + ...(await importOriginal>()), + presentPaneViewportPreservingSynchronizedOutput +})) + vi.mock('./terminal-webgl-atlas-recovery', () => ({ scheduleTerminalWebglAtlasRecovery })) @@ -201,6 +208,7 @@ describe('connectPanePty', () => { expect.stringContaining('remote snapshot with hidden remote output'), expect.any(Function) ) + expect(presentPaneViewportPreservingSynchronizedOutput).toHaveBeenCalledWith(pane) disposable.dispose() }) 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..076c9194934 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 { presentPaneViewportPreservingSynchronizedOutput } 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) { + presentPaneViewportPreservingSynchronizedOutput(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..74af99a8e14 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 @@ -8,6 +8,10 @@ import { vi.mock('@/lib/pane-manager/pane-manager-registry', () => ({ resetAndRefreshAllTerminalWebglAtlases: vi.fn() })) +const presentPaneViewport = vi.fn() +vi.mock('@/lib/pane-manager/pane-webgl-renderer', () => ({ + presentPaneViewport: (pane: unknown) => presentPaneViewport(pane) +})) vi.mock('@/lib/pane-manager/pane-terminal-output-scheduler', () => ({ flushTerminalOutput: vi.fn(), requestTerminalBacklogRecovery: vi.fn() @@ -21,10 +25,6 @@ vi.mock('./pane-helpers', () => ({ fitPanes: vi.fn(), focusActivePane: vi.fn() })) -const scheduleTabRevealWebglAtlasRecovery = vi.fn() -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTabRevealWebglAtlasRecovery: () => scheduleTabRevealWebglAtlasRecovery() -})) const flushDeferredPaneMetricOptionsIfMeasurable = vi.fn((_pane: unknown) => false) vi.mock('@/lib/pane-manager/pane-fit', () => ({ flushDeferredPaneMetricOptionsIfMeasurable: (pane: unknown) => @@ -77,18 +77,19 @@ function resumeArgs(manager: FakeManager, shouldUseLightTabResume: boolean) { describe('resumeTerminalVisibility reveal repaint', () => { beforeEach(() => { vi.clearAllMocks() + repairPaneWebglCanvasDprMismatch.mockReturnValue(false) }) - it('schedules a pane-scoped repaint on a light tab reveal', () => { + it('schedules an atlas-preserving present on a light tab reveal', () => { // The light path is the "click the tab that was not open" gesture: it has // no rendering resume or fit, so without this repaint a hidden-while- // working pane keeps compositing pre-hide pixels. const manager = createManager() resumeTerminalVisibility(resumeArgs(manager, true)) - expect(manager.scheduleRevealRepaint).toHaveBeenCalledTimes(1) + expect(manager.scheduleRevealRepaint).not.toHaveBeenCalled() + expect(manager.scheduleRevealPresent).toHaveBeenCalledTimes(1) expect(manager.resumeRendering).not.toHaveBeenCalled() - expect(scheduleTabRevealWebglAtlasRecovery).toHaveBeenCalledTimes(1) }) it('captures native trim movement before enforcing viewport intent', async () => { @@ -147,6 +148,29 @@ describe('resumeTerminalVisibility reveal repaint', () => { expect(flushDeferredPaneMetricOptionsIfMeasurable).not.toHaveBeenCalled() }) + it('defers a heavy-reveal dpr present until the shared atlas recovery', async () => { + const pane = { terminal: {} } + const manager = createManager() + manager.getPanes.mockReturnValue([pane]) + repairPaneWebglCanvasDprMismatch.mockReturnValueOnce(true) + const { resetAndRefreshAllTerminalWebglAtlases } = vi.mocked( + await import('@/lib/pane-manager/pane-manager-registry') + ) + + resumeTerminalVisibility(resumeArgs(manager, false)) + + expect(repairPaneWebglCanvasDprMismatch).toHaveBeenCalledWith(pane) + expect(presentPaneViewport).not.toHaveBeenCalled() + expect(resetAndRefreshAllTerminalWebglAtlases).toHaveBeenCalledTimes(1) + const atlasResetCallOrder = resetAndRefreshAllTerminalWebglAtlases.mock.invocationCallOrder[0] + if (atlasResetCallOrder === undefined) { + throw new Error('Shared atlas recovery call order missing') + } + expect(repairPaneWebglCanvasDprMismatch.mock.invocationCallOrder[0]).toBeLessThan( + atlasResetCallOrder + ) + }) + it('does not fit on a light tab reveal', () => { const manager = createManager() resumeTerminalVisibility(resumeArgs(manager, true)) @@ -193,6 +217,27 @@ 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]) + repairPaneWebglCanvasDprMismatch.mockReturnValueOnce(true) + + 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) + expect(presentPaneViewport).toHaveBeenCalledWith(first) + }) + 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..0ea98f1db8f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts @@ -14,9 +14,9 @@ import { resetTerminalLinkifierHoverState } from '@/lib/pane-manager/terminal-linkifier-hover-reset' 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 @@ -88,8 +88,6 @@ export function resumeTerminalVisibility({ // overlay's delayed geometry fit. Still request hidden-output recovery: // agent TUIs can suppress hidden bytes until the pane is foregrounded. requestLightTabBacklogRecovery(manager) - // Why: reveal is the lifecycle boundary that owns hidden renderer repair. - scheduleTabRevealWebglAtlasRecovery() if (flushedDeferredMetrics) { // Why: the light path normally skips fitting, but flushed metrics changed // cell size — refit so cols/rows match before the overlay settles. @@ -109,10 +107,15 @@ export function resumeTerminalVisibility({ // terminals; refresh after reset so rebuilt atlases repaint from xterm. resetAndRefreshAllTerminalWebglAtlases('visibility-resume') } - // Why: the synchronous recovery above can fire before the revealed pane is - // attached and laid out. Follow up after layout with one shared-atlas-safe - // recovery covering every visible terminal manager. - manager.scheduleRevealRepaint() + if (shouldUseLightTabResume) { + // Why: preserve the last coherent frame while a TUI holds DEC 2026. The + // settled refresh arms xterm's watchdog without clearing shared GPU data. + manager.scheduleRevealPresent() + } else { + // Why: rendering was recreated, so the heavy path still needs the proven + // shared-atlas recovery after layout settles. + manager.scheduleRevealRepaint() + } }) } @@ -161,6 +164,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 @@ -214,6 +224,12 @@ function resumeTerminalVisibilityHeavy(manager: PaneManager, isActive: boolean): // Windows (ANGLE -> D3D11) it can be 100-500 ms but a deferred resume // would paint a stretched DOM-fallback flash, which is worse UX. manager.resumeRendering() + // Why: unchanged grid geometry can skip the reveal fit, but a retained WebGL + // canvas may still carry the previous display's backing-store DPR. The + // caller's atlas recovery presents the final shared-atlas generation. + for (const pane of manager.getPanes()) { + repairPaneWebglCanvasDprMismatch(pane) + } // Why: resumeRendering just re-attached WebGL, whose cell metrics briefly differ // from the DOM renderer's; a raw fit here reflows on a transient one-column-off // grid and garbles diff-painting inline TUIs (grok minimize→restore). diff --git a/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts b/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts index 80013abb860..51be05fc091 100644 --- a/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts @@ -5,8 +5,7 @@ import { } from '@/lib/pane-manager/pane-manager-registry' import * as terminalWebglAtlasRecovery from './terminal-webgl-atlas-recovery' -const { scheduleImagePasteWebglAtlasRecovery, scheduleTabRevealWebglAtlasRecovery } = - terminalWebglAtlasRecovery +const { scheduleImagePasteWebglAtlasRecovery } = terminalWebglAtlasRecovery describe('terminal WebGL atlas recovery', () => { const registeredManagers: { resetWebglTextureAtlases(): void }[] = [] @@ -151,26 +150,4 @@ describe('terminal WebGL atlas recovery', () => { expect(healthyManager.refreshAllPanes).toHaveBeenCalledTimes(3) expect(manager.refreshAllPanes).not.toHaveBeenCalled() }) - - it('recovers immediately on a tab reveal', () => { - vi.useFakeTimers() - const rafCallbacks: FrameRequestCallback[] = [] - vi.stubGlobal( - 'requestAnimationFrame', - vi.fn((callback: FrameRequestCallback) => { - rafCallbacks.push(callback) - return rafCallbacks.length - }) - ) - const manager = registerManager() - - scheduleTabRevealWebglAtlasRecovery() - rafCallbacks[0]?.(0) - expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1) - expect(manager.refreshAllPanes).toHaveBeenCalledTimes(1) - vi.advanceTimersByTime(120) - vi.advanceTimersByTime(380) - expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(3) - expect(manager.refreshAllPanes).toHaveBeenCalledTimes(3) - }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.ts b/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.ts index 72dc08e46c1..9253bdd0ffb 100644 --- a/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.ts +++ b/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.ts @@ -33,9 +33,3 @@ export function scheduleImagePasteWebglAtlasRecovery(): void { // one-shot event, so recover immediately rather than debouncing. scheduleAtlasRecoveryBurst('image-paste') } - -export function scheduleTabRevealWebglAtlasRecovery(): void { - // Why: a tab reveal is an explicit renderer lifecycle boundary where hidden - // GPU state can be stale; ordinary PTY output must not clear the shared atlas. - scheduleAtlasRecoveryBurst('tab-reveal') -} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-visibility-resume.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-visibility-resume.test.ts index c7972f542f5..4fdf805100d 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-visibility-resume.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects-visibility-resume.test.ts @@ -260,6 +260,8 @@ describe('useTerminalPaneGlobalEffects', () => { manager.resumeRendering.mockClear() manager.resetWebglTextureAtlases.mockClear() + manager.scheduleRevealRepaint.mockClear() + manager.scheduleRevealPresent.mockClear() manager.refreshAllPanes.mockClear() manager.suspendRendering.mockClear() mocks.fitAndFocusPanes.mockClear() @@ -289,8 +291,10 @@ describe('useTerminalPaneGlobalEffects', () => { expect(manager.resumeRendering).not.toHaveBeenCalled() expect(mocks.fitAndFocusPanes).not.toHaveBeenCalled() expect(mocks.fitPanes).not.toHaveBeenCalled() - expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1) - expect(manager.refreshAllPanes).toHaveBeenCalledTimes(1) + expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled() + expect(manager.refreshAllPanes).not.toHaveBeenCalled() + expect(manager.scheduleRevealRepaint).not.toHaveBeenCalled() + expect(manager.scheduleRevealPresent).toHaveBeenCalledTimes(1) expect(mocks.focusActivePane).toHaveBeenCalledWith(manager) vi.advanceTimersByTime(500) }) @@ -342,6 +346,8 @@ describe('useTerminalPaneGlobalEffects', () => { manager.resumeRendering.mockClear() manager.resetWebglTextureAtlases.mockClear() + manager.scheduleRevealRepaint.mockClear() + manager.scheduleRevealPresent.mockClear() manager.refreshAllPanes.mockClear() mocks.fitAndFocusPanes.mockClear() mocks.fitPanes.mockClear() @@ -361,8 +367,10 @@ describe('useTerminalPaneGlobalEffects', () => { expect(manager.resumeRendering).not.toHaveBeenCalled() expect(mocks.fitAndFocusPanes).not.toHaveBeenCalled() expect(mocks.fitPanes).not.toHaveBeenCalled() - expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1) - expect(manager.refreshAllPanes).toHaveBeenCalledTimes(1) + expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled() + expect(manager.refreshAllPanes).not.toHaveBeenCalled() + expect(manager.scheduleRevealRepaint).not.toHaveBeenCalled() + expect(manager.scheduleRevealPresent).toHaveBeenCalledTimes(1) expect(mocks.focusActivePane).toHaveBeenCalledWith(manager) vi.advanceTimersByTime(500) }) 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..c903ec1a2f9 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 @@ -2,15 +2,30 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { renderHook } from '@testing-library/react' import type { PaneManager } from '@/lib/pane-manager/pane-manager' +import type { PaneWebglCanvasDprRepairState } from '@/lib/pane-manager/terminal-canvas-dpr-repair' -const { recoverVisibleTerminalWindowWakeMock } = vi.hoisted(() => ({ - recoverVisibleTerminalWindowWakeMock: vi.fn() +const { + recoverVisibleTerminalWindowWakeMock, + repairPaneWebglCanvasDprMock, + presentPaneViewportMock +} = vi.hoisted(() => ({ + recoverVisibleTerminalWindowWakeMock: vi.fn(), + repairPaneWebglCanvasDprMock: vi.fn<() => PaneWebglCanvasDprRepairState>(() => 'current'), + presentPaneViewportMock: vi.fn() })) vi.mock('./terminal-visibility-resume', () => ({ recoverVisibleTerminalWindowWake: recoverVisibleTerminalWindowWakeMock })) +vi.mock('@/lib/pane-manager/terminal-canvas-dpr-repair', () => ({ + repairPaneWebglCanvasDpr: repairPaneWebglCanvasDprMock +})) + +vi.mock('@/lib/pane-manager/pane-webgl-renderer', () => ({ + presentPaneViewport: presentPaneViewportMock +})) + import { useTerminalWindowWakeRecovery } from './use-terminal-window-wake-recovery' import { getTerminalFreezeBreadcrumbs, @@ -29,6 +44,9 @@ describe('useTerminalWindowWakeRecovery', () => { beforeEach(() => { systemResumedCallback = null recoverVisibleTerminalWindowWakeMock.mockClear() + repairPaneWebglCanvasDprMock.mockClear() + presentPaneViewportMock.mockClear() + repairPaneWebglCanvasDprMock.mockReturnValue('current') unsubscribeSystemResumed.mockClear() onSystemResumed.mockClear() resetTerminalFreezeBreadcrumbsForTesting() @@ -180,4 +198,118 @@ 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 + repairPaneWebglCanvasDprMock.mockReturnValue('repaired') + vi.stubGlobal('devicePixelRatio', 1) + const { unmount } = renderHook(() => + useTerminalWindowWakeRecovery({ + isVisible: true, + managerRef: { current: resizeManager }, + isActiveRef: { current: true }, + isVisibleRef: { current: true } + }) + ) + + window.dispatchEvent(new Event('resize')) + expect(repairPaneWebglCanvasDprMock).not.toHaveBeenCalled() + + vi.stubGlobal('devicePixelRatio', 2) + window.dispatchEvent(new Event('resize')) + + expect(repairPaneWebglCanvasDprMock).toHaveBeenCalledWith(pane) + expect(presentPaneViewportMock).toHaveBeenCalledWith(pane) + expect(recoverVisibleTerminalWindowWakeMock).not.toHaveBeenCalled() + + unmount() + vi.stubGlobal('devicePixelRatio', 3) + window.dispatchEvent(new Event('resize')) + expect(repairPaneWebglCanvasDprMock).toHaveBeenCalledTimes(1) + }) + + it('retains a dpr transition until the visible manager is available', () => { + const pane = { id: 1, terminal: {} } + const managerRef: { current: PaneManager | null } = { current: null } + vi.stubGlobal('devicePixelRatio', 1) + renderHook(() => + useTerminalWindowWakeRecovery({ + isVisible: true, + managerRef, + isActiveRef: { current: true }, + isVisibleRef: { current: true } + }) + ) + + vi.stubGlobal('devicePixelRatio', 2) + window.dispatchEvent(new Event('resize')) + expect(repairPaneWebglCanvasDprMock).not.toHaveBeenCalled() + + managerRef.current = { getPanes: () => [pane] } as unknown as PaneManager + window.dispatchEvent(new Event('resize')) + expect(repairPaneWebglCanvasDprMock).toHaveBeenCalledWith(pane) + }) + + it('deduplicates a deferred dpr retry and consumes it after repair succeeds', () => { + const pane = { id: 1, terminal: {} } + const callbacks: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callbacks.push(callback) + return callbacks.length + }) + vi.stubGlobal('devicePixelRatio', 1) + repairPaneWebglCanvasDprMock.mockReturnValueOnce('deferred').mockReturnValue('repaired') + renderHook(() => + useTerminalWindowWakeRecovery({ + isVisible: true, + managerRef: { current: { getPanes: () => [pane] } as unknown as PaneManager }, + isActiveRef: { current: true }, + isVisibleRef: { current: true } + }) + ) + + vi.stubGlobal('devicePixelRatio', 2) + window.dispatchEvent(new Event('resize')) + window.dispatchEvent(new Event('resize')) + expect(repairPaneWebglCanvasDprMock).toHaveBeenCalledTimes(1) + expect(callbacks).toHaveLength(1) + + callbacks.shift()?.(performance.now()) + expect(presentPaneViewportMock).toHaveBeenCalledWith(pane) + window.dispatchEvent(new Event('resize')) + expect(repairPaneWebglCanvasDprMock).toHaveBeenCalledTimes(2) + }) + + it('caps a deferred dpr transition at sixteen animation-frame retries', () => { + const pane = { id: 1, terminal: {} } + const callbacks: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callbacks.push(callback) + return callbacks.length + }) + vi.stubGlobal('devicePixelRatio', 1) + repairPaneWebglCanvasDprMock.mockReturnValue('deferred') + renderHook(() => + useTerminalWindowWakeRecovery({ + isVisible: true, + managerRef: { current: { getPanes: () => [pane] } as unknown as PaneManager }, + isActiveRef: { current: true }, + isVisibleRef: { current: true } + }) + ) + + vi.stubGlobal('devicePixelRatio', 2) + window.dispatchEvent(new Event('resize')) + let callbackCount = 0 + while (callbacks.length > 0) { + callbacks.shift()?.(performance.now()) + callbackCount += 1 + } + + expect(callbackCount).toBe(16) + expect(repairPaneWebglCanvasDprMock).toHaveBeenCalledTimes(17) + }) }) 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..444d0f1f9dd 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 { repairPaneWebglCanvasDpr } 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' @@ -16,6 +18,8 @@ type WindowWakePtyBinding = IDisposable & { reassertPtySizeAfterWindowWake?: () => void } +const DPR_RECOVERY_RETRY_FRAMES = 16 + export function useTerminalWindowWakeRecovery({ isVisible, managerRef, @@ -28,7 +32,10 @@ export function useTerminalWindowWakeRecovery({ return } let wakeRecoveryFrameId: number | null = null + let dprRecoveryFrameId: number | null = null + let dprRecoveryFramesRemaining = 0 let settledClearGlyphAtlases = false + let observedDevicePixelRatio = window.devicePixelRatio const cancelScheduledWakeRecovery = (): void => { if (wakeRecoveryFrameId === null || typeof cancelAnimationFrame !== 'function') { wakeRecoveryFrameId = null @@ -37,6 +44,13 @@ export function useTerminalWindowWakeRecovery({ cancelAnimationFrame(wakeRecoveryFrameId) wakeRecoveryFrameId = null } + const cancelScheduledDprRecovery = (): void => { + if (dprRecoveryFrameId !== null && typeof cancelAnimationFrame === 'function') { + cancelAnimationFrame(dprRecoveryFrameId) + } + dprRecoveryFrameId = null + dprRecoveryFramesRemaining = 0 + } const reassertPanePtySizes = (): void => { for (const binding of panePtyBindingsRef?.current.values() ?? []) { // Why: one settled read avoids duplicate SSH RPCs while still detecting a dropped resize. @@ -111,7 +125,59 @@ export function useTerminalWindowWakeRecovery({ recoverVisibleWake(true, 'system-resumed') } } + const repairVisiblePanesForDpr = (devicePixelRatio: number): boolean => { + const manager = managerRef.current + if (!manager || !isVisibleRef.current) { + return false + } + let deferred = false + for (const pane of manager.getPanes?.() ?? []) { + const state = repairPaneWebglCanvasDpr(pane) + deferred ||= state === 'deferred' + if (state === 'repaired') { + presentPaneViewport(pane) + } + } + if (!deferred) { + observedDevicePixelRatio = devicePixelRatio + } + return !deferred + } + const scheduleDprRecovery = (): void => { + if (dprRecoveryFrameId !== null || typeof requestAnimationFrame !== 'function') { + return + } + dprRecoveryFramesRemaining = DPR_RECOVERY_RETRY_FRAMES + const retry = (): void => { + dprRecoveryFrameId = null + if (repairVisiblePanesForDpr(window.devicePixelRatio)) { + dprRecoveryFramesRemaining = 0 + return + } + dprRecoveryFramesRemaining -= 1 + if (dprRecoveryFramesRemaining > 0) { + dprRecoveryFrameId = requestAnimationFrame(retry) + } + } + dprRecoveryFrameId = requestAnimationFrame(retry) + } + 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 devicePixelRatio = window.devicePixelRatio + if (devicePixelRatio === observedDevicePixelRatio) { + return + } + if (dprRecoveryFrameId !== null) { + return + } + if (!repairVisiblePanesForDpr(devicePixelRatio)) { + scheduleDprRecovery() + } + } window.addEventListener('focus', onFocus) + window.addEventListener('resize', onWindowResize) if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') { document.addEventListener('visibilitychange', onVisibilityChange) } @@ -125,7 +191,9 @@ export function useTerminalWindowWakeRecovery({ : null return () => { cancelScheduledWakeRecovery() + cancelScheduledDprRecovery() 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..bc05069bdfe 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 } @@ -296,8 +306,7 @@ export class PaneManager { } scheduleRevealPresent(): void { - // Why: same destroy guard as scheduleRevealRepaint, but presents without - // clearing the shared glyph atlas — used by the plain-refocus recovery. + // Why: ordinary reveal keeps the coherent canvas until DEC 2026 releases. schedulePaneRevealPresent(() => (this.destroyed ? [] : this.panes.values())) } 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..ab2f973bcfa 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 { presentPaneViewportPreservingSynchronizedOutput } from './pane-webgl-renderer' import { resetAndRefreshAllTerminalWebglAtlases } from './pane-manager-registry' type PaneGetter = () => Iterable @@ -83,21 +84,10 @@ 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) - } + presentPaneViewportPreservingSynchronizedOutput(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..00c8174967a 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,8 @@ import type { ManagedPaneInternal } from './pane-manager-types' import { attachWebgl, clearTerminalWebglAttachBackoff, + presentPaneViewport, + presentPaneViewportPreservingSynchronizedOutput, resetTerminalWebglSuggestion, resetWebglTextureAtlas } from './pane-webgl-renderer' @@ -75,6 +77,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 +90,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 +100,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 +178,58 @@ 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('preserves a synchronized frame on an ordinary reveal present', () => { + 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: true } } + } + } as never + + presentPaneViewportPreservingSynchronizedOutput(pane) + + expect(refreshRows).toHaveBeenCalledWith(0, 23, true) + expect(renderRows).not.toHaveBeenCalled() + expect(pane.terminal.refresh).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 +247,52 @@ 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) + presentPaneViewport(pane) + expect(queued).toHaveLength(1) + let callbacks = 0 + while (queued.length > 0) { + queued.shift()?.(callbacks * 16) + callbacks += 1 + } + + expect(callbacks).toBe(16) + expect(renderService._isPaused).toBe(true) + expect(renderService.refreshRows).toHaveBeenCalledTimes(2) + }) + 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..463bc76e53a 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,12 @@ 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, + requestFullViewportPresent +} from './terminal-render-pause-release' import { getTerminalWebglAutoDecision, resetTerminalWebglAutoDecision @@ -138,7 +141,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,22 +150,89 @@ 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 +type ViewportPresentMode = 'preserve-synchronized-output' | 'force-current-buffer' +type DisplayedPresentRetry = { frames: number; mode: ViewportPresentMode } +const pendingDisplayedPresentRetries = new WeakMap() + +function schedulePresentWhenDisplayed(pane: ManagedPaneInternal, mode: ViewportPresentMode): void { + if (typeof globalThis.requestAnimationFrame !== 'function') { + return + } + const pending = pendingDisplayedPresentRetries.get(pane) + if (pending) { + if (mode === 'force-current-buffer') { + pending.mode = mode + } + return + } + pendingDisplayedPresentRetries.set(pane, { + frames: DISPLAYED_PRESENT_RETRY_FRAMES, + mode + }) + const tick = (): void => { + const retry = pendingDisplayedPresentRetries.get(pane) + if (!retry || retry.frames <= 0 || !pane.terminal) { + pendingDisplayedPresentRetries.delete(pane) + return + } + if (isManagedPaneDisplayNone(pane)) { + if (retry.frames === 1) { + pendingDisplayedPresentRetries.delete(pane) + return + } + retry.frames -= 1 + globalThis.requestAnimationFrame(tick) + return + } + pendingDisplayedPresentRetries.delete(pane) + presentPaneViewportWithMode(pane, retry.mode) + } + globalThis.requestAnimationFrame(tick) +} + +function presentPaneViewportWithMode(pane: ManagedPane, mode: ViewportPresentMode): 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. + // paused-render gate and the pending model never repaints (stale bottom rows + // until a drag-select forces a redraw). Request 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 // pane, a restore that stays display:none for its whole reattach) is - // legitimately paused, and releasing it paints the just-cleared model into - // nothing and then leaves the service unpaused for good — the observer only + // legitimately paused, and releasing it paints into nothing and then leaves + // the service unpaused for good — the observer only // fires on a change, so it never re-pauses. Clearing _needsFullRefresh with // 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, mode) + return + } + const presented = + mode === 'force-current-buffer' + ? forceFullViewportPresent(pane.terminal) + : requestFullViewportPresent(pane.terminal) + if (!presented) { // 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 +242,19 @@ export function resetWebglTextureAtlas(pane: ManagedPaneInternal): void { } } +export function presentPaneViewport(pane: ManagedPane): void { + presentPaneViewportWithMode(pane, 'force-current-buffer') +} + +export function presentPaneViewportPreservingSynchronizedOutput(pane: ManagedPane): void { + presentPaneViewportWithMode(pane, 'preserve-synchronized-output') +} + +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-canvas-dpr-repair.test.ts b/src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.test.ts index 188b8441104..0d7f241ffbf 100644 --- a/src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.test.ts +++ b/src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.test.ts @@ -1,11 +1,15 @@ import { describe, expect, it, vi } from 'vitest' import type { ManagedPane } from './pane-manager-types' -import { repairPaneWebglCanvasDprMismatch } from './terminal-canvas-dpr-repair' +import { + repairPaneWebglCanvasDpr, + repairPaneWebglCanvasDprMismatch +} from './terminal-canvas-dpr-repair' function makePane(args: { backingWidth: number expectedWidth: number dpr: number + cachedDpr?: number backingHeight?: number expectedHeight?: number connected?: boolean @@ -32,6 +36,7 @@ function makePane(args: { (args.hasRenderer ?? true) ? { _canvas: canvas, + _devicePixelRatio: args.cachedDpr ?? args.dpr, dimensions: { device: { canvas: { width: args.expectedWidth, height: args.expectedHeight ?? 1200 } @@ -79,6 +84,19 @@ describe('repairPaneWebglCanvasDprMismatch', () => { expect(handleResize).toHaveBeenCalledTimes(1) }) + it('repairs when the canvas and renderer dimensions share a stale dpr cache', () => { + const { pane, handleDevicePixelRatioChange, handleResize } = makePane({ + backingWidth: 1080, + expectedWidth: 1080, + dpr: 2, + cachedDpr: 1 + }) + + expect(repairPaneWebglCanvasDprMismatch(pane)).toBe(true) + expect(handleDevicePixelRatioChange).toHaveBeenCalledTimes(1) + expect(handleResize).toHaveBeenCalledWith(120, 40) + }) + it('is a no-op when backing matches the renderer device dimensions', () => { const { pane, handleResize, refresh } = makePane({ backingWidth: 2160, @@ -136,6 +154,24 @@ describe('repairPaneWebglCanvasDprMismatch', () => { expect(repairPaneWebglCanvasDprMismatch(noRenderer.pane)).toBe(false) }) + it('defers an unmeasurable WebGL canvas but accepts a renderer-less pane', () => { + const detached = makePane({ + backingWidth: 2160, + expectedWidth: 1080, + dpr: 1, + connected: false + }) + const noRenderer = makePane({ + backingWidth: 2160, + expectedWidth: 1080, + dpr: 1, + hasRenderer: false + }) + + expect(repairPaneWebglCanvasDpr(detached.pane)).toBe('deferred') + expect(repairPaneWebglCanvasDpr(noRenderer.pane)).toBe('current') + }) + it('reports failure without throwing when the repair path throws mid-teardown', () => { const { pane, handleResize } = makePane({ backingWidth: 2160, diff --git a/src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.ts b/src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.ts index 72bde40b30b..587e01288e4 100644 --- a/src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.ts +++ b/src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.ts @@ -14,6 +14,7 @@ import { recordTerminalWebglDiagnostic } from '../../../../shared/terminal-webgl */ type XtermRendererInternals = { _canvas?: HTMLCanvasElement + _devicePixelRatio?: number _gl?: { canvas?: HTMLCanvasElement } dimensions?: { device?: { canvas?: { width?: number; height?: number } } @@ -22,33 +23,42 @@ type XtermRendererInternals = { handleResize?: (cols: number, rows: number) => void } -export function repairPaneWebglCanvasDprMismatch(pane: ManagedPane): boolean { +export type PaneWebglCanvasDprRepairState = 'current' | 'deferred' | 'repaired' + +export function repairPaneWebglCanvasDpr(pane: ManagedPane): PaneWebglCanvasDprRepairState { const renderer = ( pane.terminal as unknown as { _core?: { _renderService?: { _renderer?: { value?: XtermRendererInternals } } } } )._core?._renderService?._renderer?.value const canvas = renderer?._canvas ?? renderer?._gl?.canvas - if (!renderer || !canvas?.isConnected) { - return false + if (!renderer || !canvas) { + return 'current' + } + if (!canvas.isConnected) { + return 'deferred' } const view = canvas.ownerDocument?.defaultView const expected = renderer.dimensions?.device?.canvas const expectedWidth = expected?.width ?? 0 const expectedHeight = expected?.height ?? 0 if (!view || expectedWidth <= 0 || expectedHeight <= 0) { - return false + return 'deferred' } const staleBackingWidth = canvas.width const staleBackingHeight = canvas.height + const cachedDevicePixelRatio = renderer._devicePixelRatio + const devicePixelRatioChanged = + typeof cachedDevicePixelRatio === 'number' && cachedDevicePixelRatio !== view.devicePixelRatio // xterm rounds its CSS canvas size before ResizeObserver converts it back to // device pixels; allow that round trip without forcing layout on every fit. const roundingTolerance = Math.max(1, Math.ceil(view.devicePixelRatio / 2)) if ( + !devicePixelRatioChanged && Math.abs(staleBackingWidth - expectedWidth) <= roundingTolerance && Math.abs(staleBackingHeight - expectedHeight) <= roundingTolerance ) { - return false + return 'current' } try { // Order matters: refresh the renderer's cached dpr/dimensions first, then @@ -58,13 +68,18 @@ export function repairPaneWebglCanvasDprMismatch(pane: ManagedPane): boolean { pane.terminal.refresh(0, pane.terminal.rows - 1) } catch { // Pane may be mid-teardown; the next reveal/fit retries the check. - return false + return 'deferred' } recordTerminalWebglDiagnostic('webgl-canvas-dpr-repair', { paneId: pane.id, staleBackingWidth, expectedBackingWidth: expectedWidth, + ...(cachedDevicePixelRatio === undefined ? {} : { cachedDevicePixelRatio }), devicePixelRatio: view.devicePixelRatio }) - return true + return 'repaired' +} + +export function repairPaneWebglCanvasDprMismatch(pane: ManagedPane): boolean { + return repairPaneWebglCanvasDpr(pane) === 'repaired' } 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..a0a81ec16d1 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,35 @@ import { describe, expect, it, vi } from 'vitest' -import { forceRepaintThroughRenderPause } from './terminal-render-pause-release' +import { + forceFullViewportPresent, + forceRepaintThroughRenderPause, + requestFullViewportPresent +} 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 +96,131 @@ describe('forceRepaintThroughRenderPause', () => { expect(renderService._isPaused).toBe(false) }) }) + +describe('forceFullViewportPresent', () => { + it('fails closed when xterm internals are unavailable', () => { + expect(forceFullViewportPresent(null)).toBe(false) + }) + + 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) + }) +}) + +describe('requestFullViewportPresent', () => { + it('fails closed when xterm internals are unavailable', () => { + expect(requestFullViewportPresent(null)).toBe(false) + }) + + it('leaves a normal visible terminal on the debounced refresh path', () => { + const refreshRows = vi.fn() + const terminal = createTerminal({ + renderService: { _isPaused: false, refreshRows } + }) + + expect(requestFullViewportPresent(terminal)).toBe(false) + expect(refreshRows).not.toHaveBeenCalled() + }) + + it('routes synchronized output through RenderService instead of the renderer', () => { + const refreshRows = vi.fn() + const renderRows = vi.fn() + const terminal = createTerminal({ + rows: 24, + synchronizedOutput: true, + renderService: { + _isPaused: false, + refreshRows, + _renderer: { value: { renderRows } } + } + }) + + expect(requestFullViewportPresent(terminal)).toBe(true) + expect(refreshRows).toHaveBeenCalledWith(0, 23, true) + expect(renderRows).not.toHaveBeenCalled() + }) + + it('releases observer pause before requesting the synchronized frame', () => { + const refreshRows = vi.fn() + const renderService = { + _isPaused: true, + _needsFullRefresh: true, + refreshRows + } + const terminal = createTerminal({ rows: 30, renderService, synchronizedOutput: true }) + + expect(requestFullViewportPresent(terminal)).toBe(true) + expect(renderService._isPaused).toBe(false) + expect(renderService._needsFullRefresh).toBe(false) + expect(refreshRows).toHaveBeenCalledWith(0, 29, true) + }) +}) 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..552939e6cd8 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,108 @@ export function forceRepaintThroughRenderPause(terminal: unknown): boolean { return false } } + +/** + * Requests a full viewport while preserving a TUI's synchronized-output frame. + * + * Why: ordinary reveal must not publish a half-built DEC 2026 frame. Routing + * through RenderService keeps the previous canvas coherent and arms xterm's + * bounded safety timeout if the TUI never closes the frame. + */ +export function requestFullViewportPresent(terminal: unknown): boolean { + const service = getRenderService(terminal) + if (!service) { + return false + } + const rows = (terminal as TerminalWithRenderService).rows + if (typeof rows !== 'number' || rows < 1) { + return false + } + + const paused = service._isPaused === true + if (!paused && !isSynchronizedOutputHeld(terminal)) { + return false + } + + if (paused) { + service._isPaused = false + service._needsFullRefresh = false + } + + try { + service.refreshRows(0, rows - 1, true) + return true + } catch { + 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) + if (!service) { + return false + } + const rows = (terminal as TerminalWithRenderService).rows + if (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..9efabdea164 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, @@ -19,7 +20,7 @@ import { compareTerminalScreenshots } from './terminal-screenshot-diff' * is then swallowed and the freshly-cleared render model never repaints. * * This spec drives the REAL production reveal path (manager.resetWebglTextureAtlases - * -> resetWebglTextureAtlas -> forceRepaintThroughRenderPause) against a real + * -> resetWebglTextureAtlas -> forceFullViewportPresent) against a real * xterm Terminal + RenderService. It: * 1. proves the bug: while paused, a plain refresh() renders nothing; * 2. proves the fix: the real reveal repaint forces a full-viewport render @@ -50,6 +51,16 @@ type RevealRenderDebug = { type RevealProbeWindow = Window & { __revealRenderProbe?: RevealRenderDebug + __syncRevealProbe?: { + paintFrame: (marker: string, background: number, release: boolean) => Promise + forceRendererPresent: () => void + read: () => { + atlasClears: number + fullViewportRenderRows: number + screen: string + synchronizedOutput: boolean + } + } } /** @@ -182,6 +193,154 @@ 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 + let fullViewportRenderRows = 0 + const originalClear = addon.clearTextureAtlas.bind(addon) + addon.clearTextureAtlas = () => { + atlasClears += 1 + originalClear() + } + const originalRenderRows = renderer.renderRows.bind(renderer) + renderer.renderRows = (start: number, end: number) => { + if (start === 0 && end >= terminal.rows - 1) { + fullViewportRenderRows += 1 + } + originalRenderRows(start, end) + } + + 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, + fullViewportRenderRows, + 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() + }) +} + +async function captureFirstRevealedFrame(page: Page, tabId: string): Promise { + const screen = page.locator(`[data-terminal-tab-id="${tabId}"] .xterm-screen`).first() + await expect(screen).toBeVisible() + return screen.screenshot({ animations: 'disabled' }) +} + 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 +424,233 @@ test.describe('terminal reveal paused-render recovery', () => { `recovered surface matches the revealed content (diffRatio=${diff.diffRatio})` ).toBe(true) }) + + test('@headful atlas recovery presents a synchronized-output WebGL frame', async ({ + orcaPage + }, testInfo) => { + await waitForSessionReady(orcaPage) + await waitForActiveTerminalManager(orcaPage) + const tabId = (await getActiveTabId(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 + }) + 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.__paneManagers?.get(tabId)?.scheduleRevealRepaint?.() + }, 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) + }) + + test('@headful reveal preserves the coherent frame until synchronized output releases', 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 + } + expect(await installSynchronizedRevealProbe(orcaPage, tabId)).toBe(true) + + await callSynchronizedRevealProbe(orcaPage, 'paintFrame', { + marker: 'COHERENT_FRAME', + background: 17, + release: true + }) + const coherent = await captureStableTabScreenshot(orcaPage, tabId) + const beforeHide = await readSynchronizedRevealProbe(orcaPage) + 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: 'PENDING_FRAME', + background: 52, + release: false + }) + await orcaPage.evaluate((tabId) => window.__store?.getState().setActiveTab(tabId), tabId) + await expect.poll(() => getActiveTabId(orcaPage)).toBe(tabId) + const held = await captureFirstRevealedFrame(orcaPage, tabId) + const heldState = await readSynchronizedRevealProbe(orcaPage) + const heldDiff = compareTerminalScreenshots(coherent, held) + + expect(heldState.synchronizedOutput).toBe(true) + expect(heldState.screen).toContain('PENDING_FRAME') + expect(heldState.atlasClears - beforeHide.atlasClears, 'ordinary reveal atlas clears').toBe(0) + expect( + heldDiff.matches, + `held reveal preserves the coherent frame (diffRatio=${heldDiff.diffRatio})` + ).toBe(true) + + await callSynchronizedRevealProbe(orcaPage, 'paintFrame', { + marker: 'PENDING_FRAME', + background: 52, + release: true + }) + const released = await captureStableTabScreenshot(orcaPage, tabId) + const releaseState = await readSynchronizedRevealProbe(orcaPage) + await callSynchronizedRevealProbe(orcaPage, 'forceRendererPresent') + const direct = await captureStableTabScreenshot(orcaPage, tabId) + const releasedDiff = compareTerminalScreenshots(released, direct) + + await callSynchronizedRevealProbe(orcaPage, 'paintFrame', { + marker: 'WATCHDOG_FRAME', + background: 88, + release: false + }) + const beforeWatchdog = await readSynchronizedRevealProbe(orcaPage) + await orcaPage.evaluate((tabId) => { + window.__paneManagers?.get(tabId)?.scheduleRevealPresent?.() + }, tabId) + await expect + .poll(async () => (await readSynchronizedRevealProbe(orcaPage)).synchronizedOutput, { + timeout: 2_500 + }) + .toBe(false) + const watchdog = await captureStableTabScreenshot(orcaPage, tabId) + const watchdogState = await readSynchronizedRevealProbe(orcaPage) + await callSynchronizedRevealProbe(orcaPage, 'forceRendererPresent') + const watchdogDirect = await captureStableTabScreenshot(orcaPage, tabId) + const watchdogDiff = compareTerminalScreenshots(watchdog, watchdogDirect) + const coherentPath = testInfo.outputPath('synchronized-coherent-before-hide.png') + const heldPath = testInfo.outputPath('synchronized-coherent-while-held.png') + const releasedPath = testInfo.outputPath('synchronized-new-frame-after-release.png') + const watchdogPath = testInfo.outputPath('synchronized-new-frame-after-watchdog.png') + const metricsPath = testInfo.outputPath('synchronized-prevention-metrics.json') + const counts = (state: Awaited>) => ({ + atlasClears: state.atlasClears, + fullViewportRenderRows: state.fullViewportRenderRows, + synchronizedOutput: state.synchronizedOutput + }) + await Promise.all([ + writeFile(coherentPath, coherent), + writeFile(heldPath, held), + writeFile(releasedPath, released), + writeFile(watchdogPath, watchdog), + writeFile( + metricsPath, + JSON.stringify( + { + beforeHide: counts(beforeHide), + heldState: counts(heldState), + releaseState: counts(releaseState), + beforeWatchdog: counts(beforeWatchdog), + watchdogState: counts(watchdogState), + heldDiff, + releasedDiff, + watchdogDiff + }, + null, + 2 + ) + ) + ]) + for (const [name, path] of [ + ['synchronized-coherent-before-hide.png', coherentPath], + ['synchronized-coherent-while-held.png', heldPath], + ['synchronized-new-frame-after-release.png', releasedPath], + ['synchronized-new-frame-after-watchdog.png', watchdogPath], + ['synchronized-prevention-metrics.json', metricsPath] + ] as const) { + await testInfo.attach(name, { + path, + contentType: name.endsWith('.json') ? 'application/json' : 'image/png' + }) + } + expect(releaseState.synchronizedOutput).toBe(false) + expect( + releasedDiff.matches, + `released frame matches a direct renderer present (diffRatio=${releasedDiff.diffRatio})` + ).toBe(true) + expect(watchdogState.atlasClears - beforeWatchdog.atlasClears).toBe(0) + expect( + watchdogDiff.matches, + `watchdog frame matches a direct renderer present (diffRatio=${watchdogDiff.diffRatio})` + ).toBe(true) + }) })