perf(desktop): pre-warm opens the gateway socket too, not just the spawn

Answering the review question on the PR table — why a hovered-cold
switch still showed ~440ms click → WS open: getConnection-only
pre-warming left the WS connect chain to the click, and its microtask
continuation can only run after the click's fresh-draft React flush
(unmounting a large open transcript costs ~300-400ms of render work),
so the socket didn't even START connecting until the flush finished.

Add openGatewayForProfile: the same spawn + connect chain as a real
switch, minus activation — so the hover leaves the profile's socket
fully OPEN and the click's ensureGatewayForProfile just activates it
(no ws:new after the click at all; measured ws open at hover+136ms on
a warm backend). No scheduleReconnect on failure: a hover is
speculative, so a dead backend must not start a background retry loop
— the real switch owns retry and error UX. Pruning semantics are
unchanged: a hover-opened socket for an idle profile is dropped by the
next pruneSecondaryGateways recompute, which just returns the click to
the previous behavior.

Tests updated: pre-warm asserts openGatewayForProfile is called and
that activation (ensureGatewayForProfile) is NOT.
This commit is contained in:
Brooklyn Nicholson 2026-07-17 13:11:05 -04:00
parent e0390c0f70
commit 81a140266f
3 changed files with 41 additions and 19 deletions

View file

@ -181,6 +181,27 @@ function createSecondary(profile: string): Secondary {
return entry
}
// Open `profile`'s socket WITHOUT making it active — the hover-intent pre-warm
// (store/profile). Runs the same spawn + connect chain as a real switch, so by
// click time ensureGatewayForProfile finds an open socket and just activates
// it. No scheduleReconnect on failure: a hover is speculative, so a dead
// backend must not start a background retry loop — the real switch owns retry
// and error UX. An already-open (or primary) profile is a no-op.
export async function openGatewayForProfile(profile: string): Promise<void> {
const key = normKey(profile)
if (key === primaryProfile) {
return
}
const entry = secondaries.get(key) ?? createSecondary(key)
entry.wantOpen = true
if (!isOpen(entry.gateway)) {
await openSecondary(entry)
}
}
// Make `profile` the active gateway, lazily opening its socket if needed. The
// primary is a no-op fast path. Background sockets are never closed here.
export async function ensureGatewayForProfile(profile: string): Promise<void> {

View file

@ -7,10 +7,11 @@ import type { ProfileInfo } from '@/types/hermes'
// Keep profile.ts's side-effecting imports inert: the gateway socket layer and
// the REST query client must not run for real in a unit test.
const ensureGatewayForProfile = vi.fn(async () => undefined)
const openGatewayForProfile = vi.fn(async (_profile: string) => undefined)
const $gateway = atom<unknown>({ id: 'live-socket' })
const resetStarmapGraph = vi.fn()
vi.mock('@/store/gateway', () => ({ $gateway, ensureGatewayForProfile }))
vi.mock('@/store/gateway', () => ({ $gateway, ensureGatewayForProfile, openGatewayForProfile }))
vi.mock('@/hermes', () => ({
getProfiles: vi.fn(async () => ({ profiles: [] })),
setApiRequestProfile: vi.fn()
@ -46,6 +47,7 @@ const getConnection = vi.fn<(profile?: string | null) => Promise<HermesConnectio
beforeEach(() => {
getConnection.mockReset()
ensureGatewayForProfile.mockClear()
openGatewayForProfile.mockClear()
$gateway.set({ id: 'live-socket' })
$activeGatewayProfile.set('default')
$connection.set(localConn())
@ -118,12 +120,12 @@ describe('profile-scoped cache invalidation', () => {
})
describe('prewarmProfileBackend (hover-intent pool spawn)', () => {
it('kicks getConnection for a non-active profile', () => {
getConnection.mockResolvedValue(localConn())
it('opens the gateway (spawn + connect, no activation) for a non-active profile', () => {
prewarmProfileBackend('warm-basic')
expect(getConnection).toHaveBeenCalledWith('warm-basic')
expect(openGatewayForProfile).toHaveBeenCalledWith('warm-basic')
// Pre-warm must never activate — that's the click's job.
expect(ensureGatewayForProfile).not.toHaveBeenCalled()
})
it('skips the profile the gateway is already on', () => {
@ -131,23 +133,21 @@ describe('prewarmProfileBackend (hover-intent pool spawn)', () => {
prewarmProfileBackend('warm-active')
expect(getConnection).not.toHaveBeenCalled()
expect(openGatewayForProfile).not.toHaveBeenCalled()
})
it('throttles repeat pre-warms for the same profile within the interval', () => {
getConnection.mockResolvedValue(localConn())
prewarmProfileBackend('warm-throttle-a')
prewarmProfileBackend('warm-throttle-a')
prewarmProfileBackend('warm-throttle-b')
const calls = getConnection.mock.calls.map(([name]) => name)
const calls = openGatewayForProfile.mock.calls.map(([name]) => name)
expect(calls.filter(name => name === 'warm-throttle-a')).toHaveLength(1)
expect(calls.filter(name => name === 'warm-throttle-b')).toHaveLength(1)
})
it('swallows spawn failures — error UX belongs to the real switch', () => {
getConnection.mockRejectedValue(new Error('spawn failed'))
openGatewayForProfile.mockRejectedValueOnce(new Error('spawn failed'))
expect(() => prewarmProfileBackend('warm-failing')).not.toThrow()
})

View file

@ -11,7 +11,7 @@ import {
storedStringArray,
storedStringRecord
} from '@/lib/storage'
import { $gateway, ensureGatewayForProfile } from '@/store/gateway'
import { $gateway, ensureGatewayForProfile, openGatewayForProfile } from '@/store/gateway'
import { setConnection } from '@/store/session'
import { resetStarmapGraph } from '@/store/starmap'
import type { ProfileInfo } from '@/types/hermes'
@ -192,14 +192,15 @@ export const $gatewaySwapTarget = atom<string | null>(null)
// ── Hover-intent backend pre-warm ───────────────────────────────────────────
// A cold switch to a profile whose pool backend isn't running pays the full
// spawn (Python boot + port announce + readiness probe — measured ~2.5-3s)
// before its gateway can even open. The pointer entering a profile square in
// the rail signals the switch a few hundred ms before the click lands, so we
// start the spawn then. `getConnection` → `ensureBackend` in the Electron main
// is idempotent (a pooled profile returns its existing connectionPromise), so
// the real switch's getConnection joins the in-flight spawn instead of
// starting it — and a pre-warm for an already-live backend is a cheap no-op.
// plus the socket connect before the sidebar can repopulate. The pointer
// entering a profile square in the rail signals the switch a few hundred ms
// before the click lands, so we run the same spawn + connect chain then
// (openGatewayForProfile — without activating). `ensureBackend` in the
// Electron main is idempotent (a pooled profile returns its existing
// connectionPromise), so the real switch joins the in-flight work instead of
// duplicating it — and a pre-warm for an already-open profile is a no-op.
// Throttled per profile so drive-by hovers can't spam spawn attempts; failures
// stay silent here and surface on the real switch, which owns error UX.
// stay silent here and surface on the real switch, which owns retry/error UX.
const PREWARM_MIN_INTERVAL_MS = 60_000
const prewarmedAt = new Map<string, number>()
@ -218,7 +219,7 @@ export function prewarmProfileBackend(name: string): void {
}
prewarmedAt.set(key, now)
window.hermesDesktop?.getConnection?.(key).catch(() => undefined)
openGatewayForProfile(key).catch(() => undefined)
}
let gatewaySwitch: Promise<void> | null = null