From a5de22664bc036109b46733c793057866e7f4e77 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 8 Oct 2026 02:23:39 -0700 Subject: [PATCH 1/3] fix(remote): retry failed SSH conversion catalog loads --- ...-managed-server-converted-catalogs.test.ts | 95 +++++++++++++++++- .../ssh-managed-server-state-effects.ts | 59 +++++++---- .../folder-workspace-catalog-actions.ts | 3 + .../project-group-catalog-actions.ts | 3 + .../src/store/repos/repo-catalog-actions.ts | 3 + src/renderer/src/store/repos/repo-state.ts | 6 +- tests/e2e/ssh-orcad-auto-convert.spec.ts | 97 ++++++++++++++++++- 7 files changed, 244 insertions(+), 22 deletions(-) diff --git a/src/renderer/src/hooks/ipc-events/ssh-managed-server-converted-catalogs.test.ts b/src/renderer/src/hooks/ipc-events/ssh-managed-server-converted-catalogs.test.ts index f21bde21b968..9ed2076c3c44 100644 --- a/src/renderer/src/hooks/ipc-events/ssh-managed-server-converted-catalogs.test.ts +++ b/src/renderer/src/hooks/ipc-events/ssh-managed-server-converted-catalogs.test.ts @@ -72,6 +72,7 @@ beforeEach(() => { // Main hides the retained relay-era rows, so its own lists come back empty. vi.stubGlobal('window', { api: { + cache: { setGitHub: vi.fn(async () => undefined) }, repos: { list: vi.fn(async () => []) }, projects: { list: vi.fn(async () => []), listHostSetups: vi.fn(async () => []) }, projectGroups: { list: vi.fn(async () => []) }, @@ -93,7 +94,10 @@ beforeEach(() => { }, projectGroups: [{ ...group, connectionId: 'ssh-1' }], folderWorkspaces: [folder], - runtimeEnvironments: [] + runtimeEnvironments: [], + startupWorktreeRefreshCompleted: false, + activeWorktreeId: null, + activeWorkspaceExecutionHostId: null }) }) @@ -180,4 +184,93 @@ describe('a host that just moved to its managed server', () => { expect(window.api.runtimeEnvironments.list).toHaveBeenCalledTimes(1) expect(window.api.repos.list).toHaveBeenCalledTimes(1) }) + + it.each(['repo.list', 'projectGroup.list', 'folderWorkspace.list'])( + 'keeps the relay rows and retries after a failed %s refresh', + async (method) => { + const errorLog = vi.spyOn(console, 'error').mockImplementation(() => {}) + const warningLog = vi.spyOn(console, 'warn').mockImplementation(() => {}) + let failed = false + const call = vi.fn(async (args: RuntimeEnvironmentCallRequest) => { + if (args.method === method && !failed) { + failed = true + throw new Error('connection dropped during conversion catalog refresh') + } + return serverReply(args) + }) + Object.assign(window.api.runtimeEnvironments, { call }) + const managed = { kind: 'managed', environmentId: 'env-1' } as const + const targetId = `ssh-retry-${method}` + store.setState({ + repos: [{ ...repo, connectionId: targetId, executionHostId: `ssh:${targetId}` }], + worktreesByRepo: { + 'repo-1': [makeWorktree({ id: worktreeId, repoId: 'repo-1', hostId: `ssh:${targetId}` })] + }, + projectGroups: [{ ...group, connectionId: targetId }] + }) + applySshManagedServerTransition(targetId, undefined, managed) + await vi.waitFor(() => expect(errorLog).toHaveBeenCalled()) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(store.getState().worktreesByRepo['repo-1']).toHaveLength(1) + const callsBeforeReconnect = call.mock.calls.filter(([args]) => args.method === method).length + applySshManagedServerTransition(targetId, managed, { + kind: 'setting-up', + phase: 'connecting' + }) + applySshManagedServerTransition( + targetId, + { kind: 'setting-up', phase: 'connecting' }, + managed + ) + await vi.waitFor(() => + expect(call.mock.calls.filter(([args]) => args.method === method).length).toBeGreaterThan( + callsBeforeReconnect + ) + ) + await vi.waitFor(() => + expect(store.getState().folderWorkspaces.map((entry) => entry.executionHostId)).toEqual([ + 'runtime:env-1' + ]) + ) + errorLog.mockRestore() + warningLog.mockRestore() + } + ) + + it('reloads the server names after a failed environment list on reconnect', async () => { + const warningLog = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const list = vi.mocked(window.api.runtimeEnvironments.list) + list.mockRejectedValueOnce(new Error('environment catalog unavailable')) + const managed = { kind: 'managed', environmentId: 'env-1' } as const + applySshManagedServerTransition('ssh-list-retry', undefined, managed) + await vi.waitFor(() => expect(warningLog).toHaveBeenCalled()) + applySshManagedServerTransition('ssh-list-retry', managed, { + kind: 'setting-up', + phase: 'connecting' + }) + applySshManagedServerTransition('ssh-list-retry', undefined, managed) + await vi.waitFor(() => + expect(store.getState().runtimeEnvironments.map((entry) => entry.name)).toEqual([ + 'Box server' + ]) + ) + expect(list).toHaveBeenCalledTimes(2) + warningLog.mockRestore() + }) + + it('abandons an in-flight conversion refresh when the host returns to the relay', async () => { + const pending = + Promise.withResolvers>>() + vi.mocked(window.api.runtimeEnvironments.list).mockReturnValueOnce(pending.promise) + const managed = { kind: 'managed', environmentId: 'env-1' } as const + applySshManagedServerTransition('ssh-cancel-load', undefined, managed) + applySshManagedServerTransition('ssh-cancel-load', managed, { + kind: 'relay', + reason: 'orcad_unavailable' + }) + pending.resolve([]) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(window.api.repos.list).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/hooks/ipc-events/ssh-managed-server-state-effects.ts b/src/renderer/src/hooks/ipc-events/ssh-managed-server-state-effects.ts index ff41e80cf100..5151924b6864 100644 --- a/src/renderer/src/hooks/ipc-events/ssh-managed-server-state-effects.ts +++ b/src/renderer/src/hooks/ipc-events/ssh-managed-server-state-effects.ts @@ -23,18 +23,21 @@ export function applySshManagedServerTransition( next: ManagedServerStatus ): void { if (next?.kind === 'managed') { - // Why not `previous`: every start, wake and update passes through setting-up, which is no - // change of owner. Only a new environment for this host needs its catalogs loaded. - if (loadedEnvironmentByTarget.get(targetId) !== next.environmentId) { - loadedEnvironmentByTarget.set(targetId, next.environmentId) - void loadManagedServerCatalogs(targetId, next.environmentId).catch((error: unknown) => + // Starts and wakes keep the same owner; only a new server or a failed load needs a refresh. + if (catalogLoadByTarget.get(targetId)?.environmentId !== next.environmentId) { + const load = { environmentId: next.environmentId } + catalogLoadByTarget.set(targetId, load) + void loadManagedServerCatalogs(targetId, load).catch((error: unknown) => { + if (catalogLoadByTarget.get(targetId) === load) { + catalogLoadByTarget.delete(targetId) + } console.warn('[ssh] Could not load the managed server catalogs:', error) - ) + }) } return } - if (next?.kind === 'relay') { - loadedEnvironmentByTarget.delete(targetId) + if (!next || next.kind === 'relay') { + catalogLoadByTarget.delete(targetId) } if (isNewMoveOffer(previous, next) && canMoveSshHostToManagedServer()) { offerManagedServerMove(targetId, next.terminals) @@ -59,24 +62,39 @@ export function applySshManagedServerTransition( } } -const loadedEnvironmentByTarget = new Map() +const catalogLoadByTarget = new Map() /** Loads a newly managed host's server and the local catalogs, then drops its relay-era rows. */ -async function loadManagedServerCatalogs(targetId: string, environmentId: string): Promise { +async function loadManagedServerCatalogs( + targetId: string, + load: { environmentId: string } +): Promise { + const { environmentId } = load + const isCurrent = (): boolean => catalogLoadByTarget.get(targetId) === load const store = useAppStore.getState() - try { - // Why: host badges read server names from this catalog, which a conversion does not refresh. - store.setRuntimeEnvironments(await window.api.runtimeEnvironments.list()) - void store.refreshRuntimeEnvironmentStatus(environmentId) - } catch (error) { - console.warn('[ssh] Could not refresh the managed server list:', error) + // Why: host badges read server names from this catalog, which a conversion does not refresh. + const environments = await window.api.runtimeEnvironments.list() + if (!isCurrent()) { + return } + store.setRuntimeEnvironments(environments) + void store.refreshRuntimeEnvironmentStatus(environmentId) // Why local too: the host's relay-era rows come from the local catalog, which main now hides. for (const runtimeEnvironmentId of [null, environmentId]) { - await store.fetchRepos({ runtimeEnvironmentId }) + const options = { runtimeEnvironmentId, throwOnError: true } + await store.fetchRepos(options) + if (!isCurrent()) { + return + } // Why groups before folders: folder workspaces are owned through their project groups. - await store.fetchProjectGroups({ runtimeEnvironmentId }) - await store.fetchFolderWorkspaces({ runtimeEnvironmentId }) + await store.fetchProjectGroups(options) + if (!isCurrent()) { + return + } + await store.fetchFolderWorkspaces(options) + if (!isCurrent()) { + return + } } // Why gated: startup runs its own scan once every host's catalog is in. if (useAppStore.getState().startupWorktreeRefreshCompleted) { @@ -86,6 +104,9 @@ async function loadManagedServerCatalogs(targetId: string, environmentId: string .repos.filter((repo) => getRepoExecutionHostId(repo) === executionHostId) await Promise.all(repos.map((repo) => store.fetchWorktrees(repo.id, { executionHostId }))) } + if (!isCurrent()) { + return + } useAppStore.setState((state) => withoutConvertedSshHostRows(state, targetId)) rehomeActiveWorkspace(targetId, environmentId) } diff --git a/src/renderer/src/store/folder-workspaces/folder-workspace-catalog-actions.ts b/src/renderer/src/store/folder-workspaces/folder-workspace-catalog-actions.ts index 83678c50946f..e5dfff1081c9 100644 --- a/src/renderer/src/store/folder-workspaces/folder-workspace-catalog-actions.ts +++ b/src/renderer/src/store/folder-workspaces/folder-workspace-catalog-actions.ts @@ -58,6 +58,9 @@ export function createFolderWorkspaceCatalogActions( }) } catch (err) { console.error('Failed to fetch folder workspaces:', err) + if (options?.throwOnError) { + throw err + } } }, diff --git a/src/renderer/src/store/project-groups/project-group-catalog-actions.ts b/src/renderer/src/store/project-groups/project-group-catalog-actions.ts index d19ac185766e..39de358e99c0 100644 --- a/src/renderer/src/store/project-groups/project-group-catalog-actions.ts +++ b/src/renderer/src/store/project-groups/project-group-catalog-actions.ts @@ -42,6 +42,9 @@ export function createProjectGroupCatalogActions( }) } catch (err) { console.error('Failed to fetch project groups:', err) + if (options?.throwOnError) { + throw err + } } }, diff --git a/src/renderer/src/store/repos/repo-catalog-actions.ts b/src/renderer/src/store/repos/repo-catalog-actions.ts index cd5368d0c111..d254923fde9b 100644 --- a/src/renderer/src/store/repos/repo-catalog-actions.ts +++ b/src/renderer/src/store/repos/repo-catalog-actions.ts @@ -163,6 +163,9 @@ export function createRepoCatalogActions( } catch (err) { localCatalogOutcome = { status: 'rejected', reason: err } console.error('Failed to fetch repos:', err) + if (options?.throwOnError) { + throw err + } } finally { settleLocalCatalog(localCatalogOutcome) } diff --git a/src/renderer/src/store/repos/repo-state.ts b/src/renderer/src/store/repos/repo-state.ts index 325930d0872e..003ac6bdcf29 100644 --- a/src/renderer/src/store/repos/repo-state.ts +++ b/src/renderer/src/store/repos/repo-state.ts @@ -141,7 +141,11 @@ export type AddRepoPathOptions = { displayName?: string } -export type RuntimeCatalogFetchOptions = { runtimeEnvironmentId?: string | null } +export type RuntimeCatalogFetchOptions = { + runtimeEnvironmentId?: string | null + /** Conversion must retain its source rows until every destination catalog loads. */ + throwOnError?: boolean +} export type RepoSlice = { repos: readonly Repo[] diff --git a/tests/e2e/ssh-orcad-auto-convert.spec.ts b/tests/e2e/ssh-orcad-auto-convert.spec.ts index 7f9f7f0c3f27..685a884f1ca2 100644 --- a/tests/e2e/ssh-orcad-auto-convert.spec.ts +++ b/tests/e2e/ssh-orcad-auto-convert.spec.ts @@ -14,7 +14,7 @@ * Host: `ORCA_E2E_ORCAD_CONVERT_HOST=docker` (Linux fixture) or a Windows host-cell descriptor. * Template: `ORCA_E2E_ORCAD_CONVERT_TEMPLATE`, built for that host's target. */ -import { cpSync, mkdirSync, rmSync } from 'node:fs' +import { cpSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs' import os from 'node:os' import path from 'node:path' import type { ElectronApplication, Page, TestInfo } from '@stablyai/playwright-test' @@ -413,3 +413,98 @@ function connectOnce(page: Page, targetId: string): Promise { } }, targetId) } + +test('reconnect restores the managed host name after its conversion catalog fails', async (// oxlint-disable-next-line no-empty-pattern -- This persistent-profile test owns its Electron launches. +{}, testInfo) => { + test.skip(HOST !== 'docker', 'Catalog fault injection uses the isolated Linux Docker host') + test.setTimeout(180_000) + const scratch = mkdtempSync(path.join(os.tmpdir(), 'orca-catalog-retry-')) + const templateDir = path.join(scratch, 'template') + const host = startOrcadConvertHost('docker', testInfo) + const session = createRestartSession(testInfo, { ORCA_ORCAD_TEMPLATE_PATH: templateDir }) + let app: Awaited>['app'] | null = null + try { + const first = await session.launch() + app = first.app + await waitForSessionReady(first.page) + await session.close(app) + app = null + const seeded = seedRelayEraProfile(session.userDataDir, host.input, { + repoPath: host.remoteRepoPath, + folderPath: host.remoteFolderPath + }) + const relay = await session.launch() + app = relay.app + const page = relay.page + await waitForSessionReady(page) + await page.evaluate(() => window.__store!.getState().setGroupBy('none')) + await reconnect(page, seeded.targetId) + await expect.poll(() => managedServer(page, seeded.targetId)).toMatchObject({ kind: 'relay' }) + + // Fail the local catalog IPC during a real SSH migration; healing restores its production handler. + await app.evaluate(({ ipcMain }) => { + type Handler = (event: unknown, ...args: unknown[]) => unknown + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Electron's registered invoke handlers are a Map of channel callbacks. + const registry = (ipcMain as unknown as { _invokeHandlers?: Map }) + ._invokeHandlers + const original = registry?.get('runtimeEnvironments:list') + if (!original) { + throw new Error('runtimeEnvironments:list is not registered') + } + ipcMain.removeHandler('runtimeEnvironments:list') + ipcMain.handle('runtimeEnvironments:list', () => { + throw new Error('catalog-retry: managed-host catalog temporarily unavailable') + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this test installs and removes its own recovery callback within this app. + const state = globalThis as typeof globalThis & { healCatalogFault?: () => void } + state.healCatalogFault = () => { + ipcMain.removeHandler('runtimeEnvironments:list') + ipcMain.handle('runtimeEnvironments:list', original) + } + }) + const failure = page.waitForEvent('console', { + predicate: (message) => + message.text().includes('Could not refresh the managed server list') || + message.text().includes('Could not load the managed server catalogs') + }) + cpSync(TEMPLATE_SOURCE!, templateDir, { recursive: true }) + await reconnect(page, seeded.targetId) + await expect.poll(() => managedServer(page, seeded.targetId)).toMatchObject({ kind: 'managed' }) + await failure + await page.screenshot({ path: testInfo.outputPath('catalog-failure.png') }) + + await app.evaluate(() => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fault installation above owns this optional callback. + const state = globalThis as typeof globalThis & { healCatalogFault?: () => void } + state.healCatalogFault?.() + }) + const environment = (await page.evaluate(() => window.api.runtimeEnvironments.list())).find( + (entry) => entry.orcadDeployment?.sshTargetId === seeded.targetId + ) + if (!environment) { + throw new Error('Managed host missing from main catalog') + } + await page.evaluate( + (id) => window.__store!.setState({ visibleWorkspaceHostIds: ['local', `runtime:${id}`] }), + environment.id + ) + await reconnect(page, seeded.targetId) + // The folder row must retain its managed execution host after the catalog heals. + const folder = page.locator('[data-worktree-host-identity]').filter({ + has: page.getByText('orcad upgrade folder', { exact: true }) + }) + await expect(folder).toHaveCount(1, { timeout: 30_000 }) + await expect(folder).toHaveAttribute('data-worktree-host-identity', /runtime:/) + await expect( + page.locator('[data-host-header-drag-id^="runtime:"]').filter({ hasText: environment.name }) + ).toHaveCount(1) + await page.screenshot({ path: testInfo.outputPath('catalog-recovered.png') }) + } finally { + if (app) { + await session.close(app) + } + await session.dispose() + host.cleanup() + rmSync(scratch, { recursive: true, force: true }) + } +}) From 515aabfeef535d0cf35f23b65487a1e4dbcc8f02 Mon Sep 17 00:00:00 2001 From: m4air Date: Thu, 8 Oct 2026 16:51:44 -0700 Subject: [PATCH 2/3] fix(remote): keep the loaded catalog across ordinary disconnects --- .../src/hooks/ipc-events/ssh-managed-server-state-effects.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/hooks/ipc-events/ssh-managed-server-state-effects.ts b/src/renderer/src/hooks/ipc-events/ssh-managed-server-state-effects.ts index 5151924b6864..e5c952b92dcd 100644 --- a/src/renderer/src/hooks/ipc-events/ssh-managed-server-state-effects.ts +++ b/src/renderer/src/hooks/ipc-events/ssh-managed-server-state-effects.ts @@ -36,7 +36,7 @@ export function applySshManagedServerTransition( } return } - if (!next || next.kind === 'relay') { + if (next?.kind === 'relay') { catalogLoadByTarget.delete(targetId) } if (isNewMoveOffer(previous, next) && canMoveSshHostToManagedServer()) { From a5c58608d02742ad8ce85748cfaf6c1237ee401c Mon Sep 17 00:00:00 2001 From: m4air Date: Thu, 8 Oct 2026 20:22:25 -0700 Subject: [PATCH 3/3] fix(remote): replay a reconnect that lands while a failed catalog read is pending --- ...-managed-server-converted-catalogs.test.ts | 63 +++++++++++++++++++ .../ssh-managed-server-state-effects.ts | 50 ++++++++++----- 2 files changed, 99 insertions(+), 14 deletions(-) diff --git a/src/renderer/src/hooks/ipc-events/ssh-managed-server-converted-catalogs.test.ts b/src/renderer/src/hooks/ipc-events/ssh-managed-server-converted-catalogs.test.ts index 9ed2076c3c44..6c38b10f8bce 100644 --- a/src/renderer/src/hooks/ipc-events/ssh-managed-server-converted-catalogs.test.ts +++ b/src/renderer/src/hooks/ipc-events/ssh-managed-server-converted-catalogs.test.ts @@ -259,6 +259,69 @@ describe('a host that just moved to its managed server', () => { warningLog.mockRestore() }) + it('replays a reconnect that arrived while the failed catalog read was still pending', async () => { + const warningLog = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const errorLog = vi.spyOn(console, 'error').mockImplementation(() => {}) + const targetId = 'ssh-overlap' + const firstRead = Promise.withResolvers() + let repoListCalls = 0 + const call = vi.fn(async (args: RuntimeEnvironmentCallRequest) => { + if (args.method === 'repo.list' && ++repoListCalls === 1) { + return firstRead.promise + } + return serverReply(args) + }) + Object.assign(window.api.runtimeEnvironments, { call }) + store.setState({ + repos: [{ ...repo, connectionId: targetId, executionHostId: `ssh:${targetId}` }], + worktreesByRepo: { + 'repo-1': [ + makeWorktree({ id: worktreeId, repoId: 'repo-1', hostId: `ssh:${targetId}` }), + makeWorktree({ id: worktreeId, repoId: 'repo-1', hostId: 'runtime:env-1' }) + ] + }, + projectGroups: [{ ...group, connectionId: targetId }], + activeRepoId: 'repo-1', + activeWorktreeId: worktreeId, + activeWorkspaceExecutionHostId: `ssh:${targetId}` + }) + const managed = { kind: 'managed', environmentId: 'env-1' } as const + const settingUp = { kind: 'setting-up', phase: 'connecting' } as const + applySshManagedServerTransition(targetId, undefined, managed) + await vi.waitFor(() => expect(repoListCalls).toBe(1)) + applySshManagedServerTransition(targetId, managed, settingUp) + applySshManagedServerTransition(targetId, settingUp, managed) + firstRead.reject(new Error('half-open connection timed out')) + + await vi.waitFor(() => + expect(store.getState().activeWorkspaceExecutionHostId).toBe('runtime:env-1') + ) + expect(repoListCalls).toBe(2) + warningLog.mockRestore() + errorLog.mockRestore() + }) + + it('drops a pending reconnect replay when the host returns to the relay', async () => { + const warningLog = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const firstList = Promise.withResolvers() + const list = vi.mocked(window.api.runtimeEnvironments.list) + list.mockReturnValueOnce(firstList.promise) + const managed = { kind: 'managed', environmentId: 'env-1' } as const + const settingUp = { kind: 'setting-up', phase: 'connecting' } as const + applySshManagedServerTransition('ssh-overlap-relay', undefined, managed) + applySshManagedServerTransition('ssh-overlap-relay', managed, settingUp) + applySshManagedServerTransition('ssh-overlap-relay', settingUp, managed) + applySshManagedServerTransition('ssh-overlap-relay', managed, { + kind: 'relay', + reason: 'orcad_unavailable' + }) + firstList.reject(new Error('environment catalog unavailable')) + await vi.waitFor(() => expect(warningLog).toHaveBeenCalled()) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(list).toHaveBeenCalledTimes(1) + warningLog.mockRestore() + }) + it('abandons an in-flight conversion refresh when the host returns to the relay', async () => { const pending = Promise.withResolvers>>() diff --git a/src/renderer/src/hooks/ipc-events/ssh-managed-server-state-effects.ts b/src/renderer/src/hooks/ipc-events/ssh-managed-server-state-effects.ts index e5c952b92dcd..08963f8bbd3d 100644 --- a/src/renderer/src/hooks/ipc-events/ssh-managed-server-state-effects.ts +++ b/src/renderer/src/hooks/ipc-events/ssh-managed-server-state-effects.ts @@ -24,15 +24,12 @@ export function applySshManagedServerTransition( ): void { if (next?.kind === 'managed') { // Starts and wakes keep the same owner; only a new server or a failed load needs a refresh. - if (catalogLoadByTarget.get(targetId)?.environmentId !== next.environmentId) { - const load = { environmentId: next.environmentId } - catalogLoadByTarget.set(targetId, load) - void loadManagedServerCatalogs(targetId, load).catch((error: unknown) => { - if (catalogLoadByTarget.get(targetId) === load) { - catalogLoadByTarget.delete(targetId) - } - console.warn('[ssh] Could not load the managed server catalogs:', error) - }) + const current = catalogLoadByTarget.get(targetId) + if (current?.environmentId !== next.environmentId) { + startManagedServerCatalogLoad(targetId, next.environmentId) + } else if (current.pending && previous?.kind !== 'managed') { + // Why: a reconnect during a doomed read must not be lost; replay it once if that read fails. + current.reconnectedWhilePending = true } return } @@ -62,13 +59,38 @@ export function applySshManagedServerTransition( } } -const catalogLoadByTarget = new Map() +type CatalogLoad = { + environmentId: string + pending: boolean + reconnectedWhilePending: boolean +} + +const catalogLoadByTarget = new Map() + +function startManagedServerCatalogLoad(targetId: string, environmentId: string): void { + const load: CatalogLoad = { environmentId, pending: true, reconnectedWhilePending: false } + catalogLoadByTarget.set(targetId, load) + void loadManagedServerCatalogs(targetId, load).then( + () => { + load.pending = false + }, + (error: unknown) => { + load.pending = false + console.warn('[ssh] Could not load the managed server catalogs:', error) + // Why: relay fallback and server replacement swap the record, which drops any replay. + if (catalogLoadByTarget.get(targetId) !== load) { + return + } + catalogLoadByTarget.delete(targetId) + if (load.reconnectedWhilePending) { + startManagedServerCatalogLoad(targetId, environmentId) + } + } + ) +} /** Loads a newly managed host's server and the local catalogs, then drops its relay-era rows. */ -async function loadManagedServerCatalogs( - targetId: string, - load: { environmentId: string } -): Promise { +async function loadManagedServerCatalogs(targetId: string, load: CatalogLoad): Promise { const { environmentId } = load const isCurrent = (): boolean => catalogLoadByTarget.get(targetId) === load const store = useAppStore.getState()