fix(terminal): harden revealed inline-TUI convergence (#9503)

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-07-19 22:54:39 -04:00
committed by GitHub
co-authored by OrcaWin
parent 7adda25b0a
commit d628c9300e
10 changed files with 1597 additions and 19 deletions
@@ -5953,7 +5953,8 @@ describe('connectPanePty', () => {
reattach.resolve()
await flushAsyncTicks()
expect(transport.resize).toHaveBeenLastCalledWith(65, 63)
expect(transport.resize).toHaveBeenCalledWith(65, 63)
expect(transport.resize).toHaveBeenLastCalledWith(65, 63, { claim: true })
})
it('adopts a live eager PTY and withholds snapshots after its renderer dies', async () => {
@@ -7404,7 +7405,10 @@ describe('connectPanePty', () => {
const blankViewport = buildFreshShellViewportBlankingSequence(destinationRows)
expect(pane.terminal.resize).toHaveBeenCalledWith(recoveredCols, recoveredRows)
expect(transport.resize).not.toHaveBeenCalledWith(recoveredCols, recoveredRows)
expect(transport.resize).toHaveBeenLastCalledWith(destinationCols, destinationRows)
expect(transport.resize).toHaveBeenCalledWith(destinationCols, destinationRows)
expect(transport.resize).toHaveBeenLastCalledWith(destinationCols, destinationRows, {
claim: true
})
expect(written).toContain(viewportClear)
expect(written).not.toContain('\x1b[2J\x1b[3J\x1b[H')
expect(written).toEqual(
@@ -8617,6 +8621,161 @@ describe('connectPanePty', () => {
expect(liveIndex).toBeGreaterThan(snapshotIndex)
})
it('re-enforces follow intent after deferred reattach live output parses', async () => {
const { connectPanePty } = await import('./pty-connection')
const { markTerminalFollowOutput } = await import('@/lib/pane-manager/terminal-scroll-intent')
const transport = createMockTransport('tab-pty')
transport.connect.mockImplementation(
async ({ sessionId, callbacks }: { sessionId?: string; callbacks?: ConnectCallbacks }) => {
if (!sessionId) {
return null
}
callbacks?.onData?.('post-snapshot-live')
return { id: sessionId, snapshot: 'authoritative-snapshot' }
}
)
transportFactoryQueue.push(transport)
const pane = createPane(1)
pane.terminal.buffer.active.baseY = 100
pane.terminal.buffer.active.viewportY = 100
markTerminalFollowOutput(pane.terminal)
const parseCallbacks: (() => void)[] = []
pane.terminal.write = vi.fn((data: string, callback?: () => void) => {
parseCallbacks.push(() => {
if (data === 'post-snapshot-live') {
pane.terminal.buffer.active.baseY = 200
pane.terminal.buffer.active.viewportY = 100
}
callback?.()
})
})
const deps = createDeps({
restoredLeafId: LEAF_1,
restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' }
})
connectPanePty(pane as never, createManager(1) as never, deps as never)
await flushAsyncTicks(20)
for (let index = 0; index < 30; index += 1) {
parseCallbacks.shift()?.()
await flushAsyncTicks(4)
if (parseCallbacks.length === 0 && index > 5) {
break
}
}
expect(pane.terminal.buffer.active.viewportY).toBe(200)
})
it('does not steal a newer user pin while deferred reattach output settles', async () => {
const { connectPanePty } = await import('./pty-connection')
const { markTerminalFollowOutput, markTerminalPinnedViewport } =
await import('@/lib/pane-manager/terminal-scroll-intent')
const transport = createMockTransport('tab-pty')
transport.connect.mockImplementation(
async ({ sessionId, callbacks }: { sessionId?: string; callbacks?: ConnectCallbacks }) => {
if (!sessionId) {
return null
}
callbacks?.onData?.('post-snapshot-live')
return { id: sessionId, snapshot: 'authoritative-snapshot' }
}
)
transportFactoryQueue.push(transport)
const pane = createPane(1)
pane.terminal.buffer.active.baseY = 100
pane.terminal.buffer.active.viewportY = 100
markTerminalFollowOutput(pane.terminal)
const parseCallbacks: { data: string; run: () => void }[] = []
pane.terminal.write = vi.fn((data: string, callback?: () => void) => {
parseCallbacks.push({
data,
run: () => {
if (data === 'post-snapshot-live') {
pane.terminal.buffer.active.baseY = 200
pane.terminal.buffer.active.viewportY = 100
}
callback?.()
}
})
})
const deps = createDeps({
restoredLeafId: LEAF_1,
restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' }
})
connectPanePty(pane as never, createManager(1) as never, deps as never)
await flushAsyncTicks(20)
for (let index = 0; index < 40; index += 1) {
const pending = parseCallbacks.shift()
pending?.run()
await flushAsyncTicks(4)
if (pending?.data === 'post-snapshot-live') {
pane.terminal.buffer.active.viewportY = 150
markTerminalPinnedViewport(pane.terminal)
}
if (parseCallbacks.length === 0 && index > 8) {
break
}
}
expect(pane.terminal.buffer.active.viewportY).toBe(150)
})
it('does not enforce a deferred viewport after the pane becomes hidden', async () => {
const { connectPanePty } = await import('./pty-connection')
const { markTerminalFollowOutput } = await import('@/lib/pane-manager/terminal-scroll-intent')
const transport = createMockTransport('tab-pty')
transport.connect.mockImplementation(
async ({ sessionId, callbacks }: { sessionId?: string; callbacks?: ConnectCallbacks }) => {
if (!sessionId) {
return null
}
callbacks?.onData?.('post-snapshot-live')
return { id: sessionId, snapshot: 'authoritative-snapshot' }
}
)
transportFactoryQueue.push(transport)
const pane = createPane(1)
pane.terminal.buffer.active.baseY = 100
pane.terminal.buffer.active.viewportY = 100
markTerminalFollowOutput(pane.terminal)
const parseCallbacks: { data: string; run: () => void }[] = []
pane.terminal.write = vi.fn((data: string, callback?: () => void) => {
parseCallbacks.push({
data,
run: () => {
if (data === 'post-snapshot-live') {
pane.terminal.buffer.active.baseY = 200
pane.terminal.buffer.active.viewportY = 100
}
callback?.()
}
})
})
const deps = createDeps({
isVisibleRef: { current: true },
restoredLeafId: LEAF_1,
restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' }
})
connectPanePty(pane as never, createManager(1) as never, deps as never)
await flushAsyncTicks(20)
for (let index = 0; index < 40; index += 1) {
const pending = parseCallbacks.shift()
pending?.run()
await flushAsyncTicks(4)
if (pending?.data === 'post-snapshot-live') {
;(deps.isVisibleRef as { current: boolean }).current = false
}
if (parseCallbacks.length === 0 && index > 8) {
break
}
}
expect(pane.terminal.buffer.active.viewportY).toBe(100)
})
it('does not fresh-spawn after a dead deferred session delivers its buffered exit', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('tab-pty')
@@ -11777,6 +11936,99 @@ describe('connectPanePty', () => {
disposable.dispose()
})
it('slices abandoned pending chunks against a replay that already painted', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-id')
const capturedDataCallback: {
current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null
} = { current: null }
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
capturedDataCallback.current = callbacks.onData ?? null
return 'pty-id'
})
transportFactoryQueue.push(transport)
const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType<
typeof vi.fn
>
const snapshot = createDeferred<{
data: string
cols: number
rows: number
seq: number
pendingDeliveryStartSeq: number
}>()
getMainBufferSnapshot.mockReturnValue(snapshot.promise)
const hidden = 'hidden-codex-output\r\n'
const coveredLive = 'LIVE_DUP_LINE\r\n'
const afterAbandon = 'AFTER_ABANDON\r\n'
const pane = createPane(1)
const manager = createManager(1)
const deps = createDeps({
isVisibleRef: { current: false },
startup: { command: 'codex' }
})
const disposable = connectPanePty(pane as never, manager as never, deps as never)
await flushAsyncTicks(6)
expect(capturedDataCallback.current).not.toBeNull()
vi.useFakeTimers()
capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length })
;(deps.isVisibleRef as { current: boolean }).current = true
capturedDataCallback.current?.(coveredLive, {
seq: hidden.length + coveredLive.length,
rawLength: coveredLive.length
})
await flushAsyncTicks(4)
const heldCallbacks: (() => void)[] = []
pane.terminal.write.mockImplementation(function write(
data: string,
callback?: () => void
): void {
if (callback) {
heldCallbacks.push(callback)
}
void data
})
snapshot.resolve({
data: 'SNAP_STATE\r\n',
cols: 100,
rows: 30,
seq: hidden.length + coveredLive.length,
pendingDeliveryStartSeq: 0
})
await flushAsyncTicks(6)
vi.advanceTimersByTime(750)
await flushAsyncTicks(10)
const writtenAfterAbandon = pane.terminal.write.mock.calls.map(([data]) => data as string)
expect(writtenAfterAbandon.join('')).not.toContain('LIVE_DUP_LINE')
pane.terminal.write.mockClear()
capturedDataCallback.current?.(coveredLive, {
seq: hidden.length + coveredLive.length,
rawLength: coveredLive.length
})
await flushAsyncTicks(4)
expect(pane.terminal.write.mock.calls.map(([data]) => data as string).join('')).not.toContain(
'LIVE_DUP_LINE'
)
capturedDataCallback.current?.(afterAbandon, {
seq: hidden.length + coveredLive.length + afterAbandon.length,
rawLength: afterAbandon.length
})
await flushAsyncTicks(4)
expect(pane.terminal.write.mock.calls.map(([data]) => data as string).join('')).toContain(
'AFTER_ABANDON'
)
heldCallbacks.forEach((callback) => callback())
disposable.dispose()
})
it('abandons a stalled hidden restore and drains pending foreground chunks warning-first', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-id')
@@ -131,6 +131,7 @@ import { recordTerminalOutput } from '@/lib/pane-manager/pane-scroll'
import { ensureArabicShapingJoinerForText } from '@/lib/pane-manager/terminal-arabic-shaping-joiner'
import { clearTerminalScrollbackAndFollowOutput } from '@/lib/pane-manager/terminal-scrollback-clear'
import {
enforceTerminalCurrentScrollIntent,
getTerminalScrollIntentKind,
markTerminalFollowOutput
} from '@/lib/pane-manager/terminal-scroll-intent'
@@ -4907,7 +4908,8 @@ export function connectPanePty(
// scheduler's deferred drain cannot land older bytes on top of the replay.
flushTerminalOutput(pane.terminal)
replayIntoTerminal(pane, deps.replayingPanesRef, data, {
shouldRefreshViewportSynchronously: shouldRefreshForegroundSynchronously
shouldRefreshViewportSynchronously: shouldRefreshForegroundSynchronously,
shouldReleaseRenderPause: () => deps.isVisibleRef.current
})
}
@@ -4916,7 +4918,8 @@ export function connectPanePty(
// merely after the write was queued.
flushTerminalOutput(pane.terminal)
return replayIntoTerminalAsync(pane, deps.replayingPanesRef, data, {
shouldRefreshViewportSynchronously: shouldRefreshForegroundSynchronously
shouldRefreshViewportSynchronously: shouldRefreshForegroundSynchronously,
shouldReleaseRenderPause: () => deps.isVisibleRef.current
})
}
@@ -5215,6 +5218,12 @@ export function connectPanePty(
let hiddenOutputRestoreGeneration = 0
// Flood-backpressure suppression (HIDDEN_OUTPUT_RESTORE_FLOOD_SUPPRESS_MS).
let hiddenOutputRestoreFloodSuppressedUntil = 0
// Why: queued replay writes still paint after deadline abandonment; the
// fallback drain must not write snapshot-covered live bytes a second time.
let hiddenOutputRestoreReplayingSnapshot: {
seq?: number
pendingDeliveryStartSeq?: number
} | null = null
let hiddenOutputRestoreFloodRepaintTimer: ReturnType<typeof setTimeout> | null = null
// Why: after a snapshot restore, main can still drain ACK-backlog chunks
// whose bytes the snapshot already covers — writing them unguarded
@@ -6311,6 +6320,8 @@ export function connectPanePty(
? []
: hiddenOutputRestorePendingChunks.slice()
const hadPendingOverflow = hiddenOutputRestorePendingOverflow
const replayingSnapshot = hiddenOutputRestoreReplayingSnapshot
hiddenOutputRestoreReplayingSnapshot = null
hiddenOutputRestoreGeneration += 1
if (
hiddenOutputSnapshotScrollRestore?.valid &&
@@ -6346,7 +6357,21 @@ export function connectPanePty(
if (hadPendingOverflow) {
return
}
const pendingData = pendingChunks.map((chunk) => chunk.data).join('')
const replayedSeq = typeof replayingSnapshot?.seq === 'number' ? replayingSnapshot.seq : null
let pendingData = ''
for (const chunk of pendingChunks) {
const sliced =
replayedSeq === null ? chunk.data : getChunkDataAfterSnapshot(chunk, replayedSeq)
pendingData += sliced ?? chunk.data
}
if (replayingSnapshot && replayedSeq !== null) {
setRestoredSnapshotBaseline(expectedPtyId, replayingSnapshot)
for (const chunk of pendingChunks) {
if (typeof chunk.seq === 'number' && restoredSnapshotExpectedStartSeq !== null) {
restoredSnapshotExpectedStartSeq = Math.max(restoredSnapshotExpectedStartSeq, chunk.seq)
}
}
}
if (pendingData) {
writePtyOutputToXterm(pendingData, true)
}
@@ -6391,6 +6416,7 @@ export function connectPanePty(
resetHiddenRendererRiskState()
hiddenOutputRestoreNeeded = false
hiddenOutputRestorePtyId = null
hiddenOutputRestoreReplayingSnapshot = null
hiddenOutputRestoreGeneration += 1
}
@@ -6482,6 +6508,7 @@ export function connectPanePty(
cols: number
rows: number
seq?: number
pendingDeliveryStartSeq?: number
alternateScreen?: boolean
scrollbackAnsi?: string
pendingEscapeTailAnsi?: string
@@ -6517,6 +6544,14 @@ export function connectPanePty(
return
}
scrollRestore.started = true
if (typeof snapshot.seq === 'number') {
hiddenOutputRestoreReplayingSnapshot = {
seq: snapshot.seq,
...(typeof snapshot.pendingDeliveryStartSeq === 'number'
? { pendingDeliveryStartSeq: snapshot.pendingDeliveryStartSeq }
: {})
}
}
discardTerminalOutput(pane.terminal)
if (
hasSnapshotDimensions &&
@@ -6613,7 +6648,7 @@ export function connectPanePty(
}
}
},
{ shouldContinue: isCurrentRestore }
{ shouldContinue: isCurrentRestore, retryIfUnmeasurable: true }
)
pendingHiddenSnapshotFit = fit
try {
@@ -6783,6 +6818,7 @@ export function connectPanePty(
// still draining from main's ACK backlog below that point are
// duplicates the dataCallback reconciliation must suppress.
setRestoredSnapshotBaseline(currentPtyId, snapshot)
hiddenOutputRestoreReplayingSnapshot = null
const needsFreshSnapshot = hiddenOutputRestoreFreshSnapshotNeeded
hiddenOutputRestoreFreshSnapshotNeeded = false
const drainOutcome = drainPendingLiveChunksAfterSnapshot(snapshot.seq)
@@ -7162,6 +7198,7 @@ export function connectPanePty(
// Why: createOrAttach snapshots precede bytes emitted before its IPC
// reply. Paint the authoritative replay first, then admit those live
// chunks so the replay clear cannot erase newer output.
let deliveredDeferredChunks = 0
for (const chunk of chunks) {
if (
chunk.ptyId !== currentPtyId ||
@@ -7171,6 +7208,23 @@ export function connectPanePty(
continue
}
dataCallback(chunk.data, chunk.meta, chunk.streamGeneration)
deliveredDeferredChunks += 1
}
if (deliveredDeferredChunks > 0) {
// Why: replay restores the viewport before these newer bytes parse;
// settle the bounded deferred slice, then apply the latest user intent.
flushTerminalOutput(pane.terminal, { maxChars: MAX_DEFERRED_REATTACH_LIVE_CHARS })
void waitForTerminalReplayWritesParsed(pane.terminal).then(() => {
if (
disposed ||
!deps.isVisibleRef.current ||
transport.getPtyId() !== currentPtyId ||
transportStreamGeneration !== currentGeneration
) {
return
}
enforceTerminalCurrentScrollIntent(pane.terminal)
})
}
}
@@ -7472,16 +7526,22 @@ export function connectPanePty(
window.api.pty.signal(reattachPtyId, 'SIGWINCH')
}
},
{ shouldContinue: isCurrentReattachPayload }
{ shouldContinue: isCurrentReattachPayload, retryIfUnmeasurable: true }
)
pendingReattachFit = fit
let fitCompleted = false
try {
await fit.completion
fitCompleted = await fit.completion
} finally {
if (pendingReattachFit === fit) {
pendingReattachFit = null
}
}
if (fitCompleted && isCurrentReattachPayload() && deps.isVisibleRef.current) {
// Why: reattach resize is fire-and-forget; verify the provider's
// applied grid while this reveal still owns the visible pane.
ptySizeReassertion.request({ fit: false })
}
} else if (isCurrentReattachPayload() && !isRemoteRuntimePtyId(reattachPtyId)) {
window.api.pty.signal(reattachPtyId, 'SIGWINCH')
}
@@ -64,6 +64,7 @@ const REPLAY_GUARD_STALL_CHECK_MS = 10_000
type ReplayTerminalOptions = {
shouldRefreshViewportSynchronously?: () => boolean
shouldReleaseRenderPause?: () => boolean
stallCheckMs?: number
}
@@ -208,6 +209,7 @@ export function replayIntoTerminal(
forceViewportRefresh: true,
followupViewportRefresh: true,
shouldRefreshViewportSynchronously: options.shouldRefreshViewportSynchronously,
shouldReleaseRenderPause: options.shouldReleaseRenderPause,
onParsed: guardCallbacks.onParsed,
onWriteFailure: guardCallbacks.onWriteFailure
})
@@ -242,6 +244,7 @@ export function replayIntoTerminalAsync(
forceViewportRefresh: true,
followupViewportRefresh: true,
shouldRefreshViewportSynchronously: options.shouldRefreshViewportSynchronously,
shouldReleaseRenderPause: options.shouldReleaseRenderPause,
onParsed: guardCallbacks.onParsed,
onWriteFailure: guardCallbacks.onWriteFailure
})
@@ -0,0 +1,87 @@
import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder'
import type { ManagedPane } from './pane-manager-types'
const MAX_RETRY_FRAMES = 40
const LAYOUT_SETTLE_MS = 16
type RetrySchedule = { cancel: () => void }
type RetryState = {
attempts: number
schedule: RetrySchedule | null
retry: () => boolean
onExhausted: () => void
}
const retryByPane = new WeakMap<ManagedPane, RetryState>()
function scheduleRetryTick(run: () => void): RetrySchedule {
if (typeof requestAnimationFrame === 'function') {
let cancelled = false
let timer: ReturnType<typeof setTimeout> | null = null
const rafId = requestAnimationFrame(() => {
if (!cancelled) {
// Why: FitAddon must observe committed CSS, and synchronous rAF test
// shims must not recursively consume the whole retry budget inline.
timer = setTimeout(run, LAYOUT_SETTLE_MS)
}
})
return {
cancel: () => {
cancelled = true
if (typeof cancelAnimationFrame === 'function') {
cancelAnimationFrame(rafId)
}
if (timer !== null) {
clearTimeout(timer)
}
}
}
}
const timer = setTimeout(run, LAYOUT_SETTLE_MS)
return { cancel: () => clearTimeout(timer) }
}
export function clearPaneFitContinuationRetry(pane: ManagedPane): void {
const state = retryByPane.get(pane)
if (!state) {
return
}
retryByPane.delete(pane)
state.schedule?.cancel()
state.schedule = null
}
export function armPaneFitContinuationRetry(
pane: ManagedPane,
callbacks: { retry: () => boolean; onExhausted: () => void }
): void {
const state = retryByPane.get(pane) ?? {
attempts: 0,
schedule: null,
...callbacks
}
state.retry = callbacks.retry
state.onExhausted = callbacks.onExhausted
retryByPane.set(pane, state)
if (state.schedule) {
return
}
state.schedule = scheduleRetryTick(() => {
state.schedule = null
if (state.retry()) {
clearPaneFitContinuationRetry(pane)
return
}
state.attempts += 1
if (state.attempts >= MAX_RETRY_FRAMES) {
recordRendererCrashBreadcrumb('terminal_safe_fit_retry_exhausted', {
paneId: pane.id
})
clearPaneFitContinuationRetry(pane)
state.onExhausted()
return
}
armPaneFitContinuationRetry(pane, state)
})
}
@@ -0,0 +1,169 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder'
import type { ManagedPane, ScrollState } from './pane-manager-types'
import { safeFit, safeFitAndThen } from './pane-fit'
vi.mock('@/lib/crash-breadcrumb-recorder', () => ({
recordRendererCrashBreadcrumb: vi.fn()
}))
let nextRafId = 1
let pendingRafs = new Map<number, FrameRequestCallback>()
function flushAnimationFrames(timestamp = 16): void {
const callbacks = Array.from(pendingRafs.values())
pendingRafs = new Map()
for (const callback of callbacks) {
callback(timestamp)
}
}
function createPane(options: {
rect: { width: number; height: number }
proposed?: () => { cols: number; rows: number } | undefined
}): ManagedPane & { setRect: (rect: { width: number; height: number }) => void } {
let rect = options.rect
const leafId = '22222222-2222-4222-8222-222222222222'
const pane = {
id: 7,
leafId,
stablePaneId: leafId,
terminal: { cols: 80, rows: 24 },
container: {
dataset: {},
getBoundingClientRect: () => ({ width: rect.width, height: rect.height })
},
xtermContainer: {
getBoundingClientRect: () => ({ width: rect.width, height: rect.height })
},
fitAddon: {
fit: vi.fn(),
proposeDimensions: vi.fn(options.proposed ?? (() => ({ cols: 132, rows: 40 })))
},
serializeAddon: {},
searchAddon: {},
pendingSplitScrollState: null as ScrollState | null,
setRect: (next: { width: number; height: number }) => {
rect = next
}
}
return pane as unknown as ManagedPane & {
setRect: (rect: { width: number; height: number }) => void
}
}
describe('safeFitAndThen unmeasurable-pane retry', () => {
beforeEach(() => {
vi.useFakeTimers()
nextRafId = 1
pendingRafs = new Map()
vi.stubGlobal(
'requestAnimationFrame',
vi.fn((callback: FrameRequestCallback) => {
const id = nextRafId++
pendingRafs.set(id, callback)
return id
})
)
vi.stubGlobal(
'cancelAnimationFrame',
vi.fn((id: number) => {
pendingRafs.delete(id)
})
)
})
afterEach(() => {
vi.unstubAllGlobals()
vi.useRealTimers()
vi.restoreAllMocks()
})
it('runs the continuation once reveal layout becomes measurable', async () => {
const pane = createPane({ rect: { width: 0, height: 0 } })
const continuation = vi.fn()
const handle = safeFitAndThen(pane, 'reattach-pty-resize', continuation, {
retryIfUnmeasurable: true
})
pane.setRect({ width: 800, height: 600 })
flushAnimationFrames()
vi.advanceTimersByTime(16)
expect(continuation).toHaveBeenCalledTimes(1)
await expect(handle.completion).resolves.toBe(true)
})
it('cancels its scheduled frame with the continuation', async () => {
const pane = createPane({ rect: { width: 0, height: 0 } })
const continuation = vi.fn()
const handle = safeFitAndThen(pane, 'reattach-pty-resize', continuation, {
retryIfUnmeasurable: true
})
handle.cancel()
pane.setRect({ width: 800, height: 600 })
flushAnimationFrames()
expect(continuation).not.toHaveBeenCalled()
await expect(handle.completion).resolves.toBe(false)
})
it('does not retry a stale restore', async () => {
const pane = createPane({ rect: { width: 0, height: 0 } })
const continuation = vi.fn()
let current = true
const handle = safeFitAndThen(pane, 'reattach-pty-resize', continuation, {
shouldContinue: () => current,
retryIfUnmeasurable: true
})
current = false
pane.setRect({ width: 800, height: 600 })
flushAnimationFrames()
vi.advanceTimersByTime(16)
expect(continuation).not.toHaveBeenCalled()
await expect(handle.completion).resolves.toBe(false)
})
it('still flushes through an ordinary external fit', async () => {
const pane = createPane({ rect: { width: 0, height: 0 } })
const continuation = vi.fn()
const handle = safeFitAndThen(pane, 'reattach-pty-resize', continuation)
pane.setRect({ width: 800, height: 600 })
flushAnimationFrames()
vi.advanceTimersByTime(16)
expect(continuation).not.toHaveBeenCalled()
safeFit(pane)
expect(continuation).toHaveBeenCalledTimes(1)
await expect(handle.completion).resolves.toBe(true)
})
it('resolves failure after the bounded frame budget instead of hanging reattach', async () => {
const pane = createPane({ rect: { width: 0, height: 0 } })
const continuation = vi.fn()
const handle = safeFitAndThen(pane, 'reattach-pty-resize', continuation, {
retryIfUnmeasurable: true
})
for (let frame = 0; frame < 40; frame += 1) {
flushAnimationFrames(frame * 16)
vi.advanceTimersByTime(16)
}
expect(continuation).not.toHaveBeenCalled()
expect(recordRendererCrashBreadcrumb).toHaveBeenCalledWith(
'terminal_safe_fit_retry_exhausted',
{ paneId: 7 }
)
await expect(handle.completion).resolves.toBe(false)
pane.setRect({ width: 800, height: 600 })
safeFit(pane)
expect(continuation).not.toHaveBeenCalled()
})
})
+53 -3
View File
@@ -1,5 +1,9 @@
import type { ManagedPane, ManagedPaneInternal, ScrollState } from './pane-manager-types'
import { getFitOverrideForPty } from './mobile-fit-overrides'
import {
armPaneFitContinuationRetry,
clearPaneFitContinuationRetry
} from './pane-fit-continuation-retry'
import {
captureTerminalStructuralScrollIntent,
isTerminalStructuralScrollIntentCurrent,
@@ -163,6 +167,7 @@ function settlePendingSafeFitContinuation(
operations.delete(operationKey)
if (operations.size === 0) {
pendingSafeFitContinuations.delete(pane)
clearPaneFitContinuationRetry(pane)
}
pending.resolve(completed)
}
@@ -192,11 +197,52 @@ export function safeFit(pane: ManagedPane): boolean {
// Why: replay transactions may be waiting for renderer dimensions; any
// successful ordinary fit is the event that makes their PTY grid authoritative.
flushPendingSafeFitContinuations(pane)
clearPaneFitContinuationRetry(pane)
}
return completed
}
function pruneStaleSafeFitContinuations(pane: ManagedPane): void {
const operations = pendingSafeFitContinuations.get(pane)
if (!operations) {
return
}
for (const [operationKey, pending] of operations) {
if (!pending.shouldContinue()) {
settlePendingSafeFitContinuation(pane, operationKey, pending, false)
}
}
}
function failPendingSafeFitContinuations(pane: ManagedPane): void {
const operations = pendingSafeFitContinuations.get(pane)
if (!operations) {
return
}
for (const [operationKey, pending] of Array.from(operations.entries())) {
settlePendingSafeFitContinuation(pane, operationKey, pending, false)
}
}
function armSafeFitContinuationRetry(pane: ManagedPane): void {
armPaneFitContinuationRetry(pane, {
retry: () => {
pruneStaleSafeFitContinuations(pane)
if (!pendingSafeFitContinuations.get(pane)?.size) {
return true
}
return safeFit(pane)
},
onExhausted: () => {
// Why: a reveal transaction must degrade after its bounded layout wait;
// leaving completion pending forever blocks deferred output release.
failPendingSafeFitContinuations(pane)
}
})
}
export function cancelPendingSafeFitContinuations(pane: ManagedPane): void {
clearPaneFitContinuationRetry(pane)
const operations = pendingSafeFitContinuations.get(pane)
if (!operations) {
return
@@ -213,7 +259,7 @@ export function safeFitAndThen(
pane: ManagedPane,
operationKey: string,
continuation: () => void,
options: { shouldContinue?: () => boolean } = {}
options: { shouldContinue?: () => boolean; retryIfUnmeasurable?: boolean } = {}
): SafeFitContinuationHandle {
const operations = pendingSafeFitContinuations.get(pane) ?? new Map()
const replaced = operations.get(operationKey)
@@ -245,13 +291,17 @@ export function safeFitAndThen(
`safe-fit-and-then:${operationKey}`,
() => {
if (pendingSafeFitContinuations.get(pane)?.get(operationKey) === pending) {
safeFit(pane)
if (!safeFit(pane) && options.retryIfUnmeasurable) {
armSafeFitContinuationRetry(pane)
}
}
}
)
) {
return { completion, cancel }
}
safeFit(pane)
if (!safeFit(pane) && options.retryIfUnmeasurable) {
armSafeFitContinuationRetry(pane)
}
return { completion, cancel }
}
@@ -0,0 +1,83 @@
import { describe, expect, it, vi } from 'vitest'
import { writeForegroundTerminalChunk } from './pane-terminal-foreground-render-settle'
type RefreshFn = (start: number, end: number, sync?: boolean) => void
type RenderServiceStub = {
_isPaused: boolean
_needsFullRefresh: boolean
refreshRows: ReturnType<typeof vi.fn<RefreshFn>>
}
function createTerminal(paused: boolean): {
terminal: {
rows: number
buffer: { active: { cursorY: number; baseY: number; viewportY: number } }
_core: { refresh: ReturnType<typeof vi.fn<RefreshFn>>; _renderService: RenderServiceStub }
refresh: ReturnType<typeof vi.fn<(start: number, end: number) => void>>
write: (data: string, callback?: () => void) => void
}
renderService: RenderServiceStub
} {
const renderService: RenderServiceStub = {
_isPaused: paused,
_needsFullRefresh: paused,
refreshRows: vi.fn<RefreshFn>()
}
const terminal = {
rows: 24,
buffer: { active: { cursorY: 0, baseY: 0, viewportY: 0 } },
_core: { refresh: vi.fn<RefreshFn>(), _renderService: renderService },
refresh: vi.fn<(start: number, end: number) => void>(),
write: (_data: string, callback?: () => void) => callback?.()
}
return { terminal, renderService }
}
describe('writeForegroundTerminalChunk render-pause ownership', () => {
it('drives a paused render only for a currently visible reveal replay', () => {
const { terminal, renderService } = createTerminal(true)
writeForegroundTerminalChunk(terminal, 'replayed snapshot bytes', {
forceViewportRefresh: true,
shouldReleaseRenderPause: () => true
})
expect(renderService.refreshRows).toHaveBeenCalledWith(0, 23, true)
expect(renderService._isPaused).toBe(false)
expect(renderService._needsFullRefresh).toBe(false)
})
it('leaves a newly-hidden terminal paused when replay parsing finishes', () => {
const { terminal, renderService } = createTerminal(true)
let parsed: (() => void) | undefined
let visible = true
terminal.write = (_data: string, callback?: () => void) => {
parsed = callback
}
writeForegroundTerminalChunk(terminal, 'late replay bytes', {
forceViewportRefresh: true,
shouldReleaseRenderPause: () => visible
})
visible = false
parsed?.()
expect(renderService.refreshRows).not.toHaveBeenCalled()
expect(renderService._isPaused).toBe(true)
expect(renderService._needsFullRefresh).toBe(true)
})
it('does not inspect RenderService on the ordinary forced-refresh path', () => {
const { terminal, renderService } = createTerminal(true)
const renderServiceRead = vi.fn(() => renderService)
Object.defineProperty(terminal._core, '_renderService', { get: renderServiceRead })
writeForegroundTerminalChunk(terminal, 'ordinary output', {
forceViewportRefresh: true
})
expect(renderServiceRead).not.toHaveBeenCalled()
expect(terminal._core.refresh).toHaveBeenCalledWith(0, 23, true)
})
})
@@ -1,3 +1,4 @@
import { forceRepaintThroughRenderPause } from './terminal-render-pause-release'
import { runGuardedWriteCompletionStep } from './xterm-write-callback-guard'
export type ForegroundTerminalOutputTarget = {
@@ -20,6 +21,7 @@ type ForegroundTerminalWriteOptions = {
forceViewportRefresh?: boolean
followupViewportRefresh?: boolean
shouldRefreshViewportSynchronously?: () => boolean
shouldReleaseRenderPause?: () => boolean
onParsed?: () => void
onWriteFailure?: () => void
}
@@ -36,15 +38,21 @@ type ViewportSnapshot = {
function refreshVisibleRows(
terminal: ForegroundTerminalOutputTarget,
synchronously: boolean
synchronously: boolean,
shouldReleaseRenderPause?: () => boolean
): void {
if (typeof terminal.rows !== 'number' || terminal.rows < 1) {
return
}
const start = 0
const end = Math.max(0, terminal.rows - 1)
try {
// Why: only reveal-owned replay may override xterm's paused observer state;
// ordinary or newly-hidden output must leave background rendering paused.
if (shouldReleaseRenderPause?.() === true && forceRepaintThroughRenderPause(terminal)) {
return
}
const start = 0
const end = Math.max(0, terminal.rows - 1)
// Why: DOM-rendered Windows ConPTY rewrites need an immediate repair, while
// WebGL can merge this full-grid request into xterm's already-queued frame.
if (synchronously && typeof terminal._core?.refresh === 'function') {
@@ -100,13 +108,14 @@ function cancelScheduledViewportSettleRefresh(terminal: ForegroundTerminalOutput
function scheduleViewportSettleRefresh(
terminal: ForegroundTerminalOutputTarget,
shouldRefreshSynchronously?: () => boolean
shouldRefreshSynchronously?: () => boolean,
shouldReleaseRenderPause?: () => boolean
): void {
cancelScheduledViewportSettleRefresh(terminal)
if (typeof requestAnimationFrame === 'function') {
const id = requestAnimationFrame(() => {
pendingViewportSettleRefreshByTerminal.delete(terminal)
refreshVisibleRows(terminal, shouldRefreshSynchronously?.() ?? true)
refreshVisibleRows(terminal, shouldRefreshSynchronously?.() ?? true, shouldReleaseRenderPause)
})
pendingViewportSettleRefreshByTerminal.set(terminal, { kind: 'raf', id })
return
@@ -114,7 +123,7 @@ function scheduleViewportSettleRefresh(
const id = setTimeout(() => {
pendingViewportSettleRefreshByTerminal.delete(terminal)
refreshVisibleRows(terminal, shouldRefreshSynchronously?.() ?? true)
refreshVisibleRows(terminal, shouldRefreshSynchronously?.() ?? true, shouldReleaseRenderPause)
}, 16)
pendingViewportSettleRefreshByTerminal.set(terminal, { kind: 'timeout', id })
}
@@ -124,7 +133,11 @@ function settleForegroundRender(
beforeWriteViewport: ViewportSnapshot,
options: ForegroundTerminalWriteOptions
): void {
refreshVisibleRows(terminal, options.shouldRefreshViewportSynchronously?.() ?? true)
refreshVisibleRows(
terminal,
options.shouldRefreshViewportSynchronously?.() ?? true,
options.shouldReleaseRenderPause
)
// Why: when output advances the viewport, Chromium can paint the freshly
// scrolled top row one frame later than xterm finishes parsing. Repaint once
// more after the scroll settles so the user doesn't need to jiggle the window.
@@ -132,7 +145,11 @@ function settleForegroundRender(
options.followupViewportRefresh ||
viewportChangedDuringWrite(terminal, beforeWriteViewport)
) {
scheduleViewportSettleRefresh(terminal, options.shouldRefreshViewportSynchronously)
scheduleViewportSettleRefresh(
terminal,
options.shouldRefreshViewportSynchronously,
options.shouldReleaseRenderPause
)
}
}