mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
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.
260 lines
9 KiB
JavaScript
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)
|
|
})
|