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:
parent
e0390c0f70
commit
81a140266f
3 changed files with 41 additions and 19 deletions
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue