Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 101 additions & 32 deletions apps/desktop/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/electron/preload.ts
Original file line number Diff line number Diff line change
@@ -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),
Expand Down
82 changes: 82 additions & 0 deletions apps/desktop/electron/profile-backend-startup.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>(resolve => {
releaseFirst = resolve
})

let markFirstStarted: () => void

const firstStarted = new Promise<void>(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')
})
61 changes: 61 additions & 0 deletions apps/desktop/electron/profile-backend-startup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
export type ProfileBackendStartReason = 'primary_boot' | 'profile_activate' | 'background_session' | 'unknown'

const START_REASONS = new Set<ProfileBackendStartReason>([
'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<void> = Promise.resolve()

return {
run<T>(task: () => Promise<T>): Promise<T> {
const previous = tail
let release: () => void

tail = new Promise<void>(resolve => {
release = resolve
})

return previous
.catch(() => undefined)
.then(task)
.finally(release!)
}
}
}

export interface ReusablePoolConnection<T> {
connectionPromise: Promise<T>
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<T>(
entry: ReusablePoolConnection<T> | undefined,
now = Date.now()
): Promise<T> | null {
if (!entry) {
return null
}

entry.lastActiveAt = now

return entry.connectionPromise
}
7 changes: 0 additions & 7 deletions apps/desktop/src/app/chat/composer/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,12 @@ export interface ContextSuggestion {
meta?: string
}

export interface QuickModelOption {
provider: string
providerName: string
model: string
}

export interface ChatBarState {
model: {
model: string
provider: string
canSwitch: boolean
loading?: boolean
quickModels?: QuickModelOption[]
/** Reused status-bar dropdown (built with gateway + selectModel upstream). */
modelMenuContent?: ReactNode
}
Expand Down
Loading