hermes-agent/apps/desktop/scripts/profile-typing.mjs
Brooklyn Nicholson bff1b3261d perf(desktop): cut per-keystroke layout + listener churn in chat composer
Empirical work via CDP harnesses under apps/desktop/scripts/ (see
profile-typing-lag.md):

  jsListeners growth (per round of 200 chars + GC):
    before: +35  (verified leak — listeners stuck after 1st trigger popover use)
    after:  +0

Four narrow edits in src/app/chat/composer/index.tsx:

1. Drop the per-keystroke `editorRef.current.scrollHeight` read used to
   decide composer expansion. Replace with `draft.length > 60` heuristic;
   the existing ResizeObserver still catches edge cases. `scrollHeight`
   is a forced-layout call and was firing on every char until the first
   wrap.

2. Bucket measured composer height to 8px before writing
   `--composer-measured-height` / `--composer-surface-measured-height`
   on `documentElement`. Without this, the editor grows ~1px per char,
   setProperty fires every keystroke, computed style is invalidated tree-
   wide.

3. Remove the dead `$composerDraft` two-way sync. Nothing outside the
   composer subscribed to that atom (verified via grep). Two useEffects
   on `[draft]` were pushing draft→atom and atom→aui per keystroke for
   no consumer. Also drop the per-keystroke
   `reconcileComposerTerminalSelections` call; it was pruning stale
   labels for `terminalContextBlocksFromDraft`, but that helper already
   ignores labels not in the current submitted text, so pruning per
   keystroke was just bookkeeping.

4. `refreshTrigger` fast-bails when the draft contains neither `@` nor
   `/`. Previously `textBeforeCaret(editor)` ran on every input/keyup
   regardless; `range.toString()` inside is O(n) over draft length.

Synthetic typing latency p50/p90/p99 is similar before vs after on a
freshly-loaded session (Blink can already handle ~30cps typing into a
contentEditable on its own); the real win is the listener leak being
gone and the global computed-style invalidations dropping ~8× when the
composer is sitting at a fixed height row.

