Fix Windows ConPTY overlay after cold restore (#7310)

This commit is contained in:
Jinwoo Hong 2026-07-03 23:24:24 -07:00 committed by GitHub
parent 7595baccef
commit a074ed6182
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 290 additions and 17 deletions

View File

@ -36,6 +36,8 @@ beforeAll(() => {
import {
buildFontFamily,
POST_REPLAY_MODE_RESET,
restoreScrollbackBuffers,
serializePaneTree,
serializeTerminalLayout,
replayTerminalLayout,
@ -422,6 +424,38 @@ describe('replayTerminalLayout', () => {
})
})
describe('restoreScrollbackBuffers', () => {
it('marks panes with restored scrollback for fresh-shell viewport blanking', () => {
const writes: string[] = []
const pane = {
id: 1,
terminal: {
write: vi.fn((data: string, callback?: () => void) => {
writes.push(data)
callback?.()
})
}
}
const manager = {
getPanes: vi.fn(() => [pane])
}
const replayingPanesRef = { current: new Map<number, number>() }
const restoredViewportBlankingPanesRef = { current: new Set<number>() }
restoreScrollbackBuffers(
manager as unknown as Parameters<typeof restoreScrollbackBuffers>[0],
{ [LEAF_1]: 'restored output' },
new Map([[LEAF_1, 1]]),
replayingPanesRef,
restoredViewportBlankingPanesRef
)
expect(writes).toEqual(['restored output', '\r\n', POST_REPLAY_MODE_RESET])
expect(restoredViewportBlankingPanesRef.current.has(1)).toBe(true)
expect(replayingPanesRef.current.size).toBe(0)
})
})
// ---------------------------------------------------------------------------
// collectLeafIdsInReplayCreationOrder
// ---------------------------------------------------------------------------

View File

@ -6,6 +6,7 @@ import type {
import { isTerminalLeafId } from '../../../../shared/stable-pane-id'
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import { replayIntoTerminal, type ReplayingPanesRef } from './replay-guard'
import type { RestoredViewportBlankingPanesRef } from './terminal-restored-viewport'
import {
getLeftmostLeafId,
normalizeTerminalLayoutSnapshot,
@ -211,7 +212,8 @@ export function restoreScrollbackBuffers(
manager: PaneManager,
savedBuffers: Record<string, string> | undefined,
restoredPaneByLeafId: Map<string, number>,
replayingPanesRef: ReplayingPanesRef
replayingPanesRef: ReplayingPanesRef,
restoredViewportBlankingPanesRef?: RestoredViewportBlankingPanesRef
): void {
if (!savedBuffers) {
return
@ -249,6 +251,9 @@ export function restoreScrollbackBuffers(
// The shell underneath is fresh and has no TUI consuming these modes.
// See POST_REPLAY_MODE_RESET comment.
replayIntoTerminal(pane, replayingPanesRef, POST_REPLAY_MODE_RESET)
// Why: connection resolution happens after layout replay; only the
// fresh-shell paths should move these visible rows into scrollback.
restoredViewportBlankingPanesRef?.current.add(pane.id)
}
} catch {
// If restore fails, continue with blank terminal.

View File

@ -1,5 +1,6 @@
import type { PtyTransport } from './pty-transport'
import type { ReplayingPanesRef } from './replay-guard'
import type { RestoredViewportBlankingPanesRef } from './terminal-restored-viewport'
import type { AgentCompletionStatusSnapshot } from './agent-completion-coordinator-types'
import type { EventProps } from '../../../../shared/telemetry-events'
import type { TerminalColorSchemeMode } from '../../../../shared/terminal-color-scheme-protocol'
@ -38,6 +39,7 @@ export type PtyConnectionDeps = {
paneMode2031Ref: React.RefObject<Map<number, boolean>>
paneLastThemeModeRef: React.RefObject<Map<number, TerminalColorSchemeMode>>
replayingPanesRef: ReplayingPanesRef
restoredViewportBlankingPanesRef?: RestoredViewportBlankingPanesRef
isActiveRef: React.RefObject<boolean>
isVisibleRef: React.RefObject<boolean>
onPtyExitRef: React.RefObject<(ptyId: string) => void>

View File

@ -9,6 +9,7 @@ import {
RESET_KITTY_KEYBOARD_PROTOCOL,
RESET_TERMINAL_CURSOR_STYLE
} from './layout-serialization'
import { buildFreshShellViewportBlankingSequence } from './terminal-restored-viewport'
import { TERMINAL_PASTE_DIRECT_MAX_BYTES } from './terminal-paste-coordinator'
import type * as UseNotificationDispatchModule from './use-notification-dispatch'
import { getEagerPtyBufferHandle } from './pty-dispatcher'
@ -66,6 +67,31 @@ async function renderHeadlessBuffer(writes: string[], cols = 80, rows = 8): Prom
}
}
async function renderHeadlessTerminalState(
writes: string[],
cols = 80,
rows = 8
): Promise<{ allLines: string[]; visibleLines: string[]; baseY: number }> {
const term = new Terminal({ cols, rows, allowProposedApi: true })
try {
for (const write of writes) {
await writeHeadlessTerminal(term, write)
}
const allLines: string[] = []
const buffer = term.buffer.active
for (let lineIndex = 0; lineIndex < buffer.length; lineIndex++) {
allLines.push(buffer.getLine(lineIndex)?.translateToString(true) ?? '')
}
const visibleLines: string[] = []
for (let row = 0; row < term.rows; row++) {
visibleLines.push(buffer.getLine(buffer.viewportY + row)?.translateToString(true) ?? '')
}
return { allLines, visibleLines, baseY: buffer.baseY }
} finally {
term.dispose()
}
}
const toastInfo = vi.fn()
const LEAF_1 = '11111111-1111-4111-8111-111111111111' as const
const LEAF_2 = '22222222-2222-4222-8222-222222222222' as const
@ -5207,6 +5233,114 @@ describe('connectPanePty', () => {
expect(window.api.pty.ackColdRestore).toHaveBeenCalledWith('tab-pty')
})
it('blanks restored scrollback before fresh shell output', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('fresh-pty')
const written: string[] = []
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
callbacks.onData?.('PS >')
return 'fresh-pty'
})
transportFactoryQueue.push(transport)
mockStoreState = {
...mockStoreState,
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] },
ptyIdsByTabId: { 'tab-1': [] },
terminalLayoutsByTabId: {
'tab-1': {
root: { type: 'leaf', leafId: LEAF_1 },
activeLeafId: LEAF_1,
expandedLeafId: null,
ptyIdsByLeafId: {}
}
}
} as StoreState
const pane = createPane(1)
pane.terminal.rows = 4
pane.terminal.cols = 20
pane.terminal.write = vi.fn((data: string, callback?: () => void) => {
written.push(data)
callback?.()
})
const manager = createManager(1)
const deps = createDeps({
restoredViewportBlankingPanesRef: { current: new Set([1]) }
})
connectPanePty(pane as never, manager as never, deps as never)
await flushAsyncTicks(20)
const blankViewport = buildFreshShellViewportBlankingSequence(4)
expect(written).toContain(blankViewport)
expect(written.indexOf(blankViewport)).toBeLessThan(written.indexOf('PS >'))
const rendered = await renderHeadlessTerminalState(
['old TUI row with a long tail\r\nold TUI row two', blankViewport, 'PS >'],
20,
4
)
expect(rendered.baseY).toBeGreaterThan(0)
expect(rendered.allLines.some((line) => line.includes('old TUI row'))).toBe(true)
expect(rendered.visibleLines).toEqual(['PS >', '', '', ''])
})
it('cold-restores scrollback then blanks the viewport without erasing scrollback', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('fresh-pty')
const written: string[] = []
transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => {
if (sessionId) {
return {
id: 'fresh-pty',
coldRestore: { scrollback: 'cold TUI row one\r\ncold TUI row two', cwd: '/tmp/wt-1' }
}
}
return 'fresh-pty'
})
transportFactoryQueue.push(transport)
mockStoreState = {
...mockStoreState,
tabsByWorktree: {
'wt-1': [{ id: 'tab-1', ptyId: 'lost-pty' }]
}
} as StoreState
const pane = createPane(1)
pane.terminal.rows = 4
pane.terminal.cols = 20
pane.terminal.write = vi.fn((data: string, callback?: () => void) => {
written.push(data)
callback?.()
})
const manager = createManager(1)
const deps = createDeps({
restoredLeafId: LEAF_1,
restoredPtyIdByLeafId: { [LEAF_1]: 'lost-pty' }
})
connectPanePty(pane as never, manager as never, deps as never)
await flushAsyncTicks(20)
const blankViewport = buildFreshShellViewportBlankingSequence(4)
expect(written).not.toContain('\x1b[2J\x1b[3J\x1b[H')
expect(written).toEqual(
expect.arrayContaining([
'cold TUI row one\r\ncold TUI row two',
POST_REPLAY_MODE_RESET,
blankViewport
])
)
expect(written.indexOf('cold TUI row one\r\ncold TUI row two')).toBeLessThan(
written.indexOf(blankViewport)
)
const rendered = await renderHeadlessTerminalState([...written, 'PS >'], 20, 4)
expect(rendered.baseY).toBeGreaterThan(0)
expect(rendered.allLines.some((line) => line.includes('cold TUI row'))).toBe(true)
expect(rendered.visibleLines).toEqual(['PS >', '', '', ''])
})
it('resumes the provider agent session when daemon reattach cold-restores a fresh shell', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('fresh-pty')

View File

@ -57,6 +57,7 @@ import {
RESET_KITTY_KEYBOARD_PROTOCOL,
RESET_TERMINAL_CURSOR_STYLE
} from './layout-serialization'
import { buildFreshShellViewportBlankingSequence } from './terminal-restored-viewport'
import { createShellReadyMarkerScanState, scanForShellReadyMarker } from './shell-ready-marker-scan'
import { shouldUseShellReadyStartupDelivery } from '../../../../shared/codex-startup-delivery'
import { resolveSetupAgentSequenceLaunchCommand } from '../../../../shared/setup-agent-sequencing'
@ -364,6 +365,10 @@ type PendingStartupCommand = {
env?: Record<string, string>
}
type FreshSpawnOptions = {
forceBlankRestoredViewport?: boolean
}
type ColdRestoreAgentResumeStartup = PendingStartupCommand & {
agent: ResumableTuiAgent
launchConfig: NonNullable<ReturnType<typeof buildAgentResumeStartupPlan>>['launchConfig']
@ -3483,10 +3488,11 @@ export function connectPanePty(
}
: undefined
const startFreshColdRestoreAgentResume = (
startup: ColdRestoreAgentResumeStartup | null = buildColdRestoreAgentResumeStartup()
startup: ColdRestoreAgentResumeStartup | null = buildColdRestoreAgentResumeStartup(),
options: FreshSpawnOptions = {}
): void => {
applyColdRestoreAgentResumeStartup(startup)
startFreshSpawn(startup)
startFreshSpawn(startup, options)
}
// Why: the hibernation wake fires from noteVisibilityResume in the outer
// connection scope, long after this deferred-connect closure has run.
@ -3563,9 +3569,13 @@ export function connectPanePty(
}, 50)
}
const startFreshSpawn = (startupOverride?: PendingStartupCommand | null): void => {
const startFreshSpawn = (
startupOverride?: PendingStartupCommand | null,
options: FreshSpawnOptions = {}
): void => {
clearPaneMode2031State()
clearHiddenOutputRestoreState()
prepareFreshShellViewportForSpawn(options)
if (connectionId && startupOverride?.command) {
// Why: SSH providers use `command` only as spawn metadata; the renderer
// must still submit the resume command to the fresh remote shell.
@ -3832,6 +3842,24 @@ export function connectPanePty(
: POST_REPLAY_REATTACH_RESET
}
const consumeRestoredViewportBlankingMarker = (): boolean => {
return deps.restoredViewportBlankingPanesRef?.current.delete(pane.id) ?? false
}
const writeFreshShellViewportBlanking = (): void => {
writeReplayData(buildFreshShellViewportBlankingSequence(pane.terminal.rows))
}
const prepareFreshShellViewportForSpawn = (options: FreshSpawnOptions): void => {
const hadRestoredViewport = consumeRestoredViewportBlankingMarker()
if (!options.forceBlankRestoredViewport && !hadRestoredViewport) {
return
}
// Why: fresh Windows ConPTY output paints at screen coordinates, so
// restored rows must leave the viewport before the first prompt redraw.
writeFreshShellViewportBlanking()
}
const sendFocusedReattachFocusInAfterReplay = (): void => {
const scheduledGeneration = reattachReplayPayloadSignalGeneration
void waitForTerminalOutputParsed(pane.terminal).then(() => {
@ -5159,7 +5187,9 @@ export function connectPanePty(
if (staleSessionId) {
deps.clearTabPtyId(deps.tabId, staleSessionId)
}
startFreshColdRestoreAgentResume(coldRestoreStartup)
startFreshColdRestoreAgentResume(coldRestoreStartup, {
forceBlankRestoredViewport: true
})
return
}
registerEffectiveLaunchConfig(connectResult?.launchConfig, {
@ -5178,7 +5208,9 @@ export function connectPanePty(
// Why: SSH sleep/reconnect can invalidate the relay-held PTY while
// leaving the tab mounted. Replace the dead lease in-place instead of
// stranding the pane behind a stale expired-session overlay.
startFreshColdRestoreAgentResume(coldRestoreStartup)
startFreshColdRestoreAgentResume(coldRestoreStartup, {
forceBlankRestoredViewport: true
})
return
}
setPanePtyFitBinding(ptyId)
@ -5235,14 +5267,10 @@ export function connectPanePty(
}
}
} else if (connectResult?.coldRestore) {
// restoreScrollbackBuffers() already wrote the saved xterm buffer
// before this rAF ran. The cold-restore scrollback overlaps with
// that content; clear first.
// replayIntoTerminal: the recorded scrollback is raw PTY output that
// may contain query sequences the previous agent CLI emitted;
// writing them through xterm.write would trigger auto-replies that
// land in the new shell's stdin. See replay-guard.ts.
writeReplayData('\x1b[2J\x1b[3J\x1b[H')
writeReplayData(connectResult.coldRestore.scrollback)
const preparedStartup = coldRestoreStartup ?? buildColdRestoreAgentResumeStartup()
const didPrepareResume = applyColdRestoreAgentResumeStartup(preparedStartup)
@ -5257,6 +5285,8 @@ export function connectPanePty(
// crashed TUI (e.g. Claude's \e[?1004h) left in the scrollback, so
// reset them to match the fresh shell's expectations.
writeReplayData(POST_REPLAY_MODE_RESET)
consumeRestoredViewportBlankingMarker()
writeFreshShellViewportBlanking()
if (!isRemoteRuntimePtyId(ptyId)) {
window.api.pty.ackColdRestore(ptyId)
}
@ -5502,7 +5532,9 @@ export function connectPanePty(
}
deps.clearExitedPanePtyLayoutBinding(pane.id, pendingSessionId)
deps.clearTabPtyId(deps.tabId, pendingSessionId)
startFreshColdRestoreAgentResume(coldRestoreStartup)
startFreshColdRestoreAgentResume(coldRestoreStartup, {
forceBlankRestoredViewport: true
})
return
}
handleReattachResult(result, pendingSessionId, coldRestoreStartup)
@ -5525,10 +5557,14 @@ export function connectPanePty(
if (isSshSessionExpiredError(err)) {
deps.clearExitedPanePtyLayoutBinding(pane.id, pendingSessionId)
deps.clearTabPtyId(deps.tabId, pendingSessionId)
startFreshColdRestoreAgentResume(coldRestoreStartup)
startFreshColdRestoreAgentResume(coldRestoreStartup, {
forceBlankRestoredViewport: true
})
return
}
startFreshColdRestoreAgentResume(coldRestoreStartup)
startFreshColdRestoreAgentResume(coldRestoreStartup, {
forceBlankRestoredViewport: true
})
})
} else {
startFreshColdRestoreAgentResume()
@ -5671,7 +5707,9 @@ export function connectPanePty(
}
deps.clearExitedPanePtyLayoutBinding(pane.id, deferredReattachSessionId)
deps.clearTabPtyId(deps.tabId, deferredReattachSessionId)
startFreshColdRestoreAgentResume(coldRestoreStartup)
startFreshColdRestoreAgentResume(coldRestoreStartup, {
forceBlankRestoredViewport: true
})
return
}
handleReattachResult(result, deferredReattachSessionId, coldRestoreStartup)
@ -5699,11 +5737,15 @@ export function connectPanePty(
deps.clearExitedPanePtyLayoutBinding(pane.id, deferredReattachSessionId)
deps.clearTabPtyId(deps.tabId, deferredReattachSessionId)
if (connectionId && isSshSessionExpiredError(err)) {
startFreshColdRestoreAgentResume(coldRestoreStartup)
startFreshColdRestoreAgentResume(coldRestoreStartup, {
forceBlankRestoredViewport: true
})
return
}
reportError(message)
startFreshColdRestoreAgentResume(coldRestoreStartup)
startFreshColdRestoreAgentResume(coldRestoreStartup, {
forceBlankRestoredViewport: true
})
})
} else if (detachedRemoteLeafPtyId || detachedLivePtyId || eagerLivePtyId) {
// Why: mirrored web terminal layouts mount one pane per host leaf.

View File

@ -0,0 +1,37 @@
import { Terminal } from '@xterm/headless'
import { describe, expect, it } from 'vitest'
import { buildFreshShellViewportBlankingSequence } from './terminal-restored-viewport'
function writeTerminal(term: Terminal, data: string): Promise<void> {
return new Promise((resolve) => term.write(data, resolve))
}
function visibleLines(term: Terminal): string[] {
const buffer = term.buffer.active
return Array.from(
{ length: term.rows },
(_, row) => buffer.getLine(buffer.viewportY + row)?.translateToString(true) ?? ''
)
}
describe('buildFreshShellViewportBlankingSequence', () => {
it('preserves restored rows in scrollback even after a stale TUI scroll region', async () => {
const term = new Terminal({ cols: 20, rows: 5, allowProposedApi: true })
try {
await writeTerminal(term, 'row1\r\nrow2\r\nrow3\r\nrow4\r\nrow5\x1b[2;4r\x1b[?6h\x1b[H')
await writeTerminal(term, buildFreshShellViewportBlankingSequence(term.rows))
expect(term.buffer.active.baseY).toBeGreaterThan(0)
expect(visibleLines(term)).toEqual(['', '', '', '', ''])
expect(
Array.from(
{ length: term.buffer.active.length },
(_, row) => term.buffer.active.getLine(row)?.translateToString(true) ?? ''
)
).toEqual(expect.arrayContaining(['row1', 'row2', 'row3', 'row4', 'row5']))
} finally {
term.dispose()
}
})
})

View File

@ -0,0 +1,10 @@
import type { RefObject } from 'react'
export type RestoredViewportBlankingPanesRef = RefObject<Set<number>>
export function buildFreshShellViewportBlankingSequence(rows: number): string {
const viewportRows = Math.max(1, Math.floor(Number.isFinite(rows) ? rows : 24))
// Why: newline scrolling preserves restored rows in xterm scrollback; CSI S
// drops them. Reset margins first so stale TUI scroll regions cannot trap it.
return `\x1b[?6l\x1b[r\x1b[${viewportRows};1H${'\r\n'.repeat(viewportRows)}\x1b[H`
}

View File

@ -563,6 +563,7 @@ export function useTerminalPaneLifecycle({
const imeCompositionDisposablesRef = useRef(new Map<number, IDisposable>())
const imeNativeTextForwarderDisposablesRef = useRef(new Map<number, IDisposable>())
const queuedInitialCwdRef = useRef<string | null | undefined>(undefined)
const restoredViewportBlankingPanesRef = useRef(new Set<number>())
const applyAppearance = (manager: PaneManager): void => {
const currentSettings = settingsRef.current
@ -726,6 +727,7 @@ export function useTerminalPaneLifecycle({
paneMode2031Ref,
paneLastThemeModeRef,
replayingPanesRef,
restoredViewportBlankingPanesRef,
isActiveRef,
isVisibleRef,
onPtyExitRef,
@ -1215,6 +1217,7 @@ export function useTerminalPaneLifecycle({
clearRuntimePaneTitle(tabId, paneId)
paneFontSizesRef.current.delete(paneId)
replayingPanesRef.current.delete(paneId)
restoredViewportBlankingPanesRef.current.delete(paneId)
// Clean up pane title state so closed panes don't leave stale entries.
setPaneTitles((prev) => {
if (!(paneId in prev)) {
@ -1406,7 +1409,13 @@ export function useTerminalPaneLifecycle({
const restoredPaneByLeafId = replayTerminalLayout(manager, initialLayoutRef.current, isActive)
const restoredBuffers = initialLayoutRef.current.buffersByLeafId
restoreScrollbackBuffers(manager, restoredBuffers, restoredPaneByLeafId, replayingPanesRef)
restoreScrollbackBuffers(
manager,
restoredBuffers,
restoredPaneByLeafId,
replayingPanesRef,
restoredViewportBlankingPanesRef
)
if (restoredBuffers && initialLayoutRef.current.scrollbackRefsByLeafId) {
const layoutWithoutRestoredBuffers = { ...initialLayoutRef.current }
delete layoutWithoutRestoredBuffers.buffersByLeafId