Skip to content

fix(desktop): guard soft gateway-switch against concurrent invocation - #62624

Open
pierrenode wants to merge 1 commit into
NousResearch:mainfrom
pierrenode:fix/desktop-soft-gateway-switch-reentrancy
Open

fix(desktop): guard soft gateway-switch against concurrent invocation#62624
pierrenode wants to merge 1 commit into
NousResearch:mainfrom
pierrenode:fix/desktop-soft-gateway-switch-reentrancy

Conversation

@pierrenode

Copy link
Copy Markdown
Contributor

What does this PR do?

ipcMain.handle('hermes:connection-config:apply', ...)'s global/primary branch (apps/desktop/electron/main.ts) tears down the window backend via teardownPrimaryBackendAndWait({ soft: true }) and notifies the renderer via sendConnectionApplied(), which drives softSwitch() in useGatewayBoot (apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts).

Two independent UI triggers can call window.hermesDesktop.applyConnectionConfig(...) close together — the Settings "Apply" button (guarded by its own saving state) and the cloud-agent "Connect" button (guarded by its own cloudConnectingId state). Neither guard knows about the other, so both can fire back-to-back for the same global/primary scope.

Without protection, tracing the race:

  • The second teardownPrimaryBackendAndWait({ soft: true }) starts while the first is still awaiting the real child-process exit (up to 5s). Both toggle the shared softRehomeInProgress flag; the second's finally can flip it back to false before the first's real process actually exits, so that exit's sendBackendExit() is no longer suppressed — an intentional teardown surfaces as a spurious "backend stopped" error toast.
  • Both calls call sendConnectionApplied(), so the renderer's hermes:connection:applied handler fires twice. softSwitch() had no guard against running twice concurrently — each invocation independently wipes the session lists and re-dials, racing each other through getConnection() / adoptPrimaryProfile() / completeDesktopBoot().
  • Notably, previewGatewaySwitch()softSwitch()'s own dev-preview twin in apps/desktop/src/store/gateway-switch.ts, added in the same feature (feat(desktop): soft gateway switch + gateway-settings polish #61916) — already has exactly this reentrancy guard (if ($gatewaySwitching.get()) return). The production path was missing its sibling's protection.

Related Issue

