diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 064f7dd39..cfed6571b 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -20639,6 +20639,249 @@ describe('connectPanePty', () => { expect(api.pty.signal).toHaveBeenCalledWith('leaf-session', 'SIGWINCH') }) + it('paints a parked SSH model before the remote connection settles', async () => { + const { connectPanePty } = await import('./pty-connection') + const sshPtyId = toAppSshPtyId('conn-1', 'relay-pty-1') + const sshConnect = createDeferred() + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.mocked(window.api.ssh.connect).mockReturnValue(sshConnect.promise) + vi.mocked(window.api.pty.getMainBufferSnapshot).mockResolvedValue({ + data: 'PARKED-SSH-PAINTED-WITHOUT-NETWORK\r\n', + cols: 101, + rows: 31, + seq: 123, + source: 'headless' + }) + await parkTabForReveal('tab-1', sshPtyId) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: sshPtyId }] }, + ptyIdsByTabId: { 'tab-1': [sshPtyId] }, + repos: [{ id: 'repo1', connectionId: 'conn-1' }], + sshConnectionStates: new Map([['conn-1', { status: 'disconnected' }]]), + deferredSshReconnectTargets: ['conn-1'], + deferredSshSessionIdsByTabId: { 'tab-1': sshPtyId } + } + + const pane = createPane(1) + const { writes } = captureCallbackTerminalWrites(pane) + const binding = connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: sshPtyId } + }) as never + ) + await flushAsyncTicks(20) + + expect(window.api.ssh.connect).toHaveBeenCalledWith({ targetId: 'conn-1' }) + expect(window.api.pty.getMainBufferSnapshot).toHaveBeenCalledOnce() + expect(writes.join('')).toContain('PARKED-SSH-PAINTED-WITHOUT-NETWORK') + expect(transport.connect).not.toHaveBeenCalled() + + binding.dispose() + sshConnect.resolve({ + targetId: 'conn-1', + status: 'connected', + error: null, + reconnectAttempt: 0 + }) + }) + + it('drops an in-flight parked SSH prepaint after its retry lease is replaced', async () => { + const { connectPanePty } = await import('./pty-connection') + const sshPtyId = toAppSshPtyId('conn-1', 'relay-pty-1') + const snapshot = createDeferred<{ + data: string + cols: number + rows: number + seq: number + source: 'headless' + }>() + const sshConnect = createDeferred() + const pendingRetry = { + attemptId: 'attempt-prepaint', + authority: { + targetId: 'conn-1', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + }, + tabGeneration: 7, + startedAt: 1 + } + transportFactoryQueue.push(createMockTransport()) + vi.mocked(window.api.pty.getMainBufferSnapshot).mockReturnValue(snapshot.promise) + vi.mocked(window.api.ssh.connect).mockReturnValue(sshConnect.promise) + await parkTabForReveal('tab-1', sshPtyId) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: sshPtyId, generation: 7 }] }, + ptyIdsByTabId: { 'tab-1': [sshPtyId] }, + repos: [{ id: 'repo1', connectionId: 'conn-1' }], + sshConnectionStates: new Map([ + [ + 'conn-1', + { + status: 'disconnected', + providerEpoch: 'epoch-1', + connectionGeneration: 3 + } + ] + ]), + deferredSshReconnectTargets: ['conn-1'], + deferredSshSessionIdsByTabId: { 'tab-1': sshPtyId }, + directSshPaneRetryByTabId: { 'tab-1': pendingRetry } + } + + const pane = createPane(1) + const { writes } = captureCallbackTerminalWrites(pane) + const binding = connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: sshPtyId } + }) as never + ) + await flushAsyncTicks(8) + expect(window.api.pty.getMainBufferSnapshot).toHaveBeenCalledOnce() + + mockStoreState.sshConnectionStates = new Map([ + [ + 'conn-1', + { + status: 'disconnected', + providerEpoch: 'epoch-1', + connectionGeneration: 4 + } + ] + ]) + mockStoreState.directSshPaneRetryByTabId = { + 'tab-1': { + ...pendingRetry, + attemptId: 'attempt-prepaint-new', + authority: { ...pendingRetry.authority, connectionGeneration: 4 } + } + } + snapshot.resolve({ + data: 'OBSOLETE-LEASE-SNAPSHOT\r\n', + cols: 101, + rows: 31, + seq: 124, + source: 'headless' + }) + await flushAsyncTicks(12) + + expect(writes.join('')).not.toContain('OBSOLETE-LEASE-SNAPSHOT') + binding.dispose() + sshConnect.resolve(null) + await flushAsyncTicks(4) + }) + + it('does not prepaint a parked SSH snapshot owned by another connection', async () => { + const { connectPanePty } = await import('./pty-connection') + const foreignPtyId = toAppSshPtyId('conn-2', 'relay-pty-1') + const sshConnect = createDeferred() + transportFactoryQueue.push(createMockTransport()) + vi.mocked(window.api.ssh.connect).mockReturnValue(sshConnect.promise) + vi.mocked(window.api.pty.getMainBufferSnapshot).mockResolvedValue({ + data: 'FOREIGN-CONNECTION-SNAPSHOT\r\n', + cols: 101, + rows: 31, + seq: 125, + source: 'headless' + }) + await parkTabForReveal('tab-1', foreignPtyId) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: foreignPtyId }] }, + ptyIdsByTabId: { 'tab-1': [foreignPtyId] }, + repos: [{ id: 'repo1', connectionId: 'conn-1' }], + sshConnectionStates: new Map([['conn-1', { status: 'disconnected' }]]), + deferredSshReconnectTargets: ['conn-1'], + deferredSshSessionIdsByTabId: { 'tab-1': foreignPtyId } + } + + const pane = createPane(1) + const { writes } = captureCallbackTerminalWrites(pane) + const binding = connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: foreignPtyId } + }) as never + ) + await flushAsyncTicks(12) + + expect(window.api.pty.getMainBufferSnapshot).not.toHaveBeenCalled() + expect(writes.join('')).not.toContain('FOREIGN-CONNECTION-SNAPSHOT') + binding.dispose() + sshConnect.resolve(null) + await flushAsyncTicks(4) + }) + + it('does not paint a delayed parked snapshot over an expired-session replacement', async () => { + const { connectPanePty } = await import('./pty-connection') + const sshPtyId = toAppSshPtyId('conn-1', 'relay-pty-expired') + const freshPtyId = toAppSshPtyId('conn-1', 'relay-pty-fresh') + const snapshot = createDeferred<{ + data: string + cols: number + rows: number + seq: number + source: 'headless' + }>() + const transport = createMockTransport() + transport.connect.mockImplementation(async (opts) => { + if (opts.sessionId) { + opts.callbacks?.onError?.(`SSH_SESSION_EXPIRED: ${opts.sessionId}`) + return undefined + } + transport.getPtyId.mockReturnValue(freshPtyId) + return freshPtyId + }) + transportFactoryQueue.push(transport) + vi.mocked(window.api.pty.getMainBufferSnapshot).mockReturnValue(snapshot.promise) + await parkTabForReveal('tab-1', sshPtyId) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: sshPtyId }] }, + ptyIdsByTabId: { 'tab-1': [sshPtyId] }, + repos: [{ id: 'repo1', connectionId: 'conn-1' }], + sshConnectionStates: new Map([['conn-1', { status: 'connected' }]]), + deferredSshReconnectTargets: ['conn-1'], + deferredSshSessionIdsByTabId: { 'tab-1': sshPtyId } + } + + const pane = createPane(1) + const { writes } = captureCallbackTerminalWrites(pane) + const binding = connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: sshPtyId } + }) as never + ) + await flushAsyncTicks(30) + expect(transport.connect).toHaveBeenCalledTimes(2) + + snapshot.resolve({ + data: 'EXPIRED-SESSION-SNAPSHOT\r\n', + cols: 101, + rows: 31, + seq: 126, + source: 'headless' + }) + await flushAsyncTicks(20) + + expect(writes.join('')).not.toContain('EXPIRED-SESSION-SNAPSHOT') + binding.dispose() + }) + it('restores configured paired scrollback after an ordinary park reveal', async () => { const { connectPanePty } = await import('./pty-connection') const remotePtyId = 'remote:env-1@@terminal-1' diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 66cd4fb0a..f66ff91b7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -1054,6 +1054,7 @@ export function connectPanePty( // that follows this mount, so connect time is the only moment a pane can tell // a reveal remount from an in-place reattach. let mountFollowsTerminalPark = isTerminalTabParked(deps.tabId) + let authoritativeReattachGeneration = 0 exposeE2eTerminalPtyOutputDebug() let disposed = false const structuralReplayCoordinator = createTerminalStructuralReplayCoordinator(pane.terminal) @@ -5147,6 +5148,7 @@ export function connectPanePty( // about to unmount — so skip the doomed respawn instead of racing it. return Promise.resolve(null) } + authoritativeReattachGeneration += 1 clearPaneMode2031State() clearHiddenOutputRestoreState() // Why: a canceled old replay clear can preserve xterm's native @@ -7753,12 +7755,107 @@ export function connectPanePty( return true } + let parkedSshSnapshotPrefetch: { + ptyId: string + fetch: () => Promise + } | null = null + + const createSshMainModelSnapshotProbe = ( + ptyId: string + ): (() => Promise) => + memoizeSshReattachModelSnapshotProbe(async (): Promise => { + const sshParkingEnabled = useAppStore.getState().settings?.terminalSshViewParking !== false + if (!shouldFetchSshReattachModelSnapshot({ ptyId, sshParkingEnabled })) { + return null + } + const snapshot = await resolveSshReattachModelSnapshotWithTimeout( + window.api.pty.getMainBufferSnapshot(ptyId, { + scrollbackRows: resolveHiddenRestoreScrollbackRows(pane.terminal.options.scrollback) + }) + ) + return snapshot && + decideSshReattachPaintSource({ ptyId, sshParkingEnabled, snapshot }) === + 'main-model-snapshot' + ? snapshot + : null + }) + + const getSshMainModelSnapshotProbe = ( + ptyId: string + ): (() => Promise) => { + if (parkedSshSnapshotPrefetch?.ptyId !== ptyId) { + parkedSshSnapshotPrefetch = { ptyId, fetch: createSshMainModelSnapshotProbe(ptyId) } + } + return parkedSshSnapshotPrefetch.fetch + } + + const prepaintParkedSshSnapshot = (ptyId: string | null): void => { + const parsedPtyId = ptyId ? parseAppSshPtyId(ptyId) : null + if ( + !ptyId || + !mountFollowsTerminalPark || + parsedPtyId?.connectionId !== connectionId || + !capturedDirectSshRetryLeaseMatches() + ) { + return + } + const capturedGeneration = authoritativeReattachGeneration + const isCurrent = (): boolean => + !disposed && + mountFollowsTerminalPark && + authoritativeReattachGeneration === capturedGeneration && + capturedDirectSshRetryLeaseMatches() + const fetchSnapshot = getSshMainModelSnapshotProbe(ptyId) + void fetchSnapshot() + .then(async (snapshot) => { + if (!snapshot || !isCurrent()) { + return + } + await structuralReplayCoordinator.run( + async () => { + if (!isCurrent()) { + return + } + const modelData = `${snapshot.scrollbackAnsi ?? ''}${snapshot.data}` + rememberReattachPayloadAgentSignal(modelData, { fullScreenReplay: true }) + if ( + hasPositiveTerminalDimensions(snapshot.cols, snapshot.rows) && + (pane.terminal.cols !== snapshot.cols || pane.terminal.rows !== snapshot.rows) + ) { + suppressStructuralReplayPtyResize = true + try { + pane.terminal.resize(snapshot.cols, snapshot.rows) + } finally { + suppressStructuralReplayPtyResize = false + } + } + kittyKeyboardModes.scanReplay(modelData) + for (const replayChunk of buildMainModelSnapshotReplayWrites(snapshot)) { + writeReplayData(replayChunk) + } + writeReplayData(reattachReplayResetSequence(modelData)) + if (snapshot.pendingEscapeTailAnsi) { + writeReplayData(snapshot.pendingEscapeTailAnsi) + } + recordTerminalOutput(pane.terminal) + await waitForTerminalReplayWritesParsed(pane.terminal) + if (isCurrent()) { + manager.rebuildPaneWebgl(pane.id) + } + }, + { shouldRestore: isCurrent } + ) + }) + .catch(() => {}) + } + const handleReattachResult = async ( result: PtyConnectResult | string | void, staleSessionId?: string | null, coldRestoreStartup?: ColdRestoreAgentResumeStartup | null, attemptGeneration = transportStreamGeneration ): Promise => { + authoritativeReattachGeneration += 1 if (disposed) { return false } @@ -7886,28 +7983,7 @@ export function connectPanePty( // deadlock on the coordinator's tail chain. // Memoized: the prefetch and the payload task share one probe result, so a // null prefetch can never buy a second timeout before the relay paint. - const fetchSshMainModelReattachSnapshot = memoizeSshReattachModelSnapshotProbe( - async (): Promise => { - const sshParkingEnabled = - useAppStore.getState().settings?.terminalSshViewParking !== false - if (!shouldFetchSshReattachModelSnapshot({ ptyId, sshParkingEnabled })) { - return null - } - const snapshot = await resolveSshReattachModelSnapshotWithTimeout( - window.api.pty.getMainBufferSnapshot(ptyId, { - scrollbackRows: resolveHiddenRestoreScrollbackRows(pane.terminal.options.scrollback) - }) - ) - if ( - !snapshot || - decideSshReattachPaintSource({ ptyId, sshParkingEnabled, snapshot }) !== - 'main-model-snapshot' - ) { - return null - } - return snapshot - } - ) + const fetchSshMainModelReattachSnapshot = getSshMainModelSnapshotProbe(ptyId) // Why consume-once: only the first reattach of a reveal remount may pay // the probe; a later in-place reconnect on this same mount must not buy a // second timeout before the relay paint. @@ -8189,6 +8265,7 @@ export function connectPanePty( const attachRetainedLegacyPty = (ptyId: string): boolean => { try { + authoritativeReattachGeneration += 1 clearPaneMode2031State() clearHiddenOutputRestoreState() const outputCallbacks = captureTransportOutputCallbacks(reportError) @@ -8246,6 +8323,8 @@ export function connectPanePty( ) const legacyWorkerOwnsPane = isLegacyWorkerAutomaticResumeBlocked() if (gate.enterDeferredFlow && (!legacyWorkerOwnsPane || !gate.sshConnected)) { + // Paint main's parked model while SSH recovery continues off the render path. + prepaintParkedSshSnapshot(pendingSessionId) void (async () => { // Why: for a passphrase target with no cached credential, don't auto-fire ssh.connect — a prompt popping just from focusing a tab / Cmd+J would surprise the user. // Wait for a user-initiated connect first; no-passphrase targets return false here and auto-connect as before. @@ -8608,6 +8687,7 @@ export function connectPanePty( if (deferredReattachSessionId) { allowInitialIdleCacheSeed = true recordPtyConnectDiagnostic(`pane=${pane.id} -> REATTACH ${deferredReattachSessionId}`) + prepaintParkedSshSnapshot(deferredReattachSessionId) // Why: pre-signal (declare) before the reattach connect so the cooperation gate suppresses the daemon seed for this paneKey; Electron preserves IPC order. // See docs/mobile-prefer-renderer-scrollback.md (Renderer-side prerequisite requirement #4).