fix(desktop): tag the route probe of a user open as foreground too

Every user open first probes its route (sharedPrimaryRoute /
isAttachedSharedRemote) with getConnection / getConnectionFor, and only then
dials the secondary. With #102496 only the second dial carried
priority: 'foreground', so main started (or joined) the spawn as a background
slot wait on the probe and the click still waited out the probe's 20 s
RECONNECT_ATTEMPT_TIMEOUT_MS before promotion kicked in. Thread the priority
into both probes; the activation doors (ensureGatewayForProfile /
ensureGatewayForAgent) pass 'foreground' explicitly.

Also drop the renderer-side isBackgroundSlotWaitTimeout + the try/catch whose
two branches both rethrew: Electron rebuilds IPC rejections as a plain Error,
so name/silent/priority never reached the renderer and the helper was dead.

Test: gateway-spawn-priority.test.ts asserts every dial of a foreground open
carries the tag and an untagged open never does (red on the #102496 head).
This commit is contained in:
kshitijk4poor
2026-09-06 13:54:03 +05:30
committed by kshitij
parent 49ab5e2b73
commit 355058b80d
2 changed files with 157 additions and 50 deletions

View File

@@ -0,0 +1,112 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// #102281: a user-initiated open must reach Electron main as a FOREGROUND dial
// on its FIRST IPC, not only on the secondary's connect. Every open first
// probes the route (sharedPrimaryRoute / isAttachedSharedRemote) with
// getConnection / getConnectionFor; if that probe is untagged, main starts the
// spawn as a background slot wait and the click waits out the probe's 20s
// timeout before anything promotes it.
vi.mock('@/hermes', () => ({
setApiRequestConnection: vi.fn(),
HermesGateway: class {
connectionState = 'closed'
connect = async (): Promise<void> => {
this.connectionState = 'open'
}
close = (): void => {
this.connectionState = 'closed'
}
onEvent = vi.fn(() => () => {})
onState = vi.fn(() => () => {})
}
}))
vi.mock('@/store/session', () => ({ setConnection: vi.fn(), setGatewayState: vi.fn() }))
vi.mock('@/store/notify-baseline', () => ({ markNativeNotifyBaseline: vi.fn() }))
const {
closeSecondaryGateways,
configureGatewayRegistry,
ensureGatewayForAgent,
ensureGatewayForProfile,
openGatewayForAgent,
openGatewayForProfile,
setPrimaryGateway
} = await import('./gateway')
const conn = {
authMode: 'token',
baseUrl: 'https://homelab.invalid',
mode: 'remote',
profile: 'research',
token: 'fake-test-token',
wsUrl: 'wss://homelab.invalid/api/ws?token=fake-test-token'
}
function installDesktop(): { getConnection: ReturnType<typeof vi.fn>; getConnectionFor: ReturnType<typeof vi.fn> } {
const stub = {
getConnection: vi.fn(async () => conn),
getConnectionFor: vi.fn(async () => conn)
}
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = stub
return stub
}
function priorities(mock: ReturnType<typeof vi.fn>, pick: (args: unknown[]) => unknown): unknown[] {
return mock.mock.calls.map(args => pick(args))
}
beforeEach(() => {
configureGatewayRegistry({ onEvent: vi.fn() })
setPrimaryGateway({ connectionState: 'open' } as never, 'default')
})
afterEach(() => {
closeSecondaryGateways()
vi.clearAllMocks()
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
})
describe('user opens dial main as foreground from the first IPC (#102281)', () => {
it('ensureGatewayForProfile tags the route probe AND the connect dial', async () => {
const desktop = installDesktop()
await ensureGatewayForProfile('research')
const seen = priorities(desktop.getConnection, args => (args[1] as { priority?: string } | undefined)?.priority)
expect(seen.length).toBeGreaterThanOrEqual(2)
expect(seen.every(priority => priority === 'foreground')).toBe(true)
})
it('openGatewayForProfile without a priority never tags a dial as foreground', async () => {
const desktop = installDesktop()
await openGatewayForProfile('research')
const seen = priorities(desktop.getConnection, args => (args[1] as { priority?: string } | undefined)?.priority)
expect(seen.length).toBeGreaterThanOrEqual(1)
expect(seen.every(priority => priority === undefined)).toBe(true)
})
it('openGatewayForAgent forwards spawnPriority to every registry dial', async () => {
const desktop = installDesktop()
await openGatewayForAgent('homelab', 'research', { spawnPriority: 'foreground' })
const seen = priorities(desktop.getConnectionFor, args => (args[0] as { priority?: string }).priority)
expect(seen.length).toBeGreaterThanOrEqual(1)
expect(seen.every(priority => priority === 'foreground')).toBe(true)
})
it('ensureGatewayForAgent is always a foreground open', async () => {
const desktop = installDesktop()
await ensureGatewayForAgent('homelab', 'research')
const seen = priorities(desktop.getConnectionFor, args => (args[0] as { priority?: string }).priority)
expect(seen.length).toBeGreaterThanOrEqual(1)
expect(seen.every(priority => priority === 'foreground')).toBe(true)
})
})

View File

@@ -20,25 +20,24 @@ import { setConnection, setGatewayState } from '@/store/session'
const normKey = (profile: string | null | undefined): string => (profile ?? '').trim() || 'default'
// Spawn-slot priority handed to Electron main with every backend dial. A
// user-initiated open is 'foreground' and may take the pool's reserved slot;
// roster hydration, hover prewarm and untagged dials are 'background' — main's
// default, so background dials keep the pre-priority IPC payload shape.
type SpawnPriority = 'foreground' | 'background'
function isBackgroundSlotWaitTimeout(error: unknown): boolean {
if (!(error instanceof Error)) {
return false
}
const extra = error as Error & { priority?: string; silent?: boolean }
return (
extra.name === 'LocalBackendSlotWaitTimeoutError' ||
extra.silent === true ||
extra.priority === 'background' ||
(error.message.includes('timed out while waiting for a free slot') && error.message.includes('(background)'))
)
function dialPriority(spawnPriority: SpawnPriority): { priority: 'foreground' } | Record<never, never> {
return spawnPriority === 'foreground' ? { priority: 'foreground' } : {}
}
function connectionPriorityOpts(priority: SpawnPriority): { priority: 'foreground' } | undefined {
return priority === 'foreground' ? { priority: 'foreground' } : undefined
function dialProfile(
desktop: NonNullable<typeof window.hermesDesktop>,
profile: string,
spawnPriority: SpawnPriority
): Promise<HermesConnection> {
return spawnPriority === 'foreground'
? desktop.getConnection(profile, { priority: 'foreground' })
: desktop.getConnection(profile)
}
// Read connection state through a call so TS control-flow analysis doesn't
@@ -323,7 +322,11 @@ function isPrimaryRegistryRoute(connectionId: null | string, profile: string): b
* dials a second WebSocket at the same Tailscale URL, which accept/closes in
* ~30ms (`messages=1`) and never runs `session.create` (#96493). Isolated
* SSH/pooled backends (`sharedRemote: false`) still get their own secondary. */
async function isAttachedSharedRemote(connectionId: null | string, profile: string): Promise<boolean> {
async function isAttachedSharedRemote(
connectionId: null | string,
profile: string,
spawnPriority: SpawnPriority = 'background'
): Promise<boolean> {
const id = String(connectionId ?? '').trim()
const key = normKey(profile)
@@ -343,7 +346,7 @@ async function isAttachedSharedRemote(connectionId: null | string, profile: stri
try {
const conn = await withTimeout(
desktop.getConnectionFor({ connectionId: id, profile: key }),
desktop.getConnectionFor({ connectionId: id, profile: key, ...dialPriority(spawnPriority) }),
RECONNECT_ATTEMPT_TIMEOUT_MS,
`Timed out resolving shared-remote route for "${key}"`
)
@@ -575,33 +578,22 @@ async function openSecondary(entry: Secondary, spawnPriority: SpawnPriority = 'b
// this secondary (SSH terminal, messaging DELETE, session send, …) never
// settles either. Bound the same way use-gateway-boot.ts bounds the
// primary's equivalent awaits.
const conn = await (async () => {
try {
return entry.connectionId && desktop.getConnectionFor
? await withTimeout(
desktop.getConnectionFor({
connectionId: entry.connectionId,
profile: entry.profile,
...(connectionPriorityOpts(spawnPriority) ?? {})
}),
RECONNECT_ATTEMPT_TIMEOUT_MS,
`Timed out connecting to profile "${entry.profile}"`
)
: await withTimeout(
spawnPriority === 'foreground'
? desktop.getConnection(entry.profile, { priority: 'foreground' })
: desktop.getConnection(entry.profile),
RECONNECT_ATTEMPT_TIMEOUT_MS,
`Timed out connecting to profile "${entry.profile}"`
)
} catch (error) {
if (spawnPriority !== 'foreground' && isBackgroundSlotWaitTimeout(error)) {
throw error
}
throw error
}
})()
const conn =
entry.connectionId && desktop.getConnectionFor
? await withTimeout(
desktop.getConnectionFor({
connectionId: entry.connectionId,
profile: entry.profile,
...dialPriority(spawnPriority)
}),
RECONNECT_ATTEMPT_TIMEOUT_MS,
`Timed out connecting to profile "${entry.profile}"`
)
: await withTimeout(
dialProfile(desktop, entry.profile, spawnPriority),
RECONNECT_ATTEMPT_TIMEOUT_MS,
`Timed out connecting to profile "${entry.profile}"`
)
entry.connection = conn
@@ -802,7 +794,7 @@ function createSecondary(profile: string, connectionId: null | string = null): S
// the second dial fails (tunnel/token are per-backend) and the closed socket
// poisons the active gateway with "not connected" even though the primary is
// open right next to it.
async function sharedPrimaryRoute(profile: string): Promise<boolean> {
async function sharedPrimaryRoute(profile: string, spawnPriority: SpawnPriority = 'background'): Promise<boolean> {
const desktop = window.hermesDesktop
if (!desktop) {
@@ -814,8 +806,11 @@ async function sharedPrimaryRoute(profile: string): Promise<boolean> {
// like any other failure, not hang the route decision forever, since
// every caller (gatewayForProfile → requestGatewayForProfile/Agent) awaits
// this before it can fall back to dialing a secondary.
// This is the FIRST dial main sees for a user open, so it must already
// carry the foreground priority — otherwise the spawn it starts queues as
// background and the click waits out this probe before being promoted.
const conn = await withTimeout(
desktop.getConnection(profile),
dialProfile(desktop, profile, spawnPriority),
RECONNECT_ATTEMPT_TIMEOUT_MS,
`Timed out resolving the shared-primary route for profile "${profile}"`
)
@@ -841,7 +836,7 @@ async function gatewayForProfile(
return { gateway: g.primaryGateway, key, release: noRelease, scopeProfile: false }
}
if (await sharedPrimaryRoute(key)) {
if (await sharedPrimaryRoute(key, spawnPriority)) {
return { gateway: g.primaryGateway, key, release: noRelease, scopeProfile: true }
}
@@ -1386,7 +1381,7 @@ export async function openGatewayForAgent(
return openGatewayForProfile(profile, { spawnPriority })
}
if (await isAttachedSharedRemote(connectionId, profile)) {
if (await isAttachedSharedRemote(connectionId, profile, spawnPriority)) {
if (!isOpen(g.primaryGateway)) {
throw new Error('Hermes gateway unavailable')
}
@@ -1440,7 +1435,7 @@ export async function ensureGatewayForAgent(
return !signal?.aborted
}
if (await isAttachedSharedRemote(connectionId, profile)) {
if (await isAttachedSharedRemote(connectionId, profile, 'foreground')) {
return Boolean(isOpen(g.primaryGateway) && !signal?.aborted)
}
@@ -1522,7 +1517,7 @@ export async function ensureGatewayForProfile(profile: string): Promise<void> {
// primary instead of dialing a doomed duplicate socket at the same
// descriptor — $activeGatewayProfile still moves to `key`, so request
// scoping and profile-aware surfaces behave identically.
if (await sharedPrimaryRoute(key)) {
if (await sharedPrimaryRoute(key, 'foreground')) {
applyActive(g.primaryProfile, activationEpoch)
return