diff --git a/src/main/providers/ssh-provider-miss-recovery.test.ts b/src/main/providers/ssh-provider-miss-recovery.test.ts index d43d85e32be..3d54fa1b911 100644 --- a/src/main/providers/ssh-provider-miss-recovery.test.ts +++ b/src/main/providers/ssh-provider-miss-recovery.test.ts @@ -7,7 +7,8 @@ import { SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE, requireSshGitProvider } from './s import { recoverSshProviderMiss, scheduleSshProviderMissRecovery, - setSshProviderMissRecovery + setSshProviderMissRecovery, + sshProviderMissRecoveryThrottleEntryCount } from './ssh-provider-miss-recovery' const TARGET = 'runtime-ssh-orca-1' @@ -85,4 +86,32 @@ describe('scheduleSshProviderMissRecovery', () => { expect(recovery).toHaveBeenCalledWith('ssh-user-target') expect(recoverSshProviderMiss('ssh-user-target')).toBeUndefined() }) + + it('keeps re-consulting the owner for declined connections and retains none of them', () => { + // Why: these dispatchers are called with every SSH connection id in the app, most of + // which no owner claims. A declined id was never dialed, so there is nothing to back + // off from — throttling it would both retain it forever and swallow the next miss. + const recovery = vi.fn(() => undefined) + setSshProviderMissRecovery(recovery) + + scheduleSshProviderMissRecovery('ssh-user-a') + scheduleSshProviderMissRecovery('ssh-user-a') + scheduleSshProviderMissRecovery('ssh-user-b') + + expect(recovery).toHaveBeenCalledTimes(3) + expect(sshProviderMissRecoveryThrottleEntryCount()).toBe(0) + }) + + it('prunes claimed connections once their throttle interval has passed', () => { + setSshProviderMissRecovery(() => Promise.resolve()) + + scheduleSshProviderMissRecovery('runtime-ssh-orca-1') + scheduleSshProviderMissRecovery('runtime-ssh-orca-2') + expect(sshProviderMissRecoveryThrottleEntryCount()).toBe(2) + + vi.advanceTimersByTime(5_000) + scheduleSshProviderMissRecovery('runtime-ssh-orca-3') + + expect(sshProviderMissRecoveryThrottleEntryCount()).toBe(1) + }) }) diff --git a/src/main/providers/ssh-provider-miss-recovery.ts b/src/main/providers/ssh-provider-miss-recovery.ts index 9453c6e71c2..2cf7c9ae8a2 100644 --- a/src/main/providers/ssh-provider-miss-recovery.ts +++ b/src/main/providers/ssh-provider-miss-recovery.ts @@ -40,14 +40,28 @@ export function scheduleSshProviderMissRecovery(connectionId: string): void { if (startedAt !== undefined && now - startedAt < BACKGROUND_RECOVERY_THROTTLE_MS) { return } - backgroundRecoveryStartedAt.set(connectionId, now) + // Why prune before recording: an expired entry no longer throttles anything, and these + // dispatchers are called with every SSH connection id in the app. + for (const [id, at] of backgroundRecoveryStartedAt) { + if (now - at >= BACKGROUND_RECOVERY_THROTTLE_MS) { + backgroundRecoveryStartedAt.delete(id) + } + } const pending = recovery(connectionId) if (!pending) { + // Declined: the owner does not claim this connection, so there is nothing to throttle + // and recording it would retain an id this map will never act on. return } + backgroundRecoveryStartedAt.set(connectionId, now) pending.catch((error: unknown) => { console.warn( `[ssh] Background provider re-attach failed for ${connectionId}: ${error instanceof Error ? error.message : String(error)}` ) }) } + +/** Test-only: the throttle map is a memory bound, which is not observable from behaviour. */ +export function sshProviderMissRecoveryThrottleEntryCount(): number { + return backgroundRecoveryStartedAt.size +}