diff --git a/src/renderer/src/ssh/node-dist-mirror-recovery.test.ts b/src/renderer/src/ssh/node-dist-mirror-recovery.test.ts index fa4d9b9d0c4..5c71be7aa34 100644 --- a/src/renderer/src/ssh/node-dist-mirror-recovery.test.ts +++ b/src/renderer/src/ssh/node-dist-mirror-recovery.test.ts @@ -3,12 +3,25 @@ import { NODE_DIST_MIRROR_SUGGESTION, saveNodeDistMirrorAndRetry } from './node-dist-mirror-recovery' +import type { SshManagedServerStatus } from '../../../shared/ssh-types' +import { useAppStore } from '@/store' import { beginSshConnect, + endSshConnect, isSshConnectInFlight, resetSshConnectInFlightForTests } from './ssh-connect-in-flight' +function setManagedServer(managedServer: SshManagedServerStatus): void { + useAppStore.getState().setSshConnectionState('ssh-1', { + targetId: 'ssh-1', + status: managedServer.kind === 'managed' ? 'connected' : 'error', + error: null, + reconnectAttempt: 0, + managedServer + }) +} + function deps() { const calls: string[] = [] let finishConnect: () => void = () => {} @@ -27,7 +40,10 @@ function deps() { } } -afterEach(() => resetSshConnectInFlightForTests()) +afterEach(() => { + resetSshConnectInFlightForTests() + useAppStore.getState().clearRemovedSshTargetState('ssh-1') +}) describe('save a Node.js mirror and retry', () => { it('saves the normalized mirror first, then retries the connect as an in-flight attempt', async () => { @@ -62,12 +78,34 @@ describe('save a Node.js mirror and retry', () => { expect(d.connect).not.toHaveBeenCalled() }) - it('saves but leaves a connect already dialing to finish on its own', async () => { + it('retries with the new mirror once a connect already dialing fails the same way', async () => { const d = deps() beginSshConnect('ssh-1') const result = await saveNodeDistMirrorAndRetry('ssh-1', 'https://mirror.example/node', d) expect(result.outcome).toBe('retrying') - expect(d.save).toHaveBeenCalled() + expect(d.calls).toEqual(['save https://mirror.example/node']) + + // The dialing connect misses the mirror and fails with the same blocked download. + setManagedServer({ + kind: 'relay', + reason: 'failed', + nodeDownloadBlocked: { mirror: 'unset' } + }) + endSshConnect('ssh-1') + await vi.waitFor(() => expect(d.connect).toHaveBeenCalledWith('ssh-1')) + expect(d.calls).toEqual(['save https://mirror.example/node', 'connect ssh-1']) + expect(isSshConnectInFlight('ssh-1')).toBe(true) + }) + + it('does not reconnect when the connect already dialing succeeded', async () => { + const d = deps() + beginSshConnect('ssh-1') + const result = await saveNodeDistMirrorAndRetry('ssh-1', 'https://mirror.example/node', d) + setManagedServer({ kind: 'managed', environmentId: 'env-1' }) + endSshConnect('ssh-1') + if (result.outcome === 'retrying') { + await result.retry + } expect(d.connect).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/ssh/node-dist-mirror-recovery.ts b/src/renderer/src/ssh/node-dist-mirror-recovery.ts index 7becdf14a9b..5f74950f8f1 100644 --- a/src/renderer/src/ssh/node-dist-mirror-recovery.ts +++ b/src/renderer/src/ssh/node-dist-mirror-recovery.ts @@ -7,8 +7,16 @@ import type { SshManagedServerStatus } from '../../../shared/ssh-types' import { translate } from '@/i18n/i18n' import { isPairedWebClientWindow } from '@/lib/desktop-window-chrome' import { useAppStore } from '@/store' -import { isSshConnectInFlight, trackSshConnect } from './ssh-connect-in-flight' -import { nodeMirrorRecovery, type NodeMirrorRecovery } from './ssh-host-node-download-copy' +import { + isSshConnectInFlight, + subscribeSshConnectInFlight, + trackSshConnect +} from './ssh-connect-in-flight' +import { + blockedNodeDownload, + nodeMirrorRecovery, + type NodeMirrorRecovery +} from './ssh-host-node-download-copy' export const NODE_DIST_MIRROR_SUGGESTION = 'https://npmmirror.com/mirrors/node/' @@ -107,9 +115,27 @@ export async function saveNodeDistMirrorAndRetry( ) } } - // Why: a connect already dialing keeps its own attempt; a second one would race it. - if (isSshConnectInFlight(targetId)) { - return { outcome: 'retrying', retry: Promise.resolve() } + if (!isSshConnectInFlight(targetId)) { + return { outcome: 'retrying', retry: trackSshConnect(targetId, deps.connect(targetId)) } } - return { outcome: 'retrying', retry: trackSshConnect(targetId, deps.connect(targetId)) } + // Why wait: a second connect would race the one dialing, which may be past the download already. + // The status is current once the lock clears: main publishes a failed setup before it rejects. + const retry = sshConnectSettled(targetId).then(() => + blockedNodeDownload(useAppStore.getState().sshConnectionStates.get(targetId)?.managedServer) + ? trackSshConnect(targetId, deps.connect(targetId)) + : undefined + ) + return { outcome: 'retrying', retry } +} + +/** Resolves once no connect for `targetId` holds the shared in-flight lock. */ +function sshConnectSettled(targetId: string): Promise { + return new Promise((resolve) => { + const unsubscribe = subscribeSshConnectInFlight(() => { + if (!isSshConnectInFlight(targetId)) { + unsubscribe() + resolve() + } + }) + }) }