mirror of
https://github.com/stablyai/orca.git
synced 2026-10-27 08:02:51 +00:00
fix(terminal): harden revealed inline-TUI convergence (#9503)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user