Skip to content

fix(desktop): transport waits bounded, poll storms stopped, saved connections paint instantly (multi-gateway wave-2 phase B, tracker #94724) - #95495

Merged
teknium1 merged 9 commits into
mainfrom
salvage/desktop-mg-w2b-transport
Aug 26, 2026
Merged

teknium1 merged 9 commits into
mainfrom
salvage/desktop-mg-w2b-transport

Conversation

@teknium1

Copy link
Copy Markdown
Collaborator

Summary

Transport behavior on multi-gateway Desktop stops degrading silently: boot and soft-switch getConnection() waits are bounded on one shared 45s constant (no more indefinite hangs, no magic-number drift), the status stack stops storming a dead session with 4001 polls (code-specific match; guard resets when the runtime re-mints), reconnect-orphaned warm transcripts release their frozen busy pins, and a newly saved connection appears in the switcher menu immediately instead of after a reload.

Wave-2 Phase B (transport) of tracker #94724. Fixes #93454-shape unbounded waits, a chunk of #83134's storm, the renderer half of #95189, and #95393.

Salvaged contributor work (authorship preserved)

Our fix

Validation

A/B per fix: each fires with sources at origin/main under the branch tests, clean at head. Targeted: 179/179 vitest + 611 tui-gateway pytest, tsc clean both configs. Pre-push gate caught and fixed a 5-commit stale base with leaked files before push.

Live repro: #95393 confirmed live on the rig pre-fix (scripted repro + screenshot on the issue); post-merge rig verification will use the same script.

Infographic

Transport Discipline

@github-actions

github-actions Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

૮ >ﻌ< ა ci review

ran on 6c607fd — fix(desktop): poll-guard reset is fire-and-forget off the re

⚠️ Warnings

OSV vulnerability scan · View job

7 known vulnerabilities found in pinned dependencies.

How to fix:

Review the findings in the Security tab. Update the affected dependencies if a patched version is available.


debug info

CI timings

CI timings · View report · View job

Wall time 3m55s vs 4m10s (-6.0%). 8 job(s) slower, 4 faster, 1 unchanged.

  • OS-specific tests / Windows-only tests: +27.0s
  • Python tests / Run tests: -16.0s
  • JS & TS checks / JS & TS checks: +6.0s
  • OS-specific tests / macOS-only tests: +5.0s
  • Python lints / ruff enforcement (blocking): -4.0s

@alt-glitch alt-glitch added type/bug Something isn't working comp/desktop Electron desktop app (apps/desktop/*) comp/tui Terminal UI (ui-tui/ + tui_gateway/) P2 Medium — degraded but workaround exists sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state labels Aug 26, 2026
nftpoetrist and others added 8 commits August 26, 2026 04:30
…#93454)

resolveGatewayWsUrl() in attemptReconnect() because a wedged IPC
round-trip into the main process (e.g. a stuck revalidation after a
liveness-probe trip) can hang these awaits forever. A later fix
(e8d5660) extended the bound to resolveGatewayWsUrl() in boot() and
softSwitch() too, but left the getConnection() call immediately above
it in both functions unbounded.

If that call wedges: during initial boot the 'Starting Hermes...'
screen never resolves (bootCompleted never flips, nothing hits catch),
and during a soft gateway/profile switch  latches
true forever since the try block's finally never runs.

Wrap both with the same withTimeout()/RECONNECT_ATTEMPT_TIMEOUT_MS
pattern already used for the sibling calls.
…45s constant

Follow-up to the #95039 salvage: the cherry-picked bound used the 20s
RECONNECT_ATTEMPT_TIMEOUT_MS on boot()/softSwitch() getConnection(), but a
reviewer note (and the Phase A registry-restore work) established that
boot-class awaits must ride out a full backend cold spawn — main's spawn
budget is 45s (DEFAULT_BACKEND_READY_TIMEOUT_MS). A 20s renderer bound would
latch boot errors on healthy-but-slow cold boots.

Introduce BACKEND_BOOT_WAIT_TIMEOUT_MS (45s) in lib/with-timeout.ts as the
single shared boot-class budget, point boot()/softSwitch() getConnection()
and connections.ts BOOT_DESCRIPTOR_WAIT_TIMEOUT_MS at it, and keep the 20s
reconnect budget only for reconnect-class awaits against an already-spawned
backend. No magic-number drift: 45_000 now appears once in renderer code.
… 4001s

The composer status stack polls `process.list` every 5s while a background
process row is on screen. `process.list` is session-scoped, so against a
runtime id the gateway no longer holds it returns 4001 "session not found".

`refreshBackgroundProcesses` swallowed *every* failure with a bare `catch {}`
commented "transient socket loss". A gone session is not transient: the poll
re-sent the same dead runtime id every 5 seconds for the lifetime of the
window. On one machine this produced 31,518 gateway rejections in a day
(vs 663 the day before), 18,614 of them against a single runtime id, and it
is what users see reported as "sessions stopped with a session not found
error" after an update.

The trigger is a reconnect, not the poll itself: anything that mints a fresh
runtime (gateway restart, the #94219 reconnect/replay work, an idle-reaped
pooled backend) strands the id the status stack is still holding, and nothing
in this path ever re-checked it.

Distinguish the two failure classes:

- 4001 / "session not found" is TERMINAL for that runtime id — latch the id
  and stop polling it.
- A timeout or transport error is transient — keep retrying, since the
  session may well still be alive. Misclassifying that direction would
  silently freeze the status stack on a healthy session.

The latch is cleared when the status stack (re)binds a session id, so a
session that comes back under a fresh runtime resumes polling normally
rather than staying dark for the life of the app.

Also name the method in the gateway's 4001 warning. That line was added in
c305839 "for diagnosability", but without the RPC name it cannot say WHICH
client call is looping — the reason this storm could not be attributed from
the logs alone. A ContextVar set in `handle_request` carries it; it is
diagnostic only and never used for authorization.

Tests:
- composer-status: 4001 stops the poll, a timeout does not, one gone session
  never suppresses a healthy sibling, and a rebind resumes polling.
- tui_gateway: the rejection warning names the method.
Two review-thread deltas on the salvaged #94950 latch:

- Match the gateway's structured 4001 code, not a message substring, when
  the rejection carries one (JsonRpcGatewayError). A coded error that
  merely mentions 'session not found' in wrapped text (e.g. a 5007 tool
  failure) must not latch the guard and freeze the status stack on a
  healthy session. The substring fallback survives only for codeless
  legacy errors.

- Reset the latch on runtime re-mint, not only on status-stack rebind:
  wire resetBackgroundPollingGuard() at both reconnect seams that already
  drop stale runtime bindings (use-gateway-boot's post-reconnect
  resetTileRuntimeBindings and gateway.ts's reopening path), so ids the
  dead runtime 4001'd resume polling once a respawned backend re-mints
  them.

Tests: code-specific match both directions; full-reset resumes every
latched session.
…authoritative state settles

A gateway connection that dies mid-turn leaves cached session snapshots
whose busy/awaitingResponse flags can never settle: the respawned
backend re-mints runtime ids, so no terminal publish ever reaches the
orphaned snapshot again. #isWarmSettled treated those frozen flags as
live work, so every orphan pinned its full warm transcript until app
restart — roughly 5MB per reconnect cycle, which turned the restart
loop in #95189 into renderer OOM.

SessionStateCache now accepts an optional isAuthoritativelyActive
probe. When wired, in-flight flags only block eviction while the
authoritative $sessionStates record still claims work for the same
runtime id; without the probe the legacy always-block behavior is
preserved byte-for-byte. Eviction remains gated on needsInput, pending
drafts, and active references, so a genuinely running turn (which
re-asserts busy on every publish) is never a casualty.

The useSessionStateCache hook wires the probe to the store it already
imports. Reconnect reconciliation (reconcileBusyStatesOnReconnect)
settles the authoritative record, and the next prune drains the
orphaned cache entry through the normal LRU path, ownership included.
…very successful save (#95393)

Live-confirmed on the Phase B build: hermesDesktop.connections.save()
succeeds and the registry on disk gains the row, but the switcher menu
(fed by the renderer $connectionsRegistry snapshot) keeps painting the
stale list until reload. remove() already broadcasts
hermes:connections:changed; save() only did so on the dial-material-edit
branch, so a brand-new connection or a label rename never reached the
switcher's onChanged re-pull (or any other window).

Fix at the publish seam only: saveRegistryConnection now broadcasts a new
'saved' reason for every successful save that isn't a dial-material edit.
'saved' is a pure registry-refresh signal — the use-gateway-boot listener
explicitly ignores it (nothing moved, so no dispose/redial/forget), while
the switcher's existing onChanged listener re-pulls the snapshot.

Tests:
- electron/hardening.test.ts pins both broadcast branches in
  saveRegistryConnection (source-assertion pattern; main.ts has no exports).
- connection-switcher.test.tsx mirrors the live repro scenario
  (/tmp/mg-ab/w2_95393.py): menu before save lacks the row, Electron's
  'saved' push arrives, menu after — without reload — shows it.
The combined dynamic import meant a failed composer-status import (mocked
test graphs) silently skipped resetTileRuntimeBindings too — the exact
lifecycle regression CI caught. Separate best-effort trys per module.
@teknium1
teknium1 force-pushed the salvage/desktop-mg-w2b-transport branch from 9203616 to 7e1e7e2 Compare August 26, 2026 11:30
composer-status imports $gateway from this module (cycle forces the
dynamic import), and awaiting the module load inside openSecondary sat on
the timed redial path — under CI load that pushed cold-start redials past
waitFor budgets in the lifecycle suite. The reset needs no ordering
guarantee relative to the dial; detach it.
@teknium1
teknium1 merged commit b455abe into main Aug 26, 2026
35 checks passed
@teknium1
teknium1 deleted the salvage/desktop-mg-w2b-transport branch August 26, 2026 11:49
Finn763 added a commit to Finn763/hermes-agent that referenced this pull request Aug 26, 2026
Busy/awaiting snapshots whose runtime stays published but that no event
will ever reach — reconcile leftovers skipped by scope, or turns that
died before any reconnect ran — fail #isWarmSettled forever, pinning
their transcripts outside both LRU bounds (NousResearch#95276). Unpublished orphans
are already released deterministically by the authoritative liveness
probe (NousResearch#95189, NousResearch#95495); this adds the orthogonal guard for the rest:
prune() sweeps entries still busy/awaiting with no state mutation past a
conservative window (30 min default; any real event resets it) as
unconditional eviction candidates. Persisted sessions only; drafts,
in-flight messages, needsInput waits, and live references stay
protected; eviction re-validates state identity before deleting and
degrades gracefully like the LRU path.
@Sora-bluesky

Copy link
Copy Markdown
Contributor

Cross-reference: #73821 is open against use-gateway-boot.ts and its test file. A trial merge of the two current heads is conflict-free this time, so no interaction expected — flagging only for the record, same as the earlier entries in this series. If anything shifts when this lands, #73821 gets rebased on top as before.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/desktop Electron desktop app (apps/desktop/*) comp/tui Terminal UI (ui-tui/ + tui_gateway/) P2 Medium — degraded but workaround exists sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Desktop: remote gateway reconnect hangs indefinitely after liveness-probe trip, requires manual app restart

6 participants