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 d770bce906..14061f4098 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts @@ -65,6 +65,7 @@ import { } from '@/store/session' import { $attentionSessionIds, + $sessionOwnerHoldRevision, $sessionTiles, $workingSessionIds, foregroundSessionScopes, @@ -854,6 +855,7 @@ export function useGatewayBoot({ const offActiveProfile = $activeGatewayProfile.subscribe(() => recomputeKeptGateways()) const offTiles = $sessionTiles.subscribe(() => recomputeKeptGateways()) const offSelectedSession = $selectedStoredSessionId.subscribe(() => recomputeKeptGateways()) + const offSessionOwnerHolds = $sessionOwnerHoldRevision.subscribe(() => recomputeKeptGateways()) const offWindowState = desktop.onWindowStateChanged?.(payload => { const current = $connection.get() @@ -1052,6 +1054,7 @@ export function useGatewayBoot({ offActiveProfile() offTiles() offSelectedSession() + offSessionOwnerHolds() window.removeEventListener('online', onOnline) document.removeEventListener('visibilitychange', onVisible) window.removeEventListener('focus', onFocus) diff --git a/apps/desktop/src/store/connection-registry-state.ts b/apps/desktop/src/store/connection-registry-state.ts index 6b7071a134..b78136f99f 100644 --- a/apps/desktop/src/store/connection-registry-state.ts +++ b/apps/desktop/src/store/connection-registry-state.ts @@ -7,5 +7,8 @@ import type { DesktopConnectionsRegistry } from '@/global' export const $connectionsRegistry = atom(null) export function hasRegistryTopology(): boolean { - return $connectionsRegistry.get() !== null + // The bridge exists before its asynchronous cache load. Treat that window + // (and a failed list IPC) as registry topology so owner routing fails closed; + // only an older Desktop without the registry capability is truly legacy. + return $connectionsRegistry.get() !== null || Boolean(window.hermesDesktop?.connections?.list) } diff --git a/apps/desktop/src/store/session-owner-resolution.test.ts b/apps/desktop/src/store/session-owner-resolution.test.ts index 36e4198cfc..8047bed65a 100644 --- a/apps/desktop/src/store/session-owner-resolution.test.ts +++ b/apps/desktop/src/store/session-owner-resolution.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { $connectionsRegistry } from './connections' import { $profiles } from './profile' @@ -21,7 +21,28 @@ beforeEach(() => { $profiles.set([]) }) +afterEach(() => { + delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop +}) + describe('session owner topology', () => { + it('fails closed while the modern registry bridge is present but its async cache is not loaded', () => { + ;(window as unknown as { hermesDesktop?: unknown }).hermesDesktop = { + connections: { list: vi.fn(async () => Promise.reject(new Error('ipc unavailable'))) } + } + $connectionsRegistry.set(null) + $profiles.set([{ name: 'default' }] as never) + + expect(sessionOwnerIsKnown('default')).toBe(true) + expect(ambientGatewayOwnsEverySession()).toBe(false) + expect(() => + assertSessionOwnerResolved('default', { method: 'session.resume', sessionId: 'registry-loading' }) + ).not.toThrow() + expect(() => assertSessionOwnerResolved(null, { method: 'session.resume', sessionId: 'registry-loading' })).toThrow( + /could not be resolved/i + ) + }) + it('fails closed on an unknown owner in registry topology while preserving legacy profile routes', () => { // A connection registry means the ambient gateway is never provably the // sole backend, even with one profile listed: an unknown owner fails diff --git a/apps/desktop/src/store/session-states-foreground-scopes.test.ts b/apps/desktop/src/store/session-states-foreground-scopes.test.ts index 7325b6d51a..97ce973a14 100644 --- a/apps/desktop/src/store/session-states-foreground-scopes.test.ts +++ b/apps/desktop/src/store/session-states-foreground-scopes.test.ts @@ -8,6 +8,7 @@ import { } from '@/store/session' import { + $sessionOwnerHoldRevision, $sessionTiles, _resetSessionOwnerHoldsForTests, foregroundSessionScopes, @@ -85,6 +86,25 @@ describe('foregroundSessionScopes: owner hold across the create → foreground g expect(foregroundSessionScopes()).toEqual(new Set()) }) + it('publishes hold release and TTL expiry so pending gateway redials can drain without unrelated UI state', () => { + vi.useFakeTimers() + const revisions: number[] = [] + const off = $sessionOwnerHoldRevision.subscribe(value => revisions.push(value)) + + const release = holdSessionOwnerUntilForeground('stored-release', omar) + const afterHold = revisions.at(-1)! + release() + expect(revisions.at(-1)).toBeGreaterThan(afterHold) + + holdSessionOwnerUntilForeground('stored-expiry', omar) + const beforeExpiry = revisions.at(-1)! + vi.advanceTimersByTime(60_000 + 1) + expect(revisions.at(-1)).toBeGreaterThan(beforeExpiry) + expect(foregroundSessionScopes()).toEqual(new Set()) + + off() + }) + it('ignores blank ids, null owners and profile-only owners map to the legacy pool key', () => { holdSessionOwnerUntilForeground(' ', omar) holdSessionOwnerUntilForeground('stored-null', null) diff --git a/apps/desktop/src/store/session-states.ts b/apps/desktop/src/store/session-states.ts index 70ee2f7b8d..30b8c5eba2 100644 --- a/apps/desktop/src/store/session-states.ts +++ b/apps/desktop/src/store/session-states.ts @@ -117,7 +117,34 @@ export function liveSessionScopes(): Set { // selected or tiled), the caller releases it (failed create / drift close), // or a bounded TTL expires — nothing latches. const SESSION_OWNER_HOLD_TTL_MS = 60_000 -const sessionOwnerHolds = new Map() + +const sessionOwnerHolds = new Map< + string, + { owner: SessionOwnerScope; timer: ReturnType; until: number } +>() + +export const $sessionOwnerHoldRevision = atom(0) + +function bumpSessionOwnerHoldRevision(): void { + $sessionOwnerHoldRevision.set($sessionOwnerHoldRevision.get() + 1) +} + +function forgetSessionOwnerHold(storedSessionId: string, publish: boolean): boolean { + const hold = sessionOwnerHolds.get(storedSessionId) + + if (!hold) { + return false + } + + clearTimeout(hold.timer) + sessionOwnerHolds.delete(storedSessionId) + + if (publish) { + bumpSessionOwnerHoldRevision() + } + + return true +} export function holdSessionOwnerUntilForeground(storedSessionId: string, owner: SessionOwnerScope): () => void { const id = storedSessionId.trim() @@ -126,18 +153,33 @@ export function holdSessionOwnerUntilForeground(storedSessionId: string, owner: return () => undefined } - sessionOwnerHolds.set(id, { owner, until: Date.now() + SESSION_OWNER_HOLD_TTL_MS }) + forgetSessionOwnerHold(id, false) + const until = Date.now() + SESSION_OWNER_HOLD_TTL_MS + const timer = setTimeout(() => releaseSessionOwnerHold(id), SESSION_OWNER_HOLD_TTL_MS) + + sessionOwnerHolds.set(id, { owner, timer, until }) + bumpSessionOwnerHoldRevision() return () => releaseSessionOwnerHold(id) } export function releaseSessionOwnerHold(storedSessionId: string): void { - sessionOwnerHolds.delete(storedSessionId.trim()) + forgetSessionOwnerHold(storedSessionId.trim(), true) } /** @internal Tests. */ export function _resetSessionOwnerHoldsForTests(): void { + const hadHolds = sessionOwnerHolds.size > 0 + + for (const hold of sessionOwnerHolds.values()) { + clearTimeout(hold.timer) + } + sessionOwnerHolds.clear() + + if (hadHolds) { + bumpSessionOwnerHoldRevision() + } } /** @@ -196,7 +238,9 @@ export function foregroundSessionScopes(): Set { : null if (!scope || hold.until <= now || scopes.has(scope)) { - sessionOwnerHolds.delete(storedSessionId) + // This recompute was already triggered by the covering publication (or + // is itself observing expiry), so avoid recursively publishing. + forgetSessionOwnerHold(storedSessionId, false) continue }