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
parent 7adda25b0a
commit d628c9300e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 1597 additions and 19 deletions

View File

@ -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')

View File

@ -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')
}

View File

@ -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
})

View File

@ -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)
})
}

View File

@ -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()
})
})

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 }
}

View File

@ -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)
})
})

View File

@ -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
)
}
}

View File

@ -0,0 +1,139 @@
// Codex-like INLINE-mode TUI (normal buffer, never alt-screen): history lines
// scroll into terminal scrollback while a live block (working spinner + input
// box + status line) repaints glued to the bottom of the screen, wrapped in
// synchronized-output brackets. This is the write shape a real Codex CLI
// produces mid-generation — the shape the alt-screen fixtures cannot cover.
//
// argv[2] = heartbeat file path (latest frame number, rewritten every tick).
// argv[3] = history lines per second (default 4) — raise it so a hidden/parked
// window accumulates a field-sized backlog for the reveal to race.
// The stream NEVER stops on its own; tests park/hide/reveal around it and
// assert the revealed terminal converges to the live frame without a resize.
const fs = require('node:fs')
const heartbeatPath = process.argv[2]
const TICK_MS = 60
const HISTORY_LINES_PER_SECOND = Math.max(0, Number(process.argv[3]) || 4)
const BLOCK_ROWS = 6
// argv[4]: seed scrollback size — a field Codex session carries thousands of
// lines, which is what makes the reveal replay long enough to lose races.
const INITIAL_HISTORY_LINES = Math.max(0, Number(process.argv[4]) || 120)
let frame = 0
let hist = 0
function rows() {
return process.stdout.rows || 24
}
function cols() {
return process.stdout.columns || 80
}
function historyLine() {
hist += 1
return `HIST_${String(hist).padStart(6, '0')} tool call output ${'-'.repeat(24)}`
}
function liveBlock() {
const width = Math.max(20, Math.min(cols() - 2, 76))
const bar = '─'.repeat(width)
const pad = (text) => `${`${text}`.padEnd(width + 1, ' ')}`
const top = Math.max(1, rows() - BLOCK_ROWS + 1)
const lines = [
`${bar}`,
pad(`CODEX_FRAME_${String(frame).padStart(6, '0')} working${'.'.repeat(frame % 4).padEnd(3)}`),
pad(`tokens ${frame * 17} · ${frame % 2 === 0 ? 'thinking' : 'streaming'}`),
`${bar}`,
' INPUT_BOX_READY_MARKER',
'status: streaming · esc to interrupt'
]
// Absolute-position to the block top and clear below, like ratatui's inline
// viewport redraw.
return `\x1b[${top};1H\x1b[J${lines.join('\r\n')}`
}
// ratatui insert_before-style history: scroll one line into scrollback from
// the bottom row, then write the new history line just above the live block.
function insertHistory(count) {
const r = rows()
const histTop = Math.max(1, r - BLOCK_ROWS)
let out = ''
for (let i = 0; i < count; i += 1) {
out += `\x1b[${r};1H\n\x1b[${histTop};1H${historyLine()}`
}
return out
}
let historyCarry = 0
function tick() {
frame += 1
historyCarry += (HISTORY_LINES_PER_SECOND * TICK_MS) / 1000
const historyThisTick = Math.floor(historyCarry)
historyCarry -= historyThisTick
let out = '\x1b[?2026h\x1b[?25l'
if (historyThisTick > 0) {
out += insertHistory(historyThisTick)
}
out += liveBlock()
out += '\x1b[?25h\x1b[?2026l'
process.stdout.write(out)
if (heartbeatPath) {
try {
fs.writeFileSync(heartbeatPath, String(frame))
} catch {
// heartbeat is best-effort; the stream itself is the product
}
}
}
// Codex-shaped startup: terminal queries (answered by xterm or the daemon's
// model responder) and mouse reporting, so the run takes the live-agent
// classification branches instead of the plain-shell ones.
process.stdout.write('\x1b[c\x1b[6n\x1b]10;?\x07\x1b]11;?\x07')
process.stdout.write('\x1b[?1002h\x1b[?1006h')
// Seed scrollback before any park so the reveal replays real history.
{
const seed = []
for (let i = 0; i < INITIAL_HISTORY_LINES; i += 1) {
seed.push(historyLine())
}
process.stdout.write(`${seed.join('\r\n')}\r\n`)
}
const tickTimer = setInterval(tick, TICK_MS)
// A real inline TUI fully repaints its live block on SIGWINCH. Keep that
// behavior for realism, but tests must converge WITHOUT relying on it.
process.stdout.on('resize', () => {
process.stdout.write(`\x1b[?2026h\x1b[?25l${liveBlock()}\x1b[?25h\x1b[?2026l`)
})
// Swallow query replies / mouse reports / keys like a real TUI agent.
process.stdin.resume()
if (process.stdin.isTTY) {
process.stdin.setRawMode(true)
}
let stopping = false
function stop() {
if (stopping) {
return
}
stopping = true
clearInterval(tickTimer)
if (process.stdin.isTTY) {
process.stdin.setRawMode(false)
}
// Why: the e2e sends Ctrl+C while raw mode is active, so Node receives a
// byte instead of SIGINT; explicitly restore modes and terminate the fixture.
process.stdout.write('\x1b[?1002l\x1b[?1006l\x1b[?25h\x1b[?2026l', () => process.exit(0))
}
process.stdin.on('data', (data) => {
if (Buffer.from(data).includes(3)) {
stop()
}
})
process.on('SIGINT', stop)
process.on('SIGTERM', stop)