The `Enter → stall` follow-up (see profile-typing-lag.md §"Submit /
TTFT stall") is unmeasured here — needs a throwaway session because
the harness fires a real prompt. Not blocking this commit.
2026-05-21 15:45:01 -05:00

260 lines
9 KiB
JavaScript

#!/usr/bin/env node
// Profile typing lag in the Electron renderer by:
// 1. Connecting to a running renderer via CDP (--remote-debugging-port=9222)
// 2. Focusing the composer contentEditable
// 3. Starting CPU profile + heap snapshot
// 4. Synthesizing keystrokes via Input.dispatchKeyEvent (so the run is
// reproducible, no human-typing variance)
// 5. Stopping the profile + capturing a second heap snapshot
// 6. Saving .cpuprofile + .heapsnapshot
//
// Usage:
// node apps/desktop/scripts/profile-typing.mjs
// [--port=9222] [--out=/tmp/hermes-typing]
// [--chars=400] # how many characters to type
// [--cps=30] # keystrokes per second
// [--text="..."] # override generated text
// [--no-heap] # skip heap snapshots
// [--seconds=N] # idle-record for N seconds instead of typing
//
// Zero deps — uses Node 24's global WebSocket + fetch.
import { writeFileSync } from 'node:fs'
const args = Object.fromEntries(
process.argv.slice(2).flatMap(s => {
const m = s.match(/^--([^=]+)(?:=(.*))?$/)
return m ? [[m[1], m[2] ?? true]] : []
})
)
const PORT = Number(args.port ?? 9222)
const OUT = String(args.out ?? `/tmp/hermes-typing-${Date.now()}`)
const CHARS = Number(args.chars ?? 400)
const CPS = Number(args.cps ?? 30)
const HEAP = args['no-heap'] ? false : true
const IDLE_SECONDS = args.seconds ? Number(args.seconds) : null
const CUSTOM_TEXT = args.text === undefined || args.text === true ? null : String(args.text)
const log = (...m) => console.log('[profile]', ...m)
const banner = m => console.log(`\n========== ${m} ==========`)
async function pickRenderer() {
const list = await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json()
const pages = list.filter(t => t.type === 'page' && t.url.startsWith('http'))
if (!pages.length) {
console.error('No renderer page. Targets:')
list.forEach(t => console.error(' ', t.type, t.url))
process.exit(2)
}
return pages[0]
}
function connect(url) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(url)
let id = 0
const pending = new Map()
const events = new Map()
ws.addEventListener('open', () =>
resolve({
send(method, params = {}) {
const myId = ++id
ws.send(JSON.stringify({ id: myId, method, params }))
return new Promise((res, rej) => pending.set(myId, { res, rej }))
},
on(method, h) {
if (!events.has(method)) events.set(method, [])
events.get(method).push(h)
},
close: () => ws.close()
})
)
ws.addEventListener('error', reject)
ws.addEventListener('message', ev => {
const txt = typeof ev.data === 'string' ? ev.data : ev.data.toString('utf8')
const m = JSON.parse(txt)
if (m.id != null) {
const p = pending.get(m.id)
if (!p) return
pending.delete(m.id)
m.error ? p.rej(new Error(m.error.message)) : p.res(m.result)
} else if (m.method) {
;(events.get(m.method) ?? []).forEach(h => h(m.params))
}
})
})
}
async function captureHeap(cdp, path) {
log(`heap snapshot → ${path}`)
const chunks = []
cdp.on('HeapProfiler.addHeapSnapshotChunk', ({ chunk }) => chunks.push(chunk))
await cdp.send('HeapProfiler.enable')
await cdp.send('HeapProfiler.takeHeapSnapshot', { reportProgress: false, captureNumericValue: true })
writeFileSync(path, chunks.join(''))
log(` ${(Buffer.byteLength(chunks.join(''), 'utf8') / 1024 / 1024).toFixed(1)} MB`)
}
async function focusComposer(cdp) {
// Focus the rich-input contentEditable. RICH_INPUT_SLOT is the data-slot
// value used by the composer's editable div. If focus fails (no composer
// mounted yet — disabled state, etc.) the script logs and continues; the
// profile will still show idle behavior.
const result = await cdp.send('Runtime.evaluate', {
expression: `
(() => {
const el = document.querySelector('[data-slot="composer-rich-input"]')
if (!el) return { ok: false, reason: 'composer-rich-input not found' }
el.focus()
// place caret at end
const range = document.createRange()
range.selectNodeContents(el)
range.collapse(false)
const sel = window.getSelection()
sel.removeAllRanges()
sel.addRange(range)
return { ok: true, text: el.innerText.length }
})()
`,
returnByValue: true
})
if (!result.result.value?.ok) {
log(`focus failed: ${result.result.value?.reason ?? 'unknown'}`)
return false
}
log(`composer focused (existing text length: ${result.result.value.text})`)
return true
}
function genText(n) {
const lorem =
'the quick brown fox jumps over the lazy dog while the agent thinks really hard about why typing into this composer feels like wading through molasses on a hot afternoon '
let s = ''
while (s.length < n) s += lorem
return s.slice(0, n)
}
async function dispatchChar(cdp, ch) {
// For printable chars, char + keypress is enough — Electron treats it as text input
// and the contentEditable input event fires. For Enter / Space we could add
// specials; this run is one long line.
await cdp.send('Input.dispatchKeyEvent', {
type: 'char',
text: ch,
unmodifiedText: ch
})
}
async function typeText(cdp, text, cps) {
const intervalMs = Math.max(1, Math.round(1000 / cps))
const start = Date.now()
for (let i = 0; i < text.length; i++) {
await dispatchChar(cdp, text[i])
// Pace evenly; account for dispatch latency so we don't drift much.
const expected = start + (i + 1) * intervalMs
const wait = expected - Date.now()
if (wait > 0) await new Promise(r => setTimeout(r, wait))
}
}
async function main() {
log(`CDP port ${PORT}, out ${OUT}`)
const target = await pickRenderer()
log(`target ${target.url}`)
const cdp = await connect(target.webSocketDebuggerUrl)
await cdp.send('Runtime.enable')
await cdp.send('Page.enable')
await cdp.send('Profiler.enable')
// Pre-GC so the cpu profile + heap delta are clean.
try {
await cdp.send('HeapProfiler.collectGarbage')
} catch (e) {
log('GC skipped:', e.message)
}
if (HEAP) await captureHeap(cdp, `${OUT}.before.heapsnapshot`)
// 1ms sampling — fine enough for per-frame React work.
await cdp.send('Profiler.setSamplingInterval', { interval: 1000 })
let typedText = ''
if (!IDLE_SECONDS) {
const focused = await focusComposer(cdp)
if (!focused) {
log('aborting — composer not focusable. Make sure the app is past the boot screen.')
cdp.close()
process.exit(3)
}
typedText = CUSTOM_TEXT ?? genText(CHARS)
}
await cdp.send('Profiler.start')
if (IDLE_SECONDS) {
banner(`IDLE recording for ${IDLE_SECONDS}s — DO NOT TOUCH`)
await new Promise(r => setTimeout(r, IDLE_SECONDS * 1000))
} else {
banner(`TYPING ${typedText.length} chars @ ${CPS} cps (≈${(typedText.length / CPS).toFixed(1)}s)`)
const t0 = Date.now()
await typeText(cdp, typedText, CPS)
log(`typing wall time: ${((Date.now() - t0) / 1000).toFixed(2)}s`)
// Settle frame for trailing React work.
await new Promise(r => setTimeout(r, 500))
}
banner('STOP — saving profile')
const { profile } = await cdp.send('Profiler.stop')
writeFileSync(`${OUT}.cpuprofile`, JSON.stringify(profile))
log(`cpu profile → ${OUT}.cpuprofile (${(JSON.stringify(profile).length / 1024 / 1024).toFixed(1)} MB)`)
if (HEAP) {
try {
await cdp.send('HeapProfiler.collectGarbage')
} catch {}
await captureHeap(cdp, `${OUT}.after.heapsnapshot`)
}
// Quick triage: top-self-time frames from the profile.
const top = summarizeProfile(profile)
banner('TOP SELF-TIME FRAMES')
for (const row of top.slice(0, 20)) {
console.log(
` ${row.selfMs.toFixed(1).padStart(7)}ms ${row.functionName || '(anonymous)'}` +
` ${row.url ? '· ' + row.url.replace(/^.*\/src\//, 'src/').slice(0, 80) : ''}`
)
}
console.log()
log(`total samples: ${top.totalSamples}, total time: ${(top.totalMs / 1000).toFixed(2)}s`)
cdp.close()
}
function summarizeProfile(profile) {
// Cumulative samples = how many sampling ticks landed on each node.
// selfMs = own time only, using sampling interval.
const intervalMs = (profile.endTime - profile.startTime) / 1000 / Math.max(1, profile.samples?.length ?? 1)
const counts = new Map()
for (const s of profile.samples ?? []) counts.set(s, (counts.get(s) ?? 0) + 1)
const rows = profile.nodes.map(n => {
const self = counts.get(n.id) ?? 0
return {
id: n.id,
functionName: n.callFrame.functionName,
url: n.callFrame.url,
lineNumber: n.callFrame.lineNumber,
selfSamples: self,
selfMs: self * intervalMs
}
})
rows.sort((a, b) => b.selfSamples - a.selfSamples)
rows.totalSamples = (profile.samples ?? []).length
rows.totalMs = ((profile.endTime - profile.startTime) / 1000)
return rows
}
main().catch(e => {
console.error('[profile] fatal:', e.stack ?? e.message)
process.exit(1)
})