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.
222 lines
7.3 KiB
JavaScript
222 lines
7.3 KiB
JavaScript
#!/usr/bin/env node
|
||
// Leak-detection harness — measure detached DOM, listener count, and FiberNode
|
||
// growth as a function of keystrokes typed.
|
||
//
|
||
// Workflow:
|
||
// 1. Open session, focus composer
|
||
// 2. forceGC; capture baseline counts
|
||
// 3. Repeat N rounds: type M chars, forceGC, capture counts, clear composer
|
||
// 4. Print growth-per-round table
|
||
//
|
||
// Usage:
|
||
// node apps/desktop/scripts/leak-typing.mjs [--rounds=6] [--chars=200] [--cps=40] [--port=9222]
|
||
|
||
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 ROUNDS = Number(args.rounds ?? 6)
|
||
const CHARS = Number(args.chars ?? 200)
|
||
const CPS = Number(args.cps ?? 40)
|
||
|
||
const log = (...m) => console.log('[leak]', ...m)
|
||
|
||
async function pickRenderer() {
|
||
const list = await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json()
|
||
return list.find(t => t.type === 'page' && t.url.startsWith('http'))
|
||
}
|
||
|
||
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 m = JSON.parse(typeof ev.data === 'string' ? ev.data : ev.data.toString('utf8'))
|
||
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 evalInPage(cdp, expr) {
|
||
const r = await cdp.send('Runtime.evaluate', { expression: expr, returnByValue: true })
|
||
if (r.exceptionDetails) throw new Error(r.exceptionDetails.text)
|
||
return r.result.value
|
||
}
|
||
|
||
async function forceGCAndSettle(cdp) {
|
||
for (let i = 0; i < 3; i++) {
|
||
await cdp.send('HeapProfiler.collectGarbage')
|
||
await new Promise(r => setTimeout(r, 60))
|
||
}
|
||
}
|
||
|
||
async function focusComposer(cdp) {
|
||
return await evalInPage(
|
||
cdp,
|
||
`(() => {
|
||
const el = document.querySelector('[data-slot="composer-rich-input"]')
|
||
if (!el) return false
|
||
el.focus()
|
||
const range = document.createRange()
|
||
range.selectNodeContents(el)
|
||
range.collapse(false)
|
||
const sel = window.getSelection()
|
||
sel.removeAllRanges()
|
||
sel.addRange(range)
|
||
return true
|
||
})()`
|
||
)
|
||
}
|
||
|
||
async function clearComposer(cdp) {
|
||
await evalInPage(
|
||
cdp,
|
||
`(() => {
|
||
const el = document.querySelector('[data-slot="composer-rich-input"]')
|
||
if (!el) return false
|
||
// Clear via the same path as the composer's clear flow:
|
||
// dispatch a single Backspace until empty would be N round-trips; quicker
|
||
// to directly assign empty text and fire input.
|
||
el.innerHTML = ''
|
||
el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward' }))
|
||
el.focus()
|
||
return el.innerText.length === 0
|
||
})()`
|
||
)
|
||
}
|
||
|
||
async function snapshotCounts(cdp) {
|
||
// Counts via Runtime.evaluate using internal V8 counters where possible.
|
||
// For DOM stats we directly query the document.
|
||
// Performance metrics include JSHeapUsedSize, Nodes, JSEventListeners, etc.
|
||
const { metrics } = await cdp.send('Performance.getMetrics')
|
||
const byName = Object.fromEntries(metrics.map(m => [m.name, m.value]))
|
||
// Total nodes in document
|
||
const docNodes = await evalInPage(
|
||
cdp,
|
||
`document.getElementsByTagName('*').length + document.querySelectorAll('*').length / 2`
|
||
)
|
||
return {
|
||
heapUsedMB: (byName.JSHeapUsedSize / 1024 / 1024) || 0,
|
||
heapTotalMB: (byName.JSHeapTotalSize / 1024 / 1024) || 0,
|
||
nodes: byName.Nodes || 0,
|
||
jsListeners: byName.JSEventListeners || 0,
|
||
docNodes,
|
||
layoutCount: byName.LayoutCount || 0,
|
||
recalcStyleCount: byName.RecalcStyleCount || 0,
|
||
fps: byName.FramesPerSecond || 0
|
||
}
|
||
}
|
||
|
||
async function typeChars(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 cdp.send('Input.dispatchKeyEvent', { type: 'char', text: text[i], unmodifiedText: text[i] })
|
||
const expected = start + (i + 1) * intervalMs
|
||
const wait = expected - Date.now()
|
||
if (wait > 0) await new Promise(r => setTimeout(r, wait))
|
||
}
|
||
}
|
||
|
||
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 '
|
||
function genText(n) {
|
||
let s = ''
|
||
while (s.length < n) s += lorem
|
||
return s.slice(0, n)
|
||
}
|
||
|
||
async function main() {
|
||
log(`port ${PORT} · ${ROUNDS} rounds × ${CHARS} chars @ ${CPS} cps`)
|
||
const tgt = await pickRenderer()
|
||
log(`target ${tgt.url}`)
|
||
const cdp = await connect(tgt.webSocketDebuggerUrl)
|
||
await cdp.send('Runtime.enable')
|
||
await cdp.send('Performance.enable')
|
||
await cdp.send('DOM.enable')
|
||
|
||
const focused = await focusComposer(cdp)
|
||
if (!focused) {
|
||
console.error('composer not focusable')
|
||
process.exit(2)
|
||
}
|
||
|
||
await forceGCAndSettle(cdp)
|
||
const baseline = await snapshotCounts(cdp)
|
||
log('baseline:', JSON.stringify(baseline))
|
||
|
||
const text = genText(CHARS)
|
||
const history = [{ round: 0, ...baseline, charsTyped: 0 }]
|
||
|
||
for (let r = 1; r <= ROUNDS; r++) {
|
||
await typeChars(cdp, text, CPS)
|
||
await new Promise(res => setTimeout(res, 200))
|
||
await clearComposer(cdp)
|
||
await forceGCAndSettle(cdp)
|
||
const snap = await snapshotCounts(cdp)
|
||
snap.charsTyped = r * CHARS
|
||
snap.round = r
|
||
history.push(snap)
|
||
log(
|
||
`round ${r}: heap=${snap.heapUsedMB.toFixed(1)}MB ` +
|
||
`nodes=${snap.nodes} listeners=${snap.jsListeners} ` +
|
||
`domNodes=${Math.round(snap.docNodes)} ` +
|
||
`layoutCount=${snap.layoutCount} ` +
|
||
`Δheap=+${(snap.heapUsedMB - baseline.heapUsedMB).toFixed(2)}MB ` +
|
||
`Δnodes=+${snap.nodes - baseline.nodes} ` +
|
||
`Δlisteners=+${snap.jsListeners - baseline.jsListeners}`
|
||
)
|
||
}
|
||
|
||
console.log('\n=== GROWTH PER ROUND (averaged over last 5 rounds) ===')
|
||
const tail = history.slice(-5)
|
||
const first = tail[0]
|
||
const last = tail[tail.length - 1]
|
||
const rounds = last.round - first.round
|
||
const cells = ['heapUsedMB', 'nodes', 'jsListeners', 'docNodes', 'layoutCount']
|
||
for (const c of cells) {
|
||
const delta = last[c] - first[c]
|
||
const per = delta / Math.max(1, rounds)
|
||
const perChar = delta / Math.max(1, rounds * CHARS)
|
||
console.log(` ${c.padEnd(16)} Δtotal=${delta.toFixed(2).padStart(10)} /round=${per.toFixed(2).padStart(8)} /char=${perChar.toFixed(4).padStart(8)}`)
|
||
}
|
||
|
||
writeFileSync('/tmp/hermes-leak-history.json', JSON.stringify(history, null, 2))
|
||
log('wrote /tmp/hermes-leak-history.json')
|
||
cdp.close()
|
||
}
|
||
|
||
main().catch(e => {
|
||
console.error('[leak] fatal:', e.stack ?? e.message)
|
||
process.exit(1)
|
||
})
|