forked from Zakaria/hermes-agent
Hermes-agent
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* after-pack.cjs — electron-builder afterPack hook.
|
||||
*
|
||||
* Stamps the Hermes icon + identity onto the packed Windows Hermes.exe via
|
||||
* rcedit (delegated to set-exe-identity.cjs). This runs for EVERY packed build
|
||||
* — first install, `hermes desktop`, the installer's --update rebuild, and a
|
||||
* dev's manual `npm run pack` — so the branded exe can never silently revert
|
||||
* to the stock "Electron" icon/name (the bug when the stamp lived only in
|
||||
* install.ps1, which the update path doesn't use).
|
||||
*
|
||||
* Windows-only: rcedit edits PE resources, irrelevant on macOS/Linux where the
|
||||
* app identity comes from the bundle Info.plist / desktop entry. Best-effort:
|
||||
* a stamp failure must never fail an otherwise-good build (worst case is the
|
||||
* stock icon, not a broken app), so we log and resolve rather than throw.
|
||||
*
|
||||
* electron-builder passes a context with:
|
||||
* - electronPlatformName: 'win32' | 'darwin' | 'linux'
|
||||
* - appOutDir: the unpacked app directory for this target
|
||||
* - packager.appInfo.productFilename: the exe basename (e.g. 'Hermes')
|
||||
*/
|
||||
|
||||
const path = require('node:path')
|
||||
|
||||
const { stampExeIdentity } = require('./set-exe-identity.cjs')
|
||||
|
||||
exports.default = async function afterPack(context) {
|
||||
if (context.electronPlatformName !== 'win32') {
|
||||
return
|
||||
}
|
||||
|
||||
const productName = context.packager?.appInfo?.productFilename || 'Hermes'
|
||||
const exe = path.join(context.appOutDir, `${productName}.exe`)
|
||||
const desktopRoot = path.resolve(__dirname, '..')
|
||||
|
||||
try {
|
||||
await stampExeIdentity(exe, desktopRoot)
|
||||
} catch (err) {
|
||||
// Never fail the build over a cosmetic stamp.
|
||||
console.warn(`[after-pack] exe identity stamp failed (${err.message}); Hermes.exe keeps the stock Electron icon`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"use strict"
|
||||
|
||||
// Build-time guard: refuse to hand a half-built renderer to electron-builder.
|
||||
//
|
||||
// `npm run pack` / `npm run dist*` are `npm run build && npm run builder`.
|
||||
// If the `build` step (tsc -b && vite build) fails but packaging proceeds
|
||||
// anyway — a stale checkout that fails typecheck, an interrupted vite build,
|
||||
// or npm not short-circuiting `&&` in some shells — electron-builder happily
|
||||
// packages an app with an empty or missing `dist/`. The result launches but
|
||||
// blank-pages with `ERR_FILE_NOT_FOUND` for dist/index.html, with no clue why.
|
||||
//
|
||||
// This runs at the tail of `build`, after vite build, so any packaging path
|
||||
// inherits it. It fails loud and early instead of shipping a broken bundle.
|
||||
// See issues #39484 (renderer blank page) and #41327 / #39472 (dashboard 404).
|
||||
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
|
||||
// Pure check — returns { ok: true } or { ok: false, error: "..." }.
|
||||
// Kept side-effect-free so it can be unit tested without spawning a process.
|
||||
function checkDistBuilt(distDir) {
|
||||
if (!fs.existsSync(distDir) || !fs.statSync(distDir).isDirectory()) {
|
||||
return { ok: false, error: `no dist directory at ${distDir}` }
|
||||
}
|
||||
|
||||
const indexHtml = path.join(distDir, "index.html")
|
||||
if (!fs.existsSync(indexHtml) || !fs.statSync(indexHtml).isFile()) {
|
||||
return { ok: false, error: `dist/index.html is missing at ${indexHtml}` }
|
||||
}
|
||||
if (fs.statSync(indexHtml).size === 0) {
|
||||
return { ok: false, error: `dist/index.html is empty at ${indexHtml}` }
|
||||
}
|
||||
|
||||
// index.html alone isn't enough — vite emits hashed JS into dist/assets.
|
||||
// An index.html with no script bundle still blank-pages.
|
||||
const assetsDir = path.join(distDir, "assets")
|
||||
const hasAssets =
|
||||
fs.existsSync(assetsDir) &&
|
||||
fs.statSync(assetsDir).isDirectory() &&
|
||||
fs.readdirSync(assetsDir).some(name => name.endsWith(".js"))
|
||||
if (!hasAssets) {
|
||||
return { ok: false, error: `dist/assets has no built JS bundle (expected vite output under ${assetsDir})` }
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
function main() {
|
||||
const desktopRoot = path.resolve(__dirname, "..")
|
||||
const distDir = path.join(desktopRoot, "dist")
|
||||
const result = checkDistBuilt(distDir)
|
||||
|
||||
if (!result.ok) {
|
||||
console.error(`\n✗ assert-dist-built: ${result.error}`)
|
||||
console.error(" The renderer bundle is missing or incomplete, so packaging")
|
||||
console.error(" would produce an app that launches to a blank page.")
|
||||
console.error(" Re-run the build and check the tsc/vite output above for the")
|
||||
console.error(" real failure, then package again:")
|
||||
console.error(` cd ${desktopRoot} && npm run build\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log("✓ assert-dist-built: dist/index.html + assets present")
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main()
|
||||
}
|
||||
|
||||
module.exports = { checkDistBuilt }
|
||||
@@ -0,0 +1,84 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const test = require('node:test')
|
||||
|
||||
const { checkDistBuilt } = require('../scripts/assert-dist-built.cjs')
|
||||
|
||||
function makeDist(extra) {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-assert-dist-'))
|
||||
const distDir = path.join(tempRoot, 'dist')
|
||||
fs.mkdirSync(distDir, { recursive: true })
|
||||
if (extra) extra(distDir)
|
||||
return { tempRoot, distDir }
|
||||
}
|
||||
|
||||
test('checkDistBuilt passes when index.html + an assets JS bundle exist', () => {
|
||||
const { tempRoot, distDir } = makeDist(d => {
|
||||
fs.writeFileSync(path.join(d, 'index.html'), '<!doctype html><div id=root></div>', 'utf8')
|
||||
fs.mkdirSync(path.join(d, 'assets'))
|
||||
fs.writeFileSync(path.join(d, 'assets', 'index-abc123.js'), 'console.log(1)', 'utf8')
|
||||
})
|
||||
try {
|
||||
assert.deepEqual(checkDistBuilt(distDir), { ok: true })
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('checkDistBuilt fails when the dist directory is absent', () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-assert-dist-'))
|
||||
try {
|
||||
const result = checkDistBuilt(path.join(tempRoot, 'dist'))
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.error, /no dist directory/)
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('checkDistBuilt fails when index.html is missing', () => {
|
||||
const { tempRoot, distDir } = makeDist(d => {
|
||||
fs.mkdirSync(path.join(d, 'assets'))
|
||||
fs.writeFileSync(path.join(d, 'assets', 'index-abc123.js'), 'console.log(1)', 'utf8')
|
||||
})
|
||||
try {
|
||||
const result = checkDistBuilt(distDir)
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.error, /index\.html is missing/)
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('checkDistBuilt fails when index.html is empty', () => {
|
||||
const { tempRoot, distDir } = makeDist(d => {
|
||||
fs.writeFileSync(path.join(d, 'index.html'), '', 'utf8')
|
||||
fs.mkdirSync(path.join(d, 'assets'))
|
||||
fs.writeFileSync(path.join(d, 'assets', 'index-abc123.js'), 'console.log(1)', 'utf8')
|
||||
})
|
||||
try {
|
||||
const result = checkDistBuilt(distDir)
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.error, /index\.html is empty/)
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('checkDistBuilt fails when assets/ has no JS bundle', () => {
|
||||
const { tempRoot, distDir } = makeDist(d => {
|
||||
fs.writeFileSync(path.join(d, 'index.html'), '<!doctype html>', 'utf8')
|
||||
fs.mkdirSync(path.join(d, 'assets'))
|
||||
// CSS only, no JS — still a blank page at runtime.
|
||||
fs.writeFileSync(path.join(d, 'assets', 'index-abc123.css'), 'body{}', 'utf8')
|
||||
})
|
||||
try {
|
||||
const result = checkDistBuilt(distDir)
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.error, /no built JS bundle/)
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
"use strict"
|
||||
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
|
||||
const root = path.resolve(__dirname, "..", "..", "..")
|
||||
|
||||
try {
|
||||
fs.accessSync(path.join(root, "node_modules", "vite", "package.json"))
|
||||
} catch {
|
||||
console.error(`Run from repo root: cd ${root} && npm ci`)
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Desktop bundles ship precompiled renderer assets. Returning false here tells
|
||||
* electron-builder to skip the node_modules collector/install step, which
|
||||
* avoids workspace dependency graph explosions and keeps packaging
|
||||
* deterministic across environments. The Hermes Agent Python payload is no
|
||||
* longer bundled; the Electron app fetches it at first launch via
|
||||
* `install.ps1`'s stage protocol (Windows). See `electron/main.cjs`.
|
||||
*/
|
||||
module.exports = async function beforeBuild() {
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* before-pack.cjs — electron-builder beforePack hook.
|
||||
*
|
||||
* Removes any stale unpacked app directory (`appOutDir`) before
|
||||
* electron-builder stages the Electron binaries into it.
|
||||
*
|
||||
* WHY THIS EXISTS
|
||||
* ---------------
|
||||
* electron-builder's final packaging step copies the stock `electron`
|
||||
* binary into `release/<platform>-unpacked/` and then renames it to the
|
||||
* product name (`Hermes`). If a PREVIOUS `npm run pack` was interrupted
|
||||
* (Ctrl-C, OOM kill, crash, full disk) the unpacked directory is left in a
|
||||
* corrupted partial state: it keeps the already-renamed `LICENSE.electron.txt`
|
||||
* and the Chromium payload (.pak/.so/icudtl.dat/chrome-sandbox) but is MISSING
|
||||
* the `electron` binary itself.
|
||||
*
|
||||
* On the next run, electron-builder sees the destination directory already
|
||||
* populated, skips re-copying the binary it thinks is present, then tries to
|
||||
* rename a `electron` file that no longer exists. The build dies with:
|
||||
*
|
||||
* ENOENT: no such file or directory, rename
|
||||
* '.../release/linux-unpacked/electron' -> '.../release/linux-unpacked/Hermes'
|
||||
*
|
||||
* This is a hard failure with no obvious cause for the user — `hermes desktop`
|
||||
* just prints "Desktop GUI build failed" and the only fix is to manually
|
||||
* `rm -rf` the release directory, which a normal user has no way to know.
|
||||
*
|
||||
* The packaging step is not idempotent across an interrupted run, so we make
|
||||
* it idempotent ourselves: wipe the target unpacked directory up front so
|
||||
* electron-builder always stages into a clean tree. This is safe — the
|
||||
* directory is a pure build artifact that electron-builder fully recreates
|
||||
* on every pack; nothing else depends on its prior contents.
|
||||
*
|
||||
* Cross-platform: the same partial-state trap exists on macOS
|
||||
* (the mac-unpacked Hermes.app bundle) and Windows (win-unpacked), so we
|
||||
* clean whatever `appOutDir` electron-builder hands us regardless of platform.
|
||||
*
|
||||
* Best-effort: a cleanup failure must never mask the real build. We log and
|
||||
* resolve rather than throw — worst case electron-builder hits the original
|
||||
* ENOENT, which is no worse than not having this hook at all.
|
||||
*
|
||||
* electron-builder passes a context with:
|
||||
* - appOutDir: the unpacked app directory about to be staged
|
||||
* - electronPlatformName: 'win32' | 'darwin' | 'linux'
|
||||
*/
|
||||
|
||||
const fs = require('node:fs')
|
||||
|
||||
function cleanStaleAppOutDir(appOutDir) {
|
||||
if (!appOutDir || typeof appOutDir !== 'string') {
|
||||
return false
|
||||
}
|
||||
if (!fs.existsSync(appOutDir)) {
|
||||
return false
|
||||
}
|
||||
// Recursive + force so a half-written tree (read-only bits, partial files)
|
||||
// can't block the wipe. retry/maxRetries rides out transient EBUSY on
|
||||
// Windows where an AV/indexer may briefly hold a handle.
|
||||
fs.rmSync(appOutDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
|
||||
return true
|
||||
}
|
||||
|
||||
exports.cleanStaleAppOutDir = cleanStaleAppOutDir
|
||||
|
||||
exports.default = async function beforePack(context) {
|
||||
const appOutDir = context && context.appOutDir
|
||||
try {
|
||||
if (cleanStaleAppOutDir(appOutDir)) {
|
||||
console.log(`[before-pack] removed stale unpacked dir before staging: ${appOutDir}`)
|
||||
}
|
||||
} catch (err) {
|
||||
// Never fail the build over cleanup; surface why so a genuinely stuck
|
||||
// directory (permissions, mount) is still diagnosable.
|
||||
console.warn(`[before-pack] could not clean ${appOutDir} (${err.message}); continuing`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const test = require('node:test')
|
||||
|
||||
const { cleanStaleAppOutDir } = require('../scripts/before-pack.cjs')
|
||||
|
||||
test('cleanStaleAppOutDir removes a populated unpacked directory', () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-'))
|
||||
try {
|
||||
const appOutDir = path.join(tempRoot, 'linux-unpacked')
|
||||
fs.mkdirSync(appOutDir, { recursive: true })
|
||||
// Reproduce the corrupted partial state: license + payload present,
|
||||
// electron binary missing — exactly what trips the ENOENT rename.
|
||||
fs.writeFileSync(path.join(appOutDir, 'LICENSE.electron.txt'), 'x', 'utf8')
|
||||
fs.writeFileSync(path.join(appOutDir, 'resources.pak'), 'x', 'utf8')
|
||||
fs.mkdirSync(path.join(appOutDir, 'resources'), { recursive: true })
|
||||
fs.writeFileSync(path.join(appOutDir, 'resources', 'app.asar'), 'x', 'utf8')
|
||||
|
||||
const removed = cleanStaleAppOutDir(appOutDir)
|
||||
|
||||
assert.equal(removed, true)
|
||||
assert.equal(fs.existsSync(appOutDir), false)
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('cleanStaleAppOutDir is a no-op when the directory is absent', () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-'))
|
||||
try {
|
||||
const missing = path.join(tempRoot, 'does-not-exist')
|
||||
assert.equal(cleanStaleAppOutDir(missing), false)
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('cleanStaleAppOutDir ignores empty or invalid input', () => {
|
||||
assert.equal(cleanStaleAppOutDir(''), false)
|
||||
assert.equal(cleanStaleAppOutDir(undefined), false)
|
||||
assert.equal(cleanStaleAppOutDir(null), false)
|
||||
assert.equal(cleanStaleAppOutDir(42), false)
|
||||
})
|
||||
|
||||
test('beforePack default export resolves even when cleanup throws', async () => {
|
||||
const { default: beforePack } = require('../scripts/before-pack.cjs')
|
||||
// A directory path that rmSync can't remove is simulated by passing a
|
||||
// context whose appOutDir is a file the hook will try (and be allowed) to
|
||||
// remove; the contract under test is that the hook never rejects.
|
||||
await assert.doesNotReject(beforePack({ appOutDir: '', electronPlatformName: 'linux' }))
|
||||
})
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,115 @@
|
||||
// Wrap the thread scroller's properties and observe pin/scroll/RO events
|
||||
// in real time during a submit, then print the timeline.
|
||||
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 }))
|
||||
})
|
||||
const evalP = async expr => {
|
||||
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true })
|
||||
if (r.result?.exceptionDetails) throw new Error(r.result.exceptionDetails.text)
|
||||
return r.result.result.value
|
||||
}
|
||||
|
||||
await evalP(`(() => {
|
||||
const v = document.querySelector('[data-slot="aui_thread-viewport"]')
|
||||
if (v) v.scrollTop = v.scrollHeight
|
||||
})()`)
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
|
||||
await evalP(`(() => {
|
||||
const el = document.querySelector('[data-slot="composer-rich-input"]')
|
||||
el.focus()
|
||||
const r = document.createRange(); r.selectNodeContents(el); r.collapse(false)
|
||||
window.getSelection().removeAllRanges(); window.getSelection().addRange(r)
|
||||
})()`)
|
||||
|
||||
const text = 'short follow-up'
|
||||
for (const c of text) {
|
||||
await send('Input.dispatchKeyEvent', { type: 'char', text: c, unmodifiedText: c })
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
|
||||
// Hook into the viewport scrollTop setter + scroll + RO so we see every event
|
||||
await evalP(`(() => {
|
||||
const v = document.querySelector('[data-slot="aui_thread-viewport"]')
|
||||
const events = []
|
||||
window.__threadEvents = events
|
||||
const t0 = performance.now()
|
||||
const push = (kind, detail) => events.push({ t: performance.now() - t0, kind, ...detail })
|
||||
|
||||
// intercept scrollTop writes
|
||||
const desc = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollTop')
|
||||
Object.defineProperty(v, 'scrollTop', {
|
||||
get() { return desc.get.call(this) },
|
||||
set(val) {
|
||||
push('scrollTop=', { val, fromScrollHeight: this.scrollHeight, stackTop: (new Error()).stack.split('\\n').slice(2, 5).map(s => s.trim()).join(' | ') })
|
||||
desc.set.call(this, val)
|
||||
},
|
||||
configurable: true
|
||||
})
|
||||
|
||||
// scroll event
|
||||
v.addEventListener('scroll', () => {
|
||||
push('scroll', { scrollTop: v.scrollTop, scrollHeight: v.scrollHeight })
|
||||
}, { passive: true, capture: true })
|
||||
|
||||
// RO on the viewport itself
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
for (const e of entries) {
|
||||
push('RO', { target: e.target.getAttribute('data-slot') || e.target.tagName, h: e.contentRect.height })
|
||||
}
|
||||
})
|
||||
ro.observe(v)
|
||||
if (v.firstElementChild) ro.observe(v.firstElementChild)
|
||||
|
||||
// mutationobserver on the viewport
|
||||
const mo = new MutationObserver((muts) => {
|
||||
push('mut', { count: muts.length, added: muts.reduce((s, m) => s + m.addedNodes.length, 0), removed: muts.reduce((s, m) => s + m.removedNodes.length, 0) })
|
||||
})
|
||||
mo.observe(v, { childList: true, subtree: true, characterData: true })
|
||||
|
||||
window.__teardown = () => { ro.disconnect(); mo.disconnect() }
|
||||
return true
|
||||
})()`)
|
||||
|
||||
// fire Enter
|
||||
await send('Input.dispatchKeyEvent', {
|
||||
type: 'rawKeyDown', windowsVirtualKeyCode: 13, key: 'Enter', code: 'Enter', text: '\r', unmodifiedText: '\r'
|
||||
})
|
||||
await send('Input.dispatchKeyEvent', { type: 'keyUp', windowsVirtualKeyCode: 13, key: 'Enter', code: 'Enter' })
|
||||
|
||||
await new Promise(r => setTimeout(r, 1200))
|
||||
|
||||
const events = JSON.parse(await evalP(`JSON.stringify(window.__threadEvents || [])`))
|
||||
console.log(`\n${events.length} events:`)
|
||||
for (const e of events) {
|
||||
const t = String(e.t.toFixed(0)).padStart(5)
|
||||
const { kind, t: _t, ...rest } = e
|
||||
console.log(` ${t}ms ${kind.padEnd(12)} ${JSON.stringify(rest)}`)
|
||||
}
|
||||
|
||||
await evalP(`window.__teardown?.()`)
|
||||
// Cancel running agent
|
||||
await evalP(`(() => {
|
||||
for (const b of document.querySelectorAll('button')) {
|
||||
if ((b.getAttribute('aria-label') || '').toLowerCase().includes('stop')) { b.click(); return 'stopped' }
|
||||
}
|
||||
})()`)
|
||||
|
||||
ws.close()
|
||||
@@ -0,0 +1,229 @@
|
||||
// Reproduce + diagnose the "scroll wheel resets position while reading" bug.
|
||||
//
|
||||
// The complaint (Windows, mouse wheel): scrolling UP through a chat to re-read
|
||||
// older content randomly yanks the view to a different position, so you have to
|
||||
// fight the scrollbar. Mac users on trackpads don't see it.
|
||||
//
|
||||
// Hypothesis: the thread scroller has the browser default `overflow-anchor:
|
||||
// auto`, and the thread renders items in natural document flow (padding
|
||||
// spacers, NOT transforms). When an item above the viewport is measured by
|
||||
// @tanstack/react-virtual (its real height differs a lot from the 220px
|
||||
// estimate) — or when Shiki/images/fonts reflow it — TWO mechanisms both
|
||||
// adjust scrollTop for the same delta: TanStack's measurement compensation AND
|
||||
// the browser's native scroll anchoring. The double-correction lurches the
|
||||
// view. A mouse wheel's coarse, discrete notches mount/measure several
|
||||
// under-estimated turns per tick, so the over-correction is large and visible;
|
||||
// a trackpad's ~1-3px/frame keeps it sub-perceptual.
|
||||
//
|
||||
// This script drives synthetic mouse-wheel-UP scrolling on a long thread and
|
||||
// measures how much a tracked on-screen turn jumps, first with
|
||||
// `overflow-anchor: auto` (reproduce) then `overflow-anchor: none` (the fix).
|
||||
// If the fix run shows dramatically fewer/smaller jumps, the hypothesis holds.
|
||||
//
|
||||
// Prereq: a running desktop app with remote debugging on 9222, on a thread
|
||||
// with enough history to scroll (the longer / more code+tool blocks, the
|
||||
// better the repro). Then: node apps/desktop/scripts/diag-scroll-reset.mjs
|
||||
|
||||
const NOTCHES = 14 // wheel-up ticks per sweep
|
||||
const NOTCH_PX = 120 // Windows wheel notch ≈ 120px
|
||||
const NOTCH_GAP_MS = 130 // let each smooth-scroll animation settle
|
||||
const REVERSE_JUMP_PX = 6 // tracked turn moving UP while scrolling up = wrong way
|
||||
const LURCH_PX = 60 // single-frame on-screen jump that reads as a "reset"
|
||||
|
||||
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'))
|
||||
if (!tgt) {
|
||||
console.error('No page target on :9222. Is the desktop app running with --remote-debugging-port=9222?')
|
||||
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 = (m, p = {}) =>
|
||||
new Promise(r => {
|
||||
const i = ++id
|
||||
pending.set(i, r)
|
||||
ws.send(JSON.stringify({ id: i, method: m, params: p }))
|
||||
})
|
||||
const evalP = async expr => {
|
||||
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true })
|
||||
if (r.result?.exceptionDetails) throw new Error(r.result.exceptionDetails.text)
|
||||
return r.result.result.value
|
||||
}
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
||||
|
||||
// Install per-sweep instrumentation. `mode` is the overflow-anchor value to
|
||||
// force inline so we A/B the exact same thread regardless of any CSS fix.
|
||||
// Starts from ~45% down the thread so there's room to scroll up into
|
||||
// not-yet-measured turns, tags the turn nearest viewport-center as the anchor,
|
||||
// then records (per rAF) scrollTop + that turn's on-screen top, plus every
|
||||
// scrollTop *setter* write (TanStack compensation) and ResizeObserver hit.
|
||||
async function arm(mode) {
|
||||
await evalP(`(() => {
|
||||
const v = document.querySelector('[data-slot="aui_thread-viewport"]')
|
||||
if (!v) throw new Error('thread viewport not found')
|
||||
|
||||
// Force the overflow-anchor behavior under test (inline beats CSS).
|
||||
v.style.overflowAnchor = ${JSON.stringify(mode)}
|
||||
|
||||
// Park ~45% down so a wheel-up sweep climbs into estimated-but-unmeasured
|
||||
// turns above the fold (where the measurement correction fires).
|
||||
v.scrollTop = Math.round(v.scrollHeight * 0.45)
|
||||
|
||||
// Tag the turn closest to viewport center; we track its on-screen top.
|
||||
const vr = v.getBoundingClientRect()
|
||||
const center = vr.top + v.clientHeight / 2
|
||||
let best = null, bestD = Infinity
|
||||
for (const el of v.querySelectorAll('[data-index]')) {
|
||||
const r = el.getBoundingClientRect()
|
||||
const d = Math.abs((r.top + r.height / 2) - center)
|
||||
if (d < bestD) { bestD = d; best = el }
|
||||
}
|
||||
document.querySelectorAll('[data-se-anchor]').forEach(e => e.removeAttribute('data-se-anchor'))
|
||||
if (best) best.setAttribute('data-se-anchor', '1')
|
||||
const anchorIndex = best ? best.getAttribute('data-index') : null
|
||||
|
||||
const samples = []
|
||||
const writes = []
|
||||
const ros = []
|
||||
const t0 = performance.now()
|
||||
|
||||
// Intercept scrollTop writes → these are JS (TanStack) corrections.
|
||||
// Native browser scroll anchoring does NOT go through this setter, so a
|
||||
// scrollTop change with no write in the same frame is a native adjust.
|
||||
const desc = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollTop')
|
||||
Object.defineProperty(v, 'scrollTop', {
|
||||
configurable: true,
|
||||
get() { return desc.get.call(this) },
|
||||
set(val) {
|
||||
writes.push({ t: performance.now() - t0, val, sh: this.scrollHeight })
|
||||
desc.set.call(this, val)
|
||||
}
|
||||
})
|
||||
window.__restoreScrollTop = () => Object.defineProperty(v, 'scrollTop', desc)
|
||||
|
||||
const ro = new ResizeObserver(entries => {
|
||||
for (const e of entries) {
|
||||
ros.push({ t: performance.now() - t0, slot: e.target.getAttribute?.('data-slot') || e.target.tagName, h: Math.round(e.contentRect.height) })
|
||||
}
|
||||
})
|
||||
ro.observe(v)
|
||||
if (v.firstElementChild) ro.observe(v.firstElementChild)
|
||||
|
||||
let running = true
|
||||
const tick = () => {
|
||||
if (!running) return
|
||||
const a = v.querySelector('[data-se-anchor]')
|
||||
const ar = a ? a.getBoundingClientRect() : null
|
||||
samples.push({
|
||||
t: performance.now() - t0,
|
||||
st: Math.round(v.scrollTop * 100) / 100,
|
||||
sh: v.scrollHeight,
|
||||
ch: v.clientHeight,
|
||||
atop: ar ? Math.round(ar.top * 100) / 100 : null,
|
||||
aconn: !!a
|
||||
})
|
||||
requestAnimationFrame(tick)
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
|
||||
window.__se = { samples, writes, ros, anchorIndex, dpr: window.devicePixelRatio, stop() { running = false; ro.disconnect(); window.__restoreScrollTop?.() } }
|
||||
return true
|
||||
})()`)
|
||||
}
|
||||
|
||||
async function wheelUpSweep() {
|
||||
const { x, y } = await evalP(`(() => {
|
||||
const v = document.querySelector('[data-slot="aui_thread-viewport"]')
|
||||
const r = v.getBoundingClientRect()
|
||||
return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) }
|
||||
})()`)
|
||||
|
||||
for (let i = 0; i < NOTCHES; i++) {
|
||||
await send('Input.dispatchMouseEvent', { type: 'mouseWheel', x, y, deltaX: 0, deltaY: -NOTCH_PX })
|
||||
await sleep(NOTCH_GAP_MS)
|
||||
}
|
||||
await sleep(400)
|
||||
}
|
||||
|
||||
async function collect() {
|
||||
const data = JSON.parse(await evalP(`(() => { window.__se.stop(); return JSON.stringify(window.__se) })()`))
|
||||
return data
|
||||
}
|
||||
|
||||
function analyze(label, data) {
|
||||
const { samples, writes, ros, anchorIndex, dpr } = data
|
||||
let reverseJumps = 0
|
||||
let reverseSum = 0
|
||||
let lurches = 0
|
||||
let maxJump = 0
|
||||
let nativeMoves = 0
|
||||
let prev = null
|
||||
for (const s of samples) {
|
||||
if (prev && prev.aconn && s.aconn && prev.atop != null && s.atop != null) {
|
||||
const dTop = s.atop - prev.atop // wheel-up should move content DOWN → dTop >= 0
|
||||
const dSt = s.st - prev.st
|
||||
// Native (browser-anchoring) move: scrollTop changed with no setter write in this frame window.
|
||||
const wroteThisFrame = writes.some(w => w.t > prev.t && w.t <= s.t)
|
||||
if (Math.abs(dSt) > 0.5 && !wroteThisFrame) nativeMoves++
|
||||
if (dTop < -REVERSE_JUMP_PX) {
|
||||
reverseJumps++
|
||||
reverseSum += -dTop
|
||||
}
|
||||
if (Math.abs(dTop) > LURCH_PX) lurches++
|
||||
if (Math.abs(dTop) > maxJump) maxJump = Math.abs(dTop)
|
||||
}
|
||||
prev = s
|
||||
}
|
||||
console.log(`\n── ${label} ──`)
|
||||
console.log(` devicePixelRatio: ${dpr}${Number.isInteger(dpr) ? '' : ' (fractional — Windows scaling, worsens rounding jitter)'}`)
|
||||
console.log(` tracked turn index: ${anchorIndex}`)
|
||||
console.log(` rAF frames: ${samples.length}`)
|
||||
console.log(` scrollTop writes: ${writes.length} (TanStack measurement corrections)`)
|
||||
console.log(` ResizeObserver hits: ${ros.length}`)
|
||||
console.log(` native scroll moves: ${nativeMoves} (scrollTop moved with NO JS write = browser anchoring)`)
|
||||
console.log(` reverse jumps: ${reverseJumps} (tracked turn yanked UP while scrolling up; total ${reverseSum.toFixed(0)}px)`)
|
||||
console.log(` big lurches (>${LURCH_PX}px): ${lurches}`)
|
||||
console.log(` max single-frame jump: ${maxJump.toFixed(0)}px`)
|
||||
return { reverseJumps, reverseSum, lurches, maxJump, nativeMoves }
|
||||
}
|
||||
|
||||
console.log(`Wheel-up repro: ${NOTCHES} notches × ${NOTCH_PX}px, anchored mid-thread.\n`)
|
||||
|
||||
await arm('auto')
|
||||
await sleep(150)
|
||||
await wheelUpSweep()
|
||||
const a = analyze('overflow-anchor: auto (current / repro)', await collect())
|
||||
|
||||
await sleep(300)
|
||||
|
||||
await arm('none')
|
||||
await sleep(150)
|
||||
await wheelUpSweep()
|
||||
const b = analyze('overflow-anchor: none (proposed fix)', await collect())
|
||||
|
||||
// Clean up our tag.
|
||||
await evalP(`document.querySelectorAll('[data-se-anchor]').forEach(e => e.removeAttribute('data-se-anchor'))`)
|
||||
|
||||
console.log('\n══ verdict ══')
|
||||
const drop = (x, y) => (x === 0 ? (y === 0 ? '0' : 'n/a') : `${Math.round((1 - y / x) * 100)}% fewer`)
|
||||
console.log(` reverse jumps: auto=${a.reverseJumps} none=${b.reverseJumps} (${drop(a.reverseJumps, b.reverseJumps)})`)
|
||||
console.log(` big lurches: auto=${a.lurches} none=${b.lurches} (${drop(a.lurches, b.lurches)})`)
|
||||
console.log(` max jump: auto=${a.maxJump.toFixed(0)}px none=${b.maxJump.toFixed(0)}px`)
|
||||
console.log(` native moves: auto=${a.nativeMoves} none=${b.nativeMoves} (browser anchoring should ~vanish at none)`)
|
||||
if (a.reverseJumps + a.lurches > 0 && b.reverseJumps + b.lurches < a.reverseJumps + a.lurches) {
|
||||
console.log('\n → Jumps drop sharply with overflow-anchor:none → root cause confirmed.')
|
||||
} else if (a.reverseJumps + a.lurches === 0) {
|
||||
console.log('\n → No jumps captured this run. Use a longer thread (many code/tool blocks),')
|
||||
console.log(' raise NOTCHES, and ensure you start scrolled up from the bottom.')
|
||||
}
|
||||
|
||||
ws.close()
|
||||
@@ -0,0 +1,21 @@
|
||||
// Simple eval helper — runs an expression and returns the result.value.
|
||||
const targets = await (await fetch('http://127.0.0.1:9222/json')).json()
|
||||
const t = targets.find((t) => t.url.includes('5174'))
|
||||
const ws = new WebSocket(t.webSocketDebuggerUrl)
|
||||
let id = 0
|
||||
const pending = new Map()
|
||||
ws.addEventListener('message', (ev) => {
|
||||
const m = JSON.parse(ev.data)
|
||||
if (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((res) => { const i = ++id; pending.set(i, res); ws.send(JSON.stringify({ id: i, method, params })) })
|
||||
|
||||
const expr = process.argv[2] || '1+1'
|
||||
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true })
|
||||
if (r.result.exceptionDetails) {
|
||||
console.error('EXCEPTION:', r.result.exceptionDetails.exception?.description)
|
||||
} else {
|
||||
console.log(JSON.stringify(r.result.result.value, null, 2))
|
||||
}
|
||||
ws.close()
|
||||
@@ -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)
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
// Measure scroll position before and after Enter on a long thread.
|
||||
// The user's complaint: pressing Enter to submit makes the view "jump up".
|
||||
//
|
||||
// Steps:
|
||||
// 1. Scroll to the bottom of the thread
|
||||
// 2. Type a short message
|
||||
// 3. Record scroll position
|
||||
// 4. Hit Enter
|
||||
// 5. Record scroll position every 10ms for 1.5s after Enter
|
||||
// 6. Report deltas
|
||||
//
|
||||
// Usage: node apps/desktop/scripts/measure-jump.mjs
|
||||
|
||||
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 }))
|
||||
})
|
||||
const evalP = async expr => {
|
||||
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true })
|
||||
if (r.result?.exceptionDetails) throw new Error(r.result.exceptionDetails.text)
|
||||
return r.result.result.value
|
||||
}
|
||||
|
||||
// Scroll to bottom
|
||||
await evalP(`(() => {
|
||||
const v = document.querySelector('[data-slot="aui_thread-viewport"]')
|
||||
if (v) v.scrollTop = v.scrollHeight
|
||||
})()`)
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
|
||||
// Focus composer and type
|
||||
await evalP(`(() => {
|
||||
const el = document.querySelector('[data-slot="composer-rich-input"]')
|
||||
el.focus()
|
||||
const r = document.createRange(); r.selectNodeContents(el); r.collapse(false)
|
||||
window.getSelection().removeAllRanges(); window.getSelection().addRange(r)
|
||||
})()`)
|
||||
|
||||
const text = 'short follow-up message'
|
||||
for (const c of text) {
|
||||
await send('Input.dispatchKeyEvent', { type: 'char', text: c, unmodifiedText: c })
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
|
||||
// Set up sampling — sample scroll position every animation frame
|
||||
await evalP(`(() => {
|
||||
const v = document.querySelector('[data-slot="aui_thread-viewport"]')
|
||||
window.__jumpSamples = []
|
||||
window.__jumpStart = performance.now()
|
||||
const tick = () => {
|
||||
if (!v) return
|
||||
window.__jumpSamples.push({
|
||||
t: performance.now() - window.__jumpStart,
|
||||
scrollTop: v.scrollTop,
|
||||
scrollHeight: v.scrollHeight,
|
||||
clientHeight: v.clientHeight,
|
||||
distFromBottom: v.scrollHeight - v.scrollTop - v.clientHeight
|
||||
})
|
||||
if (performance.now() - window.__jumpStart < 2000) {
|
||||
requestAnimationFrame(tick)
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
})()`)
|
||||
|
||||
// Fire Enter
|
||||
await send('Input.dispatchKeyEvent', {
|
||||
type: 'rawKeyDown', windowsVirtualKeyCode: 13, key: 'Enter', code: 'Enter', text: '\r', unmodifiedText: '\r'
|
||||
})
|
||||
await send('Input.dispatchKeyEvent', { type: 'keyUp', windowsVirtualKeyCode: 13, key: 'Enter', code: 'Enter' })
|
||||
|
||||
await new Promise(r => setTimeout(r, 2200))
|
||||
|
||||
const samples = JSON.parse(await evalP(`JSON.stringify(window.__jumpSamples || [])`))
|
||||
console.log(`\n${samples.length} samples over 2s`)
|
||||
console.log(`\n t(ms) scrollTop scrollHeight clientHeight distFromBottom`)
|
||||
let prev = null
|
||||
for (const s of samples) {
|
||||
const marker = prev && Math.abs(s.scrollTop - prev.scrollTop) > 5 ? ' ← jump' : ''
|
||||
console.log(` ${String(s.t.toFixed(0)).padStart(5)} ${String(s.scrollTop).padStart(9)} ${String(s.scrollHeight).padStart(12)} ${String(s.clientHeight).padStart(12)} ${String(s.distFromBottom).padStart(14)}${marker}`)
|
||||
prev = s
|
||||
}
|
||||
|
||||
// Cancel any running agent
|
||||
await evalP(`(() => {
|
||||
for (const b of document.querySelectorAll('button')) {
|
||||
if ((b.getAttribute('aria-label') || '').toLowerCase().includes('stop')) { b.click(); return 'stopped' }
|
||||
}
|
||||
return 'no-stop'
|
||||
})()`).then(r => console.log('\ncancel:', r))
|
||||
|
||||
ws.close()
|
||||
@@ -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)
|
||||
})
|
||||
@@ -0,0 +1,252 @@
|
||||
// REAL streaming measurement — no React internals.
|
||||
//
|
||||
// Measures:
|
||||
// 1) rAF frame intervals during a verified live stream (long-frame histogram)
|
||||
// 2) MutationObserver: how often does the live assistant message mutate, what's the budget per mutation
|
||||
// 3) Text length growth rate (chars/sec)
|
||||
// 4) PerformanceObserver `longtask` entries (any task > 50ms blocks input)
|
||||
//
|
||||
// Detects REAL stream by waiting for assistant-message DOM count to grow past baseline.
|
||||
// Does NOT cancel — lets the stream run to completion or hits TIMEOUT_MS.
|
||||
|
||||
const CDP_HTTP = 'http://127.0.0.1:9222'
|
||||
const PROMPT = process.env.PROMPT || 'count from 1 to 80, one number per line'
|
||||
const TIMEOUT_MS = Number(process.env.TIMEOUT_MS || 60000)
|
||||
|
||||
async function getTarget() {
|
||||
const list = await (await fetch(`${CDP_HTTP}/json`)).json()
|
||||
const t = list.find((t) => t.type === 'page' && /5174/.test(t.url))
|
||||
if (!t) throw new Error('renderer not found')
|
||||
return t
|
||||
}
|
||||
|
||||
class CDP {
|
||||
constructor(ws) { this.ws = ws; this.id = 0; this.pending = new Map() }
|
||||
static async open(url) {
|
||||
const ws = new WebSocket(url)
|
||||
await new Promise((r, j) => {
|
||||
ws.addEventListener('open', r, { once: true })
|
||||
ws.addEventListener('error', (e) => j(e), { once: true })
|
||||
})
|
||||
const cdp = new CDP(ws)
|
||||
ws.addEventListener('message', (event) => {
|
||||
const m = JSON.parse(event.data.toString())
|
||||
if (m.id != null && cdp.pending.has(m.id)) {
|
||||
const { resolve, reject } = cdp.pending.get(m.id)
|
||||
cdp.pending.delete(m.id)
|
||||
if (m.error) reject(new Error(m.error.message))
|
||||
else resolve(m.result)
|
||||
}
|
||||
})
|
||||
return cdp
|
||||
}
|
||||
send(method, params) {
|
||||
const id = ++this.id
|
||||
return new Promise((res, rej) => {
|
||||
this.pending.set(id, { resolve: res, reject: rej })
|
||||
this.ws.send(JSON.stringify({ id, method, params }))
|
||||
})
|
||||
}
|
||||
async eval(expr) {
|
||||
const r = await this.send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true })
|
||||
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || 'eval')
|
||||
return r.result.value
|
||||
}
|
||||
close() { this.ws.close() }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const target = await getTarget()
|
||||
const cdp = await CDP.open(target.webSocketDebuggerUrl)
|
||||
|
||||
// Install recorders.
|
||||
await cdp.eval(`
|
||||
(() => {
|
||||
// rAF frame intervals
|
||||
window.__FT__ = { times: [], stop: false }
|
||||
let last = performance.now()
|
||||
const tick = () => {
|
||||
if (window.__FT__.stop) return
|
||||
const now = performance.now()
|
||||
window.__FT__.times.push(now - last)
|
||||
last = now
|
||||
requestAnimationFrame(tick)
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
|
||||
// longtask observer
|
||||
window.__LT__ = { entries: [], stop: false }
|
||||
try {
|
||||
const po = new PerformanceObserver((list) => {
|
||||
if (window.__LT__.stop) return
|
||||
for (const e of list.getEntries()) {
|
||||
window.__LT__.entries.push({ name: e.name, duration: e.duration, startTime: e.startTime })
|
||||
}
|
||||
})
|
||||
po.observe({ entryTypes: ['longtask'] })
|
||||
window.__LT__.po = po
|
||||
} catch {}
|
||||
|
||||
// mutation observer on streaming message
|
||||
window.__MO__ = { mutations: [], stop: false, currentMsg: null }
|
||||
const tryArm = () => {
|
||||
const all = document.querySelectorAll('[data-slot="aui_assistant-message-root"]')
|
||||
const last = all[all.length - 1]
|
||||
if (!last || last === window.__MO__.currentMsg) return
|
||||
window.__MO__.currentMsg = last
|
||||
if (window.__MO__.obs) window.__MO__.obs.disconnect()
|
||||
const obs = new MutationObserver((muts) => {
|
||||
if (window.__MO__.stop) return
|
||||
const t = performance.now()
|
||||
window.__MO__.mutations.push({ t, count: muts.length, len: last.textContent.length })
|
||||
})
|
||||
obs.observe(last, { childList: true, subtree: true, characterData: true })
|
||||
window.__MO__.obs = obs
|
||||
}
|
||||
window.__MO__.arm = tryArm
|
||||
return 'recorders armed'
|
||||
})()
|
||||
`)
|
||||
|
||||
// Baseline
|
||||
const base = JSON.parse(await cdp.eval(`
|
||||
JSON.stringify({
|
||||
assistantCount: document.querySelectorAll('[data-slot="aui_assistant-message-root"]').length,
|
||||
busy: !!document.querySelector('[data-status="running"], [data-busy="true"]'),
|
||||
hasComposer: !!document.querySelector('[contenteditable="true"]'),
|
||||
})
|
||||
`))
|
||||
console.log('baseline:', base)
|
||||
if (!base.hasComposer) { console.error('no composer'); cdp.close(); return }
|
||||
|
||||
// Type + submit
|
||||
await cdp.eval(`
|
||||
(() => {
|
||||
const ed = document.querySelector('[contenteditable="true"]')
|
||||
ed.focus()
|
||||
document.execCommand('insertText', false, ${JSON.stringify(PROMPT)})
|
||||
return 'typed'
|
||||
})()
|
||||
`)
|
||||
const submitT0 = Date.now()
|
||||
await cdp.eval(`
|
||||
(() => {
|
||||
const ed = document.querySelector('[contenteditable="true"]')
|
||||
ed.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', bubbles: true, cancelable: true }))
|
||||
return 'submitted'
|
||||
})()
|
||||
`)
|
||||
|
||||
// Poll for REAL stream (assistant count > baseline). 30 seconds — accommodates
|
||||
// slow first-token latencies on big providers.
|
||||
let realStreamT = null
|
||||
for (let i = 0; i < 600; i++) {
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
const s = JSON.parse(await cdp.eval(`
|
||||
JSON.stringify({
|
||||
n: document.querySelectorAll('[data-slot="aui_assistant-message-root"]').length,
|
||||
busy: !!document.querySelector('[data-status="running"], [data-busy="true"]'),
|
||||
text: (() => { const a = document.querySelectorAll('[data-slot="aui_assistant-message-root"]'); return a.length ? a[a.length-1].textContent.length : 0 })()
|
||||
})
|
||||
`))
|
||||
if (s.n > base.assistantCount) {
|
||||
realStreamT = Date.now()
|
||||
console.log('REAL stream started after', realStreamT - submitT0, 'ms — busy=', s.busy, 'text=', s.text)
|
||||
// Arm mutation observer on the new message
|
||||
await cdp.eval('window.__MO__.arm()')
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!realStreamT) {
|
||||
console.error('REAL STREAM NEVER STARTED')
|
||||
cdp.close()
|
||||
return
|
||||
}
|
||||
|
||||
// Sample length growth, wait for completion or timeout
|
||||
const samples = []
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < TIMEOUT_MS) {
|
||||
await new Promise((r) => setTimeout(r, 250))
|
||||
const s = JSON.parse(await cdp.eval(`
|
||||
JSON.stringify({
|
||||
t: performance.now(),
|
||||
len: (() => { const a = document.querySelectorAll('[data-slot="aui_assistant-message-root"]'); return a.length ? a[a.length-1].textContent.length : 0 })(),
|
||||
busy: !!document.querySelector('[data-status="running"], [data-busy="true"]')
|
||||
})
|
||||
`))
|
||||
samples.push(s)
|
||||
if (!s.busy && samples.length > 4) {
|
||||
await new Promise((r) => setTimeout(r, 300))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Pull recordings
|
||||
const data = JSON.parse(await cdp.eval(`
|
||||
(() => {
|
||||
window.__FT__.stop = true
|
||||
window.__LT__.stop = true
|
||||
window.__MO__.stop = true
|
||||
try { window.__LT__.po && window.__LT__.po.disconnect() } catch {}
|
||||
try { window.__MO__.obs && window.__MO__.obs.disconnect() } catch {}
|
||||
return JSON.stringify({
|
||||
frames: window.__FT__.times,
|
||||
longtasks: window.__LT__.entries,
|
||||
mutations: window.__MO__.mutations,
|
||||
})
|
||||
})()
|
||||
`))
|
||||
|
||||
const { frames, longtasks, mutations } = data
|
||||
|
||||
// Frame histogram (filter to stream window)
|
||||
const buckets = { '<=16.7': 0, '16.7-33': 0, '33-50': 0, '50-100': 0, '100-200': 0, '>200': 0 }
|
||||
let frameTotal = 0
|
||||
let maxFrame = 0
|
||||
for (const f of frames) {
|
||||
frameTotal += f
|
||||
if (f > maxFrame) maxFrame = f
|
||||
if (f <= 16.7) buckets['<=16.7']++
|
||||
else if (f <= 33) buckets['16.7-33']++
|
||||
else if (f <= 50) buckets['33-50']++
|
||||
else if (f <= 100) buckets['50-100']++
|
||||
else if (f <= 200) buckets['100-200']++
|
||||
else buckets['>200']++
|
||||
}
|
||||
const avgFps = frames.length ? (frames.length / (frameTotal / 1000)).toFixed(1) : 'n/a'
|
||||
const slowFrames = frames.filter((f) => f > 33).length
|
||||
const veryslowFrames = frames.filter((f) => f > 100).length
|
||||
|
||||
// Longtask summary
|
||||
const ltMs = longtasks.reduce((a, b) => a + b.duration, 0)
|
||||
const ltMax = longtasks.length ? Math.max(...longtasks.map((e) => e.duration)) : 0
|
||||
|
||||
// Mutation rate
|
||||
let mutTotal = mutations.length
|
||||
let mutDurs = []
|
||||
for (let i = 1; i < mutations.length; i++) {
|
||||
mutDurs.push(mutations[i].t - mutations[i - 1].t)
|
||||
}
|
||||
mutDurs.sort((a, b) => a - b)
|
||||
const mutP50 = mutDurs[Math.floor(mutDurs.length * 0.5)] ?? 0
|
||||
const mutP95 = mutDurs[Math.floor(mutDurs.length * 0.95)] ?? 0
|
||||
|
||||
// Growth rate
|
||||
const firstLen = samples[0]?.len ?? 0
|
||||
const lastLen = samples[samples.length - 1]?.len ?? 0
|
||||
const elapsedS = samples.length ? (samples[samples.length - 1].t - samples[0].t) / 1000 : 0
|
||||
const charsPerSec = elapsedS ? ((lastLen - firstLen) / elapsedS).toFixed(1) : 'n/a'
|
||||
|
||||
console.log('\n=== STREAM RESULTS ===')
|
||||
console.log('window:', (frameTotal / 1000).toFixed(1), 's | frames:', frames.length, '| avgFps:', avgFps, '| maxFrame:', maxFrame.toFixed(1), 'ms')
|
||||
console.log('frame histogram:', buckets)
|
||||
console.log('slow frames (>33ms):', slowFrames, '| very slow (>100ms):', veryslowFrames)
|
||||
console.log('longtasks:', longtasks.length, 'total', ltMs.toFixed(0), 'ms — max', ltMax.toFixed(1), 'ms')
|
||||
console.log('text grew', firstLen, '→', lastLen, 'chars (', charsPerSec, 'char/s )')
|
||||
console.log('mutations on streaming msg:', mutTotal, '| inter-mutation p50:', mutP50.toFixed(1), 'ms', 'p95:', mutP95.toFixed(1), 'ms')
|
||||
|
||||
cdp.close()
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1) })
|
||||
@@ -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)
|
||||
})
|
||||
@@ -0,0 +1,322 @@
|
||||
// Measure render cost of a synthetic stream driven through the live $messages atom.
|
||||
//
|
||||
// Why synthetic: the user's LLM credits are depleted; we can't fire a real stream.
|
||||
// The synthetic stream exercises the exact same React pipeline (assistant-ui runtime →
|
||||
// repository.addOrUpdateMessage → MessagePrimitive re-render → markdown reflow) as a
|
||||
// real stream. The only thing it does NOT exercise is the gateway → SSE → optimistic-
|
||||
// merge path, which is orthogonal to the rendering question.
|
||||
//
|
||||
// What we record:
|
||||
// 1) rAF frame intervals (long-frame histogram; >33ms = perceived jank, >100ms = bad)
|
||||
// 2) PerformanceObserver `longtask` entries (task >50ms blocks input)
|
||||
// 3) MutationObserver: per-message mutation count & inter-mutation latency
|
||||
// 4) Optional: typing latency overlay — typing into composer while streaming
|
||||
//
|
||||
// Output is plain text suitable for terminal + a JSON sidecar for diffing across runs.
|
||||
|
||||
import { writeFileSync } from 'node:fs'
|
||||
|
||||
const CDP_HTTP = 'http://127.0.0.1:9222'
|
||||
const TOKENS = Number(process.env.TOKENS || 300)
|
||||
const INTERVAL_MS = Number(process.env.INTERVAL_MS || 16)
|
||||
// Upstream flush throttle to apply in the synthetic driver. Mirrors what the
|
||||
// real gateway path does in `use-message-stream.scheduleDeltaFlush`. 0
|
||||
// disables (worst-case, every token = one React commit).
|
||||
const FLUSH_MIN_MS = Number(process.env.FLUSH_MIN_MS || 0)
|
||||
const CHUNK = process.env.CHUNK || 'lorem ipsum '
|
||||
const TYPE_WHILE_STREAMING = process.env.TYPE_WHILE_STREAMING === '1'
|
||||
const LABEL = process.env.LABEL || 'baseline'
|
||||
const OUT = process.env.OUT || `frame-times-${LABEL}.json`
|
||||
|
||||
async function getTarget() {
|
||||
const list = await (await fetch(`${CDP_HTTP}/json`)).json()
|
||||
const t = list.find((t) => t.type === 'page' && /5174/.test(t.url))
|
||||
if (!t) throw new Error('renderer not found')
|
||||
return t
|
||||
}
|
||||
|
||||
class CDP {
|
||||
constructor(ws) { this.ws = ws; this.id = 0; this.pending = new Map() }
|
||||
static async open(url) {
|
||||
const ws = new WebSocket(url)
|
||||
await new Promise((r, j) => {
|
||||
ws.addEventListener('open', r, { once: true })
|
||||
ws.addEventListener('error', (e) => j(e), { once: true })
|
||||
})
|
||||
const cdp = new CDP(ws)
|
||||
ws.addEventListener('message', (ev) => {
|
||||
const m = JSON.parse(ev.data.toString())
|
||||
if (m.id != null && cdp.pending.has(m.id)) {
|
||||
const { resolve, reject } = cdp.pending.get(m.id)
|
||||
cdp.pending.delete(m.id)
|
||||
if (m.error) reject(new Error(m.error.message))
|
||||
else resolve(m.result)
|
||||
}
|
||||
})
|
||||
return cdp
|
||||
}
|
||||
send(method, params) {
|
||||
const id = ++this.id
|
||||
return new Promise((res, rej) => {
|
||||
this.pending.set(id, { resolve: res, reject: rej })
|
||||
this.ws.send(JSON.stringify({ id, method, params }))
|
||||
})
|
||||
}
|
||||
async eval(expr) {
|
||||
const r = await this.send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true })
|
||||
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || 'eval')
|
||||
return r.result.value
|
||||
}
|
||||
close() { this.ws.close() }
|
||||
}
|
||||
|
||||
function pct(arr, p) {
|
||||
if (!arr.length) return 0
|
||||
const i = Math.min(arr.length - 1, Math.floor(arr.length * p))
|
||||
return arr[i]
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const target = await getTarget()
|
||||
const cdp = await CDP.open(target.webSocketDebuggerUrl)
|
||||
|
||||
// Sanity check driver is loaded.
|
||||
const probeOk = await cdp.eval('!!window.__PERF_DRIVE__ && !!window.__PERF_DRIVE__.stream')
|
||||
if (!probeOk) {
|
||||
console.error('__PERF_DRIVE__ not on window — did you reload the renderer after editing perf-probe.tsx?')
|
||||
cdp.close()
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
// Install recorders.
|
||||
await cdp.eval(`
|
||||
(() => {
|
||||
window.__FT__ = { times: [], stop: false }
|
||||
let last = performance.now()
|
||||
const tick = () => {
|
||||
if (window.__FT__.stop) return
|
||||
const now = performance.now()
|
||||
window.__FT__.times.push(now - last)
|
||||
last = now
|
||||
requestAnimationFrame(tick)
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
|
||||
window.__LT__ = { entries: [], stop: false }
|
||||
try {
|
||||
const po = new PerformanceObserver((list) => {
|
||||
if (window.__LT__.stop) return
|
||||
for (const e of list.getEntries()) {
|
||||
window.__LT__.entries.push({ name: e.name, duration: e.duration, startTime: e.startTime })
|
||||
}
|
||||
})
|
||||
po.observe({ entryTypes: ['longtask'] })
|
||||
window.__LT__.po = po
|
||||
} catch {}
|
||||
|
||||
window.__MO__ = { mutations: [], stop: false, currentMsg: null }
|
||||
const arm = () => {
|
||||
const all = document.querySelectorAll('[data-slot="aui_assistant-message-root"]')
|
||||
const last = all[all.length - 1]
|
||||
if (!last || last === window.__MO__.currentMsg) return
|
||||
window.__MO__.currentMsg = last
|
||||
if (window.__MO__.obs) window.__MO__.obs.disconnect()
|
||||
const obs = new MutationObserver((muts) => {
|
||||
if (window.__MO__.stop) return
|
||||
const t = performance.now()
|
||||
window.__MO__.mutations.push({ t, count: muts.length, len: last.textContent.length })
|
||||
})
|
||||
obs.observe(last, { childList: true, subtree: true, characterData: true })
|
||||
window.__MO__.obs = obs
|
||||
}
|
||||
window.__MO__.arm = arm
|
||||
|
||||
// Optional: typing observer — fires keystroke timings if asked.
|
||||
window.__TYP__ = { times: [], stop: false, lastKey: 0 }
|
||||
return 'recorders armed'
|
||||
})()
|
||||
`)
|
||||
|
||||
// Baseline state.
|
||||
const base = JSON.parse(await cdp.eval(`
|
||||
JSON.stringify({
|
||||
assistantCount: document.querySelectorAll('[data-slot="aui_assistant-message-root"]').length,
|
||||
atomCount: window.__PERF_DRIVE__.snapshotMsgs()
|
||||
})
|
||||
`))
|
||||
console.log('baseline:', base)
|
||||
|
||||
// Drive a synthetic stream.
|
||||
const streamStart = Date.now()
|
||||
await cdp.eval(`window.__PERF_DRIVE__.stream({ chunk: ${JSON.stringify(CHUNK)}, intervalMs: ${INTERVAL_MS}, totalTokens: ${TOKENS}, flushMinMs: ${FLUSH_MIN_MS} })`)
|
||||
|
||||
// After the first paint, arm MO on the new message.
|
||||
await new Promise((r) => setTimeout(r, 200))
|
||||
await cdp.eval('window.__MO__.arm()')
|
||||
|
||||
// Optional: type while streaming.
|
||||
if (TYPE_WHILE_STREAMING) {
|
||||
await new Promise((r) => setTimeout(r, 400))
|
||||
await cdp.eval(`(() => {
|
||||
const ed = document.querySelector('[contenteditable="true"]')
|
||||
ed.focus()
|
||||
window.__TYP__.startedAt = performance.now()
|
||||
const text = 'the quick brown fox jumps over the lazy dog '
|
||||
let i = 0
|
||||
const tick = () => {
|
||||
if (i >= text.length) return
|
||||
const t0 = performance.now()
|
||||
document.execCommand('insertText', false, text[i])
|
||||
// requestAnimationFrame to wait for next paint
|
||||
requestAnimationFrame(() => {
|
||||
window.__TYP__.times.push(performance.now() - t0)
|
||||
})
|
||||
i++
|
||||
setTimeout(tick, 60)
|
||||
}
|
||||
tick()
|
||||
return 'typing'
|
||||
})()`)
|
||||
}
|
||||
|
||||
// Wait for stream to complete + small grace.
|
||||
const expectedMs = TOKENS * INTERVAL_MS + 1500
|
||||
await new Promise((r) => setTimeout(r, expectedMs))
|
||||
|
||||
// Pull recordings.
|
||||
const data = JSON.parse(await cdp.eval(`
|
||||
(() => {
|
||||
window.__FT__.stop = true
|
||||
window.__LT__.stop = true
|
||||
window.__MO__.stop = true
|
||||
window.__TYP__.stop = true
|
||||
try { window.__LT__.po && window.__LT__.po.disconnect() } catch {}
|
||||
try { window.__MO__.obs && window.__MO__.obs.disconnect() } catch {}
|
||||
return JSON.stringify({
|
||||
frames: window.__FT__.times,
|
||||
longtasks: window.__LT__.entries,
|
||||
mutations: window.__MO__.mutations,
|
||||
typing: window.__TYP__.times,
|
||||
finalText: (() => { const a = document.querySelectorAll('[data-slot="aui_assistant-message-root"]'); return a.length ? a[a.length-1].textContent.length : 0 })()
|
||||
})
|
||||
})()
|
||||
`))
|
||||
|
||||
// Reset DOM back to baseline so we don't accumulate fake messages.
|
||||
await cdp.eval('window.__PERF_DRIVE__.reset()')
|
||||
|
||||
// Analysis (trim warm-up: drop frames before first mutation timestamp).
|
||||
const firstMut = data.mutations[0]?.t
|
||||
const frames = data.frames
|
||||
|
||||
// Sum durations to figure out when each frame happened (relative to recorder start).
|
||||
const frameTimeline = []
|
||||
let acc = 0
|
||||
for (const f of frames) { acc += f; frameTimeline.push(acc) }
|
||||
|
||||
// Mutations are in performance.now() ms; frames started recording when we installed
|
||||
// the recorder (before stream). To align: compute total stream window from frames
|
||||
// after mutation activity began. Simpler heuristic: drop first 500ms of frames as warm-up.
|
||||
const WARMUP_MS = 500
|
||||
let dropIdx = 0
|
||||
for (let i = 0; i < frames.length; i++) {
|
||||
if (frameTimeline[i] >= WARMUP_MS) { dropIdx = i; break }
|
||||
}
|
||||
const streamFrames = frames.slice(dropIdx)
|
||||
|
||||
const buckets = { '<=16.7': 0, '16.7-33': 0, '33-50': 0, '50-100': 0, '100-200': 0, '>200': 0 }
|
||||
let frameTotal = 0
|
||||
let maxFrame = 0
|
||||
for (const f of streamFrames) {
|
||||
frameTotal += f
|
||||
if (f > maxFrame) maxFrame = f
|
||||
if (f <= 16.7) buckets['<=16.7']++
|
||||
else if (f <= 33) buckets['16.7-33']++
|
||||
else if (f <= 50) buckets['33-50']++
|
||||
else if (f <= 100) buckets['50-100']++
|
||||
else if (f <= 200) buckets['100-200']++
|
||||
else buckets['>200']++
|
||||
}
|
||||
const sortedFrames = [...streamFrames].sort((a, b) => a - b)
|
||||
const fAvgFps = streamFrames.length ? (streamFrames.length / (frameTotal / 1000)).toFixed(1) : 'n/a'
|
||||
const fP50 = pct(sortedFrames, 0.5).toFixed(1)
|
||||
const fP95 = pct(sortedFrames, 0.95).toFixed(1)
|
||||
const fP99 = pct(sortedFrames, 0.99).toFixed(1)
|
||||
const slowFrames = streamFrames.filter((f) => f > 33).length
|
||||
const veryslowFrames = streamFrames.filter((f) => f > 100).length
|
||||
|
||||
const ltDur = data.longtasks.map((e) => e.duration).sort((a, b) => a - b)
|
||||
const ltMs = ltDur.reduce((a, b) => a + b, 0)
|
||||
const ltMax = ltDur.length ? ltDur[ltDur.length - 1] : 0
|
||||
const ltP95 = pct(ltDur, 0.95)
|
||||
|
||||
// Mutation cadence.
|
||||
const mutDurs = []
|
||||
for (let i = 1; i < data.mutations.length; i++) mutDurs.push(data.mutations[i].t - data.mutations[i - 1].t)
|
||||
mutDurs.sort((a, b) => a - b)
|
||||
const mutP50 = pct(mutDurs, 0.5)
|
||||
const mutP95 = pct(mutDurs, 0.95)
|
||||
const mutMax = mutDurs.length ? mutDurs[mutDurs.length - 1] : 0
|
||||
|
||||
// Typing latency (optional).
|
||||
let typingSummary = null
|
||||
if (TYPE_WHILE_STREAMING && data.typing.length) {
|
||||
const t = [...data.typing].sort((a, b) => a - b)
|
||||
typingSummary = {
|
||||
n: t.length,
|
||||
p50: pct(t, 0.5).toFixed(1),
|
||||
p95: pct(t, 0.95).toFixed(1),
|
||||
max: t[t.length - 1].toFixed(1)
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
label: LABEL,
|
||||
timestamp: new Date().toISOString(),
|
||||
config: { TOKENS, INTERVAL_MS, CHUNK, TYPE_WHILE_STREAMING, FLUSH_MIN_MS },
|
||||
streamWallMs: Date.now() - streamStart,
|
||||
frames: {
|
||||
total: streamFrames.length,
|
||||
avgFps: fAvgFps,
|
||||
windowS: (frameTotal / 1000).toFixed(1),
|
||||
p50: fP50,
|
||||
p95: fP95,
|
||||
p99: fP99,
|
||||
max: maxFrame.toFixed(1),
|
||||
slow33: slowFrames,
|
||||
veryslow100: veryslowFrames,
|
||||
histogram: buckets
|
||||
},
|
||||
longtasks: {
|
||||
n: data.longtasks.length,
|
||||
totalMs: ltMs.toFixed(0),
|
||||
maxMs: ltMax.toFixed(1),
|
||||
p95Ms: ltP95.toFixed(1)
|
||||
},
|
||||
mutations: {
|
||||
n: data.mutations.length,
|
||||
finalTextLen: data.finalText,
|
||||
interMutP50ms: mutP50.toFixed(1),
|
||||
interMutP95ms: mutP95.toFixed(1),
|
||||
interMutMaxMs: mutMax.toFixed(1)
|
||||
},
|
||||
typing: typingSummary
|
||||
}
|
||||
|
||||
writeFileSync(OUT, JSON.stringify(result, null, 2))
|
||||
|
||||
console.log('\n=== SYNTHETIC STREAM RESULTS ===')
|
||||
console.log('label:', LABEL, '| tokens:', TOKENS, '@', INTERVAL_MS, 'ms')
|
||||
console.log('streamWallMs:', result.streamWallMs)
|
||||
console.log('FRAMES: avgFps', fAvgFps, '| p50', fP50, 'ms | p95', fP95, 'ms | p99', fP99, 'ms | max', maxFrame.toFixed(1), 'ms')
|
||||
console.log('FRAMES histogram:', buckets)
|
||||
console.log('FRAMES slow(>33):', slowFrames, '/ veryslow(>100):', veryslowFrames, 'of', streamFrames.length)
|
||||
console.log('LONGTASKS:', data.longtasks.length, '| total', ltMs.toFixed(0), 'ms | max', ltMax.toFixed(1), 'ms | p95', ltP95.toFixed(1), 'ms')
|
||||
console.log('MUTATIONS:', data.mutations.length, '| finalLen', data.finalText, 'chars | inter p50', mutP50.toFixed(1), 'ms | p95', mutP95.toFixed(1), 'ms')
|
||||
if (typingSummary) console.log('TYPING-WHILE-STREAMING latency: p50', typingSummary.p50, 'ms | p95', typingSummary.p95, 'ms | n=', typingSummary.n)
|
||||
console.log('written to', OUT)
|
||||
|
||||
cdp.close()
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1) })
|
||||
@@ -0,0 +1,77 @@
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const { execFile } = require('node:child_process')
|
||||
|
||||
function run(command, args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(command, args, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
// Intentionally omit args from the rejection message: callers pass
|
||||
// notarization credentials (key id, issuer, key file path) here, and
|
||||
// surfacing them in error output would land in CI logs.
|
||||
reject(new Error(`${command} failed: ${stderr?.trim() || stdout?.trim() || error.message}`))
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function inlineKeyLooksValid(value) {
|
||||
return value.includes('BEGIN PRIVATE KEY') && value.includes('END PRIVATE KEY')
|
||||
}
|
||||
|
||||
function resolveApiKeyPath(rawValue) {
|
||||
const value = String(rawValue || '').trim()
|
||||
if (!value) return { keyPath: '', cleanup: () => {} }
|
||||
|
||||
if (fs.existsSync(value)) {
|
||||
return { keyPath: value, cleanup: () => {} }
|
||||
}
|
||||
|
||||
if (!inlineKeyLooksValid(value)) {
|
||||
throw new Error('APPLE_API_KEY must be a file path or inline .p8 key content')
|
||||
}
|
||||
|
||||
const tempPath = path.join(os.tmpdir(), `hermes-notary-${Date.now()}-${process.pid}.p8`)
|
||||
fs.writeFileSync(tempPath, value, 'utf8')
|
||||
return {
|
||||
keyPath: tempPath,
|
||||
cleanup: () => fs.rmSync(tempPath, { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const artifactPath = process.argv[2]
|
||||
if (!artifactPath || !fs.existsSync(artifactPath)) {
|
||||
throw new Error(`Missing artifact to notarize: ${artifactPath || '(none)'}`)
|
||||
}
|
||||
|
||||
const profile = String(process.env.APPLE_NOTARY_PROFILE || '').trim()
|
||||
if (profile) {
|
||||
await run('xcrun', ['notarytool', 'submit', artifactPath, '--keychain-profile', profile, '--wait'])
|
||||
await run('xcrun', ['stapler', 'staple', '-v', artifactPath])
|
||||
return
|
||||
}
|
||||
|
||||
const keyId = String(process.env.APPLE_API_KEY_ID || '').trim()
|
||||
const issuer = String(process.env.APPLE_API_ISSUER || '').trim()
|
||||
const rawApiKey = process.env.APPLE_API_KEY
|
||||
if (!rawApiKey || !keyId || !issuer) {
|
||||
throw new Error('APPLE_API_KEY, APPLE_API_KEY_ID, and APPLE_API_ISSUER are required')
|
||||
}
|
||||
|
||||
const { keyPath, cleanup } = resolveApiKeyPath(rawApiKey)
|
||||
try {
|
||||
await run('xcrun', ['notarytool', 'submit', artifactPath, '--key', keyPath, '--key-id', keyId, '--issuer', issuer, '--wait'])
|
||||
await run('xcrun', ['stapler', 'staple', '-v', artifactPath])
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(() => {
|
||||
console.error('Notarization failed. Check configuration and command output in secure CI logs.')
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const { execFile } = require('node:child_process')
|
||||
|
||||
function run(command, args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(command, args, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(
|
||||
new Error(
|
||||
`${command} ${args.join(' ')} failed: ${stderr?.trim() || stdout?.trim() || error.message}`
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
resolve({ stdout, stderr })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function inlineKeyLooksValid(value) {
|
||||
return value.includes('BEGIN PRIVATE KEY') && value.includes('END PRIVATE KEY')
|
||||
}
|
||||
|
||||
function resolveApiKeyPath(rawValue) {
|
||||
const value = String(rawValue || '').trim()
|
||||
if (!value) return { keyPath: '', cleanup: () => {} }
|
||||
|
||||
if (fs.existsSync(value)) {
|
||||
return { keyPath: value, cleanup: () => {} }
|
||||
}
|
||||
|
||||
if (!inlineKeyLooksValid(value)) {
|
||||
throw new Error('APPLE_API_KEY must be a file path or inline .p8 key content')
|
||||
}
|
||||
|
||||
const tempPath = path.join(os.tmpdir(), `hermes-notary-${Date.now()}-${process.pid}.p8`)
|
||||
fs.writeFileSync(tempPath, value, 'utf8')
|
||||
return {
|
||||
keyPath: tempPath,
|
||||
cleanup: () => {
|
||||
try {
|
||||
fs.rmSync(tempPath, { force: true })
|
||||
} catch {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exports.default = async function notarize(context) {
|
||||
const { electronPlatformName, appOutDir, packager } = context
|
||||
if (electronPlatformName !== 'darwin') return
|
||||
|
||||
const appName = packager.appInfo.productFilename
|
||||
const appPath = path.join(appOutDir, `${appName}.app`)
|
||||
if (!fs.existsSync(appPath)) {
|
||||
throw new Error(`Cannot notarize missing app bundle: ${appPath}`)
|
||||
}
|
||||
|
||||
const profile = String(process.env.APPLE_NOTARY_PROFILE || '').trim()
|
||||
if (profile) {
|
||||
const zipPath = path.join(appOutDir, `${appName}.zip`)
|
||||
await run('ditto', ['-c', '-k', '--sequesterRsrc', '--keepParent', appPath, zipPath])
|
||||
await run('xcrun', ['notarytool', 'submit', zipPath, '--keychain-profile', profile, '--wait'])
|
||||
await run('xcrun', ['stapler', 'staple', '-v', appPath])
|
||||
try {
|
||||
fs.rmSync(zipPath, { force: true })
|
||||
} catch {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const keyId = String(process.env.APPLE_API_KEY_ID || '').trim()
|
||||
const issuer = String(process.env.APPLE_API_ISSUER || '').trim()
|
||||
const rawApiKey = process.env.APPLE_API_KEY
|
||||
if (!rawApiKey || !keyId || !issuer) {
|
||||
console.log(
|
||||
'Skipping notarization: APPLE_API_KEY, APPLE_API_KEY_ID, and APPLE_API_ISSUER are not fully configured.'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const { keyPath, cleanup } = resolveApiKeyPath(rawApiKey)
|
||||
const zipPath = path.join(appOutDir, `${appName}.zip`)
|
||||
try {
|
||||
await run('ditto', ['-c', '-k', '--sequesterRsrc', '--keepParent', appPath, zipPath])
|
||||
await run('xcrun', ['notarytool', 'submit', zipPath, '--key', keyPath, '--key-id', keyId, '--issuer', issuer, '--wait'])
|
||||
await run('xcrun', ['stapler', 'staple', '-v', appPath])
|
||||
} finally {
|
||||
try {
|
||||
fs.rmSync(zipPath, { force: true })
|
||||
} catch {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -0,0 +1,40 @@
|
||||
// Probe the cloud shadows thread state — count messages, turn pairs,
|
||||
// thread height, composer state
|
||||
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 }))
|
||||
})
|
||||
|
||||
const r = await send('Runtime.evaluate', {
|
||||
expression: `JSON.stringify({
|
||||
url: location.href,
|
||||
title: document.title,
|
||||
turnPairs: document.querySelectorAll('[data-slot="aui_turn-pair"]').length,
|
||||
assistantMsgs: document.querySelectorAll('[data-slot="aui_assistant-message-root"]').length,
|
||||
userMsgs: document.querySelectorAll('[data-message-role="user"], [data-slot="aui_user-message-root"]').length,
|
||||
totalDomNodes: document.querySelectorAll('*').length,
|
||||
threadViewportScrollHeight: document.querySelector('[data-slot="aui_thread-viewport"]')?.scrollHeight ?? null,
|
||||
threadViewportClientHeight: document.querySelector('[data-slot="aui_thread-viewport"]')?.clientHeight ?? null,
|
||||
threadViewportScrollTop: document.querySelector('[data-slot="aui_thread-viewport"]')?.scrollTop ?? null,
|
||||
composer: !!document.querySelector('[data-slot="composer-rich-input"]'),
|
||||
busy: !!document.querySelector('[aria-label*="Stop"]')
|
||||
})`,
|
||||
returnByValue: true
|
||||
})
|
||||
console.log(JSON.parse(r.result.result.value))
|
||||
ws.close()
|
||||
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env node
|
||||
// Long-running stream profile + frame-rate timeline. Submits a prompt that
|
||||
// asks for ~30 paragraphs of output, then captures both a CPU profile and
|
||||
// a per-100ms frame counter so we can see if FPS sags as the message grows.
|
||||
|
||||
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-long-stream-${Date.now()}`)
|
||||
const STREAM_SEC = Number(args.seconds ?? 25)
|
||||
|
||||
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 })
|
||||
if (r.exceptionDetails) throw new Error(r.exceptionDetails.text)
|
||||
return r.result.value
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const tgt = await pickRenderer()
|
||||
console.log('target', tgt.url)
|
||||
const cdp = await connect(tgt.webSocketDebuggerUrl)
|
||||
await cdp.send('Runtime.enable')
|
||||
await cdp.send('Profiler.enable')
|
||||
await cdp.send('Performance.enable')
|
||||
|
||||
// Submit a long-form prompt
|
||||
await evalP(
|
||||
cdp,
|
||||
`(() => {
|
||||
const el = document.querySelector('[data-slot="composer-rich-input"]')
|
||||
el.focus()
|
||||
const r = document.createRange(); r.selectNodeContents(el); r.collapse(false)
|
||||
window.getSelection().removeAllRanges(); window.getSelection().addRange(r)
|
||||
})()`
|
||||
)
|
||||
const prompt = 'write 15 paragraphs about gpu memory bandwidth, memory hierarchies, roofline model, and how modern transformer inference benefits from these. include diagrams in ascii where relevant. no code. fully detailed.'
|
||||
for (const c of prompt) {
|
||||
await cdp.send('Input.dispatchKeyEvent', { type: 'char', text: c, unmodifiedText: c })
|
||||
await new Promise(r => setTimeout(r, 5))
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 200))
|
||||
await cdp.send('Input.dispatchKeyEvent', {
|
||||
type: 'rawKeyDown', windowsVirtualKeyCode: 13, key: 'Enter', code: 'Enter', text: '\r', unmodifiedText: '\r'
|
||||
})
|
||||
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', windowsVirtualKeyCode: 13, key: 'Enter', code: 'Enter' })
|
||||
|
||||
console.log('waiting for assistant…')
|
||||
let streaming = false
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const c = await evalP(cdp, `document.querySelectorAll('[data-slot="aui_assistant-message-root"]').length`)
|
||||
if (c > 0) { streaming = true; break }
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
}
|
||||
if (!streaming) {
|
||||
console.error('no assistant message')
|
||||
cdp.close()
|
||||
return
|
||||
}
|
||||
|
||||
// Install a per-rAF frame counter
|
||||
await evalP(
|
||||
cdp,
|
||||
`(() => {
|
||||
window.__fpsSamples = []
|
||||
window.__fpsT0 = performance.now()
|
||||
window.__fpsLast = performance.now()
|
||||
window.__fpsFrameCount = 0
|
||||
window.__fpsHistogram = [] // {t, fps, contentLen}
|
||||
const tick = () => {
|
||||
const now = performance.now()
|
||||
const dt = now - window.__fpsLast
|
||||
window.__fpsLast = now
|
||||
window.__fpsFrameCount++
|
||||
window.__fpsSamples.push({ t: now - window.__fpsT0, dt })
|
||||
if (performance.now() - window.__fpsT0 < ${STREAM_SEC * 1000}) {
|
||||
requestAnimationFrame(tick)
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
// Bucket fps every 500ms
|
||||
window.__fpsBucket = setInterval(() => {
|
||||
const now = performance.now()
|
||||
const recentCount = window.__fpsSamples.filter(s => now - window.__fpsT0 - s.t < 500).length
|
||||
const root = document.querySelector('[data-slot="aui_thread-content"]')
|
||||
const len = root ? root.innerText.length : 0
|
||||
const v = document.querySelector('[data-slot="aui_thread-viewport"]')
|
||||
window.__fpsHistogram.push({
|
||||
t: now - window.__fpsT0,
|
||||
frames500ms: recentCount,
|
||||
fps: recentCount * 2,
|
||||
contentLen: len,
|
||||
scrollTop: v?.scrollTop ?? 0,
|
||||
scrollHeight: v?.scrollHeight ?? 0
|
||||
})
|
||||
}, 500)
|
||||
})()`
|
||||
)
|
||||
|
||||
// Start CPU profile
|
||||
await cdp.send('Profiler.setSamplingInterval', { interval: 1000 })
|
||||
await cdp.send('Profiler.start')
|
||||
|
||||
await new Promise(r => setTimeout(r, STREAM_SEC * 1000))
|
||||
|
||||
const { profile } = await cdp.send('Profiler.stop')
|
||||
await evalP(cdp, `clearInterval(window.__fpsBucket)`)
|
||||
|
||||
writeFileSync(`${OUT}.cpuprofile`, JSON.stringify(profile))
|
||||
console.log(`cpu profile → ${OUT}.cpuprofile`)
|
||||
|
||||
// Pull fps histogram
|
||||
const hist = JSON.parse(await evalP(cdp, `JSON.stringify(window.__fpsHistogram || [])`))
|
||||
writeFileSync(`${OUT}.fps.json`, JSON.stringify(hist, null, 2))
|
||||
|
||||
console.log(`\n=== FPS over time ===`)
|
||||
console.log(` t(s) fps contentLen scrollTop/scrollHeight`)
|
||||
for (const h of hist) {
|
||||
const bar = '█'.repeat(Math.min(40, Math.max(0, Math.round(h.fps / 2))))
|
||||
console.log(` ${(h.t / 1000).toFixed(1).padStart(5)} ${String(h.fps).padStart(3)} ${String(h.contentLen).padStart(10)} ${h.scrollTop}/${h.scrollHeight} ${bar}`)
|
||||
}
|
||||
|
||||
// Top self frames
|
||||
const total = (profile.endTime - profile.startTime) / 1000
|
||||
const intMs = total / 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 => ({ id: n.id, fn: n.callFrame.functionName || '(anon)', url: n.callFrame.url || '', line: n.callFrame.lineNumber, self: counts.get(n.id) ?? 0 }))
|
||||
.sort((a, b) => b.self - a.self)
|
||||
.slice(0, 25)
|
||||
console.log(`\n=== ${total.toFixed(0)}ms wall, ${profile.samples?.length ?? 0} samples (${intMs.toFixed(2)}ms each) ===`)
|
||||
for (const r of rows) {
|
||||
if (r.self === 0) break
|
||||
const url = r.url.replace(/^.*\/src\//, 'src/').replace(/\?.*$/, '').slice(0, 70)
|
||||
console.log(` ${(r.self * intMs).toFixed(1).padStart(7)}ms (${String(r.self).padStart(4)} samp) ${r.fn.padEnd(45)} ${url}:${r.line}`)
|
||||
}
|
||||
|
||||
await evalP(cdp, `
|
||||
(() => {
|
||||
for (const b of document.querySelectorAll('button')) {
|
||||
if ((b.getAttribute('aria-label') || '').toLowerCase().includes('stop')) { b.click(); return }
|
||||
}
|
||||
})()
|
||||
`)
|
||||
|
||||
cdp.close()
|
||||
}
|
||||
|
||||
main().catch(e => {
|
||||
console.error('fatal:', e.stack ?? e.message)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,137 @@
|
||||
// CPU-profile during a real LLM stream — confirms or refutes whether the
|
||||
// synthetic stream's hotspots (Streamdown markdown re-parse, FadeText)
|
||||
// match real-world content.
|
||||
//
|
||||
// Run *after* model is set to something fast + cheap (gpt-4o-mini etc.).
|
||||
// Sends a prompt likely to produce markdown + a numbered list.
|
||||
|
||||
import { writeFileSync } from 'node:fs'
|
||||
|
||||
const CDP_HTTP = 'http://127.0.0.1:9222'
|
||||
const PROMPT = process.env.PROMPT || 'Give me a numbered list of 8 useful bash one-liners. For each: a brief description, then the command in a code block. No preamble.'
|
||||
const OUT = process.env.OUT || `/tmp/real-stream-${Date.now()}.cpuprofile`
|
||||
const START_TIMEOUT = Number(process.env.START_TIMEOUT || 45000)
|
||||
const STREAM_TIMEOUT = Number(process.env.STREAM_TIMEOUT || 60000)
|
||||
|
||||
class CDP {
|
||||
constructor(ws) { this.ws = ws; this.id = 0; this.pending = new Map() }
|
||||
static async open(url) {
|
||||
const ws = new WebSocket(url)
|
||||
await new Promise((r) => ws.addEventListener('open', r, { once: true }))
|
||||
const cdp = new CDP(ws)
|
||||
ws.addEventListener('message', (ev) => {
|
||||
const m = JSON.parse(ev.data.toString())
|
||||
if (m.id != null && cdp.pending.has(m.id)) {
|
||||
const { resolve, reject } = cdp.pending.get(m.id)
|
||||
cdp.pending.delete(m.id)
|
||||
if (m.error) reject(new Error(m.error.message))
|
||||
else resolve(m.result)
|
||||
}
|
||||
})
|
||||
return cdp
|
||||
}
|
||||
send(method, params) {
|
||||
const id = ++this.id
|
||||
return new Promise((res, rej) => {
|
||||
this.pending.set(id, { resolve: res, reject: rej })
|
||||
this.ws.send(JSON.stringify({ id, method, params }))
|
||||
})
|
||||
}
|
||||
async eval(expr) {
|
||||
const r = await this.send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true })
|
||||
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || 'eval')
|
||||
return r.result.value
|
||||
}
|
||||
close() { this.ws.close() }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const list = await (await fetch(`${CDP_HTTP}/json`)).json()
|
||||
const target = list.find((t) => t.type === 'page' && /5174/.test(t.url))
|
||||
const cdp = await CDP.open(target.webSocketDebuggerUrl)
|
||||
|
||||
const baseCount = await cdp.eval('document.querySelectorAll("[data-slot=aui_assistant-message-root]").length')
|
||||
|
||||
// Submit prompt
|
||||
await cdp.eval(`(() => {
|
||||
const ed = document.querySelector('[contenteditable="true"]')
|
||||
ed.focus()
|
||||
document.execCommand('insertText', false, ${JSON.stringify(PROMPT)})
|
||||
ed.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', which: 13, keyCode: 13, bubbles: true, cancelable: true }))
|
||||
return 'submitted'
|
||||
})()`)
|
||||
|
||||
// Wait for real stream start (assistant count grows).
|
||||
const submitT0 = Date.now()
|
||||
let streamT = null
|
||||
for (let i = 0; i < START_TIMEOUT / 50; i++) {
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
const n = await cdp.eval('document.querySelectorAll("[data-slot=aui_assistant-message-root]").length')
|
||||
if (n > baseCount) { streamT = Date.now(); break }
|
||||
}
|
||||
if (!streamT) {
|
||||
console.error('stream never started within', START_TIMEOUT, 'ms')
|
||||
cdp.close()
|
||||
process.exit(2)
|
||||
}
|
||||
console.log('REAL stream started after', streamT - submitT0, 'ms — starting CPU profile NOW')
|
||||
|
||||
// Start CPU profile NOW, only during stream phase.
|
||||
await cdp.send('Profiler.enable')
|
||||
await cdp.send('Profiler.setSamplingInterval', { interval: 100 })
|
||||
await cdp.send('Profiler.start')
|
||||
|
||||
// Wait until busy goes false + grace, or timeout.
|
||||
const cutoff = Date.now() + STREAM_TIMEOUT
|
||||
while (Date.now() < cutoff) {
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
const busy = await cdp.eval('!!document.querySelector("[data-status=running], [data-busy=true]")')
|
||||
if (!busy) {
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const { profile } = await cdp.send('Profiler.stop')
|
||||
writeFileSync(OUT, JSON.stringify(profile))
|
||||
console.log('wrote', OUT)
|
||||
|
||||
const samples = profile.samples || []
|
||||
const timeDeltas = profile.timeDeltas || []
|
||||
const nodes = new Map(profile.nodes.map((n) => [n.id, n]))
|
||||
const selfTime = new Map()
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
const id = samples[i]
|
||||
const dt = timeDeltas[i] ?? 0
|
||||
selfTime.set(id, (selfTime.get(id) || 0) + dt)
|
||||
}
|
||||
const ranked = [...selfTime.entries()]
|
||||
.map(([id, us]) => {
|
||||
const n = nodes.get(id)
|
||||
const cf = n?.callFrame || {}
|
||||
return {
|
||||
ms: us / 1000,
|
||||
name: cf.functionName || '(anonymous)',
|
||||
url: (cf.url || '').slice(-60),
|
||||
line: cf.lineNumber
|
||||
}
|
||||
})
|
||||
.filter((x) => !/\(root\)|\(idle\)|\(garbage collector\)|\(program\)/.test(x.name))
|
||||
.sort((a, b) => b.ms - a.ms)
|
||||
.slice(0, 25)
|
||||
|
||||
const finalText = await cdp.eval(`(() => {
|
||||
const all = document.querySelectorAll('[data-slot="aui_assistant-message-root"]')
|
||||
return all.length ? all[all.length-1].textContent.length : 0
|
||||
})()`)
|
||||
console.log('\nfinal assistant message length:', finalText, 'chars')
|
||||
|
||||
console.log('\n=== TOP 25 SELF TIME (ms) DURING REAL STREAM ===')
|
||||
for (const r of ranked) {
|
||||
console.log(`${r.ms.toFixed(1).padStart(7)} ${r.name.padEnd(40)} ${r.url}:${r.line}`)
|
||||
}
|
||||
|
||||
cdp.close()
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1) })
|
||||
@@ -0,0 +1,103 @@
|
||||
// CPU-profile a synthetic stream — outputs a .cpuprofile and a top-self ranking.
|
||||
// Open the .cpuprofile in Chrome DevTools Performance panel for a flamegraph.
|
||||
|
||||
import { writeFileSync } from 'node:fs'
|
||||
|
||||
const CDP_HTTP = 'http://127.0.0.1:9222'
|
||||
const TOKENS = Number(process.env.TOKENS || 400)
|
||||
const INTERVAL_MS = Number(process.env.INTERVAL_MS || 8)
|
||||
const CHUNK = process.env.CHUNK || '**word** in _italic_ with `code` '
|
||||
const LABEL = process.env.LABEL || 'profile'
|
||||
const OUT = process.env.OUT || `synth-${LABEL}.cpuprofile`
|
||||
|
||||
class CDP {
|
||||
constructor(ws) { this.ws = ws; this.id = 0; this.pending = new Map() }
|
||||
static async open(url) {
|
||||
const ws = new WebSocket(url)
|
||||
await new Promise((r) => ws.addEventListener('open', r, { once: true }))
|
||||
const cdp = new CDP(ws)
|
||||
ws.addEventListener('message', (ev) => {
|
||||
const m = JSON.parse(ev.data.toString())
|
||||
if (m.id != null && cdp.pending.has(m.id)) {
|
||||
const { resolve, reject } = cdp.pending.get(m.id)
|
||||
cdp.pending.delete(m.id)
|
||||
if (m.error) reject(new Error(m.error.message))
|
||||
else resolve(m.result)
|
||||
}
|
||||
})
|
||||
return cdp
|
||||
}
|
||||
send(method, params) {
|
||||
const id = ++this.id
|
||||
return new Promise((res, rej) => {
|
||||
this.pending.set(id, { resolve: res, reject: rej })
|
||||
this.ws.send(JSON.stringify({ id, method, params }))
|
||||
})
|
||||
}
|
||||
async eval(expr) {
|
||||
const r = await this.send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true })
|
||||
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || 'eval')
|
||||
return r.result.value
|
||||
}
|
||||
close() { this.ws.close() }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const list = await (await fetch(`${CDP_HTTP}/json`)).json()
|
||||
const target = list.find((t) => t.type === 'page' && /5174/.test(t.url))
|
||||
const cdp = await CDP.open(target.webSocketDebuggerUrl)
|
||||
|
||||
if (!await cdp.eval('!!window.__PERF_DRIVE__')) {
|
||||
console.error('no __PERF_DRIVE__')
|
||||
cdp.close()
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
await cdp.send('Profiler.enable')
|
||||
// High-resolution sampling: 100us
|
||||
await cdp.send('Profiler.setSamplingInterval', { interval: 100 })
|
||||
await cdp.send('Profiler.start')
|
||||
|
||||
await cdp.eval(`window.__PERF_DRIVE__.stream({ chunk: ${JSON.stringify(CHUNK)}, intervalMs: ${INTERVAL_MS}, totalTokens: ${TOKENS} })`)
|
||||
await new Promise((r) => setTimeout(r, TOKENS * INTERVAL_MS + 1500))
|
||||
await cdp.eval('window.__PERF_DRIVE__.reset()')
|
||||
|
||||
const { profile } = await cdp.send('Profiler.stop')
|
||||
writeFileSync(OUT, JSON.stringify(profile))
|
||||
console.log('wrote', OUT)
|
||||
|
||||
// Compute top self time per function.
|
||||
const samples = profile.samples || []
|
||||
const timeDeltas = profile.timeDeltas || []
|
||||
const nodes = new Map(profile.nodes.map((n) => [n.id, n]))
|
||||
const selfTime = new Map() // id -> microseconds
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
const id = samples[i]
|
||||
const dt = timeDeltas[i] ?? 0
|
||||
selfTime.set(id, (selfTime.get(id) || 0) + dt)
|
||||
}
|
||||
const ranked = [...selfTime.entries()]
|
||||
.map(([id, us]) => {
|
||||
const n = nodes.get(id)
|
||||
const cf = n?.callFrame || {}
|
||||
return {
|
||||
us,
|
||||
ms: us / 1000,
|
||||
name: cf.functionName || '(anonymous)',
|
||||
url: (cf.url || '').slice(-60),
|
||||
line: cf.lineNumber
|
||||
}
|
||||
})
|
||||
.filter((x) => !/\(root\)|\(idle\)|\(garbage collector\)|\(program\)/.test(x.name))
|
||||
.sort((a, b) => b.us - a.us)
|
||||
.slice(0, 30)
|
||||
|
||||
console.log('\n=== TOP 30 SELF TIME (ms) ===')
|
||||
for (const r of ranked) {
|
||||
console.log(`${r.ms.toFixed(1).padStart(7)} ${r.name.padEnd(40)} ${r.url}:${r.line}`)
|
||||
}
|
||||
|
||||
cdp.close()
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1) })
|
||||
@@ -0,0 +1,381 @@
|
||||
# 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.
|
||||
|
||||
## Streaming "5fps" investigation (May 21, 2026)
|
||||
|
||||
User complaint: "the streaming must bring fps to like 5? lol" — felt
|
||||
hitches during assistant streaming on long threads.
|
||||
|
||||
### Tooling added
|
||||
|
||||
- **`src/app/chat/perf-probe.tsx`** — dev-only side-effect import (guarded by
|
||||
`import.meta.env.MODE !== 'production'` in `main.tsx`). Attaches two
|
||||
helpers to `window`:
|
||||
- `__PERF_PROBE__` — React `<Profiler>` recorder. Currently inert because
|
||||
Vite is serving the production React build (see "Vite dev-build issue"
|
||||
below); kept for when that's fixed.
|
||||
- `__PERF_DRIVE__` — synthetic stream driver. Pushes tokens through the
|
||||
live `$messages` atom at a fixed cadence, so the assistant-ui runtime,
|
||||
incremental repository, Streamdown markdown renderer, and React commit
|
||||
pipeline all see the same workload they'd see from a real LLM stream —
|
||||
but with no LLM call (and no credit cost).
|
||||
- **`scripts/measure-synthetic-stream.mjs`** — drives `__PERF_DRIVE__`,
|
||||
records rAF frame intervals, `PerformanceObserver({entryTypes:['longtask']})`
|
||||
entries, `MutationObserver` cadence on the live message, and optional
|
||||
type-while-streaming keystroke latency.
|
||||
- **`scripts/profile-synth-stream.mjs`** — CPU profile during a synthetic
|
||||
stream; writes a `.cpuprofile` (open in Chrome DevTools Performance panel)
|
||||
and a top-30 self-time table.
|
||||
- **`scripts/measure-real-stream.mjs`** — same harness as the synthetic but
|
||||
fires a real LLM prompt. Use when you have credits and want to confirm
|
||||
the synthetic predictions hold.
|
||||
- **`scripts/profile-real-stream.mjs`** — CPU profile over the duration of
|
||||
a real LLM stream.
|
||||
|
||||
Helpers: `scripts/eval.mjs` (one-shot CDP eval), `scripts/reload.mjs`
|
||||
(hard reload renderer over CDP).
|
||||
|
||||
### Findings
|
||||
|
||||
Measured on the Cloud Shadows session (7 turns, ~11k px scrollHeight) and
|
||||
the 34 MB session `session_20260514_215353_fe0ac8.json` (110 FadeText
|
||||
instances, lots of historical tool calls).
|
||||
|
||||
| metric | Cloud Shadows | 34 MB session |
|
||||
|---|---|---|
|
||||
| avgFps (60 tok/sec, 5s) | 60.0 | 58.6 |
|
||||
| frame p50 / p95 / p99 (ms) | 16.7 / 18.0 / 21.1 | 16.6 / 25.6 / 31.4 |
|
||||
| max frame (ms) | 31.1 | 97-127 (varies) |
|
||||
| longtasks per 5s window | 0 | 1-2, 75-127 ms |
|
||||
| type-while-stream p95 latency (ms) | 17 | — |
|
||||
|
||||
A single real-LLM stream on Cloud Shadows (gpt-4o-mini, 39s window) saw
|
||||
12 longtasks totalling 1.26 s — same cadence the synthetic predicted
|
||||
(~1 hitch per 3.25 s, max 123 ms). So the **synthetic stream is a faithful
|
||||
proxy for the real one** and is fine for iterating on fixes without paying
|
||||
for tokens.
|
||||
|
||||
### CPU profile during streaming (synthetic, markdown content)
|
||||
|
||||
Top self-time costs (5 s window, 400 tokens at 125 tok/s, markdown chunks):
|
||||
|
||||
| ms (self) | function | source |
|
||||
|---|---|---|
|
||||
| 260 | `bn$1` | `chunk-BO2N…js:20003` (micromark tokenize) |
|
||||
| 249 | `m$1` | `chunk-BO2N…js:19949` (micromark) |
|
||||
| 128 | `compile` | `chunk-BO2N…js:21884` (mdast → hast compile) |
|
||||
| 73 | FadeText body | `components/ui/fade-text.tsx` |
|
||||
| 62 | `parser` | `chunk-BO2N…js:22680` |
|
||||
| 49 | `fromThreadMessageLike` | `@assistant-ui/internal` |
|
||||
|
||||
That `chunk-BO2N2NFS` is the vendored bundle containing `micromark`,
|
||||
`mdast-util-from-markdown`, `mdast-util-to-hast`, `rehype-raw`,
|
||||
`hast-util-sanitize`, etc. — i.e. **Streamdown's markdown pipeline,
|
||||
re-parsing the entire growing assistant message on every token append**.
|
||||
Cost scales linearly with message length.
|
||||
|
||||
Compare plain-text (no markdown) — the `chunk-BO2N…` entries drop out
|
||||
of the top 30 entirely; total work per 5 s window halves.
|
||||
|
||||
### Fix landed: `FadeText` memo
|
||||
|
||||
`FadeText` is used in `tool-fallback.tsx` (110 instances on a tool-heavy
|
||||
thread). Before: each parent re-render during streaming triggered a
|
||||
`useEffect([children])` that forced a `scrollWidth` layout read — even
|
||||
when the title text was unchanged. The `useResizeObserver` already covers
|
||||
the genuine resize case, so the effect was strictly redundant.
|
||||
|
||||
After: wrapped in `React.memo` with a custom comparator that compares
|
||||
`children` (scalar fast-path), `className`, `fadeWidth`, and `style`
|
||||
field-by-field. Verified via temporary render counter:
|
||||
**122 renders during a 2 s synthetic stream vs ~11 000 without memo**
|
||||
(110 instances × ~100 stream updates). Doesn't move the longtask needle
|
||||
on its own — Streamdown dwarfs it — but eliminates a class of forced
|
||||
layouts and removes a steady CPU floor.
|
||||
|
||||
### Also landed: `MarkdownText` plugins memo + upstream flush floor
|
||||
|
||||
Two smaller follow-ups in the same investigation:
|
||||
|
||||
1. **`MarkdownText` `plugins` object useMemo'd.** The inline
|
||||
`plugins={{ math: mathPlugin, ...(isStreaming ? {} : { code }) }}`
|
||||
was constructing a new object on every render, which churns
|
||||
`<Streamdown>`'s outer memo and forces its internal `rehypePlugins` /
|
||||
`remarkPlugins` arrays to rebuild. CPU profile after the change shows
|
||||
`parser` self-time dropping out of the top 10, `compile` cut roughly
|
||||
in half, and `bn$1` / `m$1` (micromark internals) dropping off the
|
||||
top entries.
|
||||
|
||||
2. **`use-message-stream.scheduleDeltaFlush` got a real minimum floor.**
|
||||
Previously the rAF-only path effectively meant "at most one flush per
|
||||
frame," but at typical LLM token rates of 30-80 tok/sec each token
|
||||
arrives slower than rAF cadence and gets its own React commit. With
|
||||
`STREAM_DELTA_FLUSH_MS = 33` (two frames) and a `lastFlushAt`-tracked
|
||||
floor, slower streams now coalesce ~2 tokens per commit, halving
|
||||
markdown re-parses. React's auto-batching already covers part of this
|
||||
probabilistically; the floor makes the batching deterministic so the
|
||||
max-longtask number tightens up.
|
||||
|
||||
A/B on the 34 MB session, 300 tokens at 50 tok/sec, markdown chunks
|
||||
(3 trials each):
|
||||
|
||||
| | avgFps | p99 frame | LTs/5s | max LT | mutations |
|
||||
|---|---|---|---|---|---|
|
||||
| no throttle | 54.0 | 38 ms | 2.0 | 145 ms | varies (2-112) |
|
||||
| 33 ms throttle | 54.3 | 41 ms | 1.7 | 110 ms | ~135 |
|
||||
|
||||
Modest. `inter-mutation` p50 tightens from 22-28 ms to a clean 33 ms,
|
||||
which is what you'd expect from a deterministic floor.
|
||||
|
||||
### Also landed: `useDeferredValue` at the streamdown-text boundary
|
||||
|
||||
The longtask CPU was unavoidable inside the block-memo pattern — the live
|
||||
tail re-parses every commit, scales linearly with current length, and
|
||||
nothing about Streamdown's architecture changes that without forking. The
|
||||
fix is to stop having that work *block* the main thread.
|
||||
|
||||
`<DeferStreamingText>` in `markdown-text.tsx` is a 12-line wrapper that
|
||||
reads the message-part state via `useMessagePartText`, runs it through
|
||||
`useDeferredValue`, and re-publishes via assistant-ui's
|
||||
`<TextMessagePartProvider>`. The inner `StreamdownTextPrimitive` reads the
|
||||
deferred value through the normal `useMessagePartText` hook — no fork,
|
||||
no internal-path imports, fully on the assistant-ui public API.
|
||||
|
||||
What React's concurrent scheduler now does:
|
||||
|
||||
- When a new token arrives mid-render, the in-flight deferred render
|
||||
is abandoned and a fresh one starts with the latest text.
|
||||
- When the main thread has urgent work (typing, scroll, layout), the
|
||||
Streamdown render gets deprioritized — input stays responsive even
|
||||
while a 100 ms parse is queued.
|
||||
|
||||
Streamdown already uses `useTransition` internally for its block-array
|
||||
setState; `useDeferredValue` here just lifts the deferral all the way up
|
||||
to the consumer text boundary, so the whole pipeline — preprocess,
|
||||
block split, repair, parse, render — runs at low priority during streaming.
|
||||
This is the industry-standard approach (see
|
||||
[Streamdown architecture analysis](https://tigerabrodi.blog/how-to-build-a-performant-ai-markdown-renderer)
|
||||
and Chrome's [LLM-response render best practices](https://developer.chrome.google.cn/docs/ai/render-llm-responses)).
|
||||
|
||||
A/B on the 34 MB session, 300 tokens at 50 tok/sec, markdown chunks
|
||||
(four trials each, prod-throttle (33 ms) on for both):
|
||||
|
||||
| | avgFps | p99 frame | LTs / 5 s | max LT | typing p95 |
|
||||
|---|---|---|---|---|---|
|
||||
| pre-defer | 54.3 | 41 ms | 1.7 | 110 ms | ~17 ms |
|
||||
| **post-defer** | **58.5** | **31 ms** | 2.0 | 117 ms | 14-18 ms |
|
||||
|
||||
Longtask count and max LT are unchanged — `useDeferredValue` doesn't
|
||||
reduce CPU, only its priority. The avgFps lift and p99 frame drop are
|
||||
the proof that the existing CPU is no longer blocking 60 fps cadence:
|
||||
when React can defer the parse, frames stay clean. One particularly
|
||||
clean run logged **MUTATIONS=0** — React skipped every intermediate
|
||||
text state and only committed the final one, the textbook
|
||||
useDeferredValue behaviour.
|
||||
|
||||
### Not fixed: Streamdown markdown re-parse cost (the elephant)
|
||||
|
||||
Total CPU spent in micromark/mdast/hast pipeline per 5 s window is still
|
||||
the same ~700 ms. With `useDeferredValue` that work no longer blocks
|
||||
input, but if you watch a CPU profile you'll see the same hot functions
|
||||
(`Tn$1`, `bn$1`, `m$1`, `parser`, `compile`).
|
||||
|
||||
The path to actually *reduce* that cost (not just defer it) is to
|
||||
replace the parser with a state machine like
|
||||
[Flowdown](https://github.com/Atomics-hub/flowdown) — process each
|
||||
character exactly once, emit DOM ops directly, no re-parse of the prefix
|
||||
on every token. Claimed ~2,000× over `marked`. Trades: not a
|
||||
`react-markdown`-compatible API, no rehype security pipeline, would
|
||||
require replacing Streamdown wholesale. Worth investigating only if
|
||||
even the deferred work shows up in user-perceptible ways (e.g.
|
||||
trackpad-scrolling a stream-in-progress stutters).
|
||||
|
||||
The synthetic harness now mirrors the real upstream pipeline via the
|
||||
`flushMinMs` option in `__PERF_DRIVE__.stream({ flushMinMs: 33 })`, so
|
||||
future Streamdown / Flowdown experiments can A/B without LLM credit cost.
|
||||
The synthetic numbers tracked the one real-LLM run we caught within
|
||||
noise, so it's a reliable proxy.
|
||||
|
||||
Possible approaches (none implemented here):
|
||||
|
||||
1. **Coalesce/throttle Streamdown updates** — render at most every 32 ms
|
||||
instead of every set-state. Reduces parses but doesn't reduce
|
||||
per-parse cost; trades latency for smoothness.
|
||||
2. **Memoize per-prefix** — diff the new text against the prior parsed
|
||||
version; only re-parse the changed suffix.
|
||||
3. **Render in stable segments** — close-form historical paragraphs as
|
||||
immutable React nodes; only the live tail goes through markdown each
|
||||
token. Probably the highest-impact change but requires forking or
|
||||
patching `@assistant-ui/react-streamdown`.
|
||||
4. **Move parsing to a Web Worker** — main thread no longer blocks on
|
||||
markdown. Largest surgery; requires double-buffered hast.
|
||||
|
||||
### Vite dev-build issue (separate)
|
||||
|
||||
`http://127.0.0.1:5174/node_modules/.vite/deps/react.js` resolves to
|
||||
`react/cjs/react.production.js`, and `react-dom_client.js` →
|
||||
`react-dom-client.production.js`. As a result:
|
||||
|
||||
- `<React.Profiler>` `onRender` is never called (production build is a
|
||||
no-op).
|
||||
- `import.meta.env.DEV` is `false`, `PROD` is `true` even under `vite dev`
|
||||
(hence `MODE !== 'production'` as the workaround in `main.tsx`).
|
||||
- All the React 19 dev-only warnings/devtools backend hooks are absent.
|
||||
|
||||
Root cause likely sits in `vite.config.ts` aliasing + dedupe + Vite 8's
|
||||
new `optimizeDeps` defaults. Worth a separate fix pass — when it's
|
||||
resolved, the `<PerfProbe>` blocks in `perf-probe.tsx` become useful
|
||||
(per-id commit timings) instead of inert.
|
||||
@@ -0,0 +1,260 @@
|
||||
#!/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)
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
// 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()
|
||||
@@ -0,0 +1,36 @@
|
||||
// Hard reload the Electron renderer over CDP. Vite-no-HMR mode means edits
|
||||
// don't auto-apply — call this after editing source.
|
||||
const targets = await (await fetch('http://127.0.0.1:9222/json')).json()
|
||||
const t = targets.find((t) => t.url.includes('5174'))
|
||||
if (!t) {
|
||||
console.error('renderer not found')
|
||||
process.exit(1)
|
||||
}
|
||||
const ws = new WebSocket(t.webSocketDebuggerUrl)
|
||||
let id = 0
|
||||
const pending = new Map()
|
||||
ws.addEventListener('message', (ev) => {
|
||||
const m = JSON.parse(ev.data)
|
||||
if (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((res) => {
|
||||
const i = ++id
|
||||
pending.set(i, res)
|
||||
ws.send(JSON.stringify({ id: i, method, params }))
|
||||
})
|
||||
|
||||
await send('Page.reload', { ignoreCache: true })
|
||||
console.log('reload sent')
|
||||
// Wait for new doc.
|
||||
await new Promise((r) => setTimeout(r, 2500))
|
||||
const r = await send('Runtime.evaluate', {
|
||||
expression: 'JSON.stringify({ hasProbe: !!window.__PERF_PROBE__, composer: !!document.querySelector("[contenteditable=true]"), url: location.hash })',
|
||||
returnByValue: true,
|
||||
})
|
||||
console.log(r.result.result.value)
|
||||
ws.close()
|
||||
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env node
|
||||
// set-exe-identity.cjs — stamp the Hermes icon + version metadata onto the
|
||||
// built Hermes.exe using rcedit, completely decoupled from electron-builder's
|
||||
// signing path.
|
||||
//
|
||||
// WHY THIS EXISTS
|
||||
// ---------------
|
||||
// apps/desktop/package.json sets build.win.signAndEditExecutable=false. That
|
||||
// flag is load-bearing: turning electron-builder's own exe-editing ON also
|
||||
// re-enables its signtool step, which fetches winCodeSign-2.6.0.7z, whose
|
||||
// macOS symlinks crash 7-Zip on non-admin Windows (no Developer Mode = no
|
||||
// SeCreateSymbolicLinkPrivilege). That is an unfixable dead end — we do NOT
|
||||
// try to extract winCodeSign.
|
||||
//
|
||||
// The cost of disabling signAndEditExecutable is that electron-builder also
|
||||
// skips rcedit, so the unpacked Hermes.exe keeps the stock Electron icon and
|
||||
// "Electron" taskbar name. This script restores the icon + identity by calling
|
||||
// rcedit DIRECTLY. rcedit is a pure PE resource editor: no signing, no certs,
|
||||
// no winCodeSign, no symlinks.
|
||||
//
|
||||
// HOW IT RUNS
|
||||
// -----------
|
||||
// Primarily as an electron-builder `afterPack` hook (scripts/after-pack.cjs),
|
||||
// so EVERY packed build — first install, `hermes desktop`, the installer's
|
||||
// --update rebuild, or a dev's manual `npm run pack` — gets a branded exe from
|
||||
// one place. Previously this stamp lived only in install.ps1, so the update
|
||||
// path (which rebuilds via `hermes desktop --build-only`, never install.ps1)
|
||||
// shipped a stock "Electron" exe. Keeping it in afterPack closes that gap.
|
||||
//
|
||||
// Also runnable standalone for ad-hoc re-stamping:
|
||||
// node scripts/set-exe-identity.cjs <path-to-Hermes.exe>
|
||||
//
|
||||
// Exits 0 on success, non-zero on failure when run as a CLI. As a hook,
|
||||
// stampExeIdentity() resolves on success and rejects on failure; the caller
|
||||
// (after-pack.cjs) swallows the rejection so a stamp failure never fails an
|
||||
// otherwise-good build (worst case: stock icon, not a broken app).
|
||||
|
||||
const path = require('node:path')
|
||||
const fs = require('node:fs')
|
||||
|
||||
// Stamp the Hermes icon + identity onto `exe`. Resolves on success, throws on
|
||||
// failure. `desktopRoot` defaults to this script's package root so the icon and
|
||||
// the rcedit dependency resolve regardless of cwd.
|
||||
async function stampExeIdentity(exe, desktopRoot = path.resolve(__dirname, '..')) {
|
||||
if (!exe || !fs.existsSync(exe)) {
|
||||
throw new Error(`target exe not found: ${exe}`)
|
||||
}
|
||||
|
||||
// Icon lives at apps/desktop/assets/icon.ico
|
||||
const icon = path.join(desktopRoot, 'assets', 'icon.ico')
|
||||
if (!fs.existsSync(icon)) {
|
||||
throw new Error(`icon not found: ${icon}`)
|
||||
}
|
||||
|
||||
// rcedit is a direct devDependency of apps/desktop, so it resolves whether
|
||||
// we're run from the desktop dir or the repo root (workspace hoist).
|
||||
// rcedit@5 exports a NAMED `rcedit` function (CommonJS: { rcedit }), not a
|
||||
// default export.
|
||||
const mod = require('rcedit')
|
||||
const rcedit = typeof mod === 'function' ? mod : mod.rcedit
|
||||
if (typeof rcedit !== 'function') {
|
||||
throw new Error(`unexpected rcedit export shape: ${typeof mod} keys=${Object.keys(mod)}`)
|
||||
}
|
||||
|
||||
console.log(`[set-exe-identity] stamping ${exe}`)
|
||||
console.log(`[set-exe-identity] icon: ${icon}`)
|
||||
|
||||
await rcedit(exe, {
|
||||
icon,
|
||||
'version-string': {
|
||||
ProductName: 'Hermes',
|
||||
FileDescription: 'Hermes',
|
||||
CompanyName: 'Nous Research',
|
||||
LegalCopyright: 'Copyright (c) 2026 Nous Research'
|
||||
}
|
||||
})
|
||||
|
||||
console.log('[set-exe-identity] done — Hermes icon + identity stamped')
|
||||
}
|
||||
|
||||
module.exports = { stampExeIdentity }
|
||||
|
||||
// CLI entry point: `node scripts/set-exe-identity.cjs <exe>`.
|
||||
if (require.main === module) {
|
||||
const exe = process.argv[2]
|
||||
if (!exe) {
|
||||
console.error('[set-exe-identity] usage: set-exe-identity.cjs <path-to-exe>')
|
||||
process.exit(2)
|
||||
}
|
||||
stampExeIdentity(exe).catch(err => {
|
||||
console.error(`[set-exe-identity] ${err.message}`)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* Stage native node-modules dependencies for electron-builder packaging.
|
||||
*
|
||||
* Workspace dedup hoists `node-pty` into the root `node_modules/`, which
|
||||
* electron-builder's default file collector (when `files:` is explicitly set
|
||||
* in package.json) cannot reach. The result: packaged builds ship with no
|
||||
* .node binaries and PTY initialization fails at runtime ("PTY support is
|
||||
* unavailable").
|
||||
*
|
||||
* Rather than restructure the workspace dedup (would require nohoist /
|
||||
* package.json shenanigans and risk breaking dev) or balloon the package
|
||||
* with the whole node_modules tree, we copy ONLY the runtime-essential
|
||||
* files of the native dep into apps/desktop/build/native-deps/ and ship
|
||||
* THAT subtree via extraResources. main.cjs falls back to require()-ing
|
||||
* from process.resourcesPath when the hoisted-root require fails.
|
||||
*
|
||||
* Runs as part of `npm run build`. Idempotent -- always re-stages on each
|
||||
* build to pick up native binary updates.
|
||||
*
|
||||
* Layout note: upstream node-pty (microsoft/node-pty 1.x) is N-API based
|
||||
* and ships its prebuilts under `prebuilds/<platform>-<arch>/` instead of
|
||||
* `build/Release/`. Its runtime resolver (lib/utils.js) checks
|
||||
* build/Release first and falls through to the per-arch prebuilds dir, so
|
||||
* shipping only the latter is sufficient for packaged runs. Per-arch
|
||||
* staging keeps the resource bundle lean -- we only need the target
|
||||
* arch's prebuilt, not all of them.
|
||||
*/
|
||||
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const APP_ROOT = path.resolve(__dirname, '..')
|
||||
const REPO_ROOT = path.resolve(APP_ROOT, '..', '..')
|
||||
const STAGE_ROOT = path.join(APP_ROOT, 'build', 'native-deps')
|
||||
|
||||
// The target arch may be overridden by electron-builder via npm_config_arch
|
||||
// (e.g. `npm run dist -- --arm64`); fall back to the build host's arch.
|
||||
const TARGET_ARCH = process.env.npm_config_arch || process.arch
|
||||
const TARGET_PLATFORM = process.platform
|
||||
|
||||
// Modules to stage. The "from" path is the hoisted location in the workspace
|
||||
// root; "to" is the layout we want inside build/native-deps/. The "include"
|
||||
// globs (relative to "from") select the runtime-essential files. Anything
|
||||
// outside the include list is left behind (source, deps/, scripts/, etc.).
|
||||
const NATIVE_DEPS = [
|
||||
{
|
||||
from: path.join(REPO_ROOT, 'node_modules', 'node-pty'),
|
||||
to: path.join(STAGE_ROOT, 'node-pty'),
|
||||
include: [
|
||||
'package.json',
|
||||
'lib/*.js',
|
||||
'lib/**/*.js',
|
||||
'build/Release/*.node',
|
||||
// Per-arch runtime payload. Explicit file types so we don't ship the
|
||||
// ~25 MB of .pdb debug symbols that prebuild-install bundles for
|
||||
// Windows crash analysis -- not used at runtime, would just bloat
|
||||
// the installer.
|
||||
`prebuilds/${TARGET_PLATFORM}-${TARGET_ARCH}/*.node`,
|
||||
`prebuilds/${TARGET_PLATFORM}-${TARGET_ARCH}/*.dll`,
|
||||
`prebuilds/${TARGET_PLATFORM}-${TARGET_ARCH}/*.exe`,
|
||||
`prebuilds/${TARGET_PLATFORM}-${TARGET_ARCH}/spawn-helper`,
|
||||
`prebuilds/${TARGET_PLATFORM}-${TARGET_ARCH}/conpty/*`
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
function rmrf(target) {
|
||||
fs.rmSync(target, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
function ensureDir(target) {
|
||||
fs.mkdirSync(target, { recursive: true })
|
||||
}
|
||||
|
||||
function walk(root) {
|
||||
const results = []
|
||||
const stack = [root]
|
||||
while (stack.length) {
|
||||
const current = stack.pop()
|
||||
let entries
|
||||
try {
|
||||
entries = fs.readdirSync(current, { withFileTypes: true })
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = path.join(current, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
stack.push(full)
|
||||
} else if (entry.isFile()) {
|
||||
results.push(full)
|
||||
}
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// Match a relative path against simple ** and * glob patterns. Implementation
|
||||
// is intentionally tiny -- the include lists are small and don't need full
|
||||
// minimatch support.
|
||||
function matchGlob(rel, pattern) {
|
||||
const r = rel.replace(/\\/g, '/')
|
||||
const re = new RegExp(
|
||||
'^' +
|
||||
pattern
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
||||
.replace(/\*\*/g, '__DOUBLE_STAR__')
|
||||
.replace(/\*/g, '[^/]*')
|
||||
.replace(/__DOUBLE_STAR__/g, '.*') +
|
||||
'$'
|
||||
)
|
||||
return re.test(r)
|
||||
}
|
||||
|
||||
function stageOne(spec) {
|
||||
if (!fs.existsSync(spec.from)) {
|
||||
throw new Error(
|
||||
`stage-native-deps: source missing at ${spec.from}. Run \`npm install\` ` +
|
||||
`at the workspace root first.`
|
||||
)
|
||||
}
|
||||
rmrf(spec.to)
|
||||
ensureDir(spec.to)
|
||||
|
||||
const files = walk(spec.from)
|
||||
let copied = 0
|
||||
for (const abs of files) {
|
||||
const rel = path.relative(spec.from, abs)
|
||||
const included = spec.include.some(g => matchGlob(rel, g))
|
||||
if (!included) continue
|
||||
const dest = path.join(spec.to, rel)
|
||||
ensureDir(path.dirname(dest))
|
||||
fs.copyFileSync(abs, dest)
|
||||
// node-pty's darwin spawn-helper and the Windows helper binaries
|
||||
// (OpenConsole.exe, winpty-agent.exe) are invoked via posix_spawn /
|
||||
// CreateProcess at runtime, so they must remain executable in the
|
||||
// staged tree. fs.copyFileSync preserves source mode on POSIX, but we
|
||||
// re-assert +x defensively for the darwin spawn-helper (no extension
|
||||
// means a stripped mode would be silently broken at runtime).
|
||||
if (path.basename(rel) === 'spawn-helper' && process.platform !== 'win32') {
|
||||
try { fs.chmodSync(dest, 0o755) } catch { /* best-effort */ }
|
||||
}
|
||||
copied += 1
|
||||
}
|
||||
console.log(`[stage-native-deps] ${path.relative(APP_ROOT, spec.to)}: ${copied} files`)
|
||||
}
|
||||
|
||||
function main() {
|
||||
rmrf(STAGE_ROOT)
|
||||
ensureDir(STAGE_ROOT)
|
||||
for (const spec of NATIVE_DEPS) {
|
||||
stageOne(spec)
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,425 @@
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { listPackage } from '@electron/asar'
|
||||
|
||||
const DESKTOP_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const PACKAGE_JSON = JSON.parse(fs.readFileSync(path.join(DESKTOP_ROOT, 'package.json'), 'utf8'))
|
||||
const MODE = process.argv[2] || 'help'
|
||||
const ARCH = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||
const RELEASE_ROOT = path.join(DESKTOP_ROOT, 'release')
|
||||
const PLATFORM = process.platform
|
||||
|
||||
// Platform-specific packaged-app layout. The thin installer ships an Electron
|
||||
// app shell plus extraResources (install-stamp.json + native-deps/) -- it
|
||||
// no longer bundles the Hermes Agent Python payload (that's fetched at first
|
||||
// launch via install.ps1 / install.sh, per the Phase 1 thin-installer flow).
|
||||
const APP = (() => {
|
||||
if (PLATFORM === 'darwin') {
|
||||
const appPath = path.join(RELEASE_ROOT, `mac-${ARCH}`, 'Hermes.app')
|
||||
return {
|
||||
appPath,
|
||||
binary: path.join(appPath, 'Contents', 'MacOS', 'Hermes'),
|
||||
resourcesPath: path.join(appPath, 'Contents', 'Resources'),
|
||||
asarPath: path.join(appPath, 'Contents', 'Resources', 'app.asar'),
|
||||
unpackedDistIndex: path.join(appPath, 'Contents', 'Resources', 'app.asar.unpacked', 'dist', 'index.html')
|
||||
}
|
||||
}
|
||||
if (PLATFORM === 'win32') {
|
||||
const unpacked = path.join(RELEASE_ROOT, 'win-unpacked')
|
||||
return {
|
||||
appPath: unpacked,
|
||||
binary: path.join(unpacked, 'Hermes.exe'),
|
||||
resourcesPath: path.join(unpacked, 'resources'),
|
||||
asarPath: path.join(unpacked, 'resources', 'app.asar'),
|
||||
unpackedDistIndex: path.join(unpacked, 'resources', 'app.asar.unpacked', 'dist', 'index.html')
|
||||
}
|
||||
}
|
||||
// linux unpacked layout matches windows but with different binary name
|
||||
const unpacked = path.join(RELEASE_ROOT, 'linux-unpacked')
|
||||
return {
|
||||
appPath: unpacked,
|
||||
binary: path.join(unpacked, 'hermes'),
|
||||
resourcesPath: path.join(unpacked, 'resources'),
|
||||
asarPath: path.join(unpacked, 'resources', 'app.asar'),
|
||||
unpackedDistIndex: path.join(unpacked, 'resources', 'app.asar.unpacked', 'dist', 'index.html')
|
||||
}
|
||||
})()
|
||||
|
||||
// Default HERMES_HOME for non-sandboxed runs -- matches main.cjs's
|
||||
// resolveHermesHome(). On Windows it's %LOCALAPPDATA%\hermes; elsewhere
|
||||
// it's ~/.hermes. The fresh-install sandbox launchFresh() sets its own
|
||||
// HERMES_HOME and never touches this.
|
||||
const DEFAULT_HERMES_HOME = (() => {
|
||||
if (PLATFORM === 'win32' && process.env.LOCALAPPDATA) {
|
||||
return path.join(process.env.LOCALAPPDATA, 'hermes')
|
||||
}
|
||||
return path.join(os.homedir(), '.hermes')
|
||||
})()
|
||||
const VENV_ROOT = path.join(DEFAULT_HERMES_HOME, 'hermes-agent', 'venv')
|
||||
const FRESH_SANDBOX_ROOT = path.join(os.tmpdir(), 'hermes-desktop-fresh-install')
|
||||
|
||||
function die(message) {
|
||||
console.error(`\n${message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: options.cwd || DESKTOP_ROOT,
|
||||
env: options.env || process.env,
|
||||
shell: Boolean(options.shell) || PLATFORM === 'win32',
|
||||
stdio: 'inherit'
|
||||
})
|
||||
|
||||
if (result.status !== 0) {
|
||||
die(`${command} ${args.join(' ')} failed`)
|
||||
}
|
||||
}
|
||||
|
||||
function exists(target) {
|
||||
return fs.existsSync(target)
|
||||
}
|
||||
|
||||
// Match nodepty native binding location to what main.cjs's resolver fallback
|
||||
// expects (apps/desktop/electron/main.cjs, packaged-build branch). Upstream
|
||||
// node-pty 1.x is N-API based and ships per-arch prebuilts under
|
||||
// prebuilds/<platform>-<arch>/ instead of build/Release/. We check the
|
||||
// per-arch dir since that's what stage-native-deps actually copies.
|
||||
function expectedNativeDepPaths() {
|
||||
const root = path.join(APP.resourcesPath, 'native-deps', 'node-pty')
|
||||
const prebuildsDir = path.join(root, 'prebuilds', `${PLATFORM}-${ARCH}`)
|
||||
return {
|
||||
packageJson: path.join(root, 'package.json'),
|
||||
prebuildsDir,
|
||||
libIndex: path.join(root, 'lib', 'index.js')
|
||||
}
|
||||
}
|
||||
|
||||
function ensurePlatformBuilds() {
|
||||
if (PLATFORM === 'darwin') return
|
||||
if (PLATFORM === 'win32') return
|
||||
die(
|
||||
`Desktop bundle validation is only wired for darwin / win32 today; platform=${PLATFORM} ` +
|
||||
`is not yet supported. The thin-installer story for Linux ships in Phase 2 alongside ` +
|
||||
`install.sh's stage protocol.`
|
||||
)
|
||||
}
|
||||
|
||||
function ensurePackagedApp() {
|
||||
if (process.env.HERMES_DESKTOP_SKIP_BUILD === '1' && exists(APP.binary)) {
|
||||
return
|
||||
}
|
||||
|
||||
run('npm', ['run', 'pack'])
|
||||
}
|
||||
|
||||
function resolveDmgPath() {
|
||||
if (!exists(RELEASE_ROOT)) {
|
||||
return path.join(RELEASE_ROOT, `Hermes-${PACKAGE_JSON.version}-${ARCH}.dmg`)
|
||||
}
|
||||
|
||||
const prefix = `Hermes-${PACKAGE_JSON.version}`
|
||||
const candidates = fs
|
||||
.readdirSync(RELEASE_ROOT)
|
||||
.filter(name => name.endsWith('.dmg'))
|
||||
.filter(name => name.startsWith(prefix))
|
||||
.filter(name => name.includes(ARCH))
|
||||
.sort((a, b) => {
|
||||
const aMtime = fs.statSync(path.join(RELEASE_ROOT, a)).mtimeMs
|
||||
const bMtime = fs.statSync(path.join(RELEASE_ROOT, b)).mtimeMs
|
||||
return bMtime - aMtime
|
||||
})
|
||||
|
||||
return candidates.length > 0
|
||||
? path.join(RELEASE_ROOT, candidates[0])
|
||||
: path.join(RELEASE_ROOT, `Hermes-${PACKAGE_JSON.version}-${ARCH}.dmg`)
|
||||
}
|
||||
|
||||
function resolveNsisPath() {
|
||||
// electron-builder NSIS artifactName template is 'Hermes-${version}-${os}-${arch}.${ext}'
|
||||
if (!exists(RELEASE_ROOT)) return null
|
||||
const candidates = fs
|
||||
.readdirSync(RELEASE_ROOT)
|
||||
.filter(name => /\.exe$/i.test(name) && /win/i.test(name))
|
||||
.sort((a, b) => {
|
||||
const aMtime = fs.statSync(path.join(RELEASE_ROOT, a)).mtimeMs
|
||||
const bMtime = fs.statSync(path.join(RELEASE_ROOT, b)).mtimeMs
|
||||
return bMtime - aMtime
|
||||
})
|
||||
return candidates.length > 0 ? path.join(RELEASE_ROOT, candidates[0]) : null
|
||||
}
|
||||
|
||||
function ensureDmg() {
|
||||
if (PLATFORM !== 'darwin') {
|
||||
die('DMG mode is macOS-only; on Windows use the `nsis` mode instead.')
|
||||
}
|
||||
if (process.env.HERMES_DESKTOP_SKIP_BUILD === '1' && exists(resolveDmgPath())) {
|
||||
return
|
||||
}
|
||||
run('npm', ['run', 'dist:mac:dmg'])
|
||||
}
|
||||
|
||||
function ensureNsis() {
|
||||
if (PLATFORM !== 'win32') {
|
||||
die('NSIS mode is win32-only; on macOS use the `dmg` mode instead.')
|
||||
}
|
||||
if (process.env.HERMES_DESKTOP_SKIP_BUILD === '1' && resolveNsisPath()) {
|
||||
return
|
||||
}
|
||||
run('npm', ['run', 'dist:win:nsis'])
|
||||
}
|
||||
|
||||
function openApp() {
|
||||
if (!exists(APP.binary)) {
|
||||
die(`Missing packaged app: ${APP.binary}`)
|
||||
}
|
||||
|
||||
if (PLATFORM === 'darwin') {
|
||||
run('open', ['-n', APP.appPath])
|
||||
} else if (PLATFORM === 'win32') {
|
||||
// Spawn detached so the test script exits while the app keeps running.
|
||||
spawn(APP.binary, [], { detached: true, stdio: 'ignore' }).unref()
|
||||
} else {
|
||||
spawn(APP.binary, [], { detached: true, stdio: 'ignore' }).unref()
|
||||
}
|
||||
}
|
||||
|
||||
function openDmg() {
|
||||
if (PLATFORM !== 'darwin') {
|
||||
die('DMG mode is macOS-only.')
|
||||
}
|
||||
const dmgPath = resolveDmgPath()
|
||||
if (!exists(dmgPath)) {
|
||||
die(`Missing DMG: ${dmgPath}`)
|
||||
}
|
||||
run('open', [dmgPath])
|
||||
}
|
||||
|
||||
const CREDENTIAL_ENV_SUFFIXES = [
|
||||
'_API_KEY',
|
||||
'_TOKEN',
|
||||
'_SECRET',
|
||||
'_PASSWORD',
|
||||
'_CREDENTIALS',
|
||||
'_ACCESS_KEY',
|
||||
'_PRIVATE_KEY',
|
||||
'_OAUTH_TOKEN'
|
||||
]
|
||||
|
||||
const CREDENTIAL_ENV_NAMES = new Set([
|
||||
'ANTHROPIC_BASE_URL',
|
||||
'ANTHROPIC_TOKEN',
|
||||
'AWS_ACCESS_KEY_ID',
|
||||
'AWS_SECRET_ACCESS_KEY',
|
||||
'AWS_SESSION_TOKEN',
|
||||
'CUSTOM_API_KEY',
|
||||
'GEMINI_BASE_URL',
|
||||
'OPENAI_BASE_URL',
|
||||
'OPENROUTER_BASE_URL',
|
||||
'OLLAMA_BASE_URL',
|
||||
'GROQ_BASE_URL',
|
||||
'XAI_BASE_URL'
|
||||
])
|
||||
|
||||
function isCredentialEnvVar(name) {
|
||||
if (CREDENTIAL_ENV_NAMES.has(name)) return true
|
||||
return CREDENTIAL_ENV_SUFFIXES.some(suffix => name.endsWith(suffix))
|
||||
}
|
||||
|
||||
function launchFresh() {
|
||||
if (!exists(APP.binary)) {
|
||||
die(`Missing app executable: ${APP.binary}`)
|
||||
}
|
||||
|
||||
const sandbox = fs.mkdtempSync(`${FRESH_SANDBOX_ROOT}-`)
|
||||
const userDataDir = path.join(sandbox, 'electron-user-data')
|
||||
const hermesHome = path.join(sandbox, 'hermes-home')
|
||||
const cwd = path.join(sandbox, 'workspace')
|
||||
|
||||
fs.mkdirSync(userDataDir, { recursive: true })
|
||||
fs.mkdirSync(hermesHome, { recursive: true })
|
||||
fs.mkdirSync(cwd, { recursive: true })
|
||||
|
||||
// Strip every credential-shaped env var so the sandbox is actually fresh.
|
||||
const env = {}
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (isCredentialEnvVar(key)) continue
|
||||
env[key] = value
|
||||
}
|
||||
|
||||
env.HERMES_DESKTOP_CWD = cwd
|
||||
env.HERMES_DESKTOP_IGNORE_EXISTING = '1'
|
||||
env.HERMES_DESKTOP_TEST_MODE = 'fresh-install'
|
||||
env.HERMES_DESKTOP_USER_DATA_DIR = userDataDir
|
||||
env.HERMES_HOME = hermesHome
|
||||
delete env.HERMES_DESKTOP_HERMES
|
||||
delete env.HERMES_DESKTOP_HERMES_ROOT
|
||||
|
||||
const child = spawn(APP.binary, [], {
|
||||
cwd: os.homedir(),
|
||||
detached: true,
|
||||
env,
|
||||
stdio: 'ignore'
|
||||
})
|
||||
child.unref()
|
||||
|
||||
console.log('\nFresh install sandbox:')
|
||||
console.log(` root: ${sandbox}`)
|
||||
console.log(` electron userData: ${userDataDir}`)
|
||||
console.log(` HERMES_HOME: ${hermesHome}`)
|
||||
console.log(` cwd: ${cwd}`)
|
||||
|
||||
return { runtimeRoot: path.join(hermesHome, 'hermes-agent', 'venv') }
|
||||
}
|
||||
|
||||
// Validate the packaged bundle matches the thin-installer architecture:
|
||||
// - The Hermes Agent Python payload is NOT shipped (it's fetched at first
|
||||
// launch via install.ps1's stage protocol).
|
||||
// - install-stamp.json IS shipped in resources/ with a valid commit + branch.
|
||||
// - native-deps/@homebridge/node-pty-prebuilt-multiarch/ IS shipped with
|
||||
// the package.json + lib/ + at least one .node binary (the renderer's
|
||||
// integrated terminal needs this; see Phase 1F.6).
|
||||
// - The renderer's dist/index.html is reachable (either unpacked or
|
||||
// inside app.asar).
|
||||
function validateBundle() {
|
||||
if (!exists(APP.binary)) {
|
||||
die(`Missing packaged app binary: ${APP.binary}`)
|
||||
}
|
||||
|
||||
// Negative assertion: the OLD fat-installer factory payload must NOT be
|
||||
// present anymore. If a stray ship of hermes_cli sneaks back in we want
|
||||
// to fail loudly rather than re-introduce the 400MB delta we just removed.
|
||||
const staleFactoryMarker = path.join(APP.resourcesPath, 'hermes-agent', 'hermes_cli', 'main.py')
|
||||
if (exists(staleFactoryMarker)) {
|
||||
die(
|
||||
`Thin-installer regression: factory-payload file should NOT be in the package: ${staleFactoryMarker}`
|
||||
)
|
||||
}
|
||||
|
||||
// Positive assertion: install-stamp.json carries a sane commit + branch
|
||||
const stampPath = path.join(APP.resourcesPath, 'install-stamp.json')
|
||||
if (!exists(stampPath)) {
|
||||
die(`Missing install-stamp.json (required for first-launch bootstrap pinning): ${stampPath}`)
|
||||
}
|
||||
let stamp
|
||||
try {
|
||||
stamp = JSON.parse(fs.readFileSync(stampPath, 'utf8'))
|
||||
} catch (err) {
|
||||
die(`install-stamp.json is not valid JSON: ${err.message}`)
|
||||
}
|
||||
if (!stamp.commit || typeof stamp.commit !== 'string' || stamp.commit.length < 7) {
|
||||
die(`install-stamp.json is missing a usable commit field: ${JSON.stringify(stamp)}`)
|
||||
}
|
||||
if (!stamp.branch || typeof stamp.branch !== 'string') {
|
||||
die(`install-stamp.json is missing the branch field: ${JSON.stringify(stamp)}`)
|
||||
}
|
||||
|
||||
// Positive assertion: node-pty native deps shipped
|
||||
const native = expectedNativeDepPaths()
|
||||
if (!exists(native.packageJson)) {
|
||||
die(`Missing node-pty package.json in resources/native-deps: ${native.packageJson}`)
|
||||
}
|
||||
if (!exists(native.libIndex)) {
|
||||
die(`Missing node-pty lib/index.js in resources/native-deps: ${native.libIndex}`)
|
||||
}
|
||||
if (!exists(native.prebuildsDir)) {
|
||||
die(`Missing node-pty prebuilds dir for ${PLATFORM}-${ARCH}: ${native.prebuildsDir}`)
|
||||
}
|
||||
const nodeBinaries = fs.readdirSync(native.prebuildsDir).filter(name => name.endsWith('.node'))
|
||||
if (nodeBinaries.length === 0) {
|
||||
die(`No .node native binaries found in: ${native.prebuildsDir}`)
|
||||
}
|
||||
// Darwin requires a runtime-execed spawn-helper alongside pty.node; missing
|
||||
// it manifests as "ENOENT: spawn-helper" on first pty.spawn() call.
|
||||
if (PLATFORM === 'darwin') {
|
||||
const spawnHelper = path.join(native.prebuildsDir, 'spawn-helper')
|
||||
if (!exists(spawnHelper)) {
|
||||
die(`Missing node-pty spawn-helper (required on darwin): ${spawnHelper}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Renderer payload check (either unpacked or in the asar)
|
||||
if (exists(APP.unpackedDistIndex)) {
|
||||
return { stamp, nodeBinaries }
|
||||
}
|
||||
if (!exists(APP.asarPath)) {
|
||||
die(`Missing renderer payload: neither ${APP.unpackedDistIndex} nor ${APP.asarPath} exists`)
|
||||
}
|
||||
const files = listPackage(APP.asarPath)
|
||||
// Normalize separators because @electron/asar's listPackage returns
|
||||
// backslash-prefixed entries on Windows ('\\dist\\index.html') and
|
||||
// forward-slash on Unix.
|
||||
const normalized = files.map(f => f.replace(/\\/g, '/').replace(/^\/+/, ''))
|
||||
if (!normalized.includes('dist/index.html')) {
|
||||
die(`Missing renderer payload file in app.asar: ${APP.asarPath} (expected dist/index.html)`)
|
||||
}
|
||||
return { stamp, nodeBinaries }
|
||||
}
|
||||
|
||||
function printArtifacts(options = {}) {
|
||||
const runtimeRoot = options.runtimeRoot || VENV_ROOT
|
||||
const stamp = options.stamp
|
||||
|
||||
console.log('\nDesktop artifacts:')
|
||||
console.log(` app: ${APP.appPath}`)
|
||||
if (PLATFORM === 'darwin') {
|
||||
console.log(` dmg: ${resolveDmgPath()}`)
|
||||
} else if (PLATFORM === 'win32') {
|
||||
const exe = resolveNsisPath()
|
||||
if (exe) console.log(` installer: ${exe}`)
|
||||
}
|
||||
console.log(` runtime: ${runtimeRoot}`)
|
||||
if (stamp) {
|
||||
console.log(` install-stamp: ${stamp.commit.slice(0, 12)} on ${stamp.branch}`)
|
||||
}
|
||||
if (options.nodeBinaries && options.nodeBinaries.length > 0) {
|
||||
console.log(` node-pty binaries: ${options.nodeBinaries.join(', ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
function help() {
|
||||
console.log(`Usage:
|
||||
npm run test:desktop:existing # build packaged app, launch with normal PATH/existing Hermes
|
||||
npm run test:desktop:fresh # build packaged app, launch with temp userData + HERMES_HOME
|
||||
npm run test:desktop:dmg # (macOS only) build DMG and open it
|
||||
npm run test:desktop:nsis # (win32 only) build NSIS installer
|
||||
npm run test:desktop:all # build installer, validate app payload, print paths
|
||||
|
||||
Fast rerun (skip rebuild if the packaged app already exists):
|
||||
HERMES_DESKTOP_SKIP_BUILD=1 npm run test:desktop:fresh
|
||||
`)
|
||||
}
|
||||
|
||||
ensurePlatformBuilds()
|
||||
|
||||
if (MODE === 'existing') {
|
||||
ensurePackagedApp()
|
||||
const result = validateBundle()
|
||||
openApp()
|
||||
printArtifacts(result)
|
||||
} else if (MODE === 'fresh') {
|
||||
ensurePackagedApp()
|
||||
const result = validateBundle()
|
||||
printArtifacts({ ...launchFresh(), ...result })
|
||||
} else if (MODE === 'dmg') {
|
||||
ensureDmg()
|
||||
openDmg()
|
||||
printArtifacts()
|
||||
} else if (MODE === 'nsis') {
|
||||
ensureNsis()
|
||||
printArtifacts(validateBundle())
|
||||
} else if (MODE === 'all') {
|
||||
if (PLATFORM === 'darwin') {
|
||||
ensureDmg()
|
||||
} else if (PLATFORM === 'win32') {
|
||||
ensureNsis()
|
||||
} else {
|
||||
ensurePackagedApp()
|
||||
}
|
||||
printArtifacts(validateBundle())
|
||||
} else {
|
||||
help()
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
"use strict"
|
||||
|
||||
/**
|
||||
* Writes apps/desktop/build/install-stamp.json with the git ref the desktop
|
||||
* .exe should pin to at first-launch bootstrap time. This file ships inside
|
||||
* the packaged app via electron-builder's extraResources entry and is read
|
||||
* by electron/main.cjs to drive the install.ps1 stage bootstrap flow.
|
||||
*
|
||||
* Schema (subject to bump via STAMP_SCHEMA_VERSION):
|
||||
* {
|
||||
* "schemaVersion": 1,
|
||||
* "commit": "<40-char SHA>",
|
||||
* "branch": "<branch name>",
|
||||
* "builtAt": "<ISO 8601 UTC timestamp>",
|
||||
* "dirty": true|false,
|
||||
* "source": "ci" | "local"
|
||||
* }
|
||||
*
|
||||
* Source preference order:
|
||||
* 1. CI env vars ($GITHUB_SHA / $GITHUB_REF_NAME) -- avoid edge cases with
|
||||
* shallow clones, detached HEADs, etc. in CI.
|
||||
* 2. Local `git rev-parse` against the parent repo (../..).
|
||||
*
|
||||
* Dev / out-of-repo builds without git produce an explicit error rather than
|
||||
* silently writing an unstamped manifest -- the packaged app refuses to
|
||||
* bootstrap without a stamp.
|
||||
*/
|
||||
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
const { execSync } = require("child_process")
|
||||
|
||||
const STAMP_SCHEMA_VERSION = 1
|
||||
|
||||
const DESKTOP_ROOT = path.resolve(__dirname, "..")
|
||||
const REPO_ROOT = path.resolve(DESKTOP_ROOT, "..", "..")
|
||||
const OUT_DIR = path.join(DESKTOP_ROOT, "build")
|
||||
const OUT_FILE = path.join(OUT_DIR, "install-stamp.json")
|
||||
|
||||
function tryExec(cmd, opts) {
|
||||
try {
|
||||
return execSync(cmd, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], ...opts }).trim()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function fromCI() {
|
||||
const sha = process.env.GITHUB_SHA
|
||||
if (!sha) return null
|
||||
const branch = process.env.GITHUB_REF_NAME || process.env.GITHUB_HEAD_REF || null
|
||||
return {
|
||||
commit: sha,
|
||||
branch: branch,
|
||||
dirty: false, // CI builds from a checkout-of-ref by definition
|
||||
source: "ci"
|
||||
}
|
||||
}
|
||||
|
||||
function fromLocalGit() {
|
||||
const sha = tryExec("git rev-parse HEAD", { cwd: REPO_ROOT })
|
||||
if (!sha) return null
|
||||
const branch = tryExec("git rev-parse --abbrev-ref HEAD", { cwd: REPO_ROOT })
|
||||
// `git status --porcelain -uno` is empty iff tracked files match HEAD.
|
||||
// We exclude untracked files (-uno) intentionally: a developer who's
|
||||
// checked out an installer scratch dir alongside the repo shouldn't
|
||||
// poison every local build with a [DIRTY] stamp. We DO care about
|
||||
// tracked-but-modified files because those mean the .exe content
|
||||
// differs from the commit being pinned.
|
||||
const status = tryExec("git status --porcelain -uno", { cwd: REPO_ROOT })
|
||||
const dirty = status !== null && status.length > 0
|
||||
return {
|
||||
commit: sha,
|
||||
branch: branch === "HEAD" ? null : branch, // detached HEAD -> null
|
||||
dirty: dirty,
|
||||
source: "local"
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const stamp = fromCI() || fromLocalGit()
|
||||
if (!stamp || !stamp.commit) {
|
||||
console.error(
|
||||
"[write-build-stamp] ERROR: could not determine git commit.\n" +
|
||||
" - $GITHUB_SHA not set\n" +
|
||||
" - `git rev-parse HEAD` failed at " +
|
||||
REPO_ROOT +
|
||||
"\n" +
|
||||
"Packaged builds require a git ref to pin first-launch install.ps1\n" +
|
||||
"against. Run from a git checkout or set $GITHUB_SHA explicitly."
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (stamp.dirty) {
|
||||
console.warn(
|
||||
"[write-build-stamp] WARNING: working tree is dirty.\n" +
|
||||
" Pinning to " +
|
||||
stamp.commit.slice(0, 12) +
|
||||
" but the packaged code may differ from that commit.\n" +
|
||||
" Commit your changes before publishing this build."
|
||||
)
|
||||
}
|
||||
|
||||
const payload = {
|
||||
schemaVersion: STAMP_SCHEMA_VERSION,
|
||||
commit: stamp.commit,
|
||||
branch: stamp.branch,
|
||||
builtAt: new Date().toISOString(),
|
||||
dirty: stamp.dirty,
|
||||
source: stamp.source
|
||||
}
|
||||
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true })
|
||||
fs.writeFileSync(OUT_FILE, JSON.stringify(payload, null, 2) + "\n", "utf8")
|
||||
console.log(
|
||||
"[write-build-stamp] wrote " +
|
||||
path.relative(REPO_ROOT, OUT_FILE) +
|
||||
" -> " +
|
||||
stamp.commit.slice(0, 12) +
|
||||
(stamp.branch ? " (" + stamp.branch + ")" : "") +
|
||||
(stamp.dirty ? " [DIRTY]" : "")
|
||||
)
|
||||
}
|
||||
|
||||
main()
|
||||
Reference in New Issue
Block a user