mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
Revert "perf(desktop): cut per-keystroke layout + listener churn in chat composer"
This reverts commit bff1b3261d.
This commit is contained in:
parent
493dd5b660
commit
b7b378e3a4
10 changed files with 24 additions and 1219 deletions
|
|
@ -1,51 +0,0 @@
|
|||
// Click on a session by partial title match.
|
||||
const list = await (await fetch('http://127.0.0.1:9222/json/list')).json()
|
||||
const tgt = list.find(t => t.type === 'page' && t.url.startsWith('http'))
|
||||
const ws = new WebSocket(tgt.webSocketDebuggerUrl)
|
||||
let id = 0
|
||||
const pending = new Map()
|
||||
ws.addEventListener('message', ev => {
|
||||
const m = JSON.parse(ev.data)
|
||||
if (m.id != null && pending.has(m.id)) {
|
||||
pending.get(m.id)(m)
|
||||
pending.delete(m.id)
|
||||
}
|
||||
})
|
||||
await new Promise(r => ws.addEventListener('open', r))
|
||||
const send = (method, params = {}) =>
|
||||
new Promise(r => {
|
||||
const i = ++id
|
||||
pending.set(i, r)
|
||||
ws.send(JSON.stringify({ id: i, method, params }))
|
||||
})
|
||||
|
||||
const title = process.argv[2] || 'Phaser particle'
|
||||
const r = await send('Runtime.evaluate', {
|
||||
expression: `
|
||||
(() => {
|
||||
const titleMatch = ${JSON.stringify(title)}
|
||||
const all = document.querySelectorAll('button, a, div[role="button"]')
|
||||
const found = [...all].find(el => (el.textContent || '').includes(titleMatch))
|
||||
if (!found) return JSON.stringify({ found: false, tried: titleMatch })
|
||||
found.scrollIntoView()
|
||||
found.click()
|
||||
return JSON.stringify({ found: true, tag: found.tagName, text: (found.textContent || '').slice(0, 80) })
|
||||
})()
|
||||
`,
|
||||
returnByValue: true
|
||||
})
|
||||
console.log('click raw:', JSON.stringify(r, null, 2))
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
|
||||
const status = await send('Runtime.evaluate', {
|
||||
expression: `JSON.stringify({
|
||||
url: location.href,
|
||||
hasComposer: !!document.querySelector('[data-slot="composer-rich-input"]'),
|
||||
threadMessages: document.querySelectorAll('[data-slot="aui_message"]').length,
|
||||
bodyTextSnippet: document.body.innerText.slice(0, 500),
|
||||
title: document.title
|
||||
})`,
|
||||
returnByValue: true
|
||||
})
|
||||
console.log('after click:', status.result.value)
|
||||
ws.close()
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
// Launch the desktop renderer with HMR disabled so the React Fast Refresh
|
||||
// preamble path is skipped. This sidesteps a current Vite 8 / plugin-react 6
|
||||
// bug where the preamble script is not injected into index.html → renderer
|
||||
// throws "$RefreshReg$ is not defined" on every TSX module → React tree
|
||||
// never mounts.
|
||||
//
|
||||
// We're not trying to use HMR while profiling typing lag anyway. Hermes desktop
|
||||
// boots, you type, profiler measures. HMR off is fine.
|
||||
//
|
||||
// Usage: node apps/desktop/scripts/dev-no-hmr.mjs
|
||||
// (then in another shell, run electron --remote-debugging-port=9222 .)
|
||||
|
||||
import { createServer } from 'vite'
|
||||
|
||||
const server = await createServer({
|
||||
configFile: new URL('../vite.config.ts', import.meta.url).pathname,
|
||||
root: new URL('../', import.meta.url).pathname,
|
||||
server: { hmr: false, host: '127.0.0.1', port: 5174, strictPort: true }
|
||||
})
|
||||
await server.listen()
|
||||
server.printUrls()
|
||||
|
|
@ -1,222 +0,0 @@
|
|||
#!/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)
|
||||
})
|
||||
|
|
@ -1,184 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
// Measure end-to-end keystroke→paint latency in the Electron renderer.
|
||||
//
|
||||
// For each synthetic keystroke we record:
|
||||
// t0 = Input.dispatchKeyEvent send time
|
||||
// t1 = first observed mutation of [data-slot="composer-rich-input"] childList/character data
|
||||
// t2 = first requestAnimationFrame callback after t1 (proxy for next paint)
|
||||
//
|
||||
// We use Page.startScreencast briefly to also get frame-presentation timestamps;
|
||||
// alternatively rely on rAF timing which is close enough for typing UX.
|
||||
//
|
||||
// Output: per-char latency histogram (min/p50/p95/p99/max) + samples > 16ms.
|
||||
//
|
||||
// Usage:
|
||||
// node apps/desktop/scripts/measure-latency.mjs [--chars=100] [--cps=15] [--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 CHARS = Number(args.chars ?? 100)
|
||||
const CPS = Number(args.cps ?? 15)
|
||||
|
||||
const log = (...m) => console.log('[latency]', ...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 main() {
|
||||
const tgt = await pickRenderer()
|
||||
log(`target ${tgt.url}`)
|
||||
const cdp = await connect(tgt.webSocketDebuggerUrl)
|
||||
await cdp.send('Runtime.enable')
|
||||
|
||||
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)
|
||||
window.__keypressTimings = []
|
||||
window.__pendingKey = null
|
||||
// Observe the composer for content/text changes; record the time relative
|
||||
// to the most recent simulated keypress timestamp set on window.__pendingKey.
|
||||
const obs = new MutationObserver(() => {
|
||||
const start = window.__pendingKey
|
||||
if (start === null) return
|
||||
const mutationT = performance.now()
|
||||
window.__pendingKey = null
|
||||
requestAnimationFrame(() => {
|
||||
const paintT = performance.now()
|
||||
window.__keypressTimings.push({
|
||||
start, mutationT, paintT,
|
||||
mutationLatency: mutationT - start,
|
||||
paintLatency: paintT - start
|
||||
})
|
||||
})
|
||||
})
|
||||
obs.observe(el, { childList: true, subtree: true, characterData: true })
|
||||
window.__keystrokeObserver = obs
|
||||
return true
|
||||
})()`
|
||||
)
|
||||
|
||||
const lorem =
|
||||
'the quick brown fox jumps over the lazy dog while typing into this composer feels like wading through molasses on a hot afternoon. '
|
||||
let text = ''
|
||||
while (text.length < CHARS) text += lorem
|
||||
text = text.slice(0, CHARS)
|
||||
|
||||
const intervalMs = Math.max(1, Math.round(1000 / CPS))
|
||||
const start = Date.now()
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
// Mark the keypress time inside the page so it's measured from the same clock.
|
||||
await evalInPage(cdp, `window.__pendingKey = performance.now()`)
|
||||
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))
|
||||
}
|
||||
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
const samples = await evalInPage(cdp, `window.__keypressTimings`)
|
||||
log(`${samples.length} keystroke samples measured out of ${text.length} typed`)
|
||||
|
||||
// Clear composer for next run
|
||||
await evalInPage(cdp, `
|
||||
(() => {
|
||||
const el = document.querySelector('[data-slot="composer-rich-input"]')
|
||||
if (el) { el.innerHTML = ''; el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward' })) }
|
||||
window.__keystrokeObserver?.disconnect()
|
||||
})()
|
||||
`)
|
||||
|
||||
const mutLat = samples.map(s => s.mutationLatency).sort((a, b) => a - b)
|
||||
const paintLat = samples.map(s => s.paintLatency).sort((a, b) => a - b)
|
||||
const stat = arr => ({
|
||||
n: arr.length,
|
||||
min: arr[0]?.toFixed(2),
|
||||
p50: arr[Math.floor(arr.length * 0.5)]?.toFixed(2),
|
||||
p90: arr[Math.floor(arr.length * 0.9)]?.toFixed(2),
|
||||
p95: arr[Math.floor(arr.length * 0.95)]?.toFixed(2),
|
||||
p99: arr[Math.floor(arr.length * 0.99)]?.toFixed(2),
|
||||
max: arr[arr.length - 1]?.toFixed(2),
|
||||
mean: arr.length ? (arr.reduce((s, x) => s + x, 0) / arr.length).toFixed(2) : 0
|
||||
})
|
||||
|
||||
console.log('\n=== keypress → mutation latency (ms) ===')
|
||||
console.log(' ', stat(mutLat))
|
||||
console.log('\n=== keypress → next rAF (≈paint) latency (ms) ===')
|
||||
console.log(' ', stat(paintLat))
|
||||
|
||||
const slow = samples.filter(s => s.paintLatency > 16)
|
||||
console.log(`\n=== ${slow.length}/${samples.length} keystrokes >16ms (one frame) ===`)
|
||||
if (slow.length) {
|
||||
const slowSorted = [...slow].sort((a, b) => b.paintLatency - a.paintLatency).slice(0, 10)
|
||||
for (const s of slowSorted) {
|
||||
console.log(` paint=${s.paintLatency.toFixed(1)}ms mut=${s.mutationLatency.toFixed(1)}ms at t=${s.start.toFixed(0)}`)
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync('/tmp/hermes-latency-samples.json', JSON.stringify(samples, null, 2))
|
||||
|
||||
cdp.close()
|
||||
}
|
||||
|
||||
main().catch(e => {
|
||||
console.error('[latency] fatal:', e.stack ?? e.message)
|
||||
process.exit(1)
|
||||
})
|
||||
|
|
@ -1,179 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
// Measure submit (Enter) latency in the composer.
|
||||
//
|
||||
// For each round:
|
||||
// 1. Focus composer, type N chars of stub text
|
||||
// 2. Mark a timestamp, fire Enter via Input.dispatchKeyEvent
|
||||
// 3. Observe: time until the composer becomes empty (submit accepted),
|
||||
// time until the user message renders in the thread viewport,
|
||||
// time until the optional "running…" indicator appears,
|
||||
// time until the next frame is painted after the message renders.
|
||||
//
|
||||
// Pre-condition: a session is loaded (load via click-session.mjs first).
|
||||
// Note: this DOES talk to the real gateway/agent, so each round triggers
|
||||
// a real prompt submission. Don't run this on a live conversation
|
||||
// you care about — use a throwaway session.
|
||||
|
||||
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 ?? 3)
|
||||
|
||||
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()
|
||||
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 }))
|
||||
},
|
||||
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)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function evalP(cdp, expr) {
|
||||
const r = await cdp.send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true })
|
||||
if (r.exceptionDetails) throw new Error(r.exceptionDetails.text)
|
||||
return r.result.value
|
||||
}
|
||||
|
||||
async function focusAndType(cdp, text) {
|
||||
await evalP(cdp, `
|
||||
(() => {
|
||||
const el = document.querySelector('[data-slot="composer-rich-input"]')
|
||||
if (!el) return
|
||||
el.focus()
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(el)
|
||||
range.collapse(false)
|
||||
const sel = window.getSelection()
|
||||
sel.removeAllRanges()
|
||||
sel.addRange(range)
|
||||
})()
|
||||
`)
|
||||
for (const c of text) {
|
||||
await cdp.send('Input.dispatchKeyEvent', { type: 'char', text: c, unmodifiedText: c })
|
||||
await new Promise(r => setTimeout(r, 8))
|
||||
}
|
||||
}
|
||||
|
||||
async function submitAndMeasure(cdp, timeoutMs = 5000) {
|
||||
// Install observers, record submit time as performance.now() inside the page,
|
||||
// and wait for all milestones.
|
||||
return await evalP(cdp, `
|
||||
new Promise((resolve) => {
|
||||
const composer = document.querySelector('[data-slot="composer-rich-input"]')
|
||||
const threadRoot = document.querySelector('[data-slot="aui_thread-content"]') ||
|
||||
document.querySelector('[data-slot="aui_thread-viewport"]')
|
||||
const startMessageCount = threadRoot ? threadRoot.querySelectorAll('[data-slot="aui_turn-pair"], [data-slot="aui_message"]').length : 0
|
||||
const startComposerText = composer ? composer.innerText : ''
|
||||
|
||||
const milestones = { start: performance.now() }
|
||||
let done = false
|
||||
const finish = (reason) => {
|
||||
if (done) return
|
||||
done = true
|
||||
clearInterval(poll); clearTimeout(timer)
|
||||
composerObs.disconnect()
|
||||
threadObs?.disconnect()
|
||||
milestones.reason = reason
|
||||
milestones.end = performance.now()
|
||||
milestones.totalMs = milestones.end - milestones.start
|
||||
resolve(milestones)
|
||||
}
|
||||
|
||||
const composerObs = new MutationObserver(() => {
|
||||
if (!milestones.composerClearedMs && composer && composer.innerText.length === 0) {
|
||||
milestones.composerClearedMs = performance.now() - milestones.start
|
||||
}
|
||||
})
|
||||
composer && composerObs.observe(composer, { childList: true, subtree: true, characterData: true })
|
||||
|
||||
let threadObs = null
|
||||
if (threadRoot) {
|
||||
threadObs = new MutationObserver(() => {
|
||||
const c = threadRoot.querySelectorAll('[data-slot="aui_turn-pair"], [data-slot="aui_message"]').length
|
||||
if (!milestones.userMessageRenderedMs && c > startMessageCount) {
|
||||
milestones.userMessageRenderedMs = performance.now() - milestones.start
|
||||
requestAnimationFrame(() => {
|
||||
milestones.userMessagePaintMs = performance.now() - milestones.start
|
||||
finish('paint')
|
||||
})
|
||||
}
|
||||
})
|
||||
threadObs.observe(threadRoot, { childList: true, subtree: true })
|
||||
}
|
||||
|
||||
const poll = setInterval(() => {
|
||||
if (milestones.composerClearedMs && !milestones.userMessageRenderedMs &&
|
||||
performance.now() - milestones.start > 2000) {
|
||||
finish('timeout-after-clear')
|
||||
}
|
||||
}, 100)
|
||||
const timer = setTimeout(() => finish('timeout-overall'), ${timeoutMs})
|
||||
|
||||
// Send Enter immediately
|
||||
window.dispatchEvent(new KeyboardEvent('keydown')) // no-op marker
|
||||
const enterEv = new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', bubbles: true, cancelable: true })
|
||||
composer?.dispatchEvent(enterEv)
|
||||
})
|
||||
`)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const tgt = await pickRenderer()
|
||||
console.log('target', tgt.url)
|
||||
const cdp = await connect(tgt.webSocketDebuggerUrl)
|
||||
await cdp.send('Runtime.enable')
|
||||
|
||||
const samples = []
|
||||
for (let i = 1; i <= ROUNDS; i++) {
|
||||
await focusAndType(cdp, `latency test ${i} ${'x'.repeat(40)}`)
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
const result = await submitAndMeasure(cdp, 4000)
|
||||
samples.push({ round: i, ...result })
|
||||
console.log(
|
||||
`r${i}: clear=${(result.composerClearedMs ?? -1).toFixed?.(0) ?? '?'}ms ` +
|
||||
`userMsg=${(result.userMessageRenderedMs ?? -1).toFixed?.(0) ?? '?'}ms ` +
|
||||
`paint=${(result.userMessagePaintMs ?? -1).toFixed?.(0) ?? '?'}ms ` +
|
||||
`reason=${result.reason}`
|
||||
)
|
||||
// wait for any agent activity to finish before next round so we're not piling up
|
||||
await new Promise(r => setTimeout(r, 4000))
|
||||
}
|
||||
writeFileSync('/tmp/hermes-submit-latency.json', JSON.stringify(samples, null, 2))
|
||||
console.log('\nwrote /tmp/hermes-submit-latency.json')
|
||||
cdp.close()
|
||||
}
|
||||
|
||||
main().catch(e => {
|
||||
console.error('fatal:', e.stack ?? e.message)
|
||||
process.exit(1)
|
||||
})
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
// quick probe — read state of the renderer
|
||||
const list = await (await fetch('http://127.0.0.1:9222/json/list')).json()
|
||||
const tgt = list.find(t => t.type === 'page' && t.url.startsWith('http'))
|
||||
console.log('target:', tgt?.url)
|
||||
if (!tgt) process.exit(1)
|
||||
const ws = new WebSocket(tgt.webSocketDebuggerUrl)
|
||||
let id = 0
|
||||
const pending = new Map()
|
||||
ws.addEventListener('message', ev => {
|
||||
const m = JSON.parse(ev.data)
|
||||
if (m.id != null && pending.has(m.id)) {
|
||||
pending.get(m.id)(m)
|
||||
pending.delete(m.id)
|
||||
}
|
||||
})
|
||||
await new Promise(r => ws.addEventListener('open', r))
|
||||
const send = (method, params = {}) =>
|
||||
new Promise(r => {
|
||||
const i = ++id
|
||||
pending.set(i, r)
|
||||
ws.send(JSON.stringify({ id: i, method, params }))
|
||||
})
|
||||
|
||||
const r = await send('Runtime.evaluate', {
|
||||
expression: `({
|
||||
url: location.href,
|
||||
title: document.title,
|
||||
rootChildren: document.getElementById('root')?.children.length ?? 0,
|
||||
rootInner: (document.getElementById('root')?.innerHTML ?? '').slice(0, 300),
|
||||
hasComposer: !!document.querySelector('[data-slot="composer-rich-input"]'),
|
||||
bootStage: (document.querySelector('[data-slot*="boot"]')?.getAttribute('data-slot')) ?? null,
|
||||
bodyText: document.body.innerText.slice(0, 300),
|
||||
errorCount: window.__errors?.length ?? 'n/a'
|
||||
})`,
|
||||
returnByValue: true
|
||||
})
|
||||
console.log('raw:', JSON.stringify(r, null, 2))
|
||||
ws.close()
|
||||
|
|
@ -1,155 +0,0 @@
|
|||
# Profiling renderer typing lag
|
||||
|
||||
Workflow for empirically measuring (and fixing) typing/submit lag in the
|
||||
desktop chat composer.
|
||||
|
||||
## Quick boot for profiling
|
||||
|
||||
Vite 8 + plugin-react 6 has a known issue where the React Fast Refresh
|
||||
preamble script isn't injected into `index.html`, so opening Electron at
|
||||
`http://127.0.0.1:5174` throws `$RefreshReg$ is not defined` on every TSX
|
||||
module and the React tree never mounts. Workaround: run vite with HMR off.
|
||||
|
||||
```bash
|
||||
# Terminal A — start dev server without HMR
|
||||
cd apps/desktop
|
||||
node scripts/dev-no-hmr.mjs
|
||||
|
||||
# Terminal B — start Electron with CDP exposed
|
||||
cd apps/desktop
|
||||
XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 \
|
||||
../../node_modules/.bin/electron --remote-debugging-port=9222 .
|
||||
```
|
||||
|
||||
Terminal C is yours to run the harnesses.
|
||||
|
||||
## Harnesses
|
||||
|
||||
All zero-dep — Node 24 built-in `WebSocket` + `fetch`.
|
||||
|
||||
### Typing latency — `measure-latency.mjs`
|
||||
|
||||
Per-keystroke `keypress → next paint` latency, p50/p90/p99/max.
|
||||
Synthesizes keystrokes via `Input.dispatchKeyEvent` so the run is
|
||||
reproducible.
|
||||
|
||||
```bash
|
||||
node apps/desktop/scripts/measure-latency.mjs --chars=120 --cps=20
|
||||
```
|
||||
|
||||
Anything > 16ms is a dropped frame. On a freshly-loaded session
|
||||
(`scripts/click-session.mjs 'Phaser particle'`) we currently see:
|
||||
|
||||
| | unpatched | patched |
|
||||
|---|---|---|
|
||||
| p50 paint | 1.9 ms | 2.0 ms |
|
||||
| p90 paint | 3.3 ms | 13.7 ms |
|
||||
| p99 paint | 16.7 ms | 15.2 ms |
|
||||
| max paint | 20.5 ms | 30.4 ms |
|
||||
| >16ms drops | 2/120 | 1/120 |
|
||||
|
||||
Roughly even on a quick session — patches don't fix typing latency
|
||||
under benign synthetic conditions because the existing baseline is
|
||||
already snappy on synthetic input. The real wins are in the leak counters
|
||||
(see below). If the user reports typing jank, capture a profile + heap
|
||||
diff during their actual usage and compare against the synthetic baseline
|
||||
to identify what condition (long thread, popover open, paste, etc.)
|
||||
makes the path slow.
|
||||
|
||||
### Leak counters — `leak-typing.mjs`
|
||||
|
||||
Types N chars per round, clears, force-GCs, captures
|
||||
`Performance.getMetrics` deltas. Reveals leaked event listeners, heap
|
||||
drift, document node growth, and forced-layout counts.
|
||||
|
||||
```bash
|
||||
# After clicking into a real session (e.g. via click-session.mjs):
|
||||
node apps/desktop/scripts/leak-typing.mjs --rounds=8 --chars=200 --cps=50
|
||||
```
|
||||
|
||||
**Real-session numbers (Phaser thread, 8 rounds × 200 chars):**
|
||||
|
||||
| | unpatched (HEAD~2) | patched (HEAD) |
|
||||
|---|---|---|
|
||||
| jsListeners growth/round | +0 | +0 |
|
||||
| DOM nodes growth/round | +0 | +0 |
|
||||
| heap growth/round | ~0 (V8 housekeeping) | ~0 |
|
||||
| **forced layouts/char** | **7.02** | **2.35** (3× fewer) |
|
||||
|
||||
The forced-layout count is the load-bearing number — typing into a real
|
||||
session was triggering ~7 layouts per character on the unpatched build
|
||||
(scrollHeight reads + per-px CSS var writes + FadeText scrollWidth reads
|
||||
all stacking up). After the patches it's down to ~2.35/char, which is
|
||||
Blink's natural cost for a 1px/char-growing contentEditable and can't
|
||||
be lowered further without architectural changes.
|
||||
|
||||
The initial "+35 listeners/round leak" I called out on the first
|
||||
unpatched run turned out to be transient warm-up (popovers initializing,
|
||||
etc.); steady-state listener growth was 0 both before and after.
|
||||
|
||||
### CPU profile + heap snapshot — `profile-typing.mjs`
|
||||
|
||||
Records a CPU profile while typing, plus before/after heap snapshots so
|
||||
you can do a comparison diff in Chrome DevTools Memory tab.
|
||||
|
||||
```bash
|
||||
node apps/desktop/scripts/profile-typing.mjs \
|
||||
--chars=400 --cps=30 --out=/tmp/hermes-typing
|
||||
# → /tmp/hermes-typing.cpuprofile (open in Chrome DevTools Performance)
|
||||
# → /tmp/hermes-typing.before.heapsnapshot
|
||||
# → /tmp/hermes-typing.after.heapsnapshot
|
||||
```
|
||||
|
||||
Loading the cpuprofile: Chrome DevTools → Performance tab → drag the file
|
||||
in, or VS Code → open the `.cpuprofile` directly.
|
||||
|
||||
For heap diff: Chrome DevTools → Memory → Load snapshot → load "before",
|
||||
then Comparison view → load "after". Sort by `# Delta`. Stay alert for
|
||||
detached DOM, FiberNodes (unmounted), and listener growth.
|
||||
|
||||
## Helpers
|
||||
|
||||
- `probe-renderer.mjs` — dump page state (URL, composer mounted?, body text)
|
||||
- `click-session.mjs <title>` — click a sidebar session by partial title match
|
||||
- `reload-renderer.mjs` — force Page.reload via CDP (no HMR available)
|
||||
- `dump-state.mjs` — richer state dump (thread message count, sticky session, etc.)
|
||||
- `probe-console.mjs` — dump recent console errors / exceptions
|
||||
|
||||
## Findings
|
||||
|
||||
See commit message for `apps/desktop/src/app/chat/composer/index.tsx`
|
||||
edits. Three changes:
|
||||
|
||||
1. **Per-keystroke `scrollHeight` read removed.** The expansion useEffect
|
||||
used to read `editorRef.current.scrollHeight` on every draft change
|
||||
(forces synchronous layout). Replaced with a `draft.length > 60`
|
||||
heuristic; the ResizeObserver catches anything the heuristic misses.
|
||||
|
||||
2. **Bucketed CSS custom-property writes.** `syncComposerMetrics`
|
||||
used to `setProperty('--composer-measured-height', height + 'px')`
|
||||
on every observed resize, invalidating computed style for the whole
|
||||
tree. Now writes only when the height crosses an 8 px bucket, so
|
||||
typing in a fixed-height row produces no style invalidation at all.
|
||||
|
||||
3. **Removed dead `$composerDraft` → `aui.composer().setText` round-trip.**
|
||||
Nothing outside the composer subscribed to `$composerDraft` (verified
|
||||
via grep). The two useEffects that pushed draft → store and store →
|
||||
composer were pure overhead per keystroke. `reconcileComposerTerminalSelections`
|
||||
was also called per keystroke; can be deferred to submit time (it's a
|
||||
stale-pruning step, not a correctness one — `terminalContextBlocksFromDraft`
|
||||
walks the current text directly at submit and ignores stale labels).
|
||||
|
||||
4. **`refreshTrigger` fast-bails when no `@`/`/` in draft.** Previously
|
||||
`textBeforeCaret()` did `range.toString()` (O(n)) on every keystroke
|
||||
even when no trigger char was present.
|
||||
|
||||
The biggest win is the listener leak in (3) — without it, each round of
|
||||
typing leaked ~35 event listeners until a steady state.
|
||||
|
||||
## Submit / TTFT stall (open)
|
||||
|
||||
User reports a perceived stall *after* Enter, before the assistant starts
|
||||
streaming. `scripts/measure-submit.mjs` measures
|
||||
`enter → composer-cleared → user-message-rendered → first-paint`. The
|
||||
script triggers a real prompt submission, so use it on a throwaway
|
||||
session. Not enabled in CI.
|
||||
|
|
@ -1,260 +0,0 @@
|
|||
#!/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)
|
||||
})
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
// Reload the renderer via CDP so it picks up the latest from Vite.
|
||||
const list = await (await fetch('http://127.0.0.1:9222/json/list')).json()
|
||||
const tgt = list.find(t => t.type === 'page' && t.url.startsWith('http'))
|
||||
const ws = new WebSocket(tgt.webSocketDebuggerUrl)
|
||||
let id = 0
|
||||
const pending = new Map()
|
||||
ws.addEventListener('message', ev => {
|
||||
const m = JSON.parse(ev.data)
|
||||
if (m.id != null && pending.has(m.id)) {
|
||||
pending.get(m.id)(m)
|
||||
pending.delete(m.id)
|
||||
}
|
||||
})
|
||||
await new Promise(r => ws.addEventListener('open', r))
|
||||
const send = (method, params = {}) =>
|
||||
new Promise(r => {
|
||||
const i = ++id
|
||||
pending.set(i, r)
|
||||
ws.send(JSON.stringify({ id: i, method, params }))
|
||||
})
|
||||
await send('Page.enable')
|
||||
await send('Page.reload', { ignoreCache: true })
|
||||
console.log('reload requested')
|
||||
await new Promise(r => setTimeout(r, 200))
|
||||
ws.close()
|
||||
|
|
@ -23,8 +23,10 @@ import { triggerHaptic } from '@/lib/haptics'
|
|||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$composerAttachments,
|
||||
$composerDraft,
|
||||
clearComposerAttachments,
|
||||
type ComposerAttachment
|
||||
type ComposerAttachment,
|
||||
reconcileComposerTerminalSelections
|
||||
} from '@/store/composer'
|
||||
import {
|
||||
$queuedPromptsBySession,
|
||||
|
|
@ -216,16 +218,10 @@ export function ChatBar({
|
|||
}
|
||||
}, [appendExternalText, disabled])
|
||||
|
||||
// Keep draftRef in sync with the assistant-ui composer state for callers
|
||||
// that read the latest text outside the React render cycle. We don't push
|
||||
// to `$composerDraft` per keystroke any more — nobody outside the composer
|
||||
// subscribes to it (verified by grep), and the round-trip
|
||||
// `setText` ⇄ `subscribe` ⇄ `setText` was adding two useEffects to the per-
|
||||
// keystroke critical path. `reconcileComposerTerminalSelections` only
|
||||
// matters when the draft is submitted; we now call it from the submit
|
||||
// path instead.
|
||||
useEffect(() => {
|
||||
draftRef.current = draft
|
||||
$composerDraft.set(draft)
|
||||
reconcileComposerTerminalSelections(draft)
|
||||
|
||||
const editor = editorRef.current
|
||||
|
||||
|
|
@ -234,20 +230,22 @@ export function ChatBar({
|
|||
}
|
||||
}, [draft])
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
$composerDraft.subscribe(value => {
|
||||
if (value !== draftRef.current) {
|
||||
aui.composer().setText(value)
|
||||
}
|
||||
}),
|
||||
[aui]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (urlOpen) {
|
||||
window.requestAnimationFrame(() => urlInputRef.current?.focus({ preventScroll: true }))
|
||||
}
|
||||
}, [urlOpen])
|
||||
|
||||
// Track expansion via cheap heuristics (newline or length threshold) instead
|
||||
// of reading editor.scrollHeight on every keystroke. scrollHeight forces a
|
||||
// synchronous layout flush — measured at 2.27 layouts per character typed
|
||||
// (see scripts/leak-typing.mjs). With ~30 chars before a typical wrap on
|
||||
// composer-default-width, this heuristic flips at roughly the right time
|
||||
// and the user only notices if they type far past the wrap boundary
|
||||
// without a newline; in that case the ResizeObserver below catches it via
|
||||
// a height delta and we still expand.
|
||||
useEffect(() => {
|
||||
if (!draft) {
|
||||
setExpanded(false)
|
||||
|
|
@ -259,22 +257,13 @@ export function ChatBar({
|
|||
return
|
||||
}
|
||||
|
||||
if (draft.includes('\n') || draft.length > 60) {
|
||||
const wraps = (editorRef.current?.scrollHeight ?? 0) > 56
|
||||
|
||||
if (draft.includes('\n') || wraps) {
|
||||
setExpanded(true)
|
||||
}
|
||||
}, [draft, expanded])
|
||||
|
||||
// Bucket measured heights so we only invalidate the global CSS var when
|
||||
// the size crosses a meaningful threshold. Without bucketing, the editor
|
||||
// grows ~1px per character → setProperty fires every keystroke → entire
|
||||
// tree's computed style is invalidated → next paint forces a full
|
||||
// recalculate-style pass. With an 8px bucket, the invalidation rate drops
|
||||
// ~8× and small char-by-char typing produces no style invalidation at all
|
||||
// until a wrap or row change actually happens.
|
||||
const lastBucketedHeightRef = useRef(0)
|
||||
const lastBucketedSurfaceHeightRef = useRef(0)
|
||||
const lastTightRef = useRef<boolean | null>(null)
|
||||
|
||||
const syncComposerMetrics = useCallback(() => {
|
||||
const composer = composerRef.current
|
||||
|
||||
|
|
@ -287,30 +276,15 @@ export function ChatBar({
|
|||
const root = document.documentElement
|
||||
|
||||
if (width > 0) {
|
||||
const nextTight = width < COMPOSER_STACK_BREAKPOINT_PX
|
||||
|
||||
if (nextTight !== lastTightRef.current) {
|
||||
lastTightRef.current = nextTight
|
||||
setTight(nextTight)
|
||||
}
|
||||
setTight(width < COMPOSER_STACK_BREAKPOINT_PX)
|
||||
}
|
||||
|
||||
if (height > 0) {
|
||||
const bucket = Math.round(height / 8) * 8
|
||||
|
||||
if (bucket !== lastBucketedHeightRef.current) {
|
||||
lastBucketedHeightRef.current = bucket
|
||||
root.style.setProperty('--composer-measured-height', `${bucket}px`)
|
||||
}
|
||||
root.style.setProperty('--composer-measured-height', `${Math.round(height)}px`)
|
||||
}
|
||||
|
||||
if (surfaceHeight && surfaceHeight > 0) {
|
||||
const bucket = Math.round(surfaceHeight / 8) * 8
|
||||
|
||||
if (bucket !== lastBucketedSurfaceHeightRef.current) {
|
||||
lastBucketedSurfaceHeightRef.current = bucket
|
||||
root.style.setProperty('--composer-surface-measured-height', `${bucket}px`)
|
||||
}
|
||||
root.style.setProperty('--composer-surface-measured-height', `${Math.round(surfaceHeight)}px`)
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
|
@ -407,28 +381,12 @@ export function ChatBar({
|
|||
return
|
||||
}
|
||||
|
||||
// Fast-bail: if neither `@` nor `/` appears in the current draft, there's
|
||||
// nothing for `detectTrigger` to match. Skip the DOM range walk inside
|
||||
// `textBeforeCaret` (which calls `range.toString()`, O(n) over the draft)
|
||||
// and the regex pass that follows. Only when a relevant char is present
|
||||
// do we pay the cost.
|
||||
const text = composerPlainText(editor)
|
||||
|
||||
if (!text.includes('@') && !text.includes('/')) {
|
||||
if (trigger) {
|
||||
setTrigger(null)
|
||||
setTriggerActive(0)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const before = textBeforeCaret(editor)
|
||||
const detected = detectTrigger(before ?? text)
|
||||
const detected = detectTrigger(before ?? composerPlainText(editor))
|
||||
|
||||
setTrigger(detected)
|
||||
setTriggerActive(0)
|
||||
}, [trigger])
|
||||
}, [])
|
||||
|
||||
const handleEditorInput = (event: FormEvent<HTMLDivElement>) => {
|
||||
const editor = event.currentTarget
|
||||
|
|
@ -1032,24 +990,7 @@ export function ChatBar({
|
|||
role="textbox"
|
||||
suppressContentEditableWarning
|
||||
/>
|
||||
{/* assistant-ui requires ComposerPrimitive.Input somewhere in the tree
|
||||
so the composer-state binding (text + IME + paste + form-submit hookup)
|
||||
wires up. We render the real input UI ourselves above via the
|
||||
contentEditable, so the primitive is invisible (sr-only).
|
||||
|
||||
IMPORTANT: don't let it render its default <TextareaAutosize>. That
|
||||
component runs `useLayoutEffect(resizeTextarea)` on every value change
|
||||
and reads `node.scrollHeight` against a hidden measurement textarea,
|
||||
forcing two synchronous layouts per keystroke for an element the
|
||||
user can't see. Profiling 400-char synthetic typing showed >900ms
|
||||
cumulative cost in getHeight2/calculateNodeHeight alone (~2.3ms/key)
|
||||
on top of the per-keystroke React commit.
|
||||
|
||||
`asChild` swaps TextareaAutosize for a Radix Slot wrapping our
|
||||
plain <textarea>, which carries the binding but skips autosize. */}
|
||||
<ComposerPrimitive.Input asChild tabIndex={-1} unstable_focusOnScrollToBottom={false}>
|
||||
<textarea aria-hidden className="sr-only" tabIndex={-1} />
|
||||
</ComposerPrimitive.Input>
|
||||
<ComposerPrimitive.Input className="sr-only" tabIndex={-1} unstable_focusOnScrollToBottom={false} />
|
||||
</div>
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue