fix(desktop): fail closed through registry boot and drain owner holds

This commit is contained in:
Deus
2026-08-25 18:24:04 +02:00
committed by Teknium
parent a0578f4ef3
commit d6e323bd63
5 changed files with 97 additions and 6 deletions
@@ -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)
@@ -7,5 +7,8 @@ import type { DesktopConnectionsRegistry } from '@/global'
export const $connectionsRegistry = atom<DesktopConnectionsRegistry | null>(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)
}
@@ -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
@@ -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)
+48 -4
View File
@@ -117,7 +117,34 @@ export function liveSessionScopes(): Set<string> {
// 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<string, { owner: SessionOwnerScope; until: number }>()
const sessionOwnerHolds = new Map<
string,
{ owner: SessionOwnerScope; timer: ReturnType<typeof setTimeout>; 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<string> {
: 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
}