View File

@ -0,0 +1,718 @@
import { readFileSync } from 'node:fs'
import path from 'node:path'
import { PNG } from 'pngjs'
import type { ElectronApplication, Page, TestInfo } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import {
ensureTerminalVisible,
getActiveTabId,
switchToOtherWorktree,
switchToWorktree,
waitForActiveWorktree,
waitForSessionReady
} from './helpers/store'
import {
sendToTerminal,
waitForActiveTerminalManager,
waitForPaneIdentitySnapshot
} from './helpers/terminal'
import { waitForTabParked } from './helpers/terminal-hidden-parking'
// Field bug (v1.4.144-rc.4): switching back to a workspace whose Codex TUI kept
// streaming while hidden shows a mostly-blank terminal — live block (input box)
// missing, viewport stranded mid-buffer — until a manual resize (Cmd+L) forces
// SIGWINCH and Codex repaints. The alt-screen park/reveal specs never caught it
// because Codex runs in INLINE mode and keeps writing across the reveal.
//
// These tests drive a codex-shaped inline TUI that never stops writing, hide
// the pane across the gate/park boundaries, reveal, and require convergence to
// the live frame WITHOUT any resize:
// 1. viewport anchored at the buffer bottom (not stranded mid-scrollback),
// 2. a recent CODEX_FRAME + the input-box row visible in the on-screen rows,
// 3. still following (frame number advances on screen) after convergence,
// 4. xterm grid == fit proposal == PTY-applied size (no stale 80x24 PTY).
const PARKING_DELAY_MS = Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || 500
test.use({
orcaAppExtraEnv: { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARKING_DELAY_MS) }
})
const FIXTURE_PATH = path.join(__dirname, 'fixtures', 'codex-inline-live-block-fixture.cjs')
const FRAME_RE = /CODEX_FRAME_(\d+)/g
const INPUT_BOX_MARKER = 'INPUT_BOX_READY_MARKER'
// The fixture ticks every 60ms; allow a generous parse/delivery lag while
// still rejecting a frozen frame from before the hide.
const MAX_VISIBLE_FRAME_LAG = 50
type RevealProbe = {
ptyId: string | null
viewportY: number
baseY: number
cols: number
rows: number
proposed: { cols: number; rows: number } | null
appliedPtySize: { cols: number; rows: number } | null
screenRows: string[]
}
function latestFrame(text: string): number {
let latest = -1
for (const match of text.matchAll(FRAME_RE)) {
latest = Math.max(latest, Number(match[1]))
}
return latest
}
function heartbeatFrame(heartbeatPath: string): number {
try {
return Number(readFileSync(heartbeatPath, 'utf8').trim())
} catch {
return -1
}
}
// Why the pane resolves by tab (not a captured ptyId): agent quick-launch
// startup can respawn the tab's PTY after the initial bind, so a ptyId
// captured at mount can go stale while the pane itself stays healthy.
async function probeRevealedPane(page: Page, tabId: string): Promise<RevealProbe | null> {
return page.evaluate(
async ({ tabId }) => {
const manager = window.__paneManagers?.get(tabId)
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
if (!pane) {
return null
}
const ptyId = pane.container.dataset.ptyId ?? null
const terminal = pane.terminal
const buffer = terminal.buffer.active
const screenRows: string[] = []
for (let i = 0; i < terminal.rows; i += 1) {
const line = buffer.getLine(buffer.viewportY + i)
screenRows.push(line ? line.translateToString(true) : '')
}
let proposed: { cols: number; rows: number } | null = null
try {
proposed = pane.fitAddon.proposeDimensions() ?? null
} catch {
proposed = null
}
let appliedPtySize: { cols: number; rows: number } | null = null
try {
appliedPtySize = ptyId ? ((await window.api.pty.getSize(ptyId)) ?? null) : null
} catch {
appliedPtySize = null
}
return {
ptyId,
viewportY: buffer.viewportY,
baseY: buffer.baseY,
cols: terminal.cols,
rows: terminal.rows,
proposed,
appliedPtySize,
screenRows
}
},
{ tabId }
)
}
// Painted-pixels check: buffer-level assertions cannot see paint-layer bugs
// (atlas wipe races, paused-RenderService swallowed refreshes), where xterm's
// buffer is perfect but the canvas shows blank/stale cells until a resize.
// Measure the "ink" (non-background pixel ratio) of a horizontal band of the
// pane screenshot; the fixture's live block guarantees box-drawing + text ink
// in its bottom rows whenever paint is healthy.
function measureBandInkRatio(
screenshot: Buffer,
bandTopFraction: number,
bandBottomFraction: number
): number {
const png = PNG.sync.read(screenshot)
const colorCounts = new Map<number, number>()
for (let offset = 0; offset < png.data.length; offset += 32) {
const key =
((png.data[offset] ?? 0) << 16) |
((png.data[offset + 1] ?? 0) << 8) |
(png.data[offset + 2] ?? 0)
colorCounts.set(key, (colorCounts.get(key) ?? 0) + 1)
}
let backgroundKey = 0
let backgroundCount = -1
for (const [key, count] of colorCounts) {
if (count > backgroundCount) {
backgroundKey = key
backgroundCount = count
}
}
const backgroundRed = (backgroundKey >> 16) & 0xff
const backgroundGreen = (backgroundKey >> 8) & 0xff
const backgroundBlue = backgroundKey & 0xff
const yStart = Math.max(0, Math.floor(png.height * bandTopFraction))
const yEnd = Math.min(png.height, Math.ceil(png.height * bandBottomFraction))
let ink = 0
let total = 0
for (let y = yStart; y < yEnd; y += 1) {
for (let x = 0; x < png.width; x += 1) {
const offset = (y * png.width + x) * 4
const diff =
Math.abs((png.data[offset] ?? 0) - backgroundRed) +
Math.abs((png.data[offset + 1] ?? 0) - backgroundGreen) +
Math.abs((png.data[offset + 2] ?? 0) - backgroundBlue)
total += 1
if (diff > 48) {
ink += 1
}
}
}
return total > 0 ? ink / total : 0
}
async function forceWebglOnActiveTab(page: Page): Promise<void> {
await page.evaluate(() => {
const state = window.__store?.getState()
if (!state?.settings) {
throw new Error('Store unavailable')
}
window.__store?.setState({
settings: {
...state.settings,
terminalGpuAcceleration: 'on'
}
})
const worktreeId = state.activeWorktreeId
const tabId =
state.activeTabType === 'terminal'
? state.activeTabId
: worktreeId
? (state.activeTabIdByWorktree?.[worktreeId] ?? null)
: null
window.__paneManagers?.get(tabId ?? '')?.setTerminalGpuAcceleration?.('on')
})
}
async function paneClipRect(
page: Page,
tabId: string
): Promise<{ x: number; y: number; width: number; height: number } | null> {
return page.evaluate((tabId) => {
const manager = window.__paneManagers?.get(tabId)
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
if (!pane) {
return null
}
const rect = pane.container.getBoundingClientRect()
if (rect.width < 10 || rect.height < 10) {
return null
}
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
}, tabId)
}
async function isTerminalPaneMounted(page: Page, tabId: string): Promise<boolean> {
return page.evaluate((tabId) => {
const manager = window.__paneManagers?.get(tabId)
return Boolean(manager && manager.getPanes().length > 0)
}, tabId)
}
function describeProbe(probe: RevealProbe | null): string {
if (!probe) {
return 'pane not mounted'
}
return JSON.stringify(
{
viewportY: probe.viewportY,
baseY: probe.baseY,
cols: probe.cols,
rows: probe.rows,
proposed: probe.proposed,
appliedPtySize: probe.appliedPtySize,
screenTail: probe.screenRows.slice(-10)
},
null,
1
)
}
async function activateTerminalTab(page: Page, tabId: string): Promise<void> {
await page.evaluate((targetTabId) => {
const store = window.__store
if (!store) {
throw new Error('activateTerminalTab: window.__store is unavailable')
}
const state = store.getState()
state.setActiveTabType('terminal')
state.setActiveTab(targetTabId)
}, tabId)
await expect
.poll(() => getActiveTabId(page), {
timeout: 5_000,
message: `terminal tab ${tabId} did not become active`
})
.toBe(tabId)
}
async function createActiveTerminalTab(page: Page, worktreeId: string): Promise<string> {
const tabId = await page.evaluate((worktreeId) => {
const store = window.__store
if (!store) {
throw new Error('createActiveTerminalTab: window.__store is unavailable')
}
const state = store.getState()
const tab = state.createTab(worktreeId, undefined, undefined, { activate: true })
state.setActiveTab(tab.id)
state.setActiveTabType('terminal')
return tab.id
}, worktreeId)
await expect
.poll(() => getActiveTabId(page), {
timeout: 5_000,
message: 'newly created terminal tab did not become active'
})
.toBe(tabId)
await waitForActiveTerminalManager(page, 30_000)
await waitForPaneIdentitySnapshot(page, 1)
return tabId
}
type StreamingTabSetup = {
worktreeId: string
tabId: string
ptyId: string
heartbeatPath: string
stop: () => Promise<void>
}
// Why a fresh tab per test: the app instance is shared across this file's
// serial tests, so reusing the initial tab would type the launch command into
// the previous test's still-running fixture instead of a shell prompt.
//
// Why agent-marked: a real Codex tab carries launchAgent/telemetry, which
// flips the reveal into the live-agent reattach branches (mode-preserving
// resets, hidden startup query grammar, post-replay focus-in) — the branches
// the field bug lives behind.
async function startStreamingInlineTui(
page: Page,
testInfo: TestInfo,
options: { historyLinesPerSecond?: number; seedLines?: number } = {}
): Promise<StreamingTabSetup> {
await waitForSessionReady(page)
const worktreeId = await waitForActiveWorktree(page)
await ensureTerminalVisible(page)
const heartbeatPath = testInfo.outputPath(`codex-inline-heartbeat-${Date.now()}.txt`)
const command = `node ${JSON.stringify(FIXTURE_PATH)} ${JSON.stringify(heartbeatPath)} ${options.historyLinesPerSecond ?? 4} ${options.seedLines ?? 120}`
const tabId = await page.evaluate(
({ worktreeId, command }) => {
const store = window.__store
if (!store) {
throw new Error('startStreamingInlineTui: window.__store is unavailable')
}
const state = store.getState()
const tab = state.createTab(worktreeId, undefined, undefined, { launchAgent: 'codex' })
state.queueTabStartupCommand(tab.id, {
command,
launchAgent: 'codex',
telemetry: {
agent_kind: 'codex',
launch_source: 'tab_bar_quick_launch',
request_kind: 'new'
}
})
state.setActiveTab(tab.id)
state.setActiveTabType('terminal')
return tab.id
},
{ worktreeId, command }
)
await expect
.poll(() => getActiveTabId(page), {
timeout: 5_000,
message: 'agent-marked streaming tab did not become active'
})
.toBe(tabId)
await waitForActiveTerminalManager(page, 30_000)
await waitForPaneIdentitySnapshot(page, 1)
// WebGL on: the paint-layer seams under test (atlas wipes, render-pause
// release) only exist on the GPU renderer path.
await forceWebglOnActiveTab(page)
await expect
.poll(
async () => latestFrame((await probeRevealedPane(page, tabId))?.screenRows.join('\n') ?? ''),
{
timeout: 20_000,
message: 'inline TUI fixture did not start streaming in the visible pane'
}
)
.toBeGreaterThan(5)
// Capture the PTY id only after the fixture streams: agent quick-launch can
// respawn the tab's PTY when the startup command binds.
const ptyId = (await probeRevealedPane(page, tabId))?.ptyId
if (!ptyId) {
throw new Error('streaming tab did not bind a PTY')
}
return {
worktreeId,
tabId,
ptyId,
heartbeatPath,
stop: async () => {
// Ctrl+C so the shared app does not accumulate streaming fixtures.
await sendToTerminal(page, ptyId, '\x03').catch(() => {})
await page.waitForTimeout(100)
}
}
}
async function assertRevealConvergence(
page: Page,
testInfo: TestInfo,
setup: StreamingTabSetup,
label: string
): Promise<void> {
const { tabId, heartbeatPath } = setup
// Premise: the fixture kept streaming while hidden.
const heartbeatAtReveal = heartbeatFrame(heartbeatPath)
expect(heartbeatAtReveal, 'fixture stopped streaming while hidden').toBeGreaterThan(5)
let lastProbe: RevealProbe | null = null
await expect
.poll(
async () => {
lastProbe = await probeRevealedPane(page, tabId)
if (!lastProbe) {
return 'pane-not-mounted'
}
if (lastProbe.viewportY !== lastProbe.baseY) {
return `viewport-stranded viewportY=${lastProbe.viewportY} baseY=${lastProbe.baseY}`
}
const screen = lastProbe.screenRows.join('\n')
if (!screen.includes(INPUT_BOX_MARKER)) {
return 'input-box-row-missing'
}
const visibleFrame = latestFrame(screen)
const liveFrame = heartbeatFrame(heartbeatPath)
if (visibleFrame < 0 || liveFrame - visibleFrame > MAX_VISIBLE_FRAME_LAG) {
return `stale-frame visible=${visibleFrame} live=${liveFrame}`
}
return 'converged'
},
{
timeout: 20_000,
message: `${label}: revealed pane did not converge to the live inline TUI without a resize`
}
)
.toBe('converged')
.catch(async (error) => {
testInfo.annotations.push({
type: `${label}-divergence-probe`,
description: describeProbe(lastProbe)
})
const screenshotPath = testInfo.outputPath(`${label}-divergence.png`)
await page.screenshot({ path: screenshotPath, fullPage: true })
await testInfo.attach(`${label}-divergence.png`, {
path: screenshotPath,
contentType: 'image/png'
})
throw error
})
// Still following: the on-screen frame must keep advancing after convergence
// with the viewport still pinned to the bottom.
const convergedFrame = latestFrame(
(await probeRevealedPane(page, tabId))?.screenRows.join('\n') ?? ''
)
await expect
.poll(
async () => {
const probe = await probeRevealedPane(page, tabId)
if (!probe || probe.viewportY !== probe.baseY) {
return -1
}
return latestFrame(probe.screenRows.join('\n'))
},
{
timeout: 10_000,
message: `${label}: revealed pane stopped following live output after convergence`
}
)
.toBeGreaterThan(convergedFrame)
// Geometry: no stale-80x24 leg — xterm grid, fit proposal, and PTY-applied
// size must agree without any manual resize.
const probe = await probeRevealedPane(page, tabId)
expect(probe, `${label}: pane disappeared after convergence`).not.toBeNull()
expect(probe!.proposed, `${label}: fit proposal diverges: ${describeProbe(probe)}`).toEqual({
cols: probe!.cols,
rows: probe!.rows
})
expect(
probe!.appliedPtySize,
`${label}: PTY applied-size read unavailable: ${describeProbe(probe)}`
).not.toBeNull()
expect(
probe!.appliedPtySize,
`${label}: PTY applied size diverges: ${describeProbe(probe)}`
).toEqual({ cols: probe!.cols, rows: probe!.rows })
// Painted pixels: the live block guarantees box-drawing + text ink in the
// pane's bottom rows. Blank band + healthy buffer = paint-layer divergence
// (atlas wipe race / paused RenderService) — the class a resize also heals.
const clip = await paneClipRect(page, tabId)
expect(clip, `${label}: pane rect unavailable for paint check`).not.toBeNull()
const bandTop = Math.max(0, 1 - 8 / (probe!.rows || 24))
await expect
.poll(
async () => {
const shot = await page.screenshot({ clip: clip! })
return measureBandInkRatio(shot, bandTop, 1)
},
{
timeout: 10_000,
message: `${label}: live block rows painted blank while the buffer holds content (paint-layer divergence)`
}
)
.toBeGreaterThan(0.005)
}
async function resizeAppWindow(
electronApp: ElectronApplication,
deltaWidth: number,
deltaHeight: number
): Promise<void> {
// Why the retry: the main-process utility context Playwright evaluates in
// can be transiently recycled ("Execution context was destroyed") right
// after heavy renderer work like a worktree switch; the resize itself is
// idempotent-safe to attempt again.
let lastError: unknown = null
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
await electronApp.evaluate(
({ BrowserWindow }, { deltaWidth, deltaHeight }) => {
const window = BrowserWindow.getAllWindows()[0]
if (!window) {
throw new Error('No Electron window')
}
const [width, height] = window.getSize()
window.setSize(width + deltaWidth, height + deltaHeight)
},
{ deltaWidth, deltaHeight }
)
return
} catch (error) {
lastError = error
await new Promise((resolve) => setTimeout(resolve, 500))
}
}
throw lastError
}
// CPU throttling around the reveal action simulates the loaded-machine
// conditions the field failures occur under, deterministically.
async function withCpuThrottle<T>(page: Page, rate: number, run: () => Promise<T>): Promise<T> {
const session = await page.context().newCDPSession(page)
try {
await session.send('Emulation.setCPUThrottlingRate', { rate })
return await run()
} finally {
await session.send('Emulation.setCPUThrottlingRate', { rate: 1 }).catch(() => {})
await session.detach().catch(() => {})
}
}
async function streamWhileParked(setup: StreamingTabSetup, minFrames: number): Promise<void> {
const heartbeatBefore = heartbeatFrame(setup.heartbeatPath)
await expect
.poll(() => heartbeatFrame(setup.heartbeatPath), {
timeout: 60_000,
message: 'fixture did not keep streaming while hidden/parked'
})
.toBeGreaterThan(heartbeatBefore + minFrames)
}
test.describe('Inline TUI reveal convergence', () => {
test('hidden-but-mounted tab reveal converges while the inline TUI streams', async ({
orcaPage
}, testInfo) => {
test.setTimeout(120_000)
const setup = await startStreamingInlineTui(orcaPage, testInfo)
try {
// Tab B hides tab A. Reveal quickly — inside the cold-park delay — so
// the reveal exercises the hidden-delivery-gate restore, not parking.
const tabBId = await createActiveTerminalTab(orcaPage, setup.worktreeId)
expect(tabBId).not.toBe(setup.tabId)
await orcaPage.waitForTimeout(Math.max(50, Math.min(PARKING_DELAY_MS / 2, 200)))
expect(
await isTerminalPaneMounted(orcaPage, setup.tabId),
'hidden-mounted scenario cold-parked before reveal'
).toBe(true)
await activateTerminalTab(orcaPage, setup.tabId)
await waitForActiveTerminalManager(orcaPage, 30_000)
await assertRevealConvergence(orcaPage, testInfo, setup, 'hidden-mounted-reveal')
} finally {
await setup.stop()
}
})
test('worktree switch reveal converges after a hidden-time window resize', async ({
orcaPage,
electronApp
}, testInfo) => {
test.setTimeout(120_000)
const setup = await startStreamingInlineTui(orcaPage, testInfo, {
historyLinesPerSecond: 20
})
try {
// Surface hide: switch to ANOTHER WORKTREE (the field action), which
// suspends rendering and takes the heavy resume path on return.
const otherWorktreeId = await switchToOtherWorktree(orcaPage, setup.worktreeId)
test.skip(!otherWorktreeId, 'test session has a single worktree; cannot surface-hide')
// Change the window size while the pane is display:none (0x0 container,
// no fit runs). This is what Cmd+L's sidebar toggle does to every hidden
// workspace: at reveal the pane grid differs from the daemon snapshot's.
await resizeAppWindow(electronApp, -180, -120)
await orcaPage.waitForTimeout(2_500)
await switchToWorktree(orcaPage, setup.worktreeId)
await activateTerminalTab(orcaPage, setup.tabId)
await waitForActiveTerminalManager(orcaPage, 30_000)
await assertRevealConvergence(orcaPage, testInfo, setup, 'worktree-resize-reveal')
} finally {
await resizeAppWindow(electronApp, 180, 120).catch(() => {})
await setup.stop()
}
})
test('parked tab reveal converges across repeated cycles while the inline TUI streams heavily', async ({
orcaPage
}, testInfo) => {
test.setTimeout(480_000)
const setup = await startStreamingInlineTui(orcaPage, testInfo, {
historyLinesPerSecond: 30,
seedLines: 8_000
})
try {
// Tab B hides tab A; the decoy then hides tab B so B (most recently
// hidden) takes the #8262 last-active exemption and tab A cold-parks.
const tabBId = await createActiveTerminalTab(orcaPage, setup.worktreeId)
const decoyTabId = await createActiveTerminalTab(orcaPage, setup.worktreeId)
// The field failure is periodic, not every reveal — cycle the park →
// stream → reveal boundary and require convergence every time.
const CYCLES = 6
for (let cycle = 0; cycle < CYCLES; cycle += 1) {
if (cycle > 0) {
await activateTerminalTab(orcaPage, tabBId)
await activateTerminalTab(orcaPage, decoyTabId)
}
await waitForTabParked(orcaPage, setup.tabId, { parkDelayMs: PARKING_DELAY_MS })
// Accumulate a field-sized backlog against the parked (unmounted)
// view so the reveal replay races the live stream, like a real Codex.
await streamWhileParked(setup, 100)
// Reveal under CPU throttle: a long replay parse + throttled frames is
// the loaded-machine window where the corrective fit and follow-anchor
// lose their races in the field.
await withCpuThrottle(orcaPage, 6, async () => {
await activateTerminalTab(orcaPage, setup.tabId)
await waitForActiveTerminalManager(orcaPage, 30_000)
await orcaPage.waitForTimeout(3_000)
})
const revealed = await waitForPaneIdentitySnapshot(orcaPage, 1)
expect(revealed.panes[0]?.ptyId).toBe(setup.ptyId)
await assertRevealConvergence(orcaPage, testInfo, setup, `parked-heavy-reveal-c${cycle}`)
}
} finally {
await setup.stop()
}
})
test('rapid tab hide/reveal flapping never wedges delivery for the streaming inline TUI', async ({
orcaPage
}, testInfo) => {
test.setTimeout(150_000)
const setup = await startStreamingInlineTui(orcaPage, testInfo, {
historyLinesPerSecond: 10
})
try {
const tabBId = await createActiveTerminalTab(orcaPage, setup.worktreeId)
// Rapid flapping drives the hidden-delivery gate claim/release IPC and
// the hidden-output restore against each other at varied phases — the
// desync class behind "bytes dropped on a visible pane" field freezes.
for (let flap = 0; flap < 12; flap += 1) {
await activateTerminalTab(orcaPage, tabBId)
await orcaPage.waitForTimeout(50 + (flap % 3) * 120)
await activateTerminalTab(orcaPage, setup.tabId)
await orcaPage.waitForTimeout(50 + ((flap * 7) % 5) * 90)
}
await waitForActiveTerminalManager(orcaPage, 30_000)
await assertRevealConvergence(orcaPage, testInfo, setup, 'tab-flapping-reveal')
} finally {
await setup.stop()
}
})
test('rapid worktree switch flapping never wedges delivery for the streaming inline TUI', async ({
orcaPage
}, testInfo) => {
test.setTimeout(150_000)
const setup = await startStreamingInlineTui(orcaPage, testInfo, {
historyLinesPerSecond: 10,
seedLines: 4_000
})
try {
const otherWorktreeId = await switchToOtherWorktree(orcaPage, setup.worktreeId)
test.skip(!otherWorktreeId, 'test session has a single worktree; cannot surface-flap')
// Surface-level flapping (the field action): suspend/resume rendering +
// heavy resume path race the gate resync and reveal repaint each cycle,
// under CPU throttle to widen the race windows like a loaded machine.
await withCpuThrottle(orcaPage, 6, async () => {
for (let flap = 0; flap < 10; flap += 1) {
await switchToWorktree(orcaPage, otherWorktreeId!)
await orcaPage.waitForTimeout(60 + (flap % 4) * 110)
await switchToWorktree(orcaPage, setup.worktreeId)
await orcaPage.waitForTimeout(60 + ((flap * 5) % 4) * 130)
}
})
await activateTerminalTab(orcaPage, setup.tabId)
await waitForActiveTerminalManager(orcaPage, 30_000)
await assertRevealConvergence(orcaPage, testInfo, setup, 'worktree-flapping-reveal')
} finally {
await setup.stop()
}
})
test('parked tab reveal converges after a parked-time window resize', async ({
orcaPage,
electronApp
}, testInfo) => {
test.setTimeout(180_000)
const setup = await startStreamingInlineTui(orcaPage, testInfo, {
historyLinesPerSecond: 20
})
try {
await createActiveTerminalTab(orcaPage, setup.worktreeId)
await createActiveTerminalTab(orcaPage, setup.worktreeId)
await waitForTabParked(orcaPage, setup.tabId, { parkDelayMs: PARKING_DELAY_MS })
// Resize while parked: the remount measures a grid that matches neither
// the pre-park xterm nor the daemon snapshot — maximum dimension churn.
await resizeAppWindow(electronApp, -180, -120)
await streamWhileParked(setup, 100)
await activateTerminalTab(orcaPage, setup.tabId)
await waitForActiveTerminalManager(orcaPage, 30_000)
const revealed = await waitForPaneIdentitySnapshot(orcaPage, 1)
expect(revealed.panes[0]?.ptyId).toBe(setup.ptyId)
await assertRevealConvergence(orcaPage, testInfo, setup, 'parked-resize-reveal')
} finally {
await resizeAppWindow(electronApp, 180, 120).catch(() => {})
await setup.stop()
}
})
})