diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 1c9323dd0..3f05c9a53 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -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') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 17fb6225b..18066ae75 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -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 | 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') } diff --git a/src/renderer/src/components/terminal-pane/replay-guard.ts b/src/renderer/src/components/terminal-pane/replay-guard.ts index 6711390d8..b7e43ff82 100644 --- a/src/renderer/src/components/terminal-pane/replay-guard.ts +++ b/src/renderer/src/components/terminal-pane/replay-guard.ts @@ -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 }) diff --git a/src/renderer/src/lib/pane-manager/pane-fit-continuation-retry.ts b/src/renderer/src/lib/pane-manager/pane-fit-continuation-retry.ts new file mode 100644 index 000000000..8567ad448 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-fit-continuation-retry.ts @@ -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() + +function scheduleRetryTick(run: () => void): RetrySchedule { + if (typeof requestAnimationFrame === 'function') { + let cancelled = false + let timer: ReturnType | 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) + }) +} diff --git a/src/renderer/src/lib/pane-manager/pane-fit.test.ts b/src/renderer/src/lib/pane-manager/pane-fit.test.ts new file mode 100644 index 000000000..5857349e3 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-fit.test.ts @@ -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() + +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() + }) +}) diff --git a/src/renderer/src/lib/pane-manager/pane-fit.ts b/src/renderer/src/lib/pane-manager/pane-fit.ts index 573ba6ab7..e555a4780 100644 --- a/src/renderer/src/lib/pane-manager/pane-fit.ts +++ b/src/renderer/src/lib/pane-manager/pane-fit.ts @@ -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 } } diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.test.ts b/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.test.ts new file mode 100644 index 000000000..cb62cd15f --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.test.ts @@ -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> +} + +function createTerminal(paused: boolean): { + terminal: { + rows: number + buffer: { active: { cursorY: number; baseY: number; viewportY: number } } + _core: { refresh: ReturnType>; _renderService: RenderServiceStub } + refresh: ReturnType void>> + write: (data: string, callback?: () => void) => void + } + renderService: RenderServiceStub +} { + const renderService: RenderServiceStub = { + _isPaused: paused, + _needsFullRefresh: paused, + refreshRows: vi.fn() + } + const terminal = { + rows: 24, + buffer: { active: { cursorY: 0, baseY: 0, viewportY: 0 } }, + _core: { refresh: vi.fn(), _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) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.ts b/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.ts index 4819212ad..508fb7e12 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.ts @@ -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 + ) } } diff --git a/tests/e2e/fixtures/codex-inline-live-block-fixture.cjs b/tests/e2e/fixtures/codex-inline-live-block-fixture.cjs new file mode 100644 index 000000000..78d3596f6 --- /dev/null +++ b/tests/e2e/fixtures/codex-inline-live-block-fixture.cjs @@ -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) diff --git a/tests/e2e/terminal-inline-tui-reveal-convergence.spec.ts b/tests/e2e/terminal-inline-tui-reveal-convergence.spec.ts new file mode 100644 index 000000000..f0ba620eb --- /dev/null +++ b/tests/e2e/terminal-inline-tui-reveal-convergence.spec.ts @@ -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 { + 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() + 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 { + 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 { + 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 { + 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 { + 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 +} + +// 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 { + 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 { + 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 { + // 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(page: Page, rate: number, run: () => Promise): Promise { + 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 { + 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() + } + }) +})