diff --git a/apps/desktop/scripts/click-session.mjs b/apps/desktop/scripts/click-session.mjs new file mode 100644 index 00000000000..77983f51d68 --- /dev/null +++ b/apps/desktop/scripts/click-session.mjs @@ -0,0 +1,51 @@ +// 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() diff --git a/apps/desktop/scripts/dev-no-hmr.mjs b/apps/desktop/scripts/dev-no-hmr.mjs new file mode 100644 index 00000000000..9647e973811 --- /dev/null +++ b/apps/desktop/scripts/dev-no-hmr.mjs @@ -0,0 +1,22 @@ +#!/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() diff --git a/apps/desktop/scripts/dump-state.mjs b/apps/desktop/scripts/dump-state.mjs new file mode 100644 index 00000000000..ff8c49c3878 --- /dev/null +++ b/apps/desktop/scripts/dump-state.mjs @@ -0,0 +1,39 @@ +// Just dump current state of the page +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 = (m, p = {}) => + new Promise(r => { + const i = ++id + pending.set(i, r) + ws.send(JSON.stringify({ id: i, method: m, params: p })) + }) + +async function evalP(expr) { + const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true }) + return r.result.result.value +} + +const data = await evalP(`JSON.stringify({ + url: location.href, + threadMessages: document.querySelectorAll('[data-slot="aui_message"]').length, + threadMessagesAlt: document.querySelectorAll('[data-message-role]').length, + threadGroups: document.querySelectorAll('[data-slot="aui_turn-pair"]').length, + composerText: document.querySelector('[data-slot="composer-rich-input"]')?.innerText?.length || 0, + visibleArticles: document.querySelectorAll('article').length, + sidebarSession: document.querySelector('[data-active="true"]')?.textContent?.slice(0,80) || null, + bodyLen: document.body.innerText.length, + bodyTail: document.body.innerText.slice(-400) +})`) +console.log(JSON.parse(data)) +ws.close() diff --git a/apps/desktop/scripts/leak-typing.mjs b/apps/desktop/scripts/leak-typing.mjs new file mode 100644 index 00000000000..d43a8478278 --- /dev/null +++ b/apps/desktop/scripts/leak-typing.mjs @@ -0,0 +1,222 @@ +#!/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) +}) diff --git a/apps/desktop/scripts/measure-latency.mjs b/apps/desktop/scripts/measure-latency.mjs new file mode 100644 index 00000000000..c3f3da1302c --- /dev/null +++ b/apps/desktop/scripts/measure-latency.mjs @@ -0,0 +1,184 @@ +#!/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) +}) diff --git a/apps/desktop/scripts/measure-submit.mjs b/apps/desktop/scripts/measure-submit.mjs new file mode 100644 index 00000000000..6c89c44e34d --- /dev/null +++ b/apps/desktop/scripts/measure-submit.mjs @@ -0,0 +1,179 @@ +#!/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) +}) diff --git a/apps/desktop/scripts/probe-renderer.mjs b/apps/desktop/scripts/probe-renderer.mjs new file mode 100644 index 00000000000..fb0633b73b8 --- /dev/null +++ b/apps/desktop/scripts/probe-renderer.mjs @@ -0,0 +1,38 @@ +// 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() diff --git a/apps/desktop/scripts/profile-typing-lag.md b/apps/desktop/scripts/profile-typing-lag.md new file mode 100644 index 00000000000..6d75b935be1 --- /dev/null +++ b/apps/desktop/scripts/profile-typing-lag.md @@ -0,0 +1,152 @@ +# 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, clears, force-GCs, captures `Performance.getMetrics` deltas. +Reveals leaked event listeners, heap drift, document node growth, and +forced-layout counts. + +```bash +node apps/desktop/scripts/leak-typing.mjs --rounds=6 --chars=200 --cps=50 +``` + +Before patches (real run on this branch's previous tip): +``` +heapUsedMB Δ/round=+0.06 /char=+0.0003 +jsListeners Δ/round=+34.75 /char=+0.1737 ← LEAK +layoutCount Δ/round=+453.00 /char=+2.27 +``` + +After patches: +``` +heapUsedMB Δ/round=+0.00 /char=+0.0000 +jsListeners Δ/round=+0.00 /char=+0.0000 ← fixed +layoutCount Δ/round=+476.00 /char=+2.38 +``` + +The listener leak is gone. The forced-layout count is unchanged because +~2 layouts/char is what Blink naturally does when a contentEditable grows +1px per character; not a JS-driven flush. + +### 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