mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-24 16:54:43 +00:00
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:
parent
7c9d05267c
commit
813ddb4555
4 changed files with 317 additions and 70 deletions
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
61
apps/desktop/src/app/gateway/hooks/gateway-hmr-survivor.ts
Normal file
61
apps/desktop/src/app/gateway/hooks/gateway-hmr-survivor.ts
Normal 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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue