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