fix(terminal): preserve synchronized frames on reveal (#16026)

This commit is contained in:
Neil
2026-08-24 00:01:53 -07:00
committed by GitHub
parent 95633a7883
commit afd76a4df9
22 changed files with 1481 additions and 119 deletions
+182 -22
View File
@@ -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",
@@ -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<Record<string, unknown>>()),
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()
})
@@ -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: () =>
@@ -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()
@@ -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).
@@ -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)
})
})
@@ -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')
}
@@ -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)
})
@@ -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)
})
})
@@ -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)
}
@@ -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', () => {
@@ -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.
@@ -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()))
}
@@ -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<ManagedPaneInternal
resetWebglTextureAtlas(pane)
}
}
export function clearPaneWebglTextureAtlases(panes: Iterable<ManagedPaneInternal>): void {
for (const pane of panes) {
clearWebglTextureAtlas(pane)
}
}
export function presentPaneViewports(panes: Iterable<ManagedPaneInternal>): void {
for (const pane of panes) {
presentPaneViewport(pane)
}
}
@@ -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<ManagedPaneInternal>
@@ -83,21 +84,10 @@ export function schedulePaneRevealRepaint(getPanes: () => Iterable<ManagedPaneIn
scheduleSettledFrame(flushPaneRevealRepaints)
}
/**
* Presents already-visible panes without clearing the shared glyph atlas.
*
* Why: a plain window refocus never hid its panes, so their WebGL model is
* already current — a `refresh` re-presents the live buffer (covering a
* compositor that dropped frames while occluded). Using the atlas-clearing
* reveal repaint here would wipe the atlas shared by every same-config pane and
* re-arm the mid-stream page-merge garble race (xterm.js issue 4480); this path
* must stay texture-atlas-preserving.
*/
/** Presents panes without clearing the shared glyph atlas or bypassing DEC 2026. */
export function schedulePaneRevealPresent(getPanes: () => Iterable<ManagedPaneInternal>): void {
forEachPaneOnSettledFrame(getPanes, (pane) => {
reattachWebglIfNeeded(pane)
if (pane.terminal.rows > 0) {
pane.terminal.refresh(0, pane.terminal.rows - 1)
}
presentPaneViewportPreservingSynchronizedOutput(pane)
})
}
@@ -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.
@@ -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<ManagedPaneInternal, DisplayedPresentRetry>()
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
@@ -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,
@@ -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'
}
@@ -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<typeof vi.fn>
_renderer?: {
value?: { clear?: ReturnType<typeof vi.fn>; renderRows?: ReturnType<typeof vi.fn> }
}
}
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)
})
})
@@ -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
}
}
@@ -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<void>
forceRendererPresent: () => void
read: () => {
atlasClears: number
fullViewportRenderRows: number
screen: string
synchronizedOutput: boolean
}
}
}
/**
@@ -182,6 +193,154 @@ async function forceWebglOn(page: Page, tabId: string): Promise<void> {
}, tabId)
}
async function installSynchronizedRevealProbe(page: Page, tabId: string): Promise<boolean> {
return page.evaluate((tabId) => {
const manager = window.__paneManagers?.get(tabId)
const pane = [
...((manager as unknown as { panes?: Map<number, unknown> } | 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<void> => {
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<void> {
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<Buffer> {
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<ReturnType<typeof readSynchronizedRevealProbe>>) => ({
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)
})
})