hermes-agent/apps/desktop/scripts/measure-profile-switch.mjs
Brooklyn Nicholson e0390c0f70 perf(desktop): pre-warm profile pool backends on hover intent
A cold profile switch pays the full pool-backend spawn — Python boot,
port announcement, readiness probe, token adoption — before the
profile's gateway can even open. Measured with the new CDP harness
(scripts/measure-profile-switch.mjs, same family as
profile-session-switch.mjs): click → WS open is ~2.5-2.9s on a cold
profile, ~3-3.6s to a settled sidebar; a warm profile settles in
~0.5-0.8s. The pointer entering a profile square telegraphs the switch
hundreds of ms before the click lands, so start the spawn then.

- store/profile: prewarmProfileBackend(name) — fires the existing
  hermesDesktop.getConnection IPC, which is idempotent (ensureBackend
  returns the pooled connectionPromise), so the real switch joins the
  in-flight spawn instead of starting it. Skips the active gateway
  profile, throttles per profile (60s) so drive-by hovers can't spam
  spawn attempts, and swallows failures — error UX belongs to the real
  switch. No new IPC surface; the pool's existing LRU cap + idle reaper
  still bound resource use, and the LRU guard never evicts a
  keepalive-fresh backend for a hover spawn.
- sidebar/use-profile-prewarm: pointerenter/pointerleave handlers with
  a 120ms dwell so sweeping the pointer across the rail or a
  mixed-profile session list doesn't spawn a backend per element
  crossed.
- Wired at the three switch surfaces: rail ProfileSquare, the condensed
  ProfileDropdown items (extracted ProfileDropdownItem so each row owns
  its dwell timer), and SidebarSessionRow (covers cross-profile resumes
  from the all-profiles view; same-profile rows no-op inside the guard).

Measured E2E over CDP: synthetic hover on a cold profile square spawns
its backend in the background; the subsequent click settles in ~519ms
vs ~3.0-3.6s unhovered — and any hover shorter than the spawn still
shaves its dwell off the click's wait.

Verification: apps/desktop `npx tsc --noEmit` clean; full
`npx vitest run` 212 files / 1777 passed (new prewarm guard/throttle
tests in store/profile.test.ts); eslint + prettier clean.
2026-07-17 10:23:49 -04:00

159 lines
6.4 KiB
JavaScript

