diff --git a/src/renderer/src/components/terminal-pane/pty-connection-deferred-ssh-passphrase.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-deferred-ssh-passphrase.test.ts index e0abf8054e8..72b51ac3d01 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-deferred-ssh-passphrase.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-deferred-ssh-passphrase.test.ts @@ -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 | 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') }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts index 8407987e48b..ff55695c857 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts @@ -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 () => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection-reattach-binding.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-reattach-binding.test.ts index 53d248666bc..047ab764814 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-reattach-binding.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-reattach-binding.test.ts @@ -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') diff --git a/src/renderer/src/components/terminal-pane/pty-connection/agent-idle-working-handlers.ts b/src/renderer/src/components/terminal-pane/pty-connection/agent-idle-working-handlers.ts index 788c76cc1dc..523b77a32f1 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/agent-idle-working-handlers.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/agent-idle-working-handlers.ts @@ -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 diff --git a/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts b/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts index e5c0ecbf404..2395bbeeafb 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts @@ -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 diff --git a/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-attach.ts b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-attach.ts index d07112252a5..4a13ac8e479 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-attach.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-attach.ts @@ -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, diff --git a/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-connect.ts b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-connect.ts index 91eb9b7dfb0..0e1b70e27fd 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-connect.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-connect.ts @@ -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 }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-reattach-recovery.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-reattach-recovery.test.ts new file mode 100644 index 00000000000..03f3da2f158 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-reattach-recovery.test.ts @@ -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 + }) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-reattach-recovery.ts b/src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-reattach-recovery.ts new file mode 100644 index 00000000000..f32896fc254 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-reattach-recovery.ts @@ -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 + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-reconnect-model-restore-handler.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-reconnect-model-restore-handler.test.ts deleted file mode 100644 index c52fdf24cb3..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection/direct-ssh-reconnect-model-restore-handler.test.ts +++ /dev/null @@ -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 { - 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(() => ''), - 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 -): 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] - ) - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.test.ts index fc7938fbc7c..51041decf04 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.test.ts @@ -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, diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.ts index 6c9264f6a80..559bde05215 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-context.ts @@ -9,7 +9,6 @@ export type ReattachPayloadContext = { attemptGeneration: number prefetchedParkModelSnapshot: PtyBufferSnapshot | null revealFollowsTerminalPark: boolean - reconnectMayUseModel: boolean fetchSshMainModelReattachSnapshot: () => Promise hasStructuralReplay: boolean coldRestoreStartup: ColdRestoreAgentResumeStartup | null | undefined diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts index f1ac44301fa..028e33d9992 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/reattach-result-handler.ts @@ -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, diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-recovery.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-recovery.test.ts index 50d3d693f7d..da14fb4b9b6 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-recovery.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-recovery.test.ts @@ -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) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-recovery.ts b/src/renderer/src/components/terminal-pane/terminal-pane-recovery.ts index 9800e95d2ac..3db158d805a 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-recovery.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-recovery.ts @@ -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