fix(desktop): keep gateway session alive across Vite HMR

Park the live primary gateway socket on Fast Refresh dispose and re-adopt it
on remount so dev UI edits don't tear down the WebSocket. Hold gateway store
singletons on globalThis + self-accept HMR on store/gateway.ts. Prod strips
import.meta.hot — live unmount unchanged.
This commit is contained in:
Brooklyn Nicholson 2026-06-25 23:50:54 -05:00
parent 7c9d05267c
commit 813ddb4555
4 changed files with 317 additions and 70 deletions

View file

@ -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<typeof fakeGateway>[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)
})
})

View file

@ -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()
}

View file

@ -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)

View file

@ -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<HermesGateway | null>(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<string, Secondary>()
// ── 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<string, Secondary>
$gateway: ReturnType<typeof atom<HermesGateway | null>>
}
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<string, Secondary>(),
// 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<HermesGateway | null>(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<void> {
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<void> {
export async function ensureGatewayForProfile(profile: string): Promise<void> {
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<void> {
// 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<HermesGateway | null> {
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<HermesGateway | null> {
// 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<string>): 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()
}