diff --git a/apps/desktop/src/store/connections.test.ts b/apps/desktop/src/store/connections.test.ts index f7d40bd36f..b0ef462222 100644 --- a/apps/desktop/src/store/connections.test.ts +++ b/apps/desktop/src/store/connections.test.ts @@ -745,6 +745,30 @@ describe('selectConnection', () => { expect($showAllProfiles.get()).toBe(true) }) + it('boot restore proceeds after the descriptor wait deadline (bounded wait)', async () => { + // A primary that never publishes (spawn failure, dead SSH target) must + // not strand the registry restore forever: after the deadline the restore + // runs exactly as it did before the wait existed. + vi.useFakeTimers() + + try { + list.mockResolvedValueOnce({ ...registry, lastUsed: 'homelab', launchMode: 'last-used' }) + + const restoring = initializeConnectionsRegistry() + + await vi.advanceTimersByTimeAsync(1_000) + expect(ensureGatewayAgent).not.toHaveBeenCalled() + + // Descriptor never arrives; deadline elapses. + await vi.advanceTimersByTimeAsync(60_000) + await restoring + + expect(ensureGatewayAgent).toHaveBeenCalledWith('homelab', 'default', expect.anything()) + } finally { + vi.useRealTimers() + } + }) + it('a user-initiated source switch still collapses "All profiles"', async () => { setConnectionsRegistry(registry) $connection.set({ connectionId: 'local', mode: 'local' }) diff --git a/apps/desktop/src/store/connections.ts b/apps/desktop/src/store/connections.ts index 2330eb312e..5fe24eaa35 100644 --- a/apps/desktop/src/store/connections.ts +++ b/apps/desktop/src/store/connections.ts @@ -32,6 +32,10 @@ const LAST_PROFILE_STORAGE_KEY = 'hermes.desktop.lastProfileByConnection' const SWITCH_DIAL_TIMEOUT_MS = 20_000 const SWITCH_COMMIT_TIMEOUT_MS = 20_000 const SWITCH_REMEMBER_TIMEOUT_MS = 5_000 +// Matches the primary spawn budget: a healthy cold boot publishes well within +// this; anything longer means the primary is not coming and the registry +// restore should stop waiting for it. +const BOOT_DESCRIPTOR_WAIT_TIMEOUT_MS = 45_000 export { $connectionsRegistry } from '@/store/connection-registry-state' @@ -141,22 +145,41 @@ async function rememberConnection(connectionId: string): Promise { * source needs a secondary dial. Otherwise a remote primary can be opened a * second time through the registry while the identical primary SSH backend is * still publishing its connection identity. + * + * Bounded: a primary that never publishes (spawn failure, dead SSH target) + * must not strand the registry restore forever — after the deadline the + * restore proceeds exactly as it did before this wait existed. The listener + * is always torn down so a late descriptor can't leak a dangling resolver. */ function waitForInitialConnection(): Promise { if ($connection.get()) { return Promise.resolve() } - return new Promise(resolve => { - const unlisten = $connection.listen(connection => { + let unlisten: (() => void) | undefined + + const published = new Promise(resolve => { + unlisten = $connection.listen(connection => { if (!connection) { return } - unlisten() + unlisten?.() resolve() }) }) + + return withTimeout( + published, + BOOT_DESCRIPTOR_WAIT_TIMEOUT_MS, + 'Timed out waiting for the primary connection descriptor' + ).catch(error => { + unlisten?.() + + if (!isTimeoutError(error)) { + throw error + } + }) } /**