Skip to content

fix(desktop): stop dead remote profiles from stalling the session list for 45s - #140

Merged
OmarB97 merged 1 commit into
mainfrom
fix/remote-profile-session-splice-fastpath
Jun 10, 2026
Merged

fix(desktop): stop dead remote profiles from stalling the session list for 45s#140
OmarB97 merged 1 commit into
mainfrom
fix/remote-profile-session-splice-fastpath

Conversation

@OmarB97

@OmarB97 OmarB97 commented Jun 10, 2026

Copy link
Copy Markdown
Owner

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 (each message.complete triggers 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/sessionsmergeRemoteProfileSessionsensureBackend(remoteProfile)spawnPoolBackendwaitForHermes, which polls /api/status every 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, ECONNREFUSED in <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 in main.cjs:

  • waitForBackendReady — the extracted readiness loop. waitForHermes is 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, and failFast: "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.
  • spliceRemoteSessionsmergeRemoteProfileSessions rebuilt on it: every remote gets a wall-clock budget (2s cold / 5s warm, tracked via a new entry.ready flag 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 — interceptSessionRequestForRemote still early-returns before any of this runs.

How to review

  1. apps/desktop/electron/remote-sessions.cjs — the behavior contract lives here (header comment), ~120 lines of logic.
  2. apps/desktop/electron/main.cjs — mechanical wiring: waitForHermes wrapper, spawnPoolBackend remote branch, mergeRemoteProfileSessions body swap, cooldown clears in the two connection-config handlers.
  3. apps/desktop/electron/remote-sessions.test.cjs — 20 tests; the interesting ones are failFast 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):

[t+0.17s]  base local list: 10 rows (total 13)
[t+45.28s] remote taro: FAILED (… connect ECONNREFUSED 127.0.0.1:19119)
[t+45.28s] sidebar would render NOW (mergeRemoteProfileSessions resolved)

After (same machine, same dead remote, driving the new module):

[t+0.616s] [remote-sessions] splice fetch for "taro" failed: Hermes backend is not listening: connect ECONNREFUSED 127.0.0.1:19119
[t+0.616s] refresh #1 (cold, dead remote): sidebar renders with 10 rows (total 17)
[t+0.906s] [remote-sessions] splice skipping "taro" (in unreachable cooldown)
[t+0.906s] refresh #2 (remote now in cooldown): sidebar renders with 10 rows (total 17)

45.3s → 0.6s on the cold refresh, 0.3s once the cooldown is armed.

Verification

  • npm run test:desktop:platforms (now includes remote-sessions.test.cjs): 105/105 pass.
  • node --check on main.cjs and remote-sessions.cjs: clean.
  • Replica scripts above run against a real dashboard backend (port 9120) with a real dead remote (port 19119) on macOS.
  • Local-boot semantics unchanged: default waitForHermes options are byte-for-byte the old deadline/interval, verified by the retries refused connections for local boots test.

Risks / gaps

  • A remote that is alive but slower than its splice budget (2s cold / 5s warm) misses one refresh and appears on the next — judged acceptable vs. stalling the sidebar; the budget constants are module-level and easy to tune.
  • The cooldown is in-memory per app run; a relaunch retries every remote once (intentional).
  • The primary backend's global remote boot path (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.

…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>
@github-actions

Copy link
Copy Markdown

🔎 Lint report: fix/remote-profile-session-splice-fastpath vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 10554 on HEAD, 10554 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 5544 pre-existing issues carried over.

Diagnostics are surfaced as warnings — this check never fails the build.

@OmarB97

OmarB97 commented Jun 10, 2026

Copy link
Copy Markdown
Owner Author

Tests gate — baseline comparison (the fork's Tests workflow is pre-existing red on main):

  • PR run 27289017061 (head f900b18, all 6 slices): 60 unique FAILED entries
  • Baseline run 27287298864 (main @ ab33859 — this PR's merge-base): 60 unique FAILED entries
  • Set difference (PR − baseline): empty — zero new failures introduced.

All other checks green: ruff + ty diff, ruff enforcement, e2e, nix, Windows footguns, attribution, supply-chain scan, common-ancestor. The new remote-sessions.test.cjs suite runs in test:desktop:platforms (105/105 locally) — upstream CI does not execute desktop electron tests.

@OmarB97
OmarB97 merged commit 84cb42b into main Jun 10, 2026
16 of 24 checks passed
@OmarB97
OmarB97 deleted the fix/remote-profile-session-splice-fastpath branch August 2, 2026 16:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant