From 0409d510792e2356b926f4fe48ef2ce50e26d48c Mon Sep 17 00:00:00 2001 From: Hao Wang Date: Wed, 22 Jul 2026 21:25:47 +0800 Subject: [PATCH 1/4] fix(desktop): recover transient client lookup races --- apps/desktop/src/app/contrib/wiring.tsx | 14 +- .../src/components/error-boundary.test.tsx | 265 ++++++++++++++++++ .../desktop/src/components/error-boundary.tsx | 147 ++++++++++ 3 files changed, 424 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/components/error-boundary.test.tsx diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index b2d0c03a98816..3fc9cf9bf0aa9 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -16,6 +16,7 @@ import { useLocation, useNavigate } from 'react-router-dom' import { formatRefValue } from '@/components/assistant-ui/directive-text' import { BootFailureOverlay } from '@/components/boot-failure-overlay' import { DesktopInstallOverlay } from '@/components/desktop-install-overlay' +import { ErrorBoundary, ScopedErrorFallback } from '@/components/error-boundary' import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overlay' import { NotificationStack } from '@/components/notifications' import { DesktopOnboardingOverlay } from '@/components/onboarding' @@ -823,8 +824,17 @@ export function ContribWiring({ children }: { children: ReactNode }) { // The voice cap changes only on config load; the gateway instance + all // chat reactivity are subscribed inside ChatRoutesSurface / ChatView. const chatRoutesNode = useMemo( - () => , - [actions, voiceMaxRecordingSeconds] + () => ( + + + + ), + [actions, activeSessionId, gatewayState, profileScope, routeToken, voiceMaxRecordingSeconds] ) const api = useMemo( diff --git a/apps/desktop/src/components/error-boundary.test.tsx b/apps/desktop/src/components/error-boundary.test.tsx new file mode 100644 index 0000000000000..5b2dabf670a28 --- /dev/null +++ b/apps/desktop/src/components/error-boundary.test.tsx @@ -0,0 +1,265 @@ +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { StrictMode } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { ErrorBoundary } from './error-boundary' + +const USE_LOOKUP_ERROR = new Error('useClientLookup: Index 0 out of bounds (length: 0)') +const TAP_LOOKUP_ERROR = new Error('tapClientLookup: Index 0 out of bounds (length: 0)') + +function makeBomb(box: { error: Error | null }) { + return function Bomb() { + if (box.error) { + throw box.error + } + + return
recovered
+ } +} + +function Fallback({ reset }: { reset: () => void }) { + return +} + +function countLogCalls(spy: ReturnType, text: string) { + return spy.mock.calls.filter((call: unknown[]) => call.some((value: unknown) => String(value).includes(text))).length +} + +describe('ErrorBoundary client lookup recovery', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.spyOn(console, 'error').mockImplementation(() => undefined) + }) + + afterEach(() => { + cleanup() + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it.each([USE_LOOKUP_ERROR, TAP_LOOKUP_ERROR])('recovers exact root lookup errors after the first delay', error => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const recoveredSpy = vi.spyOn(console, 'info').mockImplementation(() => undefined) + const box: { error: Error | null } = { error } + const Bomb = makeBomb(box) + + render( + + + + ) + + expect(countLogCalls(warnSpy, 'client lookup recovery attempt 1/3')).toBe(1) + act(() => vi.advanceTimersByTime(249)) + expect(screen.getByRole('button', { name: 'manual reset' })).toBeTruthy() + + box.error = null + act(() => vi.advanceTimersByTime(1)) + + expect(screen.getByText('recovered')).toBeTruthy() + expect(countLogCalls(recoveredSpy, 'client lookup recovery recovered after attempt 1')).toBe(1) + }) + + it('waits for a later retry when the client store recovers slowly', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const box: { error: Error | null } = { error: USE_LOOKUP_ERROR } + const Bomb = makeBomb(box) + + render( + + + + ) + + act(() => vi.advanceTimersByTime(250)) + expect(countLogCalls(warnSpy, 'client lookup recovery attempt')).toBe(2) + + box.error = null + act(() => vi.advanceTimersByTime(999)) + expect(screen.getByRole('button', { name: 'manual reset' })).toBeTruthy() + + act(() => vi.advanceTimersByTime(1)) + expect(screen.getByText('recovered')).toBeTruthy() + }) + + it('stops after three persistent errors and reports exhaustion once', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const Bomb = makeBomb({ error: USE_LOOKUP_ERROR }) + + render( + + + + ) + + act(() => vi.advanceTimersByTime(250)) + act(() => vi.advanceTimersByTime(1_000)) + act(() => vi.advanceTimersByTime(3_000)) + + expect(countLogCalls(warnSpy, 'client lookup recovery attempt')).toBe(3) + expect(countLogCalls(warnSpy, 'client lookup recovery exhausted')).toBe(1) + expect(screen.getByRole('button', { name: 'manual reset' })).toBeTruthy() + expect(vi.getTimerCount()).toBe(0) + }) + + it('does not restore the retry budget until a recovery remains stable for 30 seconds', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const box: { error: Error | null } = { error: USE_LOOKUP_ERROR } + const Bomb = makeBomb(box) + + const view = render( + + + + ) + + box.error = null + act(() => vi.advanceTimersByTime(250)) + expect(screen.getByText('recovered')).toBeTruthy() + + act(() => vi.advanceTimersByTime(29_999)) + box.error = USE_LOOKUP_ERROR + view.rerender( + + + + ) + expect(countLogCalls(warnSpy, 'client lookup recovery attempt 2/3')).toBe(1) + + box.error = null + act(() => vi.advanceTimersByTime(1_000)) + act(() => vi.advanceTimersByTime(30_000)) + + box.error = USE_LOOKUP_ERROR + view.rerender( + + + + ) + expect(countLogCalls(warnSpy, 'client lookup recovery attempt 1/3')).toBe(2) + }) + + it('clears the retry budget when the user manually resets the boundary', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const box: { error: Error | null } = { error: USE_LOOKUP_ERROR } + const Bomb = makeBomb(box) + + const view = render( + + + + ) + + act(() => vi.advanceTimersByTime(250)) + act(() => vi.advanceTimersByTime(1_000)) + act(() => vi.advanceTimersByTime(3_000)) + box.error = null + fireEvent.click(screen.getByRole('button', { name: 'manual reset' })) + expect(screen.getByText('recovered')).toBeTruthy() + + box.error = USE_LOOKUP_ERROR + view.rerender( + + + + ) + expect(countLogCalls(warnSpy, 'client lookup recovery attempt 1/3')).toBe(2) + }) + + it('clears pending timers when unmounted', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const Bomb = makeBomb({ error: USE_LOOKUP_ERROR }) + + const { unmount } = render( + + + + ) + + unmount() + act(() => vi.runAllTimers()) + + expect(vi.getTimerCount()).toBe(0) + expect(countLogCalls(warnSpy, 'client lookup recovery attempt')).toBe(1) + }) + + it('does not double-schedule recovery in StrictMode', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const Bomb = makeBomb({ error: USE_LOOKUP_ERROR }) + + render( + + + + + + ) + + expect(countLogCalls(warnSpy, 'client lookup recovery attempt 1/3')).toBe(1) + expect(vi.getTimerCount()).toBe(1) + }) + + it('does not auto-recover unrelated root errors', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const UnrelatedBomb = makeBomb({ error: new Error('tapClientResource: Index 0 out of bounds (length: 0)') }) + + render( + + + + ) + + expect(vi.getTimerCount()).toBe(0) + expect(warnSpy).not.toHaveBeenCalled() + }) + + it('does not auto-recover lookup errors outside the root boundary', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const ScopedBomb = makeBomb({ error: USE_LOOKUP_ERROR }) + + render( + + + + ) + + expect(vi.getTimerCount()).toBe(0) + expect(warnSpy).not.toHaveBeenCalled() + }) + + it('auto-recovers lookup errors in an explicitly enabled scoped boundary', () => { + const box: { error: Error | null } = { error: USE_LOOKUP_ERROR } + const ScopedBomb = makeBomb(box) + + render( + + + + ) + + box.error = null + act(() => vi.advanceTimersByTime(250)) + + expect(screen.getByText('recovered')).toBeTruthy() + }) + + it('resets a scoped failure when its connection identity changes', () => { + const box: { error: Error | null } = { error: new Error('render failed') } + const ScopedBomb = makeBomb(box) + + const view = render( + + + + ) + + box.error = null + view.rerender( + + + + ) + + expect(screen.getByText('recovered')).toBeTruthy() + }) +}) diff --git a/apps/desktop/src/components/error-boundary.tsx b/apps/desktop/src/components/error-boundary.tsx index 87b6b7743c511..5c5dd61f9e5f3 100644 --- a/apps/desktop/src/components/error-boundary.tsx +++ b/apps/desktop/src/components/error-boundary.tsx @@ -14,29 +14,147 @@ interface ErrorBoundaryProps { fallback?: (props: ErrorBoundaryFallbackProps) => ReactNode label?: string onError?: (error: Error, info: ErrorInfo) => void + recoverClientLookup?: boolean + resetKeys?: readonly unknown[] } interface ErrorBoundaryState { error: Error | null } +const CLIENT_LOOKUP_ERROR = /^(?:tap|use)ClientLookup: Index \d+\s+out of bounds \(length:\s*\d+\)$/i +const AUTO_RECOVERY_DELAYS_MS = [250, 1_000, 3_000] as const +const STABLE_RECOVERY_WINDOW_MS = 30_000 + +const isTransientClientLookupError = (error: Error): boolean => CLIENT_LOOKUP_ERROR.test(error.message) + export class ErrorBoundary extends Component { state: ErrorBoundaryState = { error: null } + private autoRecoveryCount = 0 + private autoRecoveryExhausted = false + private autoRecoveryTimer: number | null = null + private stableRecoveryTimer: number | null = null + private pendingAutoRecoveryAttempt: number | null = null static getDerivedStateFromError(error: Error): ErrorBoundaryState { return { error } } + componentDidMount() { + // React StrictMode simulates an unmount/remount cycle while preserving the + // component instance. Restore a pending retry that the simulated unmount + // cleaned up without consuming another attempt from the episode budget. + if (this.autoRecoveryTimer === null && this.pendingAutoRecoveryAttempt !== null) { + this.scheduleAutoRecovery(this.pendingAutoRecoveryAttempt) + } + } + componentDidCatch(error: Error, info: ErrorInfo) { const tag = this.props.label ? `[error-boundary:${this.props.label}]` : '[error-boundary]' console.error(tag, error, info.componentStack) this.props.onError?.(error, info) + + this.clearStableRecoveryTimer() + + const recoveryEnabled = this.props.recoverClientLookup ?? this.props.label === 'root' + + if (!recoveryEnabled || !isTransientClientLookupError(error)) { + return + } + + const attempt = this.takeAutoRecoveryAttempt() + + if (attempt === null) { + if (!this.autoRecoveryExhausted) { + this.autoRecoveryExhausted = true + console.warn(`${tag} client lookup recovery exhausted`) + } + + return + } + + console.warn(`${tag} client lookup recovery attempt ${attempt}/${AUTO_RECOVERY_DELAYS_MS.length}`) + this.scheduleAutoRecovery(attempt) + } + + componentDidUpdate(previousProps: ErrorBoundaryProps) { + if (this.state.error && resetKeysChanged(previousProps.resetKeys, this.props.resetKeys)) { + this.reset() + } + } + + componentWillUnmount() { + this.clearAutoRecoveryTimer() + this.clearStableRecoveryTimer() } reset = () => { + this.clearAutoRecoveryTimer() + this.clearStableRecoveryTimer() + this.autoRecoveryCount = 0 + this.autoRecoveryExhausted = false + this.pendingAutoRecoveryAttempt = null this.setState({ error: null }) } + private takeAutoRecoveryAttempt(): number | null { + if (this.autoRecoveryCount >= AUTO_RECOVERY_DELAYS_MS.length) { + return null + } + + this.autoRecoveryCount += 1 + + return this.autoRecoveryCount + } + + private scheduleAutoRecovery(attempt: number) { + this.clearAutoRecoveryTimer() + this.pendingAutoRecoveryAttempt = attempt + this.autoRecoveryTimer = window.setTimeout(this.autoRecover, AUTO_RECOVERY_DELAYS_MS[attempt - 1]) + } + + private autoRecover = () => { + this.autoRecoveryTimer = null + const attempt = this.pendingAutoRecoveryAttempt + this.pendingAutoRecoveryAttempt = null + + this.setState({ error: null }, () => { + if (this.state.error !== null || attempt === null) { + return + } + + const tag = this.props.label ? `[error-boundary:${this.props.label}]` : '[error-boundary]' + console.info(`${tag} client lookup recovery recovered after attempt ${attempt}`) + this.scheduleStableRecoveryReset() + }) + } + + private scheduleStableRecoveryReset() { + this.clearStableRecoveryTimer() + this.stableRecoveryTimer = window.setTimeout(() => { + this.stableRecoveryTimer = null + + if (this.state.error === null) { + this.autoRecoveryCount = 0 + this.autoRecoveryExhausted = false + } + }, STABLE_RECOVERY_WINDOW_MS) + } + + private clearAutoRecoveryTimer() { + if (this.autoRecoveryTimer !== null) { + window.clearTimeout(this.autoRecoveryTimer) + this.autoRecoveryTimer = null + } + } + + private clearStableRecoveryTimer() { + if (this.stableRecoveryTimer !== null) { + window.clearTimeout(this.stableRecoveryTimer) + this.stableRecoveryTimer = null + } + } + render() { const { error } = this.state @@ -75,3 +193,32 @@ function RootErrorFallback({ error, reset }: ErrorBoundaryFallbackProps) { ) } + +export function ScopedErrorFallback({ error, reset }: ErrorBoundaryFallbackProps) { + const { t } = useI18n() + + return ( +
+ + + + +
+ ) +} + +function resetKeysChanged(previous: readonly unknown[] | undefined, next: readonly unknown[] | undefined): boolean { + if (!previous || !next || previous.length !== next.length) { + return Boolean(previous || next) + } + + return previous.some((value, index) => !Object.is(value, next[index])) +} From 74c99e182c6e25119eb20197f61dd55f5a391854 Mon Sep 17 00:00:00 2001 From: Hao Wang Date: Wed, 22 Jul 2026 21:26:01 +0800 Subject: [PATCH 2/4] perf(desktop): serialize profile backend cold starts --- apps/desktop/electron/main.ts | 133 +++++++++++++----- apps/desktop/electron/preload.ts | 2 +- .../electron/profile-backend-startup.test.ts | 82 +++++++++++ .../electron/profile-backend-startup.ts | 61 ++++++++ .../src/app/chat/sidebar/profile-switcher.tsx | 16 +-- .../src/app/chat/sidebar/session-row.test.tsx | 30 +++- .../src/app/chat/sidebar/session-row.tsx | 8 -- .../app/chat/sidebar/use-profile-prewarm.ts | 38 ----- apps/desktop/src/global.d.ts | 4 +- apps/desktop/src/store/gateway.ts | 29 +--- apps/desktop/src/store/profile.test.ts | 44 ++---- apps/desktop/src/store/profile.ts | 37 +---- 12 files changed, 289 insertions(+), 195 deletions(-) create mode 100644 apps/desktop/electron/profile-backend-startup.test.ts create mode 100644 apps/desktop/electron/profile-backend-startup.ts delete mode 100644 apps/desktop/src/app/chat/sidebar/use-profile-prewarm.ts diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 7bf789ccf118d..9ea84d327a278 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -131,6 +131,11 @@ import { createLinkTitleWindow, guardLinkTitleSession, readLinkTitleWindowTitle import { ensureMainWindow } from './main-window-lifecycle' import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request' import { createKeepAwake } from './power-save' +import { + createProfileBackendStartupQueue, + normalizeProfileBackendStartReason, + reusePoolConnection +} from './profile-backend-startup' import { decideProfileDeleteAction, profileNameFromDeleteRequest, resolveRouteProfile } from './profile-delete-routing' import * as remoteLifecycle from './remote-lifecycle' import { RemoteLivenessTracker, RemoteRevalidationCoordinator, revalidateRemoteConnection } from './remote-liveness' @@ -988,6 +993,10 @@ let softRehomeInProgress = false // with no named profiles never populates this map, so their experience is // byte-for-byte the single-backend behavior. const backendPool = new Map() // profile -> { process, port, token, connectionPromise, lastActiveAt } +// A cold local profile boot performs the same Python import, plugin/MCP setup, +// and readiness work as the primary backend. Let only one profile do that work +// at a time; existing pool promises and remote profiles do not wait here. +const profileBackendStartupQueue = createProfileBackendStartupQueue() // Keep the pool light: cap concurrent profile backends (LRU eviction) and reap // idle ones. A user idles at exactly the primary backend; pool backends only // exist while a non-primary profile is actively being chatted through. @@ -7471,26 +7480,48 @@ function primaryProfileKey() { // profile to startHermes() (the window backend: boot UI, bootstrap, remote // mode), and any OTHER profile to a lazily-spawned pool backend. An empty / // unknown profile resolves to the primary, so all legacy callers are unchanged. -async function ensureBackend(profile) { +async function ensureBackend(profile, requestedReason = 'unknown') { const key = profile && String(profile).trim() ? String(profile).trim() : primaryProfileKey() + const reason = normalizeProfileBackendStartReason(requestedReason) if (key === primaryProfileKey()) { - return startHermes() - } + if (backendConnectionState.getPromise()) { + return startHermes() + } - const existing = backendPool.get(key) + const startedAt = Date.now() + const primaryReason = reason === 'unknown' ? 'primary_boot' : reason + rememberLog(`Starting primary Hermes backend (reason ${primaryReason})`) + + try { + const connection = await startHermes() + rememberLog( + `Primary Hermes backend ready (reason ${primaryReason}, mode ${connection.mode}, duration ${Date.now() - startedAt}ms)` + ) + + return connection + } catch (error) { + rememberLog( + `Primary Hermes backend failed (reason ${primaryReason}, duration ${Date.now() - startedAt}ms): ${error instanceof Error ? error.message : String(error)}` + ) + throw error + } + } - if (existing) { - existing.lastActiveAt = Date.now() + const existingConnection = reusePoolConnection(backendPool.get(key)) - return existing.connectionPromise + if (existingConnection) { + return existingConnection } evictLruPoolBackends(POOL_MAX_BACKENDS - 1) const entry = { process: null, port: null, token: null, connectionPromise: null, lastActiveAt: Date.now() } - entry.connectionPromise = spawnPoolBackend(key, entry).catch(error => { - backendPool.delete(key) + entry.connectionPromise = startPoolBackend(key, entry, reason).catch(error => { + if (backendPool.get(key) === entry) { + backendPool.delete(key) + } + throw error }) backendPool.set(key, entry) @@ -7499,6 +7530,63 @@ async function ensureBackend(profile) { return entry.connectionPromise } +async function startPoolBackend(profile, entry, reason) { + const requestedAt = Date.now() + const remote = await resolveRemoteBackend(profile) + + if (remote) { + rememberLog(`Connecting to remote Hermes backend for profile "${profile}" (reason ${reason})`) + + try { + await waitForHermes(remote.baseUrl, remote.token) + } catch (error) { + rememberLog( + `Remote Hermes backend failed for profile "${profile}" (reason ${reason}, duration ${Date.now() - requestedAt}ms): ${error instanceof Error ? error.message : String(error)}` + ) + throw error + } + + rememberLog( + `Remote Hermes backend ready for profile "${profile}" (reason ${reason}, duration ${Date.now() - requestedAt}ms)` + ) + + return { + ...remote, + profile, + logs: hermesLog.slice(-80), + ...getWindowState() + } + } + + rememberLog(`Queueing local Hermes backend for profile "${profile}" (reason ${reason})`) + + return profileBackendStartupQueue.run(async () => { + if (backendPool.get(profile) !== entry) { + throw new Error(`Hermes backend for profile "${profile}" was removed before startup.`) + } + + const spawnStartedAt = Date.now() + const queueWaitMs = spawnStartedAt - requestedAt + rememberLog( + `Starting queued local Hermes backend for profile "${profile}" (reason ${reason}, queue wait ${queueWaitMs}ms)` + ) + + try { + const connection = await spawnPoolBackend(profile, entry, reason) + rememberLog( + `Local Hermes backend ready for profile "${profile}" (reason ${reason}, queue wait ${queueWaitMs}ms, spawn ${Date.now() - spawnStartedAt}ms, total ${Date.now() - requestedAt}ms)` + ) + + return connection + } catch (error) { + rememberLog( + `Local Hermes backend failed for profile "${profile}" (reason ${reason}, queue wait ${queueWaitMs}ms, spawn ${Date.now() - spawnStartedAt}ms): ${error instanceof Error ? error.message : String(error)}` + ) + throw error + } + }) +} + // Mark a pool profile as recently used so the idle reaper spares it. The // renderer calls this when it opens a profile's chat WS and periodically while // streaming, since the main process can't see the direct renderer↔backend WS. @@ -7573,26 +7661,7 @@ function startPoolIdleReaper() { // Spawn an additional dashboard backend pinned to a named profile. Mirrors the // local-spawn portion of startHermes() but without the boot-progress UI, // bootstrap, or remote handling (those belong to the primary backend only). -async function spawnPoolBackend(profile, entry) { - // A profile may point at its OWN remote backend (connection.json - // `profiles[name]`), or inherit the app-wide remote (env / global settings). - // In either case there is no local child to spawn — we just verify the - // remote is reachable and hand back its connection descriptor. The pool - // entry keeps `entry.process === null`, which stopPoolBackend/evict already - // tolerate. - const remote = await resolveRemoteBackend(profile) - - if (remote) { - await waitForHermes(remote.baseUrl, remote.token) - - return { - ...remote, - profile, - logs: hermesLog.slice(-80), - ...getWindowState() - } - } - +async function spawnPoolBackend(profile, entry, reason) { const token = crypto.randomBytes(32).toString('base64url') // --profile wins over the inherited HERMES_HOME env (see _apply_profile_override // step 3 in hermes_cli/main.py), so the child re-homes to this profile. @@ -7605,7 +7674,7 @@ async function spawnPoolBackend(profile, entry) { const webDist = resolveWebDist() const readyFile = backend.readyFile ? makeDashboardReadyFile() : null - rememberLog(`Starting Hermes backend for profile "${profile}" via ${backend.label}`) + rememberLog(`Starting Hermes backend for profile "${profile}" via ${backend.label} (reason ${reason})`) const child = spawn( backend.command, @@ -8597,7 +8666,7 @@ function createWindow() { // shared (backendConnectionState), so the renderer's getConnection() joins // this in-flight boot instead of duplicating it; early boot-progress events // the renderer misses are recovered by its getBootProgress() pull on mount. - startHermes().catch(error => rememberLog(error.stack || error.message)) + ensureBackend(null, 'primary_boot').catch(error => rememberLog(error.stack || error.message)) mainWindow.webContents.once('did-finish-load', () => { // Zoom restore is handled by wireCommonWindowHandlers (shared with session @@ -8607,7 +8676,7 @@ function createWindow() { }) } -ipcMain.handle('hermes:connection', async (_event, profile) => ensureBackend(profile)) +ipcMain.handle('hermes:connection', async (_event, profile, reason) => ensureBackend(profile, reason)) // Reconnect-after-wake recovery. A REMOTE primary backend has no child process, // so the 'exit'/'error' handlers that would clear a dead connection promise never // fire — once the remote becomes unreachable across a sleep/wake the renderer diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 7652a7688dd25..69dfd137fbc9f 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -1,7 +1,7 @@ import { contextBridge, ipcRenderer, webUtils } from 'electron' contextBridge.exposeInMainWorld('hermesDesktop', { - getConnection: profile => ipcRenderer.invoke('hermes:connection', profile), + getConnection: (profile, reason) => ipcRenderer.invoke('hermes:connection', profile, reason), revalidateConnection: () => ipcRenderer.invoke('hermes:connection:revalidate'), touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile), getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile), diff --git a/apps/desktop/electron/profile-backend-startup.test.ts b/apps/desktop/electron/profile-backend-startup.test.ts new file mode 100644 index 0000000000000..786366effc375 --- /dev/null +++ b/apps/desktop/electron/profile-backend-startup.test.ts @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { + createProfileBackendStartupQueue, + normalizeProfileBackendStartReason, + reusePoolConnection +} from './profile-backend-startup' + +test('serializes concurrent local profile cold starts', async () => { + const queue = createProfileBackendStartupQueue() + let active = 0 + let maximumActive = 0 + let releaseFirst: () => void + + const firstGate = new Promise(resolve => { + releaseFirst = resolve + }) + + let markFirstStarted: () => void + + const firstStarted = new Promise(resolve => { + markFirstStarted = resolve + }) + + const started: string[] = [] + + const first = queue.run(async () => { + active += 1 + maximumActive = Math.max(maximumActive, active) + started.push('first') + markFirstStarted!() + await firstGate + active -= 1 + + return 'first' + }) + + const second = queue.run(async () => { + active += 1 + maximumActive = Math.max(maximumActive, active) + started.push('second') + active -= 1 + + return 'second' + }) + + await firstStarted + assert.deepEqual(started, ['first']) + releaseFirst!() + assert.deepEqual(await Promise.all([first, second]), ['first', 'second']) + assert.deepEqual(started, ['first', 'second']) + assert.equal(maximumActive, 1) +}) + +test('a failed local startup releases the next queued profile', async () => { + const queue = createProfileBackendStartupQueue() + + const first = queue.run(async () => { + throw new Error('profile failed to start') + }) + + const second = queue.run(async () => 'ready') + + await assert.rejects(first, /profile failed to start/) + assert.equal(await second, 'ready') +}) + +test('reuses an existing backend promise immediately while another profile starts', async () => { + const connection = Promise.resolve({ profile: 'already-running' }) + const entry = { connectionPromise: connection, lastActiveAt: 1 } + + assert.equal(reusePoolConnection(entry, 99), connection) + assert.equal(entry.lastActiveAt, 99) +}) + +test('normalizes untrusted renderer start reasons to unknown', () => { + assert.equal(normalizeProfileBackendStartReason('profile_activate'), 'profile_activate') + assert.equal(normalizeProfileBackendStartReason('not-a-reason'), 'unknown') + assert.equal(normalizeProfileBackendStartReason({}), 'unknown') +}) diff --git a/apps/desktop/electron/profile-backend-startup.ts b/apps/desktop/electron/profile-backend-startup.ts new file mode 100644 index 0000000000000..b2543bcbb0442 --- /dev/null +++ b/apps/desktop/electron/profile-backend-startup.ts @@ -0,0 +1,61 @@ +export type ProfileBackendStartReason = 'primary_boot' | 'profile_activate' | 'background_session' | 'unknown' + +const START_REASONS = new Set([ + 'primary_boot', + 'profile_activate', + 'background_session', + 'unknown' +]) + +export function normalizeProfileBackendStartReason(value: unknown): ProfileBackendStartReason { + return typeof value === 'string' && START_REASONS.has(value as ProfileBackendStartReason) + ? (value as ProfileBackendStartReason) + : 'unknown' +} + +/** + * Runs local profile backend startup work one at a time. This intentionally + * does not own remote connections: they do not spawn a local child or compete + * for the machine resources this queue protects. + */ +export function createProfileBackendStartupQueue() { + let tail: Promise = Promise.resolve() + + return { + run(task: () => Promise): Promise { + const previous = tail + let release: () => void + + tail = new Promise(resolve => { + release = resolve + }) + + return previous + .catch(() => undefined) + .then(task) + .finally(release!) + } + } +} + +export interface ReusablePoolConnection { + connectionPromise: Promise + lastActiveAt: number +} + +/** + * Keep an existing pool connection on its current path. In particular, do not + * make it wait behind a different profile's cold start. + */ +export function reusePoolConnection( + entry: ReusablePoolConnection | undefined, + now = Date.now() +): Promise | null { + if (!entry) { + return null + } + + entry.lastActiveAt = now + + return entry.connectionPromise +} diff --git a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx index 52fa8de19c0af..ba23c8ab44c27 100644 --- a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx +++ b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx @@ -66,8 +66,6 @@ import { DeleteProfileDialog } from '../../profiles/delete-profile-dialog' import { RenameProfileDialog } from '../../profiles/rename-profile-dialog' import { PROFILES_ROUTE } from '../../routes' -import { useProfilePrewarm } from './use-profile-prewarm' - const RAIL_GAP = 4 // px — matches gap-1 between squares. // Past this many profiles the strip of colored squares stops scaling (tiny @@ -471,14 +469,11 @@ function ProfileDropdown({ ) } -// One dropdown row per profile — its own component so each row can own a -// hover-intent prewarm timer (see useProfilePrewarm). function ProfileDropdownItem({ color, name }: { color: null | string; name: string }) { const hue = color ?? 'var(--ui-text-quaternary)' - const { cancelPrewarm, startPrewarm } = useProfilePrewarm(name) return ( - +