From b2a58dbb39cf1f7b3fe78f6da3145c5c99f8fa98 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 26 Aug 2026 01:40:17 -0700 Subject: [PATCH] fix(desktop): bound the boot descriptor wait so a dead primary cannot strand the registry restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the #95007 partial cherry: waitForInitialConnection() was an unbounded listen on $connection — a primary that never publishes its descriptor (spawn failure, dead SSH target) would strand initializeConnectionsRegistry() forever and the last-used source would never be restored. Bound it with the codebase's withTimeout helper (same pattern as the sibling SWITCH_* call sites in this file): after 45s (the primary spawn budget) the restore proceeds exactly as it did before the wait existed, and the listener is torn down either way. Regression test: boot restore proceeds after the deadline with the descriptor never arriving. Original-PR: #95007 --- apps/desktop/src/store/connections.test.ts | 24 ++++++++++++++++++ apps/desktop/src/store/connections.ts | 29 +++++++++++++++++++--- 2 files changed, 50 insertions(+), 3 deletions(-) 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 + } + }) } /**