refactor(pty): retry unverifiable SSH reattaches via preserved bindings

Preserve deferred SSH session IDs longer when they serve as the only retry binding,
allowing the system to attempt recovery through direct SSH retries or PTY remounts
when reattach fails in an unverifiable way. Simplify reconnect model restoration
by removing the conditional model snapshot probe and using relay replay directly.
This commit is contained in:
Jinjing
2026-08-23 10:51:14 -07:00
parent 1094832fb8
commit fed73f63ba
15 changed files with 307 additions and 209 deletions
@@ -382,6 +382,60 @@ describe('connectPanePty', () => {
expect(deps.onPtyErrorRef.current).not.toHaveBeenCalled()
})
it('consumes a sole deferred SSH session id after reattach succeeds', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport()
transportFactoryQueue.push(transport)
mockStoreState = {
...mockStoreState,
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] },
repos: [{ id: 'repo1', connectionId: 'conn-1' }],
sshConnectionStates: new Map([['conn-1', { status: 'connected' }]]),
deferredSshSessionIdsByTabId: { 'tab-1': 'saved-session' }
}
const deps = createDeps()
connectPanePty(createPane(1) as never, createManager(1) as never, deps as never)
expect(mockStoreState.removeDeferredSshSessionId).not.toHaveBeenCalled()
await flushAsyncTicks(12)
expect(transport.connect).toHaveBeenCalledWith(
expect.objectContaining({ sessionId: 'saved-session' })
)
expect(mockStoreState.removeDeferredSshSessionId).toHaveBeenCalledExactlyOnceWith('tab-1')
expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, 'saved-session')
})
it('retains a sole deferred SSH session id when its replay is disposed', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('saved-session')
transport.connect.mockResolvedValueOnce({
id: 'saved-session',
replay: 'restored output'
})
transportFactoryQueue.push(transport)
mockStoreState = {
...mockStoreState,
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] },
repos: [{ id: 'repo1', connectionId: 'conn-1' }],
sshConnectionStates: new Map([['conn-1', { status: 'connected' }]]),
deferredSshSessionIdsByTabId: { 'tab-1': 'saved-session' }
}
const pane = createPane(1)
const deps = createDeps()
let binding: ReturnType<typeof connectPanePty> | null = null
pane.terminal.write.mockImplementation((_data, callback) => {
binding?.dispose()
callback?.()
})
binding = connectPanePty(pane as never, createManager(1) as never, deps as never)
await flushAsyncTicks(12)
expect(mockStoreState.removeDeferredSshSessionId).not.toHaveBeenCalled()
expect(mockStoreState.deferredSshSessionIdsByTabId['tab-1']).toBe('saved-session')
})
it('spawns a fresh PTY when a deferred SSH session expired', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport()
@@ -418,6 +472,7 @@ describe('connectPanePty', () => {
expect(transport.connect).toHaveBeenCalledTimes(2)
expect(deps.clearExitedPanePtyLayoutBinding).toHaveBeenCalledWith(1, 'expired-session')
expect(deps.clearTabPtyId).toHaveBeenCalledWith('tab-1', 'expired-session')
expect(mockStoreState.removeDeferredSshSessionId).toHaveBeenCalledWith('tab-1')
expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, 'fresh-ssh-pty')
expect(deps.updateTabPtyId).toHaveBeenCalledWith('tab-1', 'fresh-ssh-pty')
})
@@ -282,18 +282,18 @@ describe('connectPanePty', () => {
)
})
it('restores a reconnect remount from the SSH model through pending retry classification', async () => {
it('uses the SSH relay replay for a reconnect even when main has an alt-screen model', async () => {
const { connectPanePty } = await import('./pty-connection')
const restoredPtyId = toAppSshPtyId('target-a', 'pty-restored-model')
const restoredPtyId = toAppSshPtyId('target-a', 'pty-restored-relay')
const transport = createMockTransport(restoredPtyId)
transport.connect.mockResolvedValue({
id: restoredPtyId,
isReattach: true,
replay: 'RELAY-TAIL'
replay: '9l\r\n$ real-shell-output'
})
transportFactoryQueue.push(transport)
const pendingRetry = {
attemptId: 'attempt-model-restore',
attemptId: 'attempt-relay-restore',
authority: {
targetId: 'target-a',
providerEpoch: 'epoch-1',
@@ -307,7 +307,8 @@ describe('connectPanePty', () => {
cols: 120,
rows: 40,
source: 'headless',
alternateScreen: true
alternateScreen: true,
pendingEscapeTailAnsi: '\x1b[?104'
})
mockStoreState = {
...mockStoreState,
@@ -347,11 +348,10 @@ describe('connectPanePty', () => {
)
await flushAsyncTicks(20)
expect(window.api.pty.getMainBufferSnapshot).toHaveBeenCalledWith(restoredPtyId, {
scrollbackRows: 5000
})
expect(writes.join('')).toContain('MODEL-ALT-FRAME')
expect(writes.join('')).not.toContain('RELAY-TAIL')
expect(window.api.pty.getMainBufferSnapshot).not.toHaveBeenCalled()
expect(writes.join('')).toContain('real-shell-output')
expect(writes.join('')).not.toContain('MODEL-ALT-FRAME')
expect(transport.resize).not.toHaveBeenCalledWith(119, 40)
})
it('rejects expired reattach state after its direct SSH retry lease is revoked', async () => {
@@ -413,6 +413,78 @@ describe('connectPanePty', () => {
expect(deps.updateTabPtyId).toHaveBeenCalledWith('tab-1', 'fresh-pty')
})
it.each([
['rejects', 'reject'],
['returns no PTY', 'empty']
] as const)(
'preserves a direct SSH binding and schedules another retry when reattach %s',
async (_description, outcome) => {
const { connectPanePty } = await import('./pty-connection')
const restoredPtyId = 'ssh:conn-1@@restored-session'
const transport = createMockTransport()
if (outcome === 'reject') {
transport.connect.mockRejectedValueOnce(new Error('relay attach timed out'))
} else {
transport.connect.mockResolvedValueOnce(undefined)
}
transportFactoryQueue.push(transport)
const pendingRetry = {
attemptId: 'attempt-generic-reattach-failure',
authority: {
targetId: 'conn-1',
providerEpoch: 'epoch-1',
connectionGeneration: 3
},
tabGeneration: 7,
startedAt: 1
}
const settleDirectSshPaneRetry = vi.fn()
mockStoreState = {
...mockStoreState,
tabsByWorktree: {
'wt-1': [{ id: 'tab-1', ptyId: null, generation: 7 }]
},
ptyIdsByTabId: { 'tab-1': [] },
repos: [{ id: 'repo1', connectionId: 'conn-1' }],
sshConnectionStates: new Map([
[
'conn-1',
{
targetId: 'conn-1',
status: 'connected',
providerEpoch: 'epoch-1',
connectionGeneration: 3
}
]
]),
deferredSshSessionIdsByTabId: { 'tab-1': restoredPtyId },
directSshPaneRetryByTabId: { 'tab-1': pendingRetry },
settleDirectSshPaneRetry
} as StoreState
const deps = createDeps()
connectPanePty(createPane(1) as never, createManager(1) as never, deps as never)
await flushAsyncTicks(12)
expect(transport.connect).toHaveBeenCalledTimes(1)
expect(deps.clearExitedPanePtyLayoutBinding).not.toHaveBeenCalled()
expect(deps.clearTabPtyId).not.toHaveBeenCalled()
expect(deps.updateTabPtyId).not.toHaveBeenCalled()
expect(mockStoreState.removeDeferredSshSessionId).not.toHaveBeenCalled()
expect(settleDirectSshPaneRetry).toHaveBeenCalledExactlyOnceWith({
status: 'failed',
tabId: 'tab-1',
attemptId: pendingRetry.attemptId,
authority: pendingRetry.authority,
tabGeneration: pendingRetry.tabGeneration
})
expect(mockStoreState.tabsByWorktree['wt-1']).toEqual([
{ id: 'tab-1', ptyId: null, generation: 7 }
])
expect(mockStoreState.deferredSshSessionIdsByTabId['tab-1']).toBe(restoredPtyId)
}
)
it('reattaches via the tab-level SSH pty id when deferred bookkeeping missed the tab', async () => {
// Why: restore can miss the deferred maps; the tab's SSH pty id must still drive connect-then-reattach, not a fresh spawn into a missing provider.
const { connectPanePty } = await import('./pty-connection')
@@ -138,18 +138,17 @@ export function installAgentIdleWorkingHandlers(session: ConnectPanePtySession):
DirectSshPaneRetryAttempt,
'attemptId' | 'authority' | 'tabGeneration'
>
const pendingDirectSshRetry = session.state.directSshPaneRetryByTabId?.[session.deps.tabId]
session.followsDirectSshReconnect =
pendingDirectSshRetry?.authority.targetId === session.connectionId &&
pendingDirectSshRetry.tabGeneration === (session.tab?.generation ?? 0)
session.directSshRetryAttempt = (() => {
const pendingAttempt = session.state.directSshPaneRetryByTabId?.[session.deps.tabId]
const liveBinding = session.state.directSshLivePtyBindingByTabId?.[session.deps.tabId]
const attempt = session.followsDirectSshReconnect
? pendingDirectSshRetry
: liveBinding?.authority.targetId === session.connectionId &&
liveBinding.tabGeneration === (session.tab?.generation ?? 0)
? liveBinding
: undefined
const attempt =
pendingAttempt?.authority.targetId === session.connectionId &&
pendingAttempt.tabGeneration === (session.tab?.generation ?? 0)
? pendingAttempt
: liveBinding?.authority.targetId === session.connectionId &&
liveBinding.tabGeneration === (session.tab?.generation ?? 0)
? liveBinding
: undefined
return attempt
})()
session.pendingSpawnKey = session.directSshRetryAttempt
@@ -12,10 +12,6 @@ import {
resolvePositiveTerminalDimensions,
shouldSkipAltFrameForWidthMismatch
} from '../terminal-snapshot-replay-paint'
import {
lastAlternateScreenTransition,
sshReconnectPaintsFromModel
} from '../ssh-reattach-model-restore'
import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore'
import { restoredSnapshotPaintsPrintableContent } from '../restored-snapshot-coverage'
@@ -110,27 +106,14 @@ export function createReattachPayloadHandlers(
}
}
} else if (ctx.connectResult?.replay || ctx.prefetchedParkModelSnapshot) {
// Parks prefer the complete model; direct-SSH reconnects use it only for
// a compatible alternate-screen frame that the bounded relay tail cannot rebuild.
const revealSnapshot = ctx.revealFollowsTerminalPark
// Why scoped to a park-reveal: the 100KiB relay tail loses scrollback the
// model still holds, but an in-place reattach (network reconnect, wake,
// reload) already has that replay in hand, so probing would only delay its
// paint by the timeout. Memoized, so this is never a second probe.
const modelSnapshot = ctx.revealFollowsTerminalPark
? (ctx.prefetchedParkModelSnapshot ??
(isRemoteRuntimePtyId(ctx.ptyId) ? null : await ctx.fetchSshMainModelReattachSnapshot()))
: null
const replayTransition = lastAlternateScreenTransition(ctx.connectResult?.replay)
const reconnectSnapshot =
!revealSnapshot && ctx.reconnectMayUseModel && replayTransition !== 'exited'
? await ctx.fetchSshMainModelReattachSnapshot()
: null
const paintsReconnectFromModel = sshReconnectPaintsFromModel({
snapshot: reconnectSnapshot,
hasReplay: Boolean(ctx.connectResult?.replay),
replayTransition,
altFrameWouldBeSkipped: shouldSkipAltFrameForWidthMismatch(
reconnectSnapshot?.cols,
readProposedTerminalCols(session.pane)
)
})
const modelSnapshot = revealSnapshot ?? (paintsReconnectFromModel ? reconnectSnapshot : null)
if (!ctx.isCurrentReattachPayload()) {
return
}
@@ -159,9 +142,6 @@ export function createReattachPayloadHandlers(
kittyKeyboardFlags: modelSnapshot.kittyKeyboardFlags,
snapshotSeq: modelSnapshot.seq
})
if (paintsReconnectFromModel && ctx.connectResult?.replay) {
session.kittyKeyboardModes.scanReplay(ctx.connectResult.replay)
}
// Why shared: park+reveal of an alt-screen TUI needs the same
// ?1049l/?1049h rebuild as applyMainBufferSnapshot (main strips
// the ?1049h marker when splitting scrollbackAnsi) — inlined here
@@ -13,6 +13,7 @@ import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore'
import type { ConnectPanePtySession } from './connect-pane-pty-session'
import { runDeferredSessionReattachChoice } from './deferred-session-reattach-choice'
import { recoverUnverifiableDirectSshReattach } from './direct-ssh-reattach-recovery'
export function runDeferredSessionAttach(session: ConnectPanePtySession): void {
// Why: trigger the deferred SSH connect per-tab (not per-target) so multiple tabs for one target reattach independently.
@@ -32,21 +33,29 @@ export function runDeferredSessionAttach(session: ConnectPanePtySession): void {
session.deps.restoredLeafId && session.deps.restoredPtyIdByLeafId
? (session.deps.restoredPtyIdByLeafId[session.deps.restoredLeafId] ?? null)
: null
const deferredTabSessionId = storeState.deferredSshSessionIdsByTabId[session.deps.tabId]
const tabPtyId = storeState.tabsByWorktree[session.deps.worktreeId]?.find(
(t) => t.id === session.deps.tabId
)?.ptyId
const gate = resolveSshPaneConnectGate({
connectionId: session.connectionId,
sshStatus: storeState.sshConnectionStates.get(session.connectionId)?.status,
isDeferredTarget: storeState.deferredSshReconnectTargets.includes(session.connectionId),
restoredLeafSessionId,
deferredTabSessionId: storeState.deferredSshSessionIdsByTabId[session.deps.tabId],
tabPtyId: storeState.tabsByWorktree[session.deps.worktreeId]?.find(
(t) => t.id === session.deps.tabId
)?.ptyId,
deferredTabSessionId,
tabPtyId,
hasLeafSessionMap: Boolean(
session.deps.restoredPtyIdByLeafId &&
Object.keys(session.deps.restoredPtyIdByLeafId).length > 0
)
})
const pendingSessionId = gate.pendingSessionId
const deferredSessionIsOnlyRetryBinding = Boolean(
pendingSessionId &&
pendingSessionId === deferredTabSessionId &&
restoredLeafSessionId == null &&
tabPtyId !== pendingSessionId
)
console.warn(
`[pty-connection] SSH tab=${session.deps.tabId} connectionId=${session.connectionId} pendingSessionId=${pendingSessionId} sshConnected=${gate.sshConnected}`
)
@@ -114,8 +123,10 @@ export function runDeferredSessionAttach(session: ConnectPanePtySession): void {
console.warn(
`[pty-connection] Attempting reattach for tab=${session.deps.tabId} sessionId=${pendingSessionId}`
)
// Why: the saved remote PTY id is single-use restore metadata; clear it before attach so remounts don't keep retrying an expired session.
useAppStore.getState().removeDeferredSshSessionId(session.deps.tabId)
// Why: consume redundant restore metadata before attach, but keep a sole deferred ID until the host gives a conclusive result.
if (!deferredSessionIsOnlyRetryBinding) {
useAppStore.getState().removeDeferredSshSessionId(session.deps.tabId)
}
// Why: pre-signal SSH-deferred reattach too so the cooperation gate applies uniformly to remote sessions (Electron preserves the declare→connect order).
// See docs/mobile-prefer-renderer-scrollback.md.
const preSignalPromise =
@@ -199,6 +210,7 @@ export function runDeferredSessionAttach(session: ConnectPanePtySession): void {
if (session.rejectObsoleteDirectSshReattach(pendingSessionId)) {
return
}
useAppStore.getState().removeDeferredSshSessionId(session.deps.tabId)
session.deps.clearExitedPanePtyLayoutBinding(session.pane.id, pendingSessionId)
session.deps.clearTabPtyId(session.deps.tabId, pendingSessionId)
session.startFreshColdRestoreAgentResume(coldRestoreStartup, {
@@ -213,6 +225,19 @@ export function runDeferredSessionAttach(session: ConnectPanePtySession): void {
outputCallbacks.generation
)
session.finishReattachLiveDataDeferral(accepted, outputCallbacks.generation)
const sessionExpired = Boolean(
result &&
typeof result === 'object' &&
'sessionExpired' in result &&
result.sessionExpired
)
if (
deferredSessionIsOnlyRetryBinding &&
(accepted || sessionExpired) &&
session.isCapturedDirectSshReattachCurrent(pendingSessionId)
) {
useAppStore.getState().removeDeferredSshSessionId(session.deps.tabId)
}
const gen = await preSignalPromise
if (typeof gen === 'number') {
if (!accepted) {
@@ -248,6 +273,7 @@ export function runDeferredSessionAttach(session: ConnectPanePtySession): void {
return
}
if (isSshSessionExpiredError(err)) {
useAppStore.getState().removeDeferredSshSessionId(session.deps.tabId)
session.deps.clearExitedPanePtyLayoutBinding(session.pane.id, pendingSessionId)
session.deps.clearTabPtyId(session.deps.tabId, pendingSessionId)
session.startFreshColdRestoreAgentResume(coldRestoreStartup, {
@@ -255,9 +281,8 @@ export function runDeferredSessionAttach(session: ConnectPanePtySession): void {
})
return
}
session.startFreshColdRestoreAgentResume(coldRestoreStartup, {
forceBlankRestoredViewport: true
})
session.reportError(err instanceof Error ? err.message : String(err))
recoverUnverifiableDirectSshReattach(session, pendingSessionId)
})
session.armDirectSshPaneRetryTimeout(
trackedReattachPromise,
@@ -2,6 +2,7 @@ import { warnTerminalLifecycleAnomaly } from '../terminal-lifecycle-diagnostics'
import { recordPtyConnectDiagnostic } from './pty-connect-limits'
import { isSshSessionExpiredError } from './ssh-session-connect'
import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore'
import { recoverUnverifiableDirectSshReattach } from './direct-ssh-reattach-recovery'
import type { ConnectPanePtySession } from './connect-pane-pty-session'
export function startDeferredSessionReattach(
@@ -136,15 +137,21 @@ export function startDeferredSessionReattach(
ptyId: deferredReattachSessionId,
reason: message
})
session.deps.clearExitedPanePtyLayoutBinding(session.pane.id, deferredReattachSessionId)
session.deps.clearTabPtyId(session.deps.tabId, deferredReattachSessionId)
if (session.connectionId && isSshSessionExpiredError(err)) {
session.deps.clearExitedPanePtyLayoutBinding(session.pane.id, deferredReattachSessionId)
session.deps.clearTabPtyId(session.deps.tabId, deferredReattachSessionId)
session.startFreshColdRestoreAgentResume(coldRestoreStartup, {
forceBlankRestoredViewport: true
})
return
}
session.reportError(message)
if (session.connectionId) {
recoverUnverifiableDirectSshReattach(session, deferredReattachSessionId)
return
}
session.deps.clearExitedPanePtyLayoutBinding(session.pane.id, deferredReattachSessionId)
session.deps.clearTabPtyId(session.deps.tabId, deferredReattachSessionId)
session.startFreshColdRestoreAgentResume(coldRestoreStartup, {
forceBlankRestoredViewport: true
})
@@ -0,0 +1,46 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { requestTerminalPaneRecovery } from '../terminal-pane-recovery'
import { recoverUnverifiableDirectSshReattach } from './direct-ssh-reattach-recovery'
vi.mock('../terminal-pane-recovery', () => ({
requestTerminalPaneRecovery: vi.fn()
}))
describe('recoverUnverifiableDirectSshReattach', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('retries through the exact direct SSH lease when one exists', () => {
const attempt = { attemptId: 'attempt-1' }
const settleDirectSshPaneRetryAttempt = vi.fn()
recoverUnverifiableDirectSshReattach(
{ directSshRetryAttempt: attempt, settleDirectSshPaneRetryAttempt } as never,
'ssh:target@@pty-1'
)
expect(settleDirectSshPaneRetryAttempt).toHaveBeenCalledExactlyOnceWith(attempt, 'failed')
expect(requestTerminalPaneRecovery).not.toHaveBeenCalled()
})
it('remounts over the preserved PTY when no retry lease exists', () => {
recoverUnverifiableDirectSshReattach(
{
directSshRetryAttempt: undefined,
deps: { tabId: 'tab-1' },
terminalRecoveryGeneration: 2,
terminalRecoveryInstance: { id: 3 }
} as never,
'ssh:target@@pty-1'
)
expect(requestTerminalPaneRecovery).toHaveBeenCalledExactlyOnceWith({
tabId: 'tab-1',
ptyId: 'ssh:target@@pty-1',
reason: 'reattach-unverifiable',
terminalRecoveryGeneration: 2,
terminalRecoveryInstanceId: 3
})
})
})
@@ -0,0 +1,19 @@
import { requestTerminalPaneRecovery } from '../terminal-pane-recovery'
import type { ConnectPanePtySession } from './connect-pane-pty-session'
export function recoverUnverifiableDirectSshReattach(
session: ConnectPanePtySession,
ptyId: string | null | undefined
): void {
if (session.directSshRetryAttempt) {
session.settleDirectSshPaneRetryAttempt(session.directSshRetryAttempt, 'failed')
return
}
void requestTerminalPaneRecovery({
tabId: session.deps.tabId,
ptyId: ptyId ?? null,
reason: 'reattach-unverifiable',
terminalRecoveryGeneration: session.terminalRecoveryGeneration,
terminalRecoveryInstanceId: session.terminalRecoveryInstance.id
})
}
@@ -1,139 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { PtyBufferSnapshot } from '../pty-transport'
import { createPane } from '../pty-connection-test-pane-fixtures'
import {
installTerminalTestGlobals,
restoreTerminalTestGlobals
} from '../pty-connection-test-environment'
import { createReattachPayloadHandlers } from './apply-reattach-payload'
import type { ReattachPayloadContext } from './reattach-payload-context'
import type { ReattachPayloadSession } from './reattach-payload-session'
const ALT_OFF = '\x1b[?1049l'
const KITTY_PUSH = '\x1b[>1u'
function createSnapshot(overrides: Partial<PtyBufferSnapshot> = {}): PtyBufferSnapshot {
return {
data: 'MODEL-FRAME',
cols: 120,
rows: 40,
source: 'headless',
alternateScreen: true,
...overrides
}
}
function createSession(): ReattachPayloadSession {
const pane = createPane(1)
return {
pane,
rememberReattachPayloadAgentSignal: vi.fn(),
writeReplayData: vi.fn(),
reattachReplayResetSequence: vi.fn(() => '<reset>'),
sendFocusedReattachFocusInAfterReplay: vi.fn(),
applySnapshotKittyKeyboardModes: vi.fn(),
setRestoredSnapshotBaseline: vi.fn(),
recordRendererOrderedSeq: vi.fn(),
isPaneOnAlternateScreen: vi.fn(() => false),
shouldPreserveAgentReattachModes: vi.fn(() => false),
kittyKeyboardModes: {
hasProvenBaseline: true,
resetForSnapshot: vi.fn(),
scanReplay: vi.fn()
}
} as unknown as ReattachPayloadSession
}
function createContext(
replay: string,
fetchSnapshot: () => Promise<PtyBufferSnapshot | null>
): ReattachPayloadContext {
return {
isCurrentReattachPayload: () => true,
connectResult: { id: 'pty-1', replay },
ptyId: 'pty-1',
attemptGeneration: 1,
prefetchedParkModelSnapshot: null,
revealFollowsTerminalPark: false,
reconnectMayUseModel: true,
fetchSshMainModelReattachSnapshot: fetchSnapshot,
hasStructuralReplay: true,
coldRestoreStartup: undefined,
reattachPayloadApplied: false
}
}
describe('direct SSH reconnect model restore handler', () => {
beforeEach(() => installTerminalTestGlobals())
afterEach(async () => restoreTerminalTestGlobals())
it('paints a compatible full-screen snapshot', async () => {
const snapshot = createSnapshot()
const fetchSnapshot = vi.fn(async () => snapshot)
const session = createSession()
await createReattachPayloadHandlers(
session,
createContext('RELAY-TAIL', fetchSnapshot)
).applyReattachPayload()
expect(fetchSnapshot).toHaveBeenCalledOnce()
expect(session.rememberReattachPayloadAgentSignal).toHaveBeenCalledWith('MODEL-FRAME', {
fullScreenReplay: true
})
expect(session.setRestoredSnapshotBaseline).toHaveBeenCalledWith('pty-1', snapshot)
})
it('skips the snapshot probe when replay proves the app exited alternate screen', async () => {
const fetchSnapshot = vi.fn(async () => createSnapshot())
const session = createSession()
const replay = `${ALT_OFF}\r\n$ `
await createReattachPayloadHandlers(
session,
createContext(replay, fetchSnapshot)
).applyReattachPayload()
expect(fetchSnapshot).not.toHaveBeenCalled()
expect(session.rememberReattachPayloadAgentSignal).toHaveBeenCalledWith(replay, {
fullScreenReplay: true
})
})
it('uses relay when the snapshot frame width is incompatible', async () => {
const fetchSnapshot = vi.fn(async () => createSnapshot())
const session = createSession()
session.pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 80, rows: 40 }))
await createReattachPayloadHandlers(
session,
createContext('RELAY-TAIL', fetchSnapshot)
).applyReattachPayload()
expect(fetchSnapshot).toHaveBeenCalledOnce()
expect(session.rememberReattachPayloadAgentSignal).toHaveBeenCalledWith('RELAY-TAIL', {
fullScreenReplay: true
})
expect(session.setRestoredSnapshotBaseline).not.toHaveBeenCalled()
})
it('layers outage kitty mode changes over the snapshot baseline', async () => {
const session = createSession()
const replay = `${KITTY_PUSH}REDRAW`
await createReattachPayloadHandlers(
session,
createContext(replay, async () => createSnapshot({ kittyKeyboardFlags: 0, seq: 10 }))
).applyReattachPayload()
expect(session.applySnapshotKittyKeyboardModes).toHaveBeenCalledWith('MODEL-FRAME', {
kittyKeyboardFlags: 0,
snapshotSeq: 10
})
expect(session.kittyKeyboardModes.scanReplay).toHaveBeenCalledWith(replay)
expect(session.applySnapshotKittyKeyboardModes.mock.invocationCallOrder[0]).toBeLessThan(
session.kittyKeyboardModes.scanReplay.mock.invocationCallOrder[0]
)
})
})
@@ -34,7 +34,6 @@ function createContext(replay: string, attemptGeneration: number): ReattachPaylo
attemptGeneration,
prefetchedParkModelSnapshot: null,
revealFollowsTerminalPark: false,
reconnectMayUseModel: false,
fetchSshMainModelReattachSnapshot: async () => null,
hasStructuralReplay: true,
coldRestoreStartup: undefined,
@@ -9,7 +9,6 @@ export type ReattachPayloadContext = {
attemptGeneration: number
prefetchedParkModelSnapshot: PtyBufferSnapshot | null
revealFollowsTerminalPark: boolean
reconnectMayUseModel: boolean
fetchSshMainModelReattachSnapshot: () => Promise<PtyBufferSnapshot | null>
hasStructuralReplay: boolean
coldRestoreStartup: ColdRestoreAgentResumeStartup | null | undefined
@@ -17,6 +17,7 @@ import type { ConnectPanePtySession } from './connect-pane-pty-session'
import type { ReattachPayloadContext } from './reattach-payload-context'
import { createReattachPayloadHandlers } from './apply-reattach-payload'
import type { ReattachPayloadSession } from './reattach-payload-session'
import { recoverUnverifiableDirectSshReattach } from './direct-ssh-reattach-recovery'
type ReattachResultSession = ReattachPayloadSession &
Pick<
@@ -25,10 +26,10 @@ type ReattachResultSession = ReattachPayloadSession &
| 'authoritativeReattachGeneration'
| 'capturedDirectSshRetryPtyAccepted'
| 'cacheKey'
| 'connectionId'
| 'deps'
| 'directSshRetryAttempt'
| 'disposed'
| 'followsDirectSshReconnect'
| 'getSshMainModelSnapshotProbe'
| 'handleReattachResult'
| 'mountFollowsTerminalPark'
@@ -89,6 +90,10 @@ export function bindHandleReattachResult(sessionBag: ConnectPanePtySession): voi
paneId: session.pane.id,
ptyId: staleSessionId ?? null
})
if (session.connectionId) {
recoverUnverifiableDirectSshReattach(sessionBag, staleSessionId)
return false
}
// Why: a stale restored session can fail reattach after mount; don't leave xterm alive without a backing PTY.
if (staleSessionId) {
session.deps.clearExitedPanePtyLayoutBinding(session.pane.id, staleSessionId)
@@ -200,8 +205,6 @@ export function bindHandleReattachResult(sessionBag: ConnectPanePtySession): voi
const revealFollowsTerminalPark =
session.mountFollowsTerminalPark &&
(connectResult?.isReattach === true || isRemoteRuntimePtyId(ptyId))
const reconnectMayUseModel =
Boolean(session.followsDirectSshReconnect) && !revealFollowsTerminalPark
session.mountFollowsTerminalPark = false
// Why: ordinary parking destroys xterm. Rebuild from the authoritative
// host snapshot before releasing queued live bytes; null falls back to
@@ -233,7 +236,6 @@ export function bindHandleReattachResult(sessionBag: ConnectPanePtySession): voi
attemptGeneration,
prefetchedParkModelSnapshot,
revealFollowsTerminalPark,
reconnectMayUseModel,
fetchSshMainModelReattachSnapshot,
hasStructuralReplay,
coldRestoreStartup,
@@ -62,6 +62,19 @@ describe('requestTerminalPaneRecovery', () => {
expect(mocks.hasPty).not.toHaveBeenCalled()
})
it('remounts an unverifiable reattach without requiring host-death evidence', async () => {
const result = await requestTerminalPaneRecovery({
tabId: 'tab-ssh',
ptyId: 'ssh:target@@pty-1',
reason: 'reattach-unverifiable'
})
expect(result).toBe(true)
expect(mocks.remountTerminalTabForRecovery).toHaveBeenCalledWith('tab-ssh')
expect(mocks.hasPty).not.toHaveBeenCalled()
expect(isTerminalInputQuarantined('tab-ssh')).toBe(false)
})
it('records a breadcrumb when the tab cannot be remounted, without consuming budget', async () => {
mocks.remountTerminalTabForRecovery.mockReturnValue(false)
@@ -151,6 +164,23 @@ describe('requestTerminalPaneRecovery', () => {
expect(mocks.remountTerminalTabForRecovery).toHaveBeenCalledTimes(4)
})
it('does not restart an unverifiable SSH reattach chain after its incident cap', async () => {
vi.useFakeTimers()
for (let attempt = 0; attempt < 4; attempt += 1) {
vi.setSystemTime(attempt * 20_000)
await requestTerminalPaneRecovery({
tabId: 'tab-ssh',
ptyId: 'ssh:target@@pty-1',
reason: 'reattach-unverifiable',
terminalRecoveryGeneration: captureTerminalPaneRecoveryGeneration('tab-ssh')
})
}
expect(mocks.remountTerminalTabForRecovery).toHaveBeenCalledTimes(3)
await vi.advanceTimersByTimeAsync(600_000)
expect(mocks.remountTerminalTabForRecovery).toHaveBeenCalledTimes(3)
})
it('does not retry a cooldown decline from the xterm replaced by the remount', async () => {
vi.useFakeTimers()
vi.setSystemTime(0)
@@ -27,6 +27,7 @@ export type TerminalPaneRecoveryReason =
// rejection frame is the evidence instead — it came from the process that
// owns the PTY, over a connection that is by construction still up.
| 'input-rejected-by-host'
| 'reattach-unverifiable'
// A restore was requested for a certified-dead pipeline (reveal path).
| 'restore-blocked'
@@ -83,6 +84,15 @@ type RecoveryBudget =
| { allowed: false; declinedBy: 'window-cap'; retryInMs: number }
| { allowed: false; declinedBy: 'cooldown'; retryInMs: number }
function shouldScheduleRecoveryRetry(request: RecoveryRequest, budget: RecoveryBudget): boolean {
return (
!budget.allowed &&
(budget.declinedBy === 'cooldown'
? request.terminalRecoveryGeneration !== undefined
: request.reason !== 'reattach-unverifiable')
)
}
function recoveryBudget(tabId: string, now: number): RecoveryBudget {
const timestamps = recoveryTimestampsByTabId.get(tabId) ?? []
const recent = timestamps.filter((t) => now - t < RECOVERY_WINDOW_MS)
@@ -202,10 +212,7 @@ export async function requestTerminalPaneRecovery(request: RecoveryRequest): Pro
}
const budget = recoveryBudget(request.tabId, Date.now())
if (!budget.allowed) {
if (
budget.declinedBy === 'window-cap' ||
(budget.declinedBy === 'cooldown' && request.terminalRecoveryGeneration !== undefined)
) {
if (shouldScheduleRecoveryRetry(request, budget)) {
scheduleRecoveryRetry(request, budget.retryInMs)
}
return false
@@ -239,10 +246,7 @@ export async function requestTerminalPaneRecovery(request: RecoveryRequest): Pro
}
const recheck = recoveryBudget(request.tabId, Date.now())
if (!recheck.allowed) {
if (
recheck.declinedBy === 'window-cap' ||
(recheck.declinedBy === 'cooldown' && request.terminalRecoveryGeneration !== undefined)
) {
if (shouldScheduleRecoveryRetry(request, recheck)) {
scheduleRecoveryRetry(request, recheck.retryInMs)
}
return false