fix(desktop): gate HMR session-survival entirely out of prod builds

Guard the globalThis gateway-state container and the survivor park/adopt
calls on the import.meta.hot literal (not the runtime hmrActive() helper),
so Vite dead-code-eliminates every HMR path in production. Prod now uses a
plain module-local singleton — no globalThis, no Symbol.for — and the
survivor module drops out of the bundle entirely. Verified: gatewayRegistryState,
gatewaySurvivor, and import.meta.hot are all absent from the prod build.

Removes the now-unused hmrActive() export.
This commit is contained in:
Brooklyn Nicholson 2026-07-23 02:06:53 -05:00
parent 1943c1b750
commit cbea2acbb8
3 changed files with 33 additions and 25 deletions

View file

@ -24,11 +24,6 @@ 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

View file

@ -40,7 +40,7 @@ import {
import { $attentionSessionIds, $workingSessionIds, resetTileRuntimeBindings } from '@/store/session-states'
import type { RpcEvent } from '@/types/hermes'
import { hmrActive, stashGatewaySurvivor, survivorIsStale, takeGatewaySurvivor } from './gateway-hmr-survivor'
import { 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
@ -342,9 +342,10 @@ export function useGatewayBoot({
// 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()
// socket is discarded and we boot fresh. No-op in production: import.meta.hot
// is undefined there, so this folds to `null` and the whole survivor module
// dead-code-eliminates out of the bundle.
const survivor = import.meta.hot ? takeGatewaySurvivor() : null
const adoptedFromHmr = Boolean(survivor && !survivorIsStale(survivor))
if (survivor && !adoptedFromHmr) {
@ -595,7 +596,7 @@ export function useGatewayBoot({
// 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') {
if (import.meta.hot && gateway.connectionState === 'open') {
stashGatewaySurvivor({
gateway,
profile: survivor?.profile ?? $activeGatewayProfile.get(),

View file

@ -60,24 +60,36 @@ interface GatewayRegistryState {
const STATE_KEY = Symbol.for('hermes.desktop.gatewayRegistryState')
function gatewayState(): GatewayRegistryState {
const store = globalThis as unknown as { [STATE_KEY]?: GatewayRegistryState }
function createRegistryState(): GatewayRegistryState {
return {
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)
}
}
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)
}
// Dev only: park the singletons on globalThis so an HMR re-eval of this module
// (self-accepted at the bottom) hands back the SAME live sockets/atoms instead
// of resetting them — that's what keeps the agent session alive across UI edits.
// `import.meta.hot` is undefined in production, so Vite dead-code-eliminates the
// entire globalThis branch and prod uses a plain module-local singleton — no
// globalThis, no Symbol.for. Both realms load the module once, so the container's
// shape and lifetime are identical either way.
function gatewayState(): GatewayRegistryState {
if (import.meta.hot) {
const store = globalThis as unknown as { [STATE_KEY]?: GatewayRegistryState }
store[STATE_KEY] ??= createRegistryState()
return store[STATE_KEY]
}
return store[STATE_KEY]
return createRegistryState()
}
const g = gatewayState()