No existing issue — found while auditing the soft gateway-switch feature (#61916/#61912) for sibling gaps after a #of N sites review pass.

Type of Change

  • 🐛 Bug fix

Changes Made

  • apps/desktop/electron/main.ts: dedupe concurrent connection-config:apply calls for the global/primary scope through a single primaryRehomeInFlight promise — a second caller awaits the first's in-flight re-home instead of starting its own teardownPrimaryBackendAndWait() + sendConnectionApplied().
  • apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts: softSwitch() gets the same $gatewaySwitching reentrancy check its dev-preview twin previewGatewaySwitch() already has, as defense in depth against any other path that could fire hermes:connection:applied twice.
  • apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx (new test): simulates main firing hermes:connection:applied twice back-to-back and asserts softSwitch()'s body (desktop.getConnection()) only runs once. (gateway.connect() has its own 'connecting'-state dedup, so a duplicate literal WebSocket was never the observable symptom — a duplicate getConnection()/session-wipe pass was.)

How to Test

cd apps/desktop
npx vitest run src/app/gateway/hooks/use-gateway-boot.test.tsx --environment jsdom

Mutation-verified: temporarily reverting just the softSwitch() guard makes the new test fail (expected 2 to be 1) while the other 4 tests in the file still pass; restoring the guard passes all 5. Also ran the neighboring gateway-switch.test.ts and gateway-connecting-overlay.test.tsx suites (7 + 4 tests) — all green. tsc --noEmit (both renderer and electron configs) and eslint on both changed source files are clean (0 errors; pre-existing unrelated style warnings elsewhere in main.ts are untouched).

The main.ts half of the fix (primaryRehomeInFlight) has no automated regression test — main.ts is a non-modular Electron entry script with zero exports and no existing test seam for its internal IPC-handler logic anywhere in this codebase (every electron/*.test.ts file tests a separate, exported helper module instead). Verified by tracing the promise-dedup logic by hand instead.

Checklist

  • Contributing Guide read | Conventional Commits | No duplicate PR found (searched by function/variable name, "desktop gateway switch", "connection-config apply"; noted fix(desktop): preserve active connection across stale backend exits #62308 touches nearby main.ts code — different bug, generation-tagged stale-process ownership — but doesn't touch the connection-config:apply handler or add apply-call dedup)
  • Single logical fix (guard soft gateway-switch against concurrent invocation, main + renderer) | Tests added where a seam exists (mutation-verified) | Platform: macOS
  • Docs — N/A | Cross-platform — N/A (pure JS control flow, no OS dependency)

@tonydwb tonydwb left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Summary

Looks good! No obvious issues found.


Reviewed by Hermes Agent

@tonydwb

tonydwb commented Jul 11, 2026

Copy link
Copy Markdown

Code Review Summary\n\nLooks good! No obvious issues found.\n\n---\nReviewed by Hermes Agent

@alt-glitch alt-glitch added type/bug Something isn't working comp/desktop Electron desktop app (apps/desktop/*) P2 Medium — degraded but workaround exists labels Jul 11, 2026

@tonydwb tonydwb left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Summary

Looks good! No obvious issues found (reviewed in read-only mode).


Reviewed by Hermes Agent

@tonydwb

tonydwb commented Jul 11, 2026

Copy link
Copy Markdown

Code Review Summary\n\nLooks good! No obvious issues found (reviewed in read-only mode).\n\n---\nReviewed by Hermes Agent

@teknium1

Copy link
Copy Markdown
Contributor

Thanks for the focused concurrency fix. I found no substantive correctness issue.

Current main still independently performs teardownPrimaryBackendAndWait({ soft: true }) and sendConnectionApplied() for every global/primary apply (apps/desktop/electron/main.ts:7698-7717). Its shared soft-exit flag is cleared in the teardown finally (apps/desktop/electron/main.ts:6360-6375), while backend-exit suppression reads that flag (apps/desktop/electron/main.ts:4496-4511). The PR's shared in-flight promise directly closes that race.

Likewise, current softSwitch() starts after only a cancelled check and performs the session wipe plus asynchronous re-dial (apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts:255-300); the added $gatewaySwitching entry guard prevents overlapping event handlers. The new hook test exercises the duplicate hermes:connection:applied path. GitHub reports the branch mergeable; it is 10 commits behind current main, but no merge conflict is reported.

Automated hermes-sweeper review.

@teknium1 teknium1 added sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 11, 2026
connection-config:apply's global/primary branch (apps/desktop/electron/
connection-apply.ts::applyConnectionChange) tears down the window backend
and notifies the renderer via hermes:connection:applied, which drives
softSwitch() in useGatewayBoot. Two independent UI triggers (Settings
"Apply" and the cloud-agent "Connect" button) each have their own
pending-state guard but don't know about each other, so both can call
applyConnectionChange back-to-back for the primary scope. Without
protection: a second teardownPrimary() races the first's still-pending
process exit, fires a second hermes:connection:applied, and the
resulting second softSwitch() races the first through
getConnection()/adoptPrimaryProfile() with no guard against it.

Fix: applyConnectionChange dedupes concurrent primary-scope calls
through a single in-flight promise, so a second caller awaits the
first's re-home instead of racing its own. softSwitch() gets an early
$gatewaySwitching reentrancy check as defense in depth — the flag is
already set for the whole switch, just never checked at entry. No
behavior change for the common single-call path.
@pierrenode
pierrenode force-pushed the fix/desktop-soft-gateway-switch-reentrancy branch from 5d9b1d9 to 927b4cf Compare August 11, 2026 18:33
@pierrenode

Copy link
Copy Markdown
Contributor Author

Substantially rewritten against current `upstream/main`, not a mechanical rebase — the original diff couldn't apply cleanly because `connection-config:apply`'s inline handler in `main.ts` (what this PR originally targeted) has since been extracted into a standalone, independently-tested module (`apps/desktop/electron/connection-apply.ts`, `applyConnectionChange()`). The underlying race is unchanged and still live: the extracted function's plain teardown-primary path (`isPrimary && !rehomePrimary`) still has zero protection against two concurrent primary-scope calls.

What changed vs. the original PR:

  • The `primaryRehomeInFlight` dedup now lives in `connection-apply.ts` (module-level, scoped to `applyConnectionChange`) instead of inline in `main.ts` — same mechanism, just relocated to match the refactor.
  • `use-gateway-boot.ts`'s `softSwitch()` reentrancy guard (`if (cancelled || $gatewaySwitching.get()) return`) is unchanged in intent; confirmed the flag itself (`$gatewaySwitching.set(true)/false`) was already present pre-fix for other bookkeeping, but nothing ever checked it at entry — the guard was genuinely missing, not already covered.
  • `main.ts` itself is untouched by this PR now — a net improvement, since the fix moved from an untestable 8000+-line entry script into two independently unit-tested modules.

Test coverage, now easier than the original (which had no test seam for `main.ts`): `connection-apply.test.ts` already existed with a clean-room testing convention (pure function, injected callbacks, no Electron mocking) — added a dedup regression test there. `use-gateway-boot.test.tsx` already has real `onConnectionApplied` fake wiring from an unrelated later commit; added a back-to-back-firing regression test using the existing `beforeConnectionSwitch` spy as the signal (guarded: called once; unguarded: called twice).

Both fixes mutation-verified independently (reverting either one reproduces the pre-fix call count in its respective new test). `tsc --noEmit` clean on both `tsconfig.json` and `tsconfig.electron.json`. ESLint couldn't run in this workspace-scoped checkout (missing root-only `globals` peer dependency, pre-existing environment gap, unrelated to this change) — relying on the clean typecheck + passing tests instead. Fresh competitor search found no PR touching `connection-apply.ts` or this reentrancy path.

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/*) P2 Medium — degraded but workaround exists sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform 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.

4 participants