From 81a140266f4d75a3bc64deaca60c492c3f27a03c Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 17 Jul 2026 13:11:05 -0400 Subject: [PATCH] perf(desktop): pre-warm opens the gateway socket too, not just the spawn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/desktop/src/store/gateway.ts | 21 +++++++++++++++++++++ apps/desktop/src/store/profile.test.ts | 20 ++++++++++---------- apps/desktop/src/store/profile.ts | 19 ++++++++++--------- 3 files changed, 41 insertions(+), 19 deletions(-) diff --git a/apps/desktop/src/store/gateway.ts b/apps/desktop/src/store/gateway.ts index 04c024c22e4..9b0f037b2ac 100644 --- a/apps/desktop/src/store/gateway.ts +++ b/apps/desktop/src/store/gateway.ts @@ -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 { + 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 { diff --git a/apps/desktop/src/store/profile.test.ts b/apps/desktop/src/store/profile.test.ts index 30f097a45b5..b7489fe0556 100644 --- a/apps/desktop/src/store/profile.test.ts +++ b/apps/desktop/src/store/profile.test.ts @@ -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({ 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 { 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() }) diff --git a/apps/desktop/src/store/profile.ts b/apps/desktop/src/store/profile.ts index e0b1cee1001..1049d315311 100644 --- a/apps/desktop/src/store/profile.ts +++ b/apps/desktop/src/store/profile.ts @@ -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(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() @@ -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 | null = null