diff --git a/apps/desktop/scripts/click-session.mjs b/apps/desktop/scripts/click-session.mjs deleted file mode 100644 index 77983f51d68..00000000000 --- a/apps/desktop/scripts/click-session.mjs +++ /dev/null @@ -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() diff --git a/apps/desktop/scripts/dev-no-hmr.mjs b/apps/desktop/scripts/dev-no-hmr.mjs deleted file mode 100644 index 9647e973811..00000000000 --- a/apps/desktop/scripts/dev-no-hmr.mjs +++ /dev/null @@ -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() diff --git a/apps/desktop/scripts/leak-typing.mjs b/apps/desktop/scripts/leak-typing.mjs deleted file mode 100644 index d43a8478278..00000000000 --- a/apps/desktop/scripts/leak-typing.mjs +++ /dev/null @@ -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) -}) diff --git a/apps/desktop/scripts/measure-latency.mjs b/apps/desktop/scripts/measure-latency.mjs deleted file mode 100644 index c3f3da1302c..00000000000 --- a/apps/desktop/scripts/measure-latency.mjs +++ /dev/null @@ -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) -}) diff --git a/apps/desktop/scripts/measure-submit.mjs b/apps/desktop/scripts/measure-submit.mjs deleted file mode 100644 index 6c89c44e34d..00000000000 --- a/apps/desktop/scripts/measure-submit.mjs +++ /dev/null @@ -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) -}) diff --git a/apps/desktop/scripts/probe-renderer.mjs b/apps/desktop/scripts/probe-renderer.mjs deleted file mode 100644 index fb0633b73b8..00000000000 --- a/apps/desktop/scripts/probe-renderer.mjs +++ /dev/null @@ -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() diff --git a/apps/desktop/scripts/profile-typing-lag.md b/apps/desktop/scripts/profile-typing-lag.md deleted file mode 100644 index ecbab9d5475..00000000000 --- a/apps/desktop/scripts/profile-typing-lag.md +++ /dev/null @@ -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