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 eb978324b66..7e0486523ef 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 @@ -301,6 +301,47 @@ describe('requestTerminalPaneRecovery', () => { secondSplit.unregister() }) + it('does not abandon a certified sibling behind a failed liveness retry', async () => { + vi.useFakeTimers() + vi.setSystemTime(0) + await requestTerminalPaneRecovery({ + tabId: 'tab-1', + ptyId: 'pty-initial', + reason: 'write-stalled' + }) + const recoveryGeneration = captureTerminalPaneRecoveryGeneration('tab-1') + const livenessSplit = registerTerminalPaneRecoveryInstance('tab-1') + const certifiedSplit = registerTerminalPaneRecoveryInstance('tab-1') + mocks.hasPty.mockResolvedValue(false) + + await requestTerminalPaneRecovery({ + tabId: 'tab-1', + ptyId: 'pty-not-live', + reason: 'input-undeliverable', + terminalRecoveryGeneration: recoveryGeneration, + terminalRecoveryInstanceId: livenessSplit.id, + requireAuthoritativeLiveness: true + }) + await requestTerminalPaneRecovery({ + tabId: 'tab-1', + ptyId: 'pty-certified-dead', + reason: 'write-stalled', + terminalRecoveryGeneration: recoveryGeneration, + terminalRecoveryInstanceId: certifiedSplit.id + }) + + await vi.advanceTimersByTimeAsync(15_000) + + expect(mocks.hasPty).toHaveBeenCalledWith('pty-not-live') + expect(mocks.remountTerminalTabForRecovery).toHaveBeenCalledTimes(2) + expect(mocks.recordRendererCrashBreadcrumb).toHaveBeenLastCalledWith( + 'terminal_pane_recovery_remount', + { tabId: 'tab-1', reason: 'write-stalled' } + ) + livenessSplit.unregister() + certifiedSplit.unregister() + }) + it('budgets tabs independently', async () => { expect( await requestTerminalPaneRecovery({ tabId: 'tab-1', ptyId: 'pty-1', reason: 'write-stalled' }) 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 bc5077221c9..791234b232b 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-recovery.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-recovery.ts @@ -138,13 +138,18 @@ function scheduleRecoveryRetry(request: RecoveryRequest, delayMs: number): void const timer = setTimeout( () => { pendingRetryByTabId.delete(request.tabId) - const currentRequest = [...requestsByInstanceId.values()].find( + const currentRequests = [...requestsByInstanceId.values()].filter( isCurrentTerminalRecoveryRequest ) - if (!currentRequest) { + if (currentRequests.length === 0) { return } - void requestTerminalPaneRecovery(currentRequest) + // Why: one split's liveness probe may fail or never settle while a + // sibling has a probe-certified dead renderer. Start every current + // request so the first valid remount wins and invalidates the rest. + void Promise.all( + currentRequests.map((currentRequest) => requestTerminalPaneRecovery(currentRequest)) + ) }, Math.max(delayMs, 1_000) )