mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(terminal): never present a half-built synchronized-output frame on reveal or recovery (#32281)
Settled reveal and every other atlas recovery forced the current xterm buffer onto the screen even while a TUI held DEC 2026 synchronized output. Codex erases rows inside each held redraw, so a reveal mid-redraw showed a blank pane for a frame (#32198). Viewport presents now have one mode: they keep the last coherent canvas and let xterm present when the TUI closes the frame, bounded by xterm's 1s synchronized-output safety timeout. Structural replay keeps its retained frame up until an open synchronized frame closes, bounded by its existing hold cap. Co-authored-by: YEONGUK LEE <contact@abruption.dev>
This commit is contained in:
+4
-4
@@ -34,7 +34,7 @@ const {
|
||||
shouldSeedCacheTimerOnInitialTitle,
|
||||
toastInfo,
|
||||
notifyCodexPaneBoundForStaleSweep,
|
||||
presentPaneViewportPreservingSynchronizedOutput
|
||||
presentPaneViewport
|
||||
} = vi.hoisted(() => ({
|
||||
resetAndRefreshAllTerminalWebglAtlases: vi.fn(),
|
||||
scheduleTerminalWebglAtlasRecovery: vi.fn(),
|
||||
@@ -42,7 +42,7 @@ const {
|
||||
shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false),
|
||||
toastInfo: vi.fn(),
|
||||
notifyCodexPaneBoundForStaleSweep: vi.fn(),
|
||||
presentPaneViewportPreservingSynchronizedOutput: vi.fn()
|
||||
presentPaneViewport: vi.fn()
|
||||
}))
|
||||
|
||||
let mockStoreState: StoreState
|
||||
@@ -61,7 +61,7 @@ vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({
|
||||
|
||||
vi.mock('@/lib/pane-manager/pane-webgl-renderer', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
presentPaneViewportPreservingSynchronizedOutput
|
||||
presentPaneViewport
|
||||
}))
|
||||
|
||||
vi.mock('./terminal-webgl-atlas-recovery', () => ({
|
||||
@@ -211,7 +211,7 @@ describe('connectPanePty', () => {
|
||||
expect.stringContaining('remote snapshot with hidden remote output'),
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(presentPaneViewportPreservingSynchronizedOutput).toHaveBeenCalledWith(pane)
|
||||
expect(presentPaneViewport).toHaveBeenCalledWith(pane)
|
||||
disposable.dispose()
|
||||
})
|
||||
|
||||
|
||||
+2
-2
@@ -15,7 +15,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 { presentPaneViewport } from '@/lib/pane-manager/pane-webgl-renderer'
|
||||
import {
|
||||
buildMainModelSnapshotReplayWrites,
|
||||
hasPositiveTerminalDimensions,
|
||||
@@ -198,7 +198,7 @@ export function bindHiddenOutputRestoreSnapshot(session: ConnectPanePtySession):
|
||||
recordTerminalOutput(session.pane.terminal)
|
||||
await waitForTerminalReplayWritesParsed(session.pane.terminal)
|
||||
if (session.deps.isVisibleRef.current) {
|
||||
presentPaneViewportPreservingSynchronizedOutput(session.pane)
|
||||
presentPaneViewport(session.pane)
|
||||
recordTerminalFreezeBreadcrumb('stale-pixel-restore-present', {
|
||||
paneId: session.pane.id
|
||||
})
|
||||
|
||||
@@ -9,11 +9,8 @@ vi.mock('@/lib/pane-manager/pane-manager-registry', () => ({
|
||||
resetAndRefreshAllTerminalWebglAtlases: vi.fn()
|
||||
}))
|
||||
const presentPaneViewport = vi.fn()
|
||||
const presentPaneViewportPreservingSynchronizedOutput = vi.fn()
|
||||
vi.mock('@/lib/pane-manager/pane-webgl-renderer', () => ({
|
||||
presentPaneViewport: (pane: unknown) => presentPaneViewport(pane),
|
||||
presentPaneViewportPreservingSynchronizedOutput: (pane: unknown) =>
|
||||
presentPaneViewportPreservingSynchronizedOutput(pane)
|
||||
presentPaneViewport: (pane: unknown) => presentPaneViewport(pane)
|
||||
}))
|
||||
vi.mock('@/lib/pane-manager/pane-terminal-output-scheduler', () => ({
|
||||
flushTerminalOutput: vi.fn(),
|
||||
@@ -173,7 +170,7 @@ describe('resumeTerminalVisibility reveal repaint', () => {
|
||||
expect(repairPaneWebglCanvasDprMismatch).toHaveBeenCalledWith(pane)
|
||||
expect(resetAndRefreshAllTerminalWebglAtlases).toHaveBeenCalledTimes(1)
|
||||
expect(resetAndRefreshAllTerminalWebglAtlases).toHaveBeenCalledWith('visibility-resume-dpr')
|
||||
expect(presentPaneViewportPreservingSynchronizedOutput).not.toHaveBeenCalled()
|
||||
expect(presentPaneViewport).not.toHaveBeenCalled()
|
||||
expect(manager.scheduleRevealRepaint).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
@@ -189,7 +186,7 @@ describe('resumeTerminalVisibility reveal repaint', () => {
|
||||
|
||||
resumeTerminalVisibility(resumeArgs(manager, false))
|
||||
|
||||
expect(presentPaneViewportPreservingSynchronizedOutput).toHaveBeenCalledWith(pane)
|
||||
expect(presentPaneViewport).toHaveBeenCalledWith(pane)
|
||||
// The expensive registry-wide rebuild is still deferred to the settled frame.
|
||||
expect(resetAndRefreshAllTerminalWebglAtlases).not.toHaveBeenCalled()
|
||||
expect(manager.scheduleRevealRepaint).toHaveBeenCalledTimes(1)
|
||||
|
||||
@@ -18,10 +18,7 @@ import { useAppStore } from '@/store'
|
||||
import { auditPaneWeightParity } from './terminal-render-desync-weight-probe'
|
||||
import { flushDeferredPaneMetricOptionsIfMeasurable } from '@/lib/pane-manager/pane-fit'
|
||||
import { repairPaneWebglCanvasDprMismatch } from '@/lib/pane-manager/terminal-canvas-dpr-repair'
|
||||
import {
|
||||
presentPaneViewport,
|
||||
presentPaneViewportPreservingSynchronizedOutput
|
||||
} from '@/lib/pane-manager/pane-webgl-renderer'
|
||||
import { presentPaneViewport } from '@/lib/pane-manager/pane-webgl-renderer'
|
||||
|
||||
const VISIBLE_RESUME_FLUSH_CHARS = 256 * 1024
|
||||
const WINDOW_WAKE_FLUSH_CHARS = 64 * 1024
|
||||
@@ -130,7 +127,7 @@ export function resumeTerminalVisibility({
|
||||
// present now; the settled rebuild is two frames out and would otherwise
|
||||
// leave pre-hide pixels composited until then.
|
||||
for (const pane of manager.getPanes()) {
|
||||
presentPaneViewportPreservingSynchronizedOutput(pane)
|
||||
presentPaneViewport(pane)
|
||||
}
|
||||
// Why: one settled rebuild repairs recreated rendering without paying a
|
||||
// duplicate global atlas rebuild before the pane is attached and measured.
|
||||
|
||||
@@ -66,14 +66,14 @@ describe('pane manager registry', () => {
|
||||
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')),
|
||||
presentViewports: 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')),
|
||||
presentViewports: vi.fn<() => void>(() => order.push('second-present')),
|
||||
refreshAllPanes: vi.fn<() => void>(() => order.push('second-refresh')),
|
||||
isVisibleForAtlasRecovery: () => true
|
||||
}
|
||||
@@ -93,7 +93,7 @@ describe('pane manager registry', () => {
|
||||
const visible = {
|
||||
resetWebglTextureAtlases: vi.fn<() => void>(),
|
||||
clearWebglTextureAtlases: vi.fn<() => void>(),
|
||||
presentForcedViewports: vi.fn<() => void>(),
|
||||
presentViewports: vi.fn<() => void>(),
|
||||
refreshAllPanes: vi.fn<() => void>(),
|
||||
isVisibleForAtlasRecovery: () => true
|
||||
}
|
||||
@@ -102,7 +102,7 @@ describe('pane manager registry', () => {
|
||||
const hidden = Array.from({ length: 64 }, () => ({
|
||||
resetWebglTextureAtlases: vi.fn<() => void>(),
|
||||
clearWebglTextureAtlases: vi.fn<() => void>(),
|
||||
presentForcedViewports: vi.fn<() => void>(),
|
||||
presentViewports: vi.fn<() => void>(),
|
||||
refreshAllPanes: vi.fn<() => void>(),
|
||||
isVisibleForAtlasRecovery: () => false
|
||||
}))
|
||||
@@ -114,13 +114,11 @@ describe('pane manager registry', () => {
|
||||
resetAndRefreshAllTerminalWebglAtlases()
|
||||
|
||||
expect(visible.clearWebglTextureAtlases).toHaveBeenCalledOnce()
|
||||
expect(visible.presentForcedViewports).toHaveBeenCalledOnce()
|
||||
expect(visible.presentViewports).toHaveBeenCalledOnce()
|
||||
expect(
|
||||
hidden.every((manager) => manager.clearWebglTextureAtlases.mock.calls.length === 0)
|
||||
).toBe(true)
|
||||
expect(hidden.every((manager) => manager.presentForcedViewports.mock.calls.length === 0)).toBe(
|
||||
true
|
||||
)
|
||||
expect(hidden.every((manager) => manager.presentViewports.mock.calls.length === 0)).toBe(true)
|
||||
})
|
||||
|
||||
it('continues reset-and-refresh recovery when one manager throws', () => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { PaneRenderingDiagnostics } from './pane-manager-types'
|
||||
type RegisteredPaneManager = {
|
||||
resetWebglTextureAtlases(): void
|
||||
clearWebglTextureAtlases?: () => void
|
||||
presentForcedViewports?: () => void
|
||||
presentViewports?: () => void
|
||||
fitAllPanes?: () => void
|
||||
refreshAllPanes?: () => void
|
||||
getRenderingDiagnostics?: () => PaneRenderingDiagnostics[]
|
||||
@@ -60,8 +60,8 @@ export function resetAndRefreshAllTerminalWebglAtlases(reason?: string): void {
|
||||
}
|
||||
for (const manager of resetManagers) {
|
||||
try {
|
||||
if (manager.presentForcedViewports) {
|
||||
manager.presentForcedViewports()
|
||||
if (manager.presentViewports) {
|
||||
manager.presentViewports()
|
||||
} else {
|
||||
manager.refreshAllPanes?.()
|
||||
}
|
||||
|
||||
@@ -291,7 +291,7 @@ export class PaneManager {
|
||||
clearPaneWebglTextureAtlases(this.panes.values())
|
||||
}
|
||||
|
||||
presentForcedViewports(): void {
|
||||
presentViewports(): void {
|
||||
presentPaneViewports(this.panes.values())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ManagedPaneInternal } from './pane-manager-types'
|
||||
import { reattachWebglIfNeeded } from './pane-webgl-reattach'
|
||||
import { presentPaneViewportPreservingSynchronizedOutput } from './pane-webgl-renderer'
|
||||
import { presentPaneViewport } from './pane-webgl-renderer'
|
||||
import { resetAndRefreshAllTerminalWebglAtlases } from './pane-manager-registry'
|
||||
|
||||
type PaneGetter = () => Iterable<ManagedPaneInternal>
|
||||
@@ -132,6 +132,6 @@ export function schedulePaneRevealPresent(
|
||||
): void {
|
||||
forEachPaneOnSettledFrame(resolvePaneGetter(ownerOrGetter, panes), (pane) => {
|
||||
reattachWebglIfNeeded(pane)
|
||||
presentPaneViewportPreservingSynchronizedOutput(pane)
|
||||
presentPaneViewport(pane)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,18 +10,10 @@ import { isTerminalLeafId } from '../../../../shared/stable-pane-id'
|
||||
import type { ManagedPaneInternal } from './pane-manager-types'
|
||||
import { disposePane } from './pane-lifecycle'
|
||||
import { toPublicPane } from './pane-public-view'
|
||||
import {
|
||||
cancelPendingTerminalViewportPresents,
|
||||
presentPaneViewport,
|
||||
presentPaneViewportPreservingSynchronizedOutput
|
||||
} from './pane-viewport-present'
|
||||
import {
|
||||
forceFullViewportPresent,
|
||||
requestFullViewportPresent
|
||||
} from './terminal-render-pause-release'
|
||||
import { cancelPendingTerminalViewportPresents, presentPaneViewport } from './pane-viewport-present'
|
||||
import { requestFullViewportPresent } from './terminal-render-pause-release'
|
||||
|
||||
vi.mock('./terminal-render-pause-release', () => ({
|
||||
forceFullViewportPresent: vi.fn(() => false),
|
||||
requestFullViewportPresent: vi.fn(() => false)
|
||||
}))
|
||||
|
||||
@@ -103,25 +95,23 @@ describe('pane viewport retry lifetime', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
for (const present of [presentPaneViewport, presentPaneViewportPreservingSynchronizedOutput]) {
|
||||
for (const elapsed of [0, 3]) {
|
||||
it(`cancels every public-wrapper retry after ${elapsed} ticks`, () => {
|
||||
const pane = createPane()
|
||||
present(toPublicPane(pane))
|
||||
present(toPublicPane(pane))
|
||||
for (let tick = 0; tick < elapsed; tick++) {
|
||||
flushFrame()
|
||||
}
|
||||
expect(frames.size).toBe(2)
|
||||
const lateCallbacks = [...frames.values()]
|
||||
disposePane(pane, new Map([[pane.id, pane]]))
|
||||
expect(frames.size).toBe(0)
|
||||
for (const callback of lateCallbacks) {
|
||||
callback(16)
|
||||
}
|
||||
expect(frames.size).toBe(0)
|
||||
})
|
||||
}
|
||||
for (const elapsed of [0, 3]) {
|
||||
it(`cancels every public-wrapper retry after ${elapsed} ticks`, () => {
|
||||
const pane = createPane()
|
||||
presentPaneViewport(toPublicPane(pane))
|
||||
presentPaneViewport(toPublicPane(pane))
|
||||
for (let tick = 0; tick < elapsed; tick++) {
|
||||
flushFrame()
|
||||
}
|
||||
expect(frames.size).toBe(2)
|
||||
const lateCallbacks = [...frames.values()]
|
||||
disposePane(pane, new Map([[pane.id, pane]]))
|
||||
expect(frames.size).toBe(0)
|
||||
for (const callback of lateCallbacks) {
|
||||
callback(16)
|
||||
}
|
||||
expect(frames.size).toBe(0)
|
||||
})
|
||||
}
|
||||
|
||||
it('releases real disposed xterm buffers before paused frames resume', async () => {
|
||||
@@ -131,7 +121,7 @@ describe('pane viewport retry lifetime', () => {
|
||||
pane.terminal.write('retained history\r\n'.repeat(500), resolve)
|
||||
)
|
||||
presentPaneViewport(toPublicPane(pane))
|
||||
presentPaneViewportPreservingSynchronizedOutput(toPublicPane(pane))
|
||||
presentPaneViewport(toPublicPane(pane))
|
||||
flushFrame()
|
||||
disposePane(pane, new Map([[pane.id, pane]]))
|
||||
pane.container.remove()
|
||||
@@ -153,25 +143,24 @@ describe('pane viewport retry lifetime', () => {
|
||||
const retired = createPane()
|
||||
const live = createPane()
|
||||
presentPaneViewport(toPublicPane(retired))
|
||||
presentPaneViewportPreservingSynchronizedOutput(toPublicPane(live))
|
||||
presentPaneViewport(toPublicPane(live))
|
||||
disposePane(retired, new Map([[retired.id, retired]]))
|
||||
expect(frames.size).toBe(1)
|
||||
live.container.style.display = 'block'
|
||||
flushFrame()
|
||||
expect(requestFullViewportPresent).toHaveBeenCalledOnce()
|
||||
expect(requestFullViewportPresent).toHaveBeenCalledWith(live.terminal)
|
||||
expect(forceFullViewportPresent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('deduplicates the same wrapper and upgrades its force mode', () => {
|
||||
it('deduplicates retries for the same wrapper', () => {
|
||||
const pane = createPane()
|
||||
const view = toPublicPane(pane)
|
||||
presentPaneViewportPreservingSynchronizedOutput(view)
|
||||
presentPaneViewport(view)
|
||||
presentPaneViewport(view)
|
||||
expect(frames.size).toBe(1)
|
||||
pane.container.style.display = 'block'
|
||||
flushFrame()
|
||||
expect(forceFullViewportPresent).toHaveBeenCalledOnce()
|
||||
expect(requestFullViewportPresent).not.toHaveBeenCalled()
|
||||
expect(requestFullViewportPresent).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('preserves the sixteen-frame budget for a live collapsed pane', () => {
|
||||
@@ -185,13 +174,13 @@ describe('pane viewport retry lifetime', () => {
|
||||
expect(frames.size).toBe(0)
|
||||
pane.container.style.display = 'block'
|
||||
presentPaneViewport(toPublicPane(pane))
|
||||
expect(forceFullViewportPresent).toHaveBeenCalledOnce()
|
||||
expect(requestFullViewportPresent).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps immediate displayed presentation and the no-frame fallback', () => {
|
||||
const pane = createPane()
|
||||
pane.container.style.display = 'block'
|
||||
presentPaneViewportPreservingSynchronizedOutput(toPublicPane(pane))
|
||||
presentPaneViewport(toPublicPane(pane))
|
||||
expect(requestFullViewportPresent).toHaveBeenCalledOnce()
|
||||
expect(frames.size).toBe(0)
|
||||
pane.container.style.display = 'none'
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import { SearchAddon } from '@xterm/addon-search'
|
||||
import { SerializeAddon } from '@xterm/addon-serialize'
|
||||
import { Unicode11Addon } from '@xterm/addon-unicode11'
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||
import { isTerminalLeafId } from '../../../../shared/stable-pane-id'
|
||||
import type { ManagedPaneInternal } from './pane-manager-types'
|
||||
import { presentPaneViewport } from './pane-viewport-present'
|
||||
|
||||
const SYNC_START = '\x1b[?2026h'
|
||||
const SYNC_END = '\x1b[?2026l'
|
||||
const ERASE_LINE = '\x1b[K'
|
||||
// xterm's SynchronizedOutputHandler safety timeout.
|
||||
const XTERM_SYNCHRONIZED_OUTPUT_TIMEOUT_MS = 1_000
|
||||
|
||||
// Codex 0.158 wraps each redraw in DEC 2026 and erases rows before repainting them.
|
||||
function firstCodexFrameWithErase(): { erased: string; rest: string } {
|
||||
const transcript = readFileSync(
|
||||
join(__dirname, '../../../../main/runtime/__fixtures__/codex-0-158-0-timed-turn.txt'),
|
||||
'utf8'
|
||||
)
|
||||
for (const block of transcript.split(SYNC_START).slice(1)) {
|
||||
const body = block.split(SYNC_END)[0]
|
||||
const erase = body.indexOf(ERASE_LINE)
|
||||
if (erase > 0 && !body.includes('\x1b[?1049h')) {
|
||||
const cut = erase + ERASE_LINE.length
|
||||
return { erased: body.slice(0, cut), rest: body.slice(cut) }
|
||||
}
|
||||
}
|
||||
throw new Error('fixture has no erasing synchronized frame')
|
||||
}
|
||||
|
||||
// xterm parses writes on a timer; fake timers must be stepped for the callback to fire.
|
||||
async function write(terminal: Terminal, data: string): Promise<void> {
|
||||
let parsed = false
|
||||
terminal.write(data, () => {
|
||||
parsed = true
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(parsed).toBe(true)
|
||||
}
|
||||
|
||||
function createPane(terminal: Terminal, container: HTMLElement): ManagedPaneInternal {
|
||||
const leafId = '11111111-1111-4111-8111-111111111111'
|
||||
if (!isTerminalLeafId(leafId)) {
|
||||
throw new Error('Expected a valid fixture leaf id')
|
||||
}
|
||||
return {
|
||||
id: 1,
|
||||
leafId,
|
||||
stablePaneId: leafId,
|
||||
terminal,
|
||||
container,
|
||||
xtermContainer: container,
|
||||
linkTooltip: document.createElement('div'),
|
||||
fitAddon: new FitAddon(),
|
||||
searchAddon: new SearchAddon(),
|
||||
serializeAddon: new SerializeAddon(),
|
||||
unicode11Addon: new Unicode11Addon(),
|
||||
webLinksAddon: new WebLinksAddon(),
|
||||
terminalGpuAcceleration: 'off',
|
||||
gpuRenderingEnabled: false,
|
||||
webglAttachmentDeferred: false,
|
||||
webglDisabledAfterContextLoss: false,
|
||||
hasComplexScriptOutput: false,
|
||||
webglAddon: null,
|
||||
imageAddon: null,
|
||||
ligaturesAddon: null,
|
||||
fitResizeObserver: null,
|
||||
pendingObservedFitRafId: null,
|
||||
compositionHandler: null,
|
||||
pendingSplitScrollState: null,
|
||||
debugLabel: null
|
||||
}
|
||||
}
|
||||
|
||||
const COHERENT_TEXT = 'COHERENT FRAME'
|
||||
|
||||
describe('presentPaneViewport during synchronized output', () => {
|
||||
const originalGetContext = Object.getOwnPropertyDescriptor(
|
||||
HTMLCanvasElement.prototype,
|
||||
'getContext'
|
||||
)
|
||||
let terminal: Terminal
|
||||
let pane: ManagedPaneInternal
|
||||
// What the DOM renderer has put on screen, not the parsed buffer.
|
||||
const screenText = (): string => pane.container.querySelector('.xterm-rows')?.textContent ?? ''
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'requestAnimationFrame'] })
|
||||
// Why: happy-dom has no canvas text metrics; xterm measures glyphs on open().
|
||||
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {
|
||||
configurable: true,
|
||||
value: () => ({ measureText: () => ({ width: 10 }) })
|
||||
})
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
terminal = new Terminal({ cols: 120, rows: 40, allowProposedApi: true })
|
||||
terminal.open(container)
|
||||
pane = createPane(terminal, container)
|
||||
await write(terminal, `\x1b[5;2H${COHERENT_TEXT}`)
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
expect(screenText()).toContain(COHERENT_TEXT)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
terminal.dispose()
|
||||
document.body.replaceChildren()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
if (originalGetContext) {
|
||||
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', originalGetContext)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the last coherent frame until the agent closes its redraw', async () => {
|
||||
const frame = firstCodexFrameWithErase()
|
||||
await write(terminal, SYNC_START + frame.erased)
|
||||
|
||||
presentPaneViewport(pane)
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
expect(screenText()).toContain(COHERENT_TEXT)
|
||||
|
||||
await write(terminal, frame.rest + SYNC_END)
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
expect(screenText()).not.toContain(COHERENT_TEXT)
|
||||
})
|
||||
|
||||
it('still paints a frame the agent never closes once xterm times out', async () => {
|
||||
const frame = firstCodexFrameWithErase()
|
||||
await write(terminal, SYNC_START + frame.erased)
|
||||
|
||||
presentPaneViewport(pane)
|
||||
await vi.advanceTimersByTimeAsync(XTERM_SYNCHRONIZED_OUTPUT_TIMEOUT_MS - 1)
|
||||
expect(screenText()).toContain(COHERENT_TEXT)
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
expect(screenText()).not.toContain(COHERENT_TEXT)
|
||||
})
|
||||
})
|
||||
@@ -1,17 +1,13 @@
|
||||
import type { ManagedPane, ManagedPaneInternal } from './pane-manager-types'
|
||||
import { isManagedPaneDisplayNone } from './pane-display-visibility'
|
||||
import {
|
||||
forceFullViewportPresent,
|
||||
requestFullViewportPresent
|
||||
} from './terminal-render-pause-release'
|
||||
import { requestFullViewportPresent } from './terminal-render-pause-release'
|
||||
|
||||
// Presenting a pane's viewport is a distinct concern from owning the WebGL
|
||||
// addon's lifecycle: it runs for DOM-rendered panes too, and its retry loop is
|
||||
// about DOM visibility, not about the renderer.
|
||||
|
||||
const DISPLAYED_PRESENT_RETRY_FRAMES = 16
|
||||
type ViewportPresentMode = 'preserve-synchronized-output' | 'force-current-buffer'
|
||||
type DisplayedPresentRetry = { frames: number; mode: ViewportPresentMode }
|
||||
type DisplayedPresentRetry = { frames: number }
|
||||
const pendingDisplayedPresentRetries = new WeakMap<ManagedPaneInternal, DisplayedPresentRetry>()
|
||||
// Public pane views are recreated; terminal teardown must cancel every view's retry.
|
||||
const presentRetryCancellations = new WeakMap<ManagedPane['terminal'], Set<() => void>>()
|
||||
@@ -22,21 +18,14 @@ export function cancelPendingTerminalViewportPresents(terminal: ManagedPane['ter
|
||||
}
|
||||
}
|
||||
|
||||
function schedulePresentWhenDisplayed(pane: ManagedPaneInternal, mode: ViewportPresentMode): void {
|
||||
function schedulePresentWhenDisplayed(pane: ManagedPaneInternal): void {
|
||||
if (typeof globalThis.requestAnimationFrame !== 'function') {
|
||||
return
|
||||
}
|
||||
const pending = pendingDisplayedPresentRetries.get(pane)
|
||||
if (pending) {
|
||||
if (mode === 'force-current-buffer') {
|
||||
pending.mode = mode
|
||||
}
|
||||
if (pendingDisplayedPresentRetries.has(pane)) {
|
||||
return
|
||||
}
|
||||
pendingDisplayedPresentRetries.set(pane, {
|
||||
frames: DISPLAYED_PRESENT_RETRY_FRAMES,
|
||||
mode
|
||||
})
|
||||
pendingDisplayedPresentRetries.set(pane, { frames: DISPLAYED_PRESENT_RETRY_FRAMES })
|
||||
const terminal = pane.terminal
|
||||
const cancellations = presentRetryCancellations.get(terminal) ?? new Set<() => void>()
|
||||
presentRetryCancellations.set(terminal, cancellations)
|
||||
@@ -78,12 +67,20 @@ function schedulePresentWhenDisplayed(pane: ManagedPaneInternal, mode: ViewportP
|
||||
return
|
||||
}
|
||||
finish()
|
||||
presentPaneViewportWithMode(pane, retry.mode)
|
||||
presentPaneViewport(pane)
|
||||
}
|
||||
frameId = globalThis.requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
function presentPaneViewportWithMode(pane: ManagedPane, mode: ViewportPresentMode): void {
|
||||
/**
|
||||
* Presents the full viewport, keeping the last coherent canvas while a TUI holds
|
||||
* DEC 2026 synchronized output.
|
||||
*
|
||||
* Why one mode: a forced paint mid-frame publishes the TUI's erase-then-repaint
|
||||
* half frame (a blank flash; #32198). Every reveal and atlas recovery waits for
|
||||
* the frame instead, bounded by xterm's synchronized-output safety timeout.
|
||||
*/
|
||||
export function presentPaneViewport(pane: ManagedPane): void {
|
||||
const internal = pane as ManagedPaneInternal
|
||||
if (internal.webglDisabledAfterContextLoss) {
|
||||
return
|
||||
@@ -112,14 +109,10 @@ function presentPaneViewportWithMode(pane: ManagedPane, mode: ViewportPresentMod
|
||||
// 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)
|
||||
schedulePresentWhenDisplayed(internal)
|
||||
return
|
||||
}
|
||||
const presented =
|
||||
mode === 'force-current-buffer'
|
||||
? forceFullViewportPresent(pane.terminal)
|
||||
: requestFullViewportPresent(pane.terminal)
|
||||
if (!presented) {
|
||||
if (!requestFullViewportPresent(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)
|
||||
@@ -128,11 +121,3 @@ function presentPaneViewportWithMode(pane: ManagedPane, mode: ViewportPresentMod
|
||||
/* ignore — pane may have been disposed in the meantime */
|
||||
}
|
||||
}
|
||||
|
||||
export function presentPaneViewport(pane: ManagedPane): void {
|
||||
presentPaneViewportWithMode(pane, 'force-current-buffer')
|
||||
}
|
||||
|
||||
export function presentPaneViewportPreservingSynchronizedOutput(pane: ManagedPane): void {
|
||||
presentPaneViewportWithMode(pane, 'preserve-synchronized-output')
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
attachWebgl,
|
||||
clearTerminalWebglAttachBackoff,
|
||||
presentPaneViewport,
|
||||
presentPaneViewportPreservingSynchronizedOutput,
|
||||
primeTerminalWebglAddon,
|
||||
resetTerminalWebglSuggestion,
|
||||
resetWebglTextureAtlas
|
||||
@@ -183,7 +182,7 @@ describe('terminal WebGL addon lifecycle', () => {
|
||||
|
||||
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).
|
||||
// requestFullViewportPresent is a no-op (not paused, no DEC 2026).
|
||||
const refreshRows = vi.fn()
|
||||
const renderRows = vi.fn()
|
||||
const pane = createPane()
|
||||
@@ -208,7 +207,7 @@ describe('terminal WebGL addon lifecycle', () => {
|
||||
expect(renderRows).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves a synchronized frame on an ordinary reveal present', () => {
|
||||
it('preserves a synchronized frame on a viewport present', () => {
|
||||
const refreshRows = vi.fn()
|
||||
const renderRows = vi.fn()
|
||||
const pane = createPane()
|
||||
@@ -226,7 +225,7 @@ describe('terminal WebGL addon lifecycle', () => {
|
||||
}
|
||||
} as never
|
||||
|
||||
presentPaneViewportPreservingSynchronizedOutput(pane)
|
||||
presentPaneViewport(pane)
|
||||
|
||||
expect(refreshRows).toHaveBeenCalledWith(0, 23, true)
|
||||
expect(renderRows).not.toHaveBeenCalled()
|
||||
|
||||
@@ -12,10 +12,7 @@ import { repairPaneWebglCanvasDprMismatch } from './terminal-canvas-dpr-repair'
|
||||
import { recordPaneWebglContextLoss } from './pane-webgl-context-loss-policy'
|
||||
import { presentPaneViewport } from './pane-viewport-present'
|
||||
|
||||
export {
|
||||
presentPaneViewport,
|
||||
presentPaneViewportPreservingSynchronizedOutput
|
||||
} from './pane-viewport-present'
|
||||
export { presentPaneViewport } from './pane-viewport-present'
|
||||
import {
|
||||
getTerminalWebglAddonConstructor,
|
||||
primeTerminalWebglAddon,
|
||||
|
||||
@@ -157,6 +157,10 @@ export function isSynchronizedOutputHeld(terminal: unknown): boolean {
|
||||
* One synchronous full-viewport present when xterm would otherwise swallow it:
|
||||
* IntersectionObserver pause, or DEC 2026 synchronized output.
|
||||
*
|
||||
* Why so few callers: painting mid DEC 2026 publishes a half-built TUI frame
|
||||
* (#32198). Only a keystroke echo inside a held frame and the replay hold cap
|
||||
* may do that; reveals and recovery use requestFullViewportPresent.
|
||||
*
|
||||
* 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
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import {
|
||||
forceFullViewportPresent,
|
||||
forceRepaintThroughRenderPause,
|
||||
isSynchronizedOutputHeld
|
||||
isSynchronizedOutputHeld,
|
||||
requestFullViewportPresent
|
||||
} from './terminal-render-pause-release'
|
||||
import { retainTerminalReplayFrame } from './terminal-replay-frame'
|
||||
|
||||
@@ -23,6 +24,7 @@ type ReplayPresentation = {
|
||||
generation: number
|
||||
present: boolean
|
||||
timeout: ReturnType<typeof setTimeout>
|
||||
frameWait: number | null
|
||||
}
|
||||
|
||||
const presentations = new WeakMap<HTMLElement, ReplayPresentation>()
|
||||
@@ -54,15 +56,23 @@ export function beginTerminalReplayPresentation(
|
||||
terminal.refresh?.(0, terminal.rows - 1)
|
||||
}
|
||||
}
|
||||
const unhide = (held: ReplayPresentation): void => {
|
||||
const unhide = (held: ReplayPresentation, atHoldCap = false): void => {
|
||||
clearTimeout(held.timeout)
|
||||
if (held.frameWait !== null) {
|
||||
cancelAnimationFrame(held.frameWait)
|
||||
held.frameWait = null
|
||||
}
|
||||
if (presentations.get(screen) === held) {
|
||||
presentations.delete(screen)
|
||||
}
|
||||
try {
|
||||
const bounds = element.getBoundingClientRect()
|
||||
if (held.present && bounds.width > 0 && bounds.height > 0 && terminal.rows) {
|
||||
if (!forceFullViewportPresent(terminal)) {
|
||||
// Only the hold cap may publish an unfinished DEC 2026 frame.
|
||||
const presented = atHoldCap
|
||||
? forceFullViewportPresent(terminal)
|
||||
: requestFullViewportPresent(terminal)
|
||||
if (!presented) {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
@@ -77,6 +87,28 @@ export function beginTerminalReplayPresentation(
|
||||
held.frame?.remove()
|
||||
}
|
||||
}
|
||||
// Why: unhiding mid-DEC 2026 publishes the TUI's half-built frame over the
|
||||
// retained copy; the hold cap still unhides a frame that never closes.
|
||||
const unhideWhenFrameComplete = (held: ReplayPresentation): void => {
|
||||
if (held.frameWait !== null) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
held.present &&
|
||||
held.frame &&
|
||||
isSynchronizedOutputHeld(terminal) &&
|
||||
typeof requestAnimationFrame === 'function'
|
||||
) {
|
||||
held.frameWait = requestAnimationFrame(() => {
|
||||
held.frameWait = null
|
||||
if (held.pending === 0 && presentations.get(screen) === held) {
|
||||
unhideWhenFrameComplete(held)
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
unhide(held)
|
||||
}
|
||||
let presentation = presentations.get(screen)
|
||||
if (!presentation) {
|
||||
const capture = retainTerminalReplayFrame(screen, () => {
|
||||
@@ -100,9 +132,10 @@ export function beginTerminalReplayPresentation(
|
||||
pending: 0,
|
||||
generation: 0,
|
||||
present: false,
|
||||
frameWait: null,
|
||||
timeout: setTimeout(() => {
|
||||
created.present = true
|
||||
unhide(created)
|
||||
unhide(created, true)
|
||||
}, TERMINAL_REPLAY_PRESENTATION_MAX_HOLD_MS)
|
||||
}
|
||||
presentation = created
|
||||
@@ -127,6 +160,6 @@ export function beginTerminalReplayPresentation(
|
||||
if (--held.pending > 0 || presentations.get(screen) !== held) {
|
||||
return
|
||||
}
|
||||
unhide(held)
|
||||
unhideWhenFrameComplete(held)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,25 +328,64 @@ describe('structural replay presentation', () => {
|
||||
expect(getContext).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still retains a DOM frame during synchronized output without repainting it', async () => {
|
||||
const { terminal, screen } = createTerminal()
|
||||
const refreshRows = vi.fn()
|
||||
const synchronized = {
|
||||
...terminal,
|
||||
_core: {
|
||||
...terminal._core,
|
||||
coreService: { decPrivateModes: { synchronizedOutput: true } },
|
||||
_renderService: { _isPaused: false, refreshRows }
|
||||
it('keeps the retained frame up until an open synchronized frame closes', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { terminal, screen } = createTerminal()
|
||||
const refreshRows = vi.fn()
|
||||
const renderRows = vi.fn()
|
||||
const decPrivateModes = { synchronizedOutput: true }
|
||||
const synchronized = {
|
||||
...terminal,
|
||||
_core: {
|
||||
...terminal._core,
|
||||
coreService: { decPrivateModes },
|
||||
_renderService: { _isPaused: false, refreshRows, _renderer: { value: { renderRows } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
await createTerminalStructuralReplayCoordinator(synchronized).run(() => {
|
||||
await createTerminalStructuralReplayCoordinator(synchronized).run(() => {
|
||||
expect(screen.style.opacity).toBe('0')
|
||||
expect(refreshRows).not.toHaveBeenCalled()
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
expect(screen.style.opacity).toBe('0')
|
||||
expect(terminal.element.querySelector('[data-terminal-replay-frame]')?.textContent).toBe(
|
||||
'Last coherent frame'
|
||||
)
|
||||
expect(refreshRows).not.toHaveBeenCalled()
|
||||
})
|
||||
expect(screen.style.opacity).toBe('')
|
||||
expect(renderRows).not.toHaveBeenCalled()
|
||||
|
||||
decPrivateModes.synchronizedOutput = false
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
expect(screen.style.opacity).toBe('')
|
||||
expect(terminal.element.querySelector('[data-terminal-replay-frame]')).toBeNull()
|
||||
expect(renderRows).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('shows the screen at the hold cap when a synchronized frame never closes', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { terminal, screen } = createTerminal()
|
||||
const synchronized = {
|
||||
...terminal,
|
||||
_core: {
|
||||
...terminal._core,
|
||||
coreService: { decPrivateModes: { synchronizedOutput: true } },
|
||||
_renderService: { _isPaused: false, refreshRows: vi.fn() }
|
||||
}
|
||||
}
|
||||
await createTerminalStructuralReplayCoordinator(synchronized).run(() => {})
|
||||
await vi.advanceTimersByTimeAsync(TERMINAL_REPLAY_PRESENTATION_MAX_HOLD_MS - 1)
|
||||
expect(screen.style.opacity).toBe('0')
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(screen.style.opacity).toBe('')
|
||||
expect(terminal.element.querySelector('[data-terminal-replay-frame]')).toBeNull()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps decorations positioned in the retained frame', async () => {
|
||||
|
||||
@@ -425,7 +425,7 @@ test.describe('terminal reveal paused-render recovery', () => {
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('@headful atlas recovery presents a synchronized-output WebGL frame', async ({
|
||||
test('@headful atlas recovery keeps the coherent frame until synchronized output releases', async ({
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
@@ -455,6 +455,7 @@ test.describe('terminal reveal paused-render recovery', () => {
|
||||
background: 17,
|
||||
release: true
|
||||
})
|
||||
const baseline = await captureStableTabScreenshot(orcaPage, tabId)
|
||||
await callSynchronizedRevealProbe(orcaPage, 'paintFrame', {
|
||||
marker: 'REVEALED_FRAME',
|
||||
background: 52,
|
||||
@@ -470,36 +471,45 @@ test.describe('terminal reveal paused-render recovery', () => {
|
||||
await expect
|
||||
.poll(async () => (await readSynchronizedRevealProbe(orcaPage)).atlasClears)
|
||||
.toBeGreaterThan(0)
|
||||
const afterReveal = await captureStableTabScreenshot(orcaPage, tabId)
|
||||
// Why not a stable capture: the atlas clear's redraw starts xterm's 1s DEC 2026 safety timeout.
|
||||
const afterReveal = await captureFirstRevealedFrame(orcaPage, tabId)
|
||||
const heldDiff = compareTerminalScreenshots(baseline, afterReveal)
|
||||
|
||||
await callSynchronizedRevealProbe(orcaPage, 'paintFrame', {
|
||||
marker: 'REVEALED_FRAME',
|
||||
background: 52,
|
||||
release: true
|
||||
})
|
||||
const released = await captureStableTabScreenshot(orcaPage, tabId)
|
||||
await callSynchronizedRevealProbe(orcaPage, 'forceRendererPresent')
|
||||
const afterForcedPresent = await captureStableTabScreenshot(orcaPage, tabId)
|
||||
const diff = compareTerminalScreenshots(afterReveal, afterForcedPresent)
|
||||
const direct = await captureStableTabScreenshot(orcaPage, tabId)
|
||||
const releasedDiff = compareTerminalScreenshots(released, direct)
|
||||
const afterRevealPath = testInfo.outputPath('synchronized-frame-after-reveal.png')
|
||||
const afterForcedPresentPath = testInfo.outputPath(
|
||||
'synchronized-frame-after-forced-present.png'
|
||||
)
|
||||
const releasedPath = testInfo.outputPath('synchronized-frame-after-release.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))
|
||||
writeFile(releasedPath, released),
|
||||
writeFile(pixelDiffPath, JSON.stringify({ heldDiff, releasedDiff }, 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'
|
||||
})
|
||||
for (const [name, path, contentType] of [
|
||||
['synchronized-frame-after-reveal.png', afterRevealPath, 'image/png'],
|
||||
['synchronized-frame-after-release.png', releasedPath, 'image/png'],
|
||||
['synchronized-frame-pixel-diff.json', pixelDiffPath, 'application/json']
|
||||
] as const) {
|
||||
await testInfo.attach(name, { path, contentType })
|
||||
}
|
||||
expect(
|
||||
diff.matches,
|
||||
`revealed pixels already match a forced buffer present (diffRatio=${diff.diffRatio})`
|
||||
heldDiff.matches,
|
||||
`atlas recovery keeps the coherent frame while DEC 2026 is held (diffRatio=${heldDiff.diffRatio})`
|
||||
).toBe(true)
|
||||
expect(
|
||||
compareTerminalScreenshots(baseline, released).matches,
|
||||
'release replaces the coherent frame'
|
||||
).toBe(false)
|
||||
expect(
|
||||
releasedDiff.matches,
|
||||
`release presents the completed frame (diffRatio=${releasedDiff.diffRatio})`
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user