// Measure a profile switch end-to-end: click a profile square in the rail,
// then break the wall time into the phases the renderer can observe:
// - getConnection IPC (Electron: pool backend spawn / reuse + readiness)
// - gateway WS connect
// - swap-target clear ($gatewaySwapTarget → sidebar loader gone)
// - sidebar session rows for the new profile painted
//
// Instruments window.hermesDesktop.getConnection + WebSocket to timestamp the
// phases without touching app code.
//
// Usage:
// node apps/desktop/scripts/measure-profile-switch.mjs <profileName> [settleTimeoutMs]
const CDP_HTTP = 'http://127.0.0.1:9222'
const PROFILE = process.argv[2]
const SETTLE_TIMEOUT = Number(process.argv[3] || 60000)
if (!PROFILE) {
console.error('usage: measure-profile-switch.mjs <profileName>')
process.exit(1)
}
class CDP {
constructor(ws) { this.ws = ws; this.id = 0; this.pending = new Map() }
static async open(url) {
const ws = new WebSocket(url)
await new Promise((r) => ws.addEventListener('open', r, { once: true }))
const cdp = new CDP(ws)
ws.addEventListener('message', (ev) => {
const m = JSON.parse(ev.data.toString())
if (m.id != null && cdp.pending.has(m.id)) {
const { resolve, reject } = cdp.pending.get(m.id)
cdp.pending.delete(m.id)
if (m.error) reject(new Error(m.error.message))
else resolve(m.result)
}
})
ws.addEventListener('close', () => {
for (const { reject } of cdp.pending.values()) reject(new Error('CDP socket closed'))
cdp.pending.clear()
})
return cdp
}
send(method, params) {
const id = ++this.id
return new Promise((res, rej) => {
this.pending.set(id, { resolve: res, reject: rej })
this.ws.send(JSON.stringify({ id, method, params }))
})
}
async eval(expr) {
const r = await this.send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true })
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || 'eval failed')
return r.result.value
}
close() { this.ws.close() }
}
async function main() {
const list = await (await fetch(`${CDP_HTTP}/json`)).json()
const target = list.find((t) => t.type === 'page' && /5174/.test(t.url))
if (!target) { console.error('renderer not found on 9222'); process.exit(1) }
const cdp = await CDP.open(target.webSocketDebuggerUrl)
// Instrument getConnection + WebSocket once.
await cdp.eval(`(() => {
if (window.__PROFILE_SWITCH_OBS__) return 'already'
const obs = { events: [] }
const mark = (name, extra) => obs.events.push({ name, t: performance.now(), ...(extra || {}) })
window.__PROFILE_SWITCH_OBS__ = obs
window.__psMark = mark
const desktop = window.hermesDesktop
if (desktop && desktop.getConnection) {
const orig = desktop.getConnection.bind(desktop)
desktop.getConnection = async (profile) => {
mark('getConnection:start', { profile })
try {
const res = await orig(profile)
mark('getConnection:done', { profile })
return res
} catch (e) {
mark('getConnection:error', { profile, error: String(e).slice(0, 120) })
throw e
}
}
}
const OrigWS = window.WebSocket
window.WebSocket = function (url, ...rest) {
const ws = new OrigWS(url, ...rest)
if (String(url).includes('/api/ws')) {
mark('ws:new', { url: String(url).replace(/token=[^&]+/, 'token=…').slice(0, 90) })
ws.addEventListener('open', () => mark('ws:open'))
}
return ws
}
window.WebSocket.prototype = OrigWS.prototype
Object.assign(window.WebSocket, OrigWS)
return 'installed'
})()`)
const before = await cdp.eval(`(() => {
const rail = document.querySelector('[data-slot="profile-rail"]')
return {
railButtons: rail ? [...rail.querySelectorAll('[role="tab"], button')].map(b => (b.getAttribute('aria-label') || b.title || b.textContent || '').slice(0, 30)) : [],
sessions: document.querySelectorAll('[data-slot="sidebar-session-row"], [data-session-id]').length
}
})()`)
console.log('rail buttons:', JSON.stringify(before.railButtons))
const clicked = await cdp.eval(`(() => {
window.__psMark('click', { profile: ${JSON.stringify(PROFILE)} })
const rail = document.querySelector('[data-slot="profile-rail"]')
if (!rail) return 'no-rail'
const target = [...rail.querySelectorAll('button, [role="tab"]')].find(b =>
((b.getAttribute('aria-label') || '') + ' ' + (b.title || '') + ' ' + (b.textContent || '')).toLowerCase().includes(${JSON.stringify(PROFILE.toLowerCase())}))
if (!target) return 'not-found'
target.click()
return 'clicked'
})()`)
console.log('click:', clicked)
if (clicked !== 'clicked') { cdp.close(); process.exit(2) }
// Poll until the swap settles: loader gone + session rows painted (or empty
// list settled) + active profile pill shows the target.
const t0 = Date.now()
let settled = null
while (Date.now() - t0 < SETTLE_TIMEOUT) {
await new Promise((r) => setTimeout(r, 100))
const s = await cdp.eval(`(() => {
// The swap overlay stays mounted at opacity-0 after the swap — check the
// computed opacity of the container that holds the "Waking up …" label.
const label = [...document.querySelectorAll('div[aria-hidden]')].find(el => /waking up/i.test(el.textContent || ''))
const overlayVisible = label ? Number(getComputedStyle(label).opacity) > 0.05 : false
return {
t: performance.now(),
overlayVisible,
sessions: document.querySelectorAll('[data-slot="row-button"]').length
}
})()`)
if (!s.overlayVisible && s.sessions > 0) { settled = s; break }
}
await new Promise((r) => setTimeout(r, 400))
const obs = await cdp.eval('window.__PROFILE_SWITCH_OBS__')
const events = obs.events
const click = events.find((e) => e.name === 'click' && e.profile === PROFILE)
console.log('\n=== PHASES (ms after click) ===')
for (const e of events) {
if (e.t < click.t - 5) continue
console.log(`${(e.t - click.t).toFixed(0).padStart(7)} ${e.name}${e.profile ? ' [' + e.profile + ']' : ''}${e.error ? ' ' + e.error : ''}${e.url ? ' ' + e.url : ''}`)
}
console.log(settled ? `\nsettled (loader gone + rows painted) at ~${Date.now() - t0} ms wall` : '\nTIMEOUT waiting for settle')
cdp.close()
}
main().catch((e) => { console.error(e); process.exit(1) })