fix(desktop): preserve registered gateways during mode switches

This commit is contained in:
joaomarcos
2026-08-25 14:38:08 -07:00
committed by Teknium
parent 1808d33a24
commit 700ff78097
6 changed files with 148 additions and 10 deletions
@@ -9,7 +9,7 @@ import {
selectConnection,
setConnectionsRegistry
} from '@/store/connections'
import { closeSecondaryGateways, isActivePrimary, requestGatewayForAgent } from '@/store/gateway'
import { activeGateway, closeSecondaryGateways, ensureGatewayForAgent, isActivePrimary, requestGatewayForAgent } from '@/store/gateway'
import { reconnectGateway } from '@/store/gateway-reconnect'
import {
$gatewaySwitching,
@@ -880,6 +880,45 @@ describe('useGatewayBoot remote reconnect loop (real hook, fake socket)', () =>
expect(FakeWebSocket.instances).toHaveLength(1)
})
it('keeps registered source sockets alive during a legacy mode apply', async () => {
const desktop = fakeDesktop() as ReturnType<typeof fakeDesktop> & {
getConnectionFor: ReturnType<typeof vi.fn>
}
desktop.getConnectionFor = vi.fn(async ({ connectionId, profile }: { connectionId: string; profile: string }) => ({
...coderConn,
connectionId,
profile,
wsUrl: `wss://${connectionId}.example.com/api/ws?token=r`
}))
;(window as { hermesDesktop?: unknown }).hermesDesktop = desktop
render(<Harness />)
await flushAsync()
expect($gatewayState.get()).toBe('open')
let opening!: Promise<boolean>
act(() => {
opening = ensureGatewayForAgent('cloud', 'default')
})
await flushAsync()
await opening
const registeredGateway = activeGateway()
expect(registeredGateway).not.toBeNull()
expect(isActivePrimary()).toBe(false)
act(() => connectionApplied?.())
await flushAsync()
await flushAsync()
// Applying the legacy Local/Cloud mode must not close an independent v2
// source. The foreground returns to the new primary, while the registered
// socket remains reusable and cannot arm ws_orphan_reap on the old backend.
expect(registeredGateway?.connectionState).toBe('open')
expect(isActivePrimary()).toBe(true)
})
it('re-fetches the profile rail from the NEW backend after a connection apply (#85731)', async () => {
// The reported repro: connected to backend A, the rail shows A's named
// profiles; the user applies a different remote/Cloud connection (soft
@@ -18,6 +18,7 @@ import {
} from '@/store/boot'
import {
$gateway,
closeLegacySecondaryGateways,
closeSecondaryGateways,
configureGatewayRegistry,
disposeSecondariesForConnection,
@@ -62,6 +63,7 @@ import {
$attentionSessionIds,
$workingSessionIds,
$sessionTiles,
foregroundSessionScopes,
liveSessionScopes,
openTileGatewayScopes,
reconcileBusyStatesOnReconnect,
@@ -524,7 +526,11 @@ export function useGatewayBoot({
reauthNotified = false
gateway.close()
closeSecondaryGateways()
// The primary mode is changing, but registered v2 sources remain
// independent gateways. Retire only legacy profile sockets whose
// routing follows connection.json; closing every secondary here
// detached valid registered sessions and armed ws_orphan_reap.
closeLegacySecondaryGateways()
// Same override rule as boot(): a profile-pinned helper window stays
// on its pinned profile's backend across a soft switch.
@@ -788,7 +794,7 @@ export function useGatewayBoot({
// sources can expose the same profile name (every source has a
// 'default'), so bare profile names can't represent a non-local
// source's liveness without keeping the wrong gateway alive.
const keep = liveSessionScopes()
const keep = new Set([...liveSessionScopes(), ...foregroundSessionScopes()])
for (const session of $sessions.get()) {
if (live.has(session.id)) {
@@ -53,6 +53,7 @@ vi.mock('@/store/session-states', () => reconnectStateMocks)
const {
activeGateway,
closeLegacySecondaryGateways,
closeSecondaryGateways,
configureGatewayRegistry,
disposeSecondariesForConnection,
@@ -181,6 +182,30 @@ describe('disposeSecondariesForConnection', () => {
})
})
describe('legacy secondary teardown', () => {
it('closes v1 profile sockets without detaching registered sources', async () => {
const getConnection = vi.fn(async (profile: string) => descriptorFor('legacy-local', profile))
const getConnectionFor = vi.fn(async ({ connectionId, profile }: { connectionId: string; profile: string }) =>
descriptorFor(connectionId, profile)
)
installDesktop({ getConnection, getConnectionFor })
await openGatewayForProfile('writer')
await ensureGatewayForAgent('homelab', 'default')
const legacy = gatewayMocks.instances[0]
const registered = gatewayMocks.instances[1]
closeLegacySecondaryGateways()
expect(legacy.close).toHaveBeenCalledOnce()
expect(registered.close).not.toHaveBeenCalled()
expect(activeGateway()).toBe(registered)
})
})
describe('secondary reconnect runtime scope', () => {
it('invalidates stale runtime bindings before a direct secondary reopen publishes open', async () => {
installDesktop({
+30 -6
View File
@@ -1418,7 +1418,36 @@ export function pruneSecondaryGateways(keep: Set<string>): void {
restoreActiveToPrimaryIfEvicted()
}
function closeSecondariesWhere(shouldClose: (entry: Secondary) => boolean): void {
for (const [scope, entry] of [...g.secondaries]) {
if (!shouldClose(entry)) {
continue
}
disposeSecondary(entry)
g.secondaries.delete(scope)
}
restoreActiveToPrimaryIfEvicted()
}
/**
* Close only profile sockets that follow the legacy v1 connection config.
*
* A global mode apply re-homes the primary backend, but registered connection
* sockets are independent sources in the v2 registry. Closing every secondary
* here would detach their sessions and arm `ws_orphan_reap` even though those
* sources remain valid and reusable. Legacy profile sockets still need to be
* retired because their endpoint is derived from the v1 config being changed.
*/
export function closeLegacySecondaryGateways(): void {
closeSecondariesWhere(entry => entry.connectionId == null)
}
export function closeSecondaryGateways(): void {
// Full teardown releases every routed-turn lease (class-2 #94284) and the
// renderer-generation ledger; the predicate close leaves live sources'
// leases alone (their sockets stay open).
for (const timer of g.turnLeaseReleaseTimers.values()) {
clearTimeout(timer)
}
@@ -1431,13 +1460,8 @@ export function closeSecondaryGateways(): void {
g.turnLeases.clear()
for (const entry of g.secondaries.values()) {
disposeSecondary(entry)
}
g.secondaries.clear()
closeSecondariesWhere(() => true)
openedSecondaryScopes().clear()
restoreActiveToPrimaryIfEvicted()
}
// A local profile can have two renderer-owned sockets: the legacy bare
+29 -1
View File
@@ -4,14 +4,16 @@ import type { ClientSessionState } from '@/app/types'
import { findGroupOfPane, group, split } from '@/components/pane-shell/tree/model'
import { $layoutTree } from '@/components/pane-shell/tree/store'
import { $activeGatewayProfile } from '@/store/profile'
import { $connection, $selectedStoredSessionId, setSessions } from '@/store/session'
import { $activeSessionId, $connection, $selectedStoredSessionId, setSessions } from '@/store/session'
import type { SessionTile } from '@/store/session-states'
import {
$sessionStates,
$sessionTiles,
blankDraftTile,
clearAllSessionStates,
focusedSessionNeedsRoute,
focusOpenSession,
foregroundSessionScopes,
isSessionRemote,
knownOwnerForSession,
markSelectionRestore,
@@ -19,6 +21,7 @@ import {
openSessionTile,
orderTilesByTree,
patchSessionTile,
recordSessionEventScope,
releaseSessionTranscript,
requestForOwnedSession,
resetTileRuntimeBindings,
@@ -32,6 +35,31 @@ import {
const tile = (storedSessionId: string): SessionTile => ({ storedSessionId })
const tilePane = (id: string) => `session-tile:${id}`
describe('foregroundSessionScopes', () => {
beforeEach(() => {
clearAllSessionStates()
$activeSessionId.set(null)
})
afterEach(() => {
clearAllSessionStates()
$activeSessionId.set(null)
})
it('keeps the exact registry owner of an idle foreground runtime', () => {
recordSessionEventScope({ connectionId: 'cloud', profile: 'default', session_id: 'runtime-1' })
$activeSessionId.set('runtime-1')
expect(foregroundSessionScopes()).toEqual(new Set(['conn:cloud::default']))
})
it('fails closed when the foreground runtime has no registered source', () => {
$activeSessionId.set('legacy-runtime')
expect(foregroundSessionScopes()).toEqual(new Set())
})
})
describe('resetTileRuntimeBindings', () => {
afterEach(() => {
$sessionTiles.set([])
+16
View File
@@ -99,6 +99,22 @@ export function liveSessionScopes(): Set<string> {
return scopes
}
/**
* The exact registry scope that owns the foreground runtime, when known.
*
* The secondary-gateway pruner normally keeps only busy/needs-input work. A
* source switch briefly changes the active gateway before the old foreground
* runtime is cleared, though, and an idle conversation can otherwise look
* disposable during that handoff. Preserve the owner for that narrow window;
* tiles have their own persisted owner route and are handled separately.
*/
export function foregroundSessionScopes(): Set<string> {
const runtimeId = $activeSessionId.get()
const scope = runtimeId ? sessionScopeByRuntimeId.get(runtimeId) : undefined
return scope ? new Set([scope]) : new Set()
}
// Stored session ids whose authoritative state is still busy, but whose
// runtime has produced no state publish for the watchdog window. Silence is
// not completion: long tool calls can legitimately stay quiet, so this is a