fix(desktop): bound the boot descriptor wait so a dead primary cannot strand the registry restore

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
This commit is contained in:
Teknium
2026-08-26 01:40:17 -07:00
parent fd031488aa
commit b2a58dbb39
2 changed files with 50 additions and 3 deletions
@@ -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' })
+26 -3
View File
@@ -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<void> {
* 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<void> {
if ($connection.get()) {
return Promise.resolve()
}
return new Promise(resolve => {
const unlisten = $connection.listen(connection => {
let unlisten: (() => void) | undefined
const published = new Promise<void>(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
}
})
}
/**