fix(desktop): fail closed through registry boot and drain owner holds
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user