fix(desktop): stop dead remote profiles from stalling the session list for 45s - #140
Merged
Merged
Conversation
…t for 45s A profile pointing at a remote backend (connection.json profiles[name]) participates in the unified sidebar session list. When that remote was unreachable (dead tunnel, sleeping host), every sidebar refresh — boot included — blocked on waitForHermes()'s 45s local-boot readiness loop before the list rendered, and the failed pool entry was dropped so the next refresh paid the full probe again. Sessions appeared after 45s on an otherwise healthy app, every single refresh. Measured on a live install with one dead remote profile: the local list was ready at t+0.17s but the sidebar rendered at t+45.28s. After this change the same refresh renders at t+0.6s cold and t+0.3s once the remote is in cooldown. - Extract the readiness loop into remote-sessions.cjs (waitForBackendReady) with an injectable deadline/interval/clock, and give waitForHermes an options pass-through. Local child boots keep the 45s patience; REMOTE reachability probes now use a 3s deadline, 1.5s per-attempt timeout, and failFast: a "nothing is listening" error (ECONNREFUSED & co) rejects immediately instead of being retried — retrying cannot help a port nobody is bound to, while a local child legitimately refuses connections until uvicorn binds mid-boot. - Add a cooldown registry (createRemoteAvailability): a remote that fails its probe is skipped instantly by the session splice for 30s instead of being re-probed on every refresh. Explicit user actions (opening a remote session, switching profiles) still probe for real, and a successful probe — or saving/applying connection settings — lifts the cooldown. - Rebuild mergeRemoteProfileSessions on spliceRemoteSessions: each remote gets a wall-clock budget (2s cold, 5s warm) and one that cannot answer in time contributes nothing to THIS refresh while its fetch keeps running in the background to warm the pool for the next one. A late rejection is swallowed so it cannot become an unhandled rejection. - Track pool-entry warmness (entry.ready) to pick the splice budget. - Cover the new module with unit tests and wire them into test:desktop:platforms. Users without remote profiles are untouched: the intercept still early-returns before any of this code runs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
🔎 Lint report:
|
Owner
Author
|
Tests gate — baseline comparison (the fork's Tests workflow is pre-existing red on
All other checks green: ruff + ty diff, ruff enforcement, e2e, nix, Windows footguns, attribution, supply-chain scan, common-ancestor. The new |
14 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
On a machine with a remote profile configured in
connection.json(profiles[name].mode: "remote"), the desktop sidebar's session list takes 45 seconds to populate whenever that remote is unreachable (dead SSH tunnel, sleeping host) — on boot and again on every subsequent refresh (eachmessage.completetriggers one). The app looks broken: "Gateway ready" in the status bar, cron sections loaded, and skeleton rows where sessions should be.Mechanism:
GET /api/profiles/sessions→mergeRemoteProfileSessions→ensureBackend(remoteProfile)→spawnPoolBackend→waitForHermes, which polls/api/statusevery 500ms with a 45s deadline. That deadline exists for freshly spawned local children (the port stays refused until uvicorn binds mid-boot) but is wrong for a remote that's supposed to already be running. Worse, the rejected pool entry is deleted, so the next refresh starts the 45s probe from scratch — there is no memory of the failure.Measured on a live install (one dead remote profile,
ECONNREFUSEDin <1ms): local list ready at t+0.17s, sidebar rendered at t+45.28s.What changed
New
apps/desktop/electron/remote-sessions.cjs(unit-testable, dependency-injected) + wiring inmain.cjs:waitForBackendReady— the extracted readiness loop.waitForHermesis now a thin wrapper with an options pass-through; all existing local-boot callers keep the exact 45s/500ms behavior. Remote reachability probes (spawnPoolBackend's remote branch) now use a 3s deadline, 1.5s per-attempt timeout, andfailFast: "nothing is listening" errors (ECONNREFUSED,EHOSTUNREACH,ENOTFOUND, …) reject on the first attempt — retrying cannot help a port nobody is bound to.createRemoteAvailability— a cooldown registry. A remote that fails its probe is skipped instantly by the session splice for 30s instead of being re-probed every refresh. Explicit user actions (opening a remote session, switching profiles) still probe for real; a successful probe or saving/applying connection settings lifts the cooldown.spliceRemoteSessions—mergeRemoteProfileSessionsrebuilt on it: every remote gets a wall-clock budget (2s cold / 5s warm, tracked via a newentry.readyflag on pool entries). A remote that can't answer in budget contributes nothing to this refresh while its fetch keeps running in the background, warming the pool so the next refresh includes it. Late rejections are swallowed (can't become unhandled rejections).Users without remote profiles are untouched —
interceptSessionRequestForRemotestill early-returns before any of this runs.How to review
apps/desktop/electron/remote-sessions.cjs— the behavior contract lives here (header comment), ~120 lines of logic.apps/desktop/electron/main.cjs— mechanical wiring:waitForHermeswrapper,spawnPoolBackendremote branch,mergeRemoteProfileSessionsbody swap, cooldown clears in the two connection-config handlers.apps/desktop/electron/remote-sessions.test.cjs— 20 tests; the interesting ones arefailFast rejects on the first refused connection,splice gives up after the budget but leaves the slow fetch running, and the unhandled-rejection guard.Evidence
Before (replica of the exact renderer boot fetch against a live install, remote tunnel down):
After (same machine, same dead remote, driving the new module):
45.3s → 0.6s on the cold refresh, 0.3s once the cooldown is armed.
Verification
npm run test:desktop:platforms(now includesremote-sessions.test.cjs): 105/105 pass.node --checkonmain.cjsandremote-sessions.cjs: clean.waitForHermesoptions are byte-for-byte the old deadline/interval, verified by theretries refused connections for local bootstest.Risks / gaps
startHermes) keeps its patient 45s wait with the boot overlay — that's an explicit whole-app configuration with visible progress UI, and changing it is out of scope here.