mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-30 19:09:28 +00:00
A CDP trace of one sash drag settled what the render counters could not. I had assumed the remaining cost was layout/paint; it was not: script 6770ms | style 1866ms | layout 71ms Layout was never the problem. The top attributable callsite in our own code was use-resize-observer.ts at 977ms. Counting the callbacks named the mechanism exactly: 8,620 ResizeObserver instances constructed, and during a 40-move drag 2,600 callbacks each carrying exactly ONE entry — 65 separate callbacks per pointermove. Every consumer owned a private observer, so N elements resizing under a common ancestor meant N trips through the observer machinery instead of one batched delivery. With five mounted tiles that is ~100 user bubbles, each with its own observer, all woken by a width change. One shared observer with a WeakMap of target -> handlers. Callers keep their exact contract: a handler observing several elements is still invoked once with all of its entries, and unobserve happens when the last handler for an element goes away. Verified by trace, before -> after: use-resize-observer 977ms -> 42.5ms (-96%) style recalc 1866ms -> 1145ms (-39%) total script 6770ms -> 3929ms (-42%) Callback count 2,600 -> 43: one delivery per frame instead of 65. Adds the two probes that found it. diag-drag-trace.mjs takes a real timeline trace and prints the style/layout/script split plus the top script callsites — that split is what disproved the layout theory. diag-ro-storm.mjs counts RO callbacks vs entries, which is what distinguished 'a few expensive calls' from 'very many cheap ones'.
170 lines
6 KiB
JavaScript
170 lines
6 KiB
JavaScript
// How many ResizeObserver callbacks does one sash drag actually fire, and for
|
|
// how many DISTINCT elements? The trace named use-resize-observer.ts at 977ms
|
|
// but not whether that's a few expensive calls or a great many cheap ones —
|
|
// and the fix differs completely between those.
|
|
//
|
|
// node scripts/diag-ro-storm.mjs [--port 9222] [--tiles 5]
|
|
|
|
import { attach } from './perf/lib/launch.mjs'
|
|
import { sleep } from './perf/lib/cdp.mjs'
|
|
|
|
const arg = (name, fallback) => {
|
|
const i = process.argv.indexOf(`--${name}`)
|
|
|
|
return i === -1 ? fallback : process.argv[i + 1]
|
|
}
|
|
|
|
const port = Number(arg('port', 9222))
|
|
const TILES = Number(arg('tiles', 5))
|
|
const TURNS = Number(arg('turns', 20))
|
|
|
|
const setup = `
|
|
(() => {
|
|
const hook = window.__HERMES_SESSION_TILES__
|
|
if (!hook) return 'no-hook'
|
|
const turn = (sid, i) => ([
|
|
{ id: sid + '-u' + i, role: 'user', timestamp: Date.now(),
|
|
parts: [{ type: 'text', text: 'Question ' + i + ' about the diff and its error path.' }] },
|
|
{ id: sid + '-a' + i, role: 'assistant', timestamp: Date.now(), pending: false,
|
|
parts: [{ type: 'text', text: '## Finding ' + i + '\\n\\nProse with **bold** and \`code\`.\\n' }] }
|
|
])
|
|
window.__R__ = { ids: [] }
|
|
for (let n = 1; n <= ${TILES}; n++) {
|
|
const sid = 'ro-tile-' + n
|
|
const rid = 'ro-rt-' + n
|
|
const messages = []
|
|
for (let i = 0; i < ${TURNS}; i++) messages.push(...turn(sid, i))
|
|
messages.push({ id: sid + '-stream', role: 'assistant', timestamp: Date.now(), pending: true,
|
|
parts: [{ type: 'text', text: 'Working.' }] })
|
|
window.__R__.ids.push({ sid, rid })
|
|
hook.open(sid, 'center')
|
|
hook.patch(sid, { runtimeId: rid })
|
|
hook.publish(rid, {
|
|
storedSessionId: sid, messages, branch: '', cwd: '', model: '', provider: '',
|
|
reasoningEffort: '', serviceTier: '', fast: false, yolo: false, personality: '',
|
|
busy: true, awaitingResponse: false, streamId: sid + '-stream', sawAssistantPayload: true,
|
|
pendingBranchGroup: null, interrupted: false, interimBoundaryPending: false,
|
|
needsInput: false, turnStartedAt: Date.now(), usage: null
|
|
})
|
|
}
|
|
return 'ok'
|
|
})()
|
|
`
|
|
|
|
const reveal = sid => `window.__HERMES_LAYOUT_TREE__.reveal(${JSON.stringify(`session-tile:${sid}`)})`
|
|
|
|
// Patch ResizeObserver to count callbacks + distinct observed targets, then
|
|
// drag and report. Counting happens in the page so nothing crosses CDP per call.
|
|
//
|
|
// NOTE: the app's shared observer (hooks/use-resize-observer.ts) is created
|
|
// lazily on first use, so this patch must be installed BEFORE any surface
|
|
// mounts — otherwise the shared instance is a native one this wrapper never
|
|
// sees and every counter reads zero. `constructed` is the tell: a run showing
|
|
// a handful of constructions and zero callbacks means the patch landed late,
|
|
// not that the app stopped observing.
|
|
const INSTRUMENT = `
|
|
(() => {
|
|
if (window.__ROSTATS__) return 'already'
|
|
const Native = window.ResizeObserver
|
|
const stats = { constructed: 0, observed: 0, callbacks: 0, entries: 0, targets: new Set(), on: false }
|
|
window.__ROSTATS__ = stats
|
|
window.ResizeObserver = class extends Native {
|
|
constructor(cb) {
|
|
super((entries, obs) => {
|
|
if (stats.on) {
|
|
stats.callbacks += 1
|
|
stats.entries += entries.length
|
|
for (const e of entries) stats.targets.add(e.target)
|
|
}
|
|
return cb(entries, obs)
|
|
})
|
|
stats.constructed += 1
|
|
}
|
|
observe(...args) {
|
|
stats.observed += 1
|
|
return super.observe(...args)
|
|
}
|
|
}
|
|
return 'patched'
|
|
})()
|
|
`
|
|
|
|
const DRAG = `
|
|
(async () => {
|
|
const s = window.__ROSTATS__
|
|
s.callbacks = 0; s.entries = 0; s.targets = new Set(); s.on = true
|
|
const handle = document.querySelector('[role="separator"]')
|
|
if (!handle) { s.on = false; return JSON.stringify({ error: 'no sash' }) }
|
|
const box = handle.getBoundingClientRect()
|
|
const y = box.top + box.height / 2
|
|
const x0 = box.left + box.width / 2
|
|
let x = x0
|
|
const opts = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 }
|
|
const t0 = performance.now()
|
|
handle.dispatchEvent(new PointerEvent('pointerdown', { ...opts, clientX: x, clientY: y }))
|
|
for (let i = 0; i < 40; i++) {
|
|
x += (i < 20 ? 3 : -3)
|
|
window.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: x, clientY: y }))
|
|
await new Promise(r => setTimeout(r, 16))
|
|
}
|
|
window.dispatchEvent(new PointerEvent('pointerup', { ...opts, buttons: 0, clientX: x, clientY: y }))
|
|
await new Promise(r => setTimeout(r, 300))
|
|
s.on = false
|
|
return JSON.stringify({
|
|
ms: Math.round(performance.now() - t0),
|
|
moves: 40,
|
|
constructed: s.constructed,
|
|
observed: s.observed,
|
|
callbacks: s.callbacks,
|
|
entries: s.entries,
|
|
distinctTargets: s.targets.size,
|
|
userBubbles: document.querySelectorAll('[data-slot="aui_user-message-root"]').length
|
|
})
|
|
})()
|
|
`
|
|
|
|
const CLEANUP = `
|
|
(() => {
|
|
if (window.__R__) {
|
|
for (const { sid, rid } of window.__R__.ids) {
|
|
const s = window.__HERMES_SESSION_TILES__.states()
|
|
window.__HERMES_SESSION_TILES__.publish(rid, { ...s[rid], busy: false, streamId: null })
|
|
window.__HERMES_SESSION_TILES__.close(sid)
|
|
}
|
|
window.__R__ = null
|
|
}
|
|
return 'cleaned'
|
|
})()
|
|
`
|
|
|
|
const { cdp, teardown } = await attach({ port })
|
|
|
|
try {
|
|
await cdp.send('Runtime.enable')
|
|
await cdp.eval(INSTRUMENT)
|
|
|
|
const ok = await cdp.eval(setup)
|
|
|
|
if (ok !== 'ok') {
|
|
throw new Error(`setup failed: ${ok}`)
|
|
}
|
|
|
|
for (let n = 1; n <= TILES; n++) {
|
|
await cdp.eval(reveal(`ro-tile-${n}`))
|
|
await sleep(300)
|
|
}
|
|
|
|
await sleep(1500)
|
|
|
|
const r = JSON.parse(await cdp.eval(DRAG))
|
|
await cdp.eval(CLEANUP)
|
|
|
|
console.log(JSON.stringify(r, null, 2))
|
|
|
|
if (r.moves) {
|
|
console.log(`\nper pointermove: ${(r.entries / r.moves).toFixed(1)} RO entries`)
|
|
console.log(`distinct elements resized: ${r.distinctTargets} (user bubbles in DOM: ${r.userBubbles})`)
|
|
}
|
|
} finally {
|
|
teardown?.()
|
|
}
|