diff --git a/apps/desktop/src/app/gateway/hooks/gateway-hmr-survivor.test.ts b/apps/desktop/src/app/gateway/hooks/gateway-hmr-survivor.test.ts new file mode 100644 index 000000000000..36171959a4ef --- /dev/null +++ b/apps/desktop/src/app/gateway/hooks/gateway-hmr-survivor.test.ts @@ -0,0 +1,71 @@ +import { afterEach, describe, expect, it } from 'vitest' + +import { + type GatewaySurvivor, + stashGatewaySurvivor, + survivorIsStale, + takeGatewaySurvivor +} from './gateway-hmr-survivor' + +// A minimal stand-in for HermesGateway: the survivor cache only reads +// `connectionState`. Cast through unknown so we don't drag the whole class in. +function fakeGateway(state: 'idle' | 'connecting' | 'open' | 'closed' | 'error') { + return { connectionState: state } as unknown as GatewaySurvivor['gateway'] +} + +function makeSurvivor(state: Parameters[0]): GatewaySurvivor { + return { gateway: fakeGateway(state), profile: 'default', connection: null } +} + +afterEach(() => { + // Drain any survivor a test parked but didn't take, so cases don't leak across + // the shared globalThis slot. + takeGatewaySurvivor() +}) + +describe('gateway HMR survivor cache', () => { + it('returns null when nothing is parked', () => { + expect(takeGatewaySurvivor()).toBeNull() + }) + + it('round-trips a parked survivor', () => { + const survivor = makeSurvivor('open') + stashGatewaySurvivor(survivor) + expect(takeGatewaySurvivor()).toBe(survivor) + }) + + it('is single-shot — a second take returns null', () => { + stashGatewaySurvivor(makeSurvivor('open')) + expect(takeGatewaySurvivor()).not.toBeNull() + expect(takeGatewaySurvivor()).toBeNull() + }) + + it('persists across re-imports via the globalThis slot', async () => { + const survivor = makeSurvivor('open') + stashGatewaySurvivor(survivor) + + // A fresh import of the module (simulating an HMR module swap) must resolve + // the same underlying store, not a reset one. + const reimported = await import('./gateway-hmr-survivor') + expect(reimported.takeGatewaySurvivor()).toBe(survivor) + }) + + it('treats open / connecting sockets as adoptable', () => { + expect(survivorIsStale(makeSurvivor('open'))).toBe(false) + expect(survivorIsStale(makeSurvivor('connecting'))).toBe(false) + }) + + it('treats closed / error / idle sockets as stale', () => { + expect(survivorIsStale(makeSurvivor('closed'))).toBe(true) + expect(survivorIsStale(makeSurvivor('error'))).toBe(true) + expect(survivorIsStale(makeSurvivor('idle'))).toBe(true) + }) + + it('returns a stale survivor from take() so the caller can close it', () => { + const dead = makeSurvivor('closed') + stashGatewaySurvivor(dead) + const taken = takeGatewaySurvivor() + expect(taken).toBe(dead) + expect(taken && survivorIsStale(taken)).toBe(true) + }) +}) diff --git a/apps/desktop/src/app/gateway/hooks/gateway-hmr-survivor.ts b/apps/desktop/src/app/gateway/hooks/gateway-hmr-survivor.ts new file mode 100644 index 000000000000..dec9f4d1ab74 --- /dev/null +++ b/apps/desktop/src/app/gateway/hooks/gateway-hmr-survivor.ts @@ -0,0 +1,61 @@ +// Keep the live primary gateway socket alive across Vite HMR so editing UI never +// drops the agent session. Stash on dispose, re-adopt on remount. globalThis + +// self-accept so this module's own reload doesn't reset the cache. Prod strips +// import.meta.hot → byte-for-byte unchanged live unmount. + +import type { HermesConnection } from '@/global' +import type { HermesGateway } from '@/hermes' + +export interface GatewaySurvivor { + gateway: HermesGateway + profile: string + connection: HermesConnection | null +} + +// One slot on globalThis, keyed by a process-stable Symbol so repeated imports +// (across hot reloads) resolve the exact same store. +const SURVIVOR_KEY = Symbol.for('hermes.desktop.gatewaySurvivor') + +interface SurvivorGlobal { + [SURVIVOR_KEY]?: GatewaySurvivor | null +} + +function slot(): SurvivorGlobal { + return globalThis as unknown as SurvivorGlobal +} + +/** True only in a dev server with HMR wired up. Production strips this. */ +export function hmrActive(): boolean { + return Boolean(import.meta.hot) +} + +/** Park the live gateway so the next module instance can re-adopt it. */ +export function stashGatewaySurvivor(survivor: GatewaySurvivor): void { + slot()[SURVIVOR_KEY] = survivor +} + +/** + * Take the parked gateway, if any. Single-shot: the slot is cleared on read. + * The caller decides whether to adopt or discard it via `survivorIsStale` — a + * socket that died while parked (e.g. backend restart between edits) is still + * returned so the caller can close it and boot fresh. + */ +export function takeGatewaySurvivor(): GatewaySurvivor | null { + const store = slot() + const survivor = store[SURVIVOR_KEY] ?? null + store[SURVIVOR_KEY] = null + + return survivor +} + +/** A parked survivor whose socket is no longer open — caller should discard it. */ +export function survivorIsStale(survivor: GatewaySurvivor): boolean { + const state = survivor.gateway.connectionState + + return state !== 'open' && state !== 'connecting' +} + +// Self-accept so editing THIS module doesn't blow away the cache it manages. +if (import.meta.hot) { + import.meta.hot.accept() +} diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts index ee47db73642a..ac0fb1a17b15 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts @@ -40,6 +40,8 @@ import { import { $attentionSessionIds, $workingSessionIds, resetTileRuntimeBindings } from '@/store/session-states' import type { RpcEvent } from '@/types/hermes' +import { hmrActive, stashGatewaySurvivor, survivorIsStale, takeGatewaySurvivor } from './gateway-hmr-survivor' + // After this many consecutive failed reconnects (≈45s with the 1→15s backoff) // raise a recoverable boot error. Otherwise a dropped remote gateway loops the // backoff forever behind the fullscreen CONNECTING overlay with no way to reach @@ -337,9 +339,27 @@ export function useGatewayBoot({ progress: 6 }) - const gateway = new HermesGateway() + // HMR adoption: in a dev hot update, the previous effect instance parked its + // still-open socket instead of closing it (see the cleanup below). Re-adopt + // it so an edit doesn't drop the live agent session. A stale (closed) parked + // socket is discarded and we boot fresh. No-op in production (survivor is + // always null — the stash path is gated on import.meta.hot). + const survivor = takeGatewaySurvivor() + const adoptedFromHmr = Boolean(survivor && !survivorIsStale(survivor)) + + if (survivor && !adoptedFromHmr) { + // Parked socket died between edits (e.g. backend restart) — release it. + try { + survivor.gateway.close() + } catch { + // ignore + } + } + + const gateway = adoptedFromHmr ? survivor!.gateway : new HermesGateway() + callbacksRef.current.onGatewayReady(gateway) - setPrimaryGateway(gateway, normalizeProfileKey($activeGatewayProfile.get())) + setPrimaryGateway(gateway, survivor?.profile ?? normalizeProfileKey($activeGatewayProfile.get())) // Secondary (background-profile) sockets funnel into the same handler. configureGatewayRegistry({ onEvent: event => callbacksRef.current.handleGatewayEvent(event) }) @@ -513,7 +533,41 @@ export function useGatewayBoot({ } } - void boot() + // Adopt the parked socket without re-running the full boot handshake: the + // socket is already open, the backend session is untouched, and we already + // know the profile. We only re-publish the connection, re-sync config + + // sessions (cheap, and the backend may have moved on between edits), and + // dismiss any boot overlay. This is what keeps a live, mid-stream session + // intact across an HMR update. + async function adoptBoot() { + bootCompleted = true + completeDesktopBoot() + + if (survivor?.connection) { + publish(survivor.connection) + } + + $activeGatewayProfile.set(survivor?.profile ?? $activeGatewayProfile.get()) + void ensureGatewayForProfile(survivor?.profile ?? $activeGatewayProfile.get()) + + // Mirror the current (already-open) socket state into the composer so the + // input doesn't sit disabled after the swap. + reportPrimaryGatewayState(gateway.connectionState) + + await callbacksRef.current.refreshHermesConfig().catch(() => undefined) + + if (cancelled) { + return + } + + await callbacksRef.current.refreshSessions().catch(() => undefined) + } + + if (adoptedFromHmr) { + void adoptBoot() + } else { + void boot() + } return () => { cancelled = true @@ -532,6 +586,24 @@ export function useGatewayBoot({ offExit() offWindowState?.() offBootProgress() + + // HMR teardown vs. real unmount. On a hot update we must NOT close the + // socket — that's the whole bug. Detach this instance's listeners (their + // closures capture the disposed module), park the still-open gateway, and + // let the freshly loaded effect re-adopt it. Secondaries are owned by the + // gateway store (HMR-stable module state), so they survive untouched. + // Production: import.meta.hot is undefined, so this branch never runs and + // the original destructive teardown below is byte-for-byte preserved. + if (hmrActive() && gateway.connectionState === 'open') { + stashGatewaySurvivor({ + gateway, + profile: survivor?.profile ?? $activeGatewayProfile.get(), + connection: $connection.get() + }) + + return + } + closeSecondaryGateways() gateway.close() publish(null) diff --git a/apps/desktop/src/store/gateway.ts b/apps/desktop/src/store/gateway.ts index b07a322e6775..5027273f86eb 100644 --- a/apps/desktop/src/store/gateway.ts +++ b/apps/desktop/src/store/gateway.ts @@ -20,41 +20,10 @@ const normKey = (profile: string | null | undefined): string => (profile ?? ''). // narrow the getter to a constant across guards (it genuinely changes). const isOpen = (gateway: HermesGateway | null): boolean => gateway?.connectionState === 'open' -// The active gateway instance, exposed for inline message-stream components -// (e.g. inline ClarifyTool, model overlays) that call gateway methods without -// the instance threaded down through props. -export const $gateway = atom(null) - interface RegistryConfig { onEvent: (event: GatewayEvent) => void } -let config: RegistryConfig | null = null - -export function configureGatewayRegistry(cfg: RegistryConfig): void { - config = cfg -} - -/** - * Feed a synthetic event through the exact same fan-out a real socket frame - * takes (`config.onEvent` → the desktop's `handleGatewayEvent`). Used by - * dev-only tooling to exercise the real event branches (e.g. the credit-notice - * demo) without a backend that can produce the event on demand. No-op until a - * registry is configured. - */ -export function emitLocalGatewayEvent(event: GatewayEvent): void { - config?.onEvent(event) -} - -// ── Primary (window) backend ─────────────────────────────────────────────── -let primaryGateway: HermesGateway | null = null -let primaryProfile = 'default' - -export function setPrimaryGateway(gateway: HermesGateway | null, profile = 'default'): void { - primaryGateway = gateway - primaryProfile = normKey(profile) -} - // ── Secondary (pool) backends ────────────────────────────────────────────── interface Secondary { profile: string @@ -69,20 +38,85 @@ interface Secondary { wantOpen: boolean } -const secondaries = new Map() +// ── HMR-stable module state ───────────────────────────────────────────────── +// All mutable singletons (live sockets, active-profile routing, the event +// registry) live in ONE container parked on globalThis, NOT in module-level +// `let`/`const` bindings. Reason: this module is imported widely without an HMR +// boundary that accepts it, so editing it (or anything that fans out to it) +// makes Vite issue a FULL PAGE RELOAD — which would kill every live socket and +// drop the agent session on an unrelated edit. Persisting the state on +// globalThis + self-accepting HMR (bottom of file) turns that full reload into +// an in-place hot update that preserves the sockets. Production strips +// import.meta.hot, and a fresh page realm starts with an empty container, so the +// runtime behavior is identical to plain module state. +interface GatewayRegistryState { + config: RegistryConfig | null + primaryGateway: HermesGateway | null + primaryProfile: string + activeKey: string + secondaries: Map + $gateway: ReturnType> +} -let activeKey = 'default' +const STATE_KEY = Symbol.for('hermes.desktop.gatewayRegistryState') + +function gatewayState(): GatewayRegistryState { + const store = globalThis as unknown as { [STATE_KEY]?: GatewayRegistryState } + + if (!store[STATE_KEY]) { + store[STATE_KEY] = { + config: null, + primaryGateway: null, + primaryProfile: 'default', + activeKey: 'default', + secondaries: new Map(), + // The active gateway instance, exposed for inline message-stream + // components (inline ClarifyTool, model overlays) that call gateway + // methods without the instance threaded down through props. + $gateway: atom(null) + } + } + + return store[STATE_KEY] +} + +const g = gatewayState() + +// Re-exported as a stable binding: the atom instance lives in `g`, so every hot +// reload of this module hands back the SAME atom subscribers are already wired +// to. (A fresh `atom()` per reload would orphan existing subscriptions.) +export const $gateway = g.$gateway + +export function configureGatewayRegistry(cfg: RegistryConfig): void { + g.config = cfg +} + +/** + * Feed a synthetic event through the exact same fan-out a real socket frame + * takes (`config.onEvent` → the desktop's `handleGatewayEvent`). Used by + * dev-only tooling to exercise the real event branches (e.g. the credit-notice + * demo) without a backend that can produce the event on demand. No-op until a + * registry is configured. + */ +export function emitLocalGatewayEvent(event: GatewayEvent): void { + g.config?.onEvent(event) +} + +export function setPrimaryGateway(gateway: HermesGateway | null, profile = 'default'): void { + g.primaryGateway = gateway + g.primaryProfile = normKey(profile) +} export function isActivePrimary(): boolean { - return activeKey === primaryProfile + return g.activeKey === g.primaryProfile } export function activeGateway(): HermesGateway | null { - if (activeKey === primaryProfile) { - return primaryGateway + if (g.activeKey === g.primaryProfile) { + return g.primaryGateway } - return secondaries.get(activeKey)?.gateway ?? primaryGateway + return g.secondaries.get(g.activeKey)?.gateway ?? g.primaryGateway } // Mirror a backend's connection state into the global composer state, but only @@ -90,19 +124,19 @@ export function activeGateway(): HermesGateway | null { // composer reflect the active profile's socket without a background reconnect // flipping the foreground enabled/disabled state. function reportGatewayState(profile: string, state: ConnectionState): void { - if (normKey(profile) === activeKey) { + if (normKey(profile) === g.activeKey) { setGatewayState(state) } } export function reportPrimaryGatewayState(state: ConnectionState): void { - reportGatewayState(primaryProfile, state) + reportGatewayState(g.primaryProfile, state) } function setActive(profile: string): void { - activeKey = normKey(profile) + g.activeKey = normKey(profile) const gateway = activeGateway() - $gateway.set(gateway) + g.$gateway.set(gateway) setGatewayState(gateway?.connectionState ?? 'closed') } @@ -175,7 +209,7 @@ function createSecondary(profile: string): Secondary { wantOpen: true } - entry.offEvent = gateway.onEvent(event => config?.onEvent({ ...event, profile })) + entry.offEvent = gateway.onEvent(event => g.config?.onEvent({ ...event, profile })) entry.offState = gateway.onState(state => { reportGatewayState(profile, state) @@ -187,7 +221,7 @@ function createSecondary(profile: string): Secondary { } }) - secondaries.set(profile, entry) + g.secondaries.set(profile, entry) return entry } @@ -201,11 +235,11 @@ function createSecondary(profile: string): Secondary { export async function openGatewayForProfile(profile: string): Promise { const key = normKey(profile) - if (key === primaryProfile) { + if (key === g.primaryProfile) { return } - const entry = secondaries.get(key) ?? createSecondary(key) + const entry = g.secondaries.get(key) ?? createSecondary(key) entry.wantOpen = true if (!isOpen(entry.gateway)) { @@ -218,13 +252,13 @@ export async function openGatewayForProfile(profile: string): Promise { export async function ensureGatewayForProfile(profile: string): Promise { const key = normKey(profile) - if (key === primaryProfile) { + if (key === g.primaryProfile) { setActive(key) return } - let entry = secondaries.get(key) + let entry = g.secondaries.get(key) if (!entry) { entry = createSecondary(key) @@ -249,11 +283,11 @@ export async function ensureGatewayForProfile(profile: string): Promise { // Reconnect the active gateway after a transient request failure. Primary // reconnects are owned by use-gateway-boot, so we only drive secondaries here. export async function ensureActiveGatewayOpen(): Promise { - if (activeKey === primaryProfile) { - return primaryGateway + if (g.activeKey === g.primaryProfile) { + return g.primaryGateway } - const entry = secondaries.get(activeKey) + const entry = g.secondaries.get(g.activeKey) if (!entry) { return null @@ -268,7 +302,7 @@ export async function ensureActiveGatewayOpen(): Promise { // Wake signal (sleep/network/visibility): nudge every live secondary back open. export function reconnectSecondaryGateways(): void { - for (const entry of secondaries.values()) { + for (const entry of g.secondaries.values()) { if (!entry.wantOpen || isOpen(entry.gateway)) { continue } @@ -284,38 +318,47 @@ export function reconnectSecondaryGateways(): void { export function touchSecondaryGateways(): void { const desktop = window.hermesDesktop - for (const entry of secondaries.values()) { + for (const entry of g.secondaries.values()) { if (entry.wantOpen) { void desktop?.touchBackend?.(entry.profile).catch(() => undefined) } } } +// Tear a secondary down: stop its reconnect loop, detach listeners, close the +// socket. Caller handles removal from the map. +function disposeSecondary(entry: Secondary): void { + entry.wantOpen = false + clearTimer(entry) + entry.offEvent() + entry.offState() + entry.gateway.close() +} + // Close + evict secondaries whose profile is neither active nor in `keep` // (profiles with a running / needs-input session). Bounds cost to live work. export function pruneSecondaryGateways(keep: Set): void { - for (const [key, entry] of [...secondaries]) { - if (key === activeKey || keep.has(key)) { + for (const [key, entry] of [...g.secondaries]) { + if (key === g.activeKey || keep.has(key)) { continue } - entry.wantOpen = false - clearTimer(entry) - entry.offEvent() - entry.offState() - entry.gateway.close() - secondaries.delete(key) + disposeSecondary(entry) + g.secondaries.delete(key) } } export function closeSecondaryGateways(): void { - for (const entry of secondaries.values()) { - entry.wantOpen = false - clearTimer(entry) - entry.offEvent() - entry.offState() - entry.gateway.close() + for (const entry of g.secondaries.values()) { + disposeSecondary(entry) } - secondaries.clear() + g.secondaries.clear() +} + +// Self-accept so editing this module (or a fan-out that lands here) is an +// in-place hot update instead of a full page reload — the live sockets in `g` +// survive the swap. Dev-only: production strips import.meta.hot. +if (import.meta.hot) { + import.meta.hot.accept() }