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