Files
hermes-agent/apps/desktop/scripts/run-short-session-hang-repro.mjs
T
Royalaid 73dcd75afe perf(desktop): harden the spinner strip and tighten status invalidation
Review follow-ups on the compositor spinner and the invalidation scoping.

Spinner CSS:
- Clip each frame to its own box. Braille renders from a system fallback
  face (JetBrains Mono has no U+2800 block), whose metrics are not
  guaranteed to fit the 1em frame, so neighbouring ink could bleed into
  the viewport.
- Name descendants explicitly in the selection guard. The competing
  `[data-selectable-text='true'] *` rule has the same (0,1,0)
  specificity, so relying on inheritance made the winner depend on
  stylesheet order.
- Scope the compositor promotion to spinners that are actually running.
  A permanently promoted layer per parked spinner is pure memory at
  fan-out breadth, where many sit mounted and paused at once.
- Give every var() the braille default as its fallback, so a missing
  custom property degrades to a working spinner rather than an invalid
  declaration.

Spinner component: replace the bare `as CSSProperties` cast on the inline
style with an exported GlyphSpinnerVars contract, so a typo in a custom
property name is a compile error rather than a silently dead declaration.

Assistant message:
- Render the inter-agent collapse as a CHILD of the normal body instead
  of a competing root. The settled case previously returned a different
  element type than the running case, so settling unmounted the whole row
  and mounted a fresh one — discarding the DOM the scroll anchor held.
  One component, one root, children vary; the truth table is unchanged,
  including the collapsed row carrying no tapback listener.
- Collapse AssistantStatusSlot's separate subscriptions into one selector
  returning a stable string. The inputs always move together on a status
  flip, so reading them separately just multiplied the wake-ups.
- Give StreamingMarker a stable `data-slot` and assert on that rather
  than on `span.hidden`.

Repro script: count settled rows by subtracting streaming markers from
message roots instead of `:not(:has(...))`. The selector walked every
row's subtree on each evaluation, inside the very latency window the
probe measures.

Comments: drop the stale translateY(-100%) description, name both pause
triggers, replace hard-coded line-number citations with selector/symbol
ones, note that only the primary window arms the renderer-pause
attribute, and move the forensic trace numbers out of source comments
into the PR.

Delete the three tests that asserted on stylesheet TEXT. AGENTS.md bans
reading source in tests outright, and they demonstrated exactly why: a
var()-fallback edit that changed no rendered pixel broke one of them.
Replacements that exercise the CSS in a real browser follow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QwTc9XqUjhbay446VjugHZ
2026-08-21 04:23:41 -07:00

1717 lines
58 KiB
JavaScript

#!/usr/bin/env node
import { spawn, spawnSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import {
appendFileSync,
cpSync,
copyFileSync,
createWriteStream,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
symlinkSync,
writeFileSync
} from 'node:fs'
import http from 'node:http'
import { createRequire } from 'node:module'
import { createServer } from 'node:net'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { finished } from 'node:stream/promises'
import { fileURLToPath } from 'node:url'
import { CDP, discoverTarget, sleep } from './perf/lib/cdp.mjs'
const HERE = dirname(fileURLToPath(import.meta.url))
const DESKTOP_ROOT = resolve(HERE, '..')
const REPO_ROOT = resolve(DESKTOP_ROOT, '..', '..')
const HARNESS_SOURCE = resolve(DESKTOP_ROOT, 'src/app/chat/short-session-hang-repro.tsx')
const UPSTREAM_URL = 'https://github.com/NousResearch/hermes-agent.git'
const DEFAULT_BASELINE = '3651627d88858912e8460e6f949b7125725600c3'
const DEFAULT_CANDIDATE = '3651627d88858912e8460e6f949b7125725600c3'
const FREEZE_MS = 5_000
const JOURNAL_SEED_TIMEOUT_MS = 30_000
const STREAM_RESPONSE_TIMEOUT_MS = 30_000
const STREAM_RESPONSE_EVALUATION_TIMEOUT_MS = 30_000
const STREAM_PAYLOAD_BYTES = 512 * 1024
const LEGACY_STORAGE_KEY = 'hermes.desktop.inflightTurnJournal.v1'
const LEGACY_MIGRATION_KEY = 'hermes.desktop.inflightTurnJournal.v2.migrated'
const LEGACY_SEED_ENTRY_COUNT = 15
const MAX_LEGACY_SEED_BYTES = 1.9 * 1024 * 1024
// Reserve space for the JSON envelope and per-entry metadata so the generated
// fixture remains below the guard even when the entry count or shape changes.
const LEGACY_SEED_METADATA_BUDGET_BYTES = 16 * 1024
const LEGACY_SEED_PAYLOAD_CHARS = Math.floor(
(MAX_LEGACY_SEED_BYTES - LEGACY_SEED_METADATA_BUDGET_BYTES) / LEGACY_SEED_ENTRY_COUNT
)
const EXCHANGE_COUNT = 5
const NATIVE_VISIBILITY_PAUSE_MS = 750
const EXCHANGE_FREEZE_TIMEOUTS = 9
const POST_EXCHANGE_FREEZE_TIMEOUTS = 13
const WATCHDOG_OVERHEAD_MS = 120_000
// The watchdog is a last-resort guard around the complete five-exchange run.
// Inner timeouts still identify actual renderer hangs; this budget prevents a
// slow but valid stream/response sequence from being misclassified by the old
// fixed 120s outer limit. The overhead covers renderer startup/cleanup and
// native visibility calls not represented by the per-operation timers.
const OUTER_WATCHDOG_MS =
EXCHANGE_COUNT *
(EXCHANGE_FREEZE_TIMEOUTS * FREEZE_MS +
STREAM_RESPONSE_TIMEOUT_MS +
STREAM_RESPONSE_EVALUATION_TIMEOUT_MS +
NATIVE_VISIBILITY_PAUSE_MS) +
POST_EXCHANGE_FREEZE_TIMEOUTS * FREEZE_MS +
2_000 +
WATCHDOG_OVERHEAD_MS
const SECRET_ENV_RE = /(credential|token|secret|password|(^|_)key($|_)|auth|cookie)/i
const HEX_OBJECT_RE = /^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/
const PULL_REF_RE = /^refs\/pull\/[1-9][0-9]*\/(?:head|merge)$/
const HEAD_REF_RE = /^refs\/heads\/[A-Za-z0-9][A-Za-z0-9._/-]*$/
const HEAD_COMPONENT_RE = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/
const require = createRequire(import.meta.url)
class ReproductionError extends Error {
name = 'ReproductionError'
}
function usage() {
console.log(`Usage: node scripts/run-short-session-hang-repro.mjs [options]
Options:
--baseline <ref> baseline ref (default: ${DEFAULT_BASELINE})
--candidate <ref> candidate ref (default: ${DEFAULT_CANDIDATE})
--repetitions <n> measured repetitions per ref (default: 5)
--output <dir> artifact directory (default: short-session-hang-artifacts)
--keep-worktrees retain ephemeral source copies
--dry-run validate refs, lockfile/Electron parity, and print the plan
--help show this help
Each ref gets one warm-up plus N measured fresh-app runs. Measured A/B order is
counterbalanced. A run is a reproduction only when a renderer/main operation,
heartbeat, or event-loop gap exceeds ${FREEZE_MS}ms, or Electron becomes
unresponsive, loses the renderer, or exits unexpectedly.`)
}
function parseArgs(argv) {
const out = {
baseline: DEFAULT_BASELINE,
candidate: DEFAULT_CANDIDATE,
repetitions: 5,
output: resolve(process.cwd(), 'short-session-hang-artifacts'),
dryRun: false,
keepWorktrees: false
}
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i]
const next = argv[i + 1]
if (arg === '--help') {
usage()
process.exit(0)
} else if (arg === '--dry-run') {
out.dryRun = true
} else if (arg === '--keep-worktrees') {
out.keepWorktrees = true
} else if (arg === '--baseline' || arg === '--candidate' || arg === '--repetitions' || arg === '--output') {
if (!next || next.startsWith('--')) {
throw new Error(`${arg} requires a value`)
}
const key = arg.slice(2)
out[key] = key === 'repetitions' ? Number(next) : key === 'output' ? resolve(next) : next
i += 1
} else {
throw new Error(`unknown option: ${arg}`)
}
}
if (!Number.isInteger(out.repetitions) || out.repetitions < 1 || out.repetitions > 20) {
throw new Error('--repetitions must be an integer from 1 to 20')
}
return out
}
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: options.cwd ?? REPO_ROOT,
encoding: 'utf8',
env: options.env ?? process.env,
maxBuffer: 64 * 1024 * 1024
})
if (options.logPath) {
writeFileSync(options.logPath, `${result.stdout ?? ''}${result.stderr ?? ''}`)
}
if (result.error) {
throw new Error(`${command} ${args.join(' ')} failed to run: ${result.error.message}`)
}
if (result.status !== 0) {
throw new Error(
`${command} ${args.join(' ')} exited ${result.status ?? `signal ${result.signal}`}:\n${result.stderr || result.stdout}`
)
}
return String(result.stdout ?? '').trim()
}
function sha256(value) {
return createHash('sha256').update(value).digest('hex')
}
function validateRef(ref) {
const headParts = ref.startsWith('refs/heads/') ? ref.slice('refs/heads/'.length).split('/') : []
const invalidHead =
ref.includes('..') ||
headParts.length === 0 ||
headParts.some(part => !HEAD_COMPONENT_RE.test(part) || part.endsWith('.lock'))
if (HEX_OBJECT_RE.test(ref) || PULL_REF_RE.test(ref) || (HEAD_REF_RE.test(ref) && !invalidHead)) {
return ref
}
throw new Error(
`unsafe ref ${JSON.stringify(ref)}; use a 40/64-digit hex object ID, refs/heads/<safe>, or refs/pull/<number>/(head|merge)`
)
}
function sanitizedEnv(extra = {}) {
const clean = Object.fromEntries(Object.entries(process.env).filter(([name]) => !SECRET_ENV_RE.test(name)))
return { ...clean, ...extra }
}
function resolveRef(rawRef) {
const ref = validateRef(rawRef)
const tryResolve = () => {
const result = spawnSync('git', ['rev-parse', '--verify', '--end-of-options', `${ref}^{commit}`], {
cwd: REPO_ROOT,
encoding: 'utf8'
})
return result.status === 0 ? result.stdout.trim() : null
}
const local = HEX_OBJECT_RE.test(ref) ? tryResolve() : null
if (local) {
return local
}
run('git', ['fetch', '--no-tags', UPSTREAM_URL, ref], {
env: sanitizedEnv({ GCM_INTERACTIVE: 'never', GIT_TERMINAL_PROMPT: '0' })
})
if (ref.startsWith('refs/')) {
const fetched = run('git', ['rev-parse', '--verify', '--end-of-options', 'FETCH_HEAD^{commit}'])
return fetched
}
const resolved = tryResolve()
if (!resolved) {
throw new Error(`cannot resolve ref ${ref}`)
}
return resolved
}
function readTargetMetadata(sha) {
const lock = run('git', ['show', '--end-of-options', `${sha}:package-lock.json`])
const pyproject = run('git', ['show', '--end-of-options', `${sha}:pyproject.toml`])
const uvLock = run('git', ['show', '--end-of-options', `${sha}:uv.lock`])
const pkg = JSON.parse(run('git', ['show', '--end-of-options', `${sha}:apps/desktop/package.json`]))
return {
electron: pkg.devDependencies?.electron,
electronBuild: pkg.build?.electronVersion,
lockSha256: sha256(lock),
pyprojectSha256: sha256(pyproject),
uvLockSha256: sha256(uvLock)
}
}
function readJournalContract(sha) {
const source = run('git', ['show', '--end-of-options', `${sha}:apps/desktop/src/lib/inflight-turn-journal.ts`])
const storageKey = source.match(/const (?:LEGACY_STORAGE_KEY|STORAGE_KEY) = '([^']+)'/)?.[1] ?? null
const migrationKey = source.match(/const LEGACY_MIGRATION_KEY = '([^']+)'/)?.[1] ?? null
const legacyStoreLimit = source.match(/const MAX_LEGACY_STORE_CHARS = (\d+) \* 1024 \* 1024/)?.[1]
return {
legacyStoreLimit: legacyStoreLimit ? Number(legacyStoreLimit) * 1024 * 1024 : null,
migrationKey,
storageKey
}
}
function injectHarness(targetRoot) {
const targetHarness = join(targetRoot, 'apps/desktop/src/app/chat/short-session-hang-repro.tsx')
const targetMain = join(targetRoot, 'apps/desktop/src/main.tsx')
const main = readFileSync(targetMain, 'utf8')
mkdirSync(dirname(targetHarness), { recursive: true })
copyFileSync(HARNESS_SOURCE, targetHarness)
if (!main.includes("import('./app/chat/short-session-hang-repro')")) {
writeFileSync(
targetMain,
`${main}\nif (import.meta.env.VITE_SHORT_SESSION_HANG_REPRO === '1') {\n import('./app/chat/short-session-hang-repro')\n}\n`
)
}
}
function linkShared(targetRoot, relativePath) {
const source = join(REPO_ROOT, relativePath)
const target = join(targetRoot, relativePath)
if (existsSync(source) && !existsSync(target)) {
mkdirSync(dirname(target), { recursive: true })
symlinkSync(source, target, 'dir')
}
}
function prepareTarget(label, sha, root, output) {
const targetRoot = join(root, label)
run('git', ['worktree', 'add', '--detach', targetRoot, sha])
try {
injectHarness(targetRoot)
linkShared(targetRoot, 'node_modules')
linkShared(targetRoot, 'apps/desktop/node_modules')
linkShared(targetRoot, '.venv')
const targetDesktop = join(targetRoot, 'apps/desktop')
const buildLog = join(output, `${label}-build.log`)
run('npm', ['run', '--prefix', 'apps/desktop', 'build'], {
cwd: targetRoot,
env: sanitizedEnv({ VITE_SHORT_SESSION_HANG_REPRO: '1' }),
logPath: buildLog
})
return { journalContract: readJournalContract(sha), label, sha, targetDesktop, targetRoot }
} catch (error) {
try {
run('git', ['worktree', 'remove', '--force', targetRoot])
} catch {
// The original build error is more useful; cleanup is retried manually.
}
throw error
}
}
function startMockInference() {
const reply = `deterministic-tool-heavy-output:${'x'.repeat(STREAM_PAYLOAD_BYTES)}`
let streamingCompletionRequests = 0
let activeStreamingRequests = 0
let listening = false
const transportErrors = []
const requests = []
const recordTransportError = (source, error) => {
transportErrors.push({
at: new Date().toISOString(),
message: error instanceof Error ? error.message : String(error),
source
})
}
const server = http.createServer((request, response) => {
requests.push({ method: request.method, url: request.url ?? null })
request.on('error', error => recordTransportError('request', error))
response.on('error', error => recordTransportError('response', error))
if (request.method === 'GET' && request.url === '/v1/models') {
response.writeHead(200, { 'content-type': 'application/json' })
response.end(JSON.stringify({ data: [{ id: 'short-session-model', object: 'model' }], object: 'list' }))
return
}
if (request.method === 'POST' && request.url?.startsWith('/v1/chat/completions')) {
let body = ''
request.on('data', chunk => {
body += String(chunk)
})
request.on('end', () => {
let stream = false
try {
stream = JSON.parse(body).stream === true
} catch {
stream = false
}
if (stream) {
streamingCompletionRequests += 1
activeStreamingRequests += 1
let finishedStream = false
const finishStream = () => {
if (finishedStream) return
finishedStream = true
activeStreamingRequests = Math.max(0, activeStreamingRequests - 1)
}
response.once('close', finishStream)
response.writeHead(200, { 'content-type': 'text/event-stream' })
let offset = 0
const writeNext = () => {
if (response.destroyed) {
finishStream()
return
}
if (offset >= reply.length) {
response.write(
`data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: 'stop', index: 0 }], id: 'short-session', object: 'chat.completion.chunk' })}\n\n`
)
response.end('data: [DONE]\n\n')
finishStream()
return
}
const chunk = reply.slice(offset, offset + 16 * 1024)
offset += chunk.length
response.write(
`data: ${JSON.stringify({ choices: [{ delta: { content: chunk }, finish_reason: null, index: 0 }], id: 'short-session', object: 'chat.completion.chunk' })}\n\n`
)
setTimeout(writeNext, 10)
}
writeNext()
} else {
response.writeHead(200, { 'content-type': 'application/json' })
response.end(
JSON.stringify({
choices: [{ finish_reason: 'stop', index: 0, message: { content: reply, role: 'assistant' } }],
id: 'short-session',
object: 'chat.completion'
})
)
}
})
return
}
response.writeHead(404, { 'content-type': 'application/json' })
response.end('{"error":"not found"}')
})
return new Promise((resolveStart, reject) => {
server.on('error', error => {
recordTransportError('server', error)
if (!listening) reject(error)
})
server.listen(0, '127.0.0.1', () => {
const address = server.address()
if (!address || typeof address === 'string') {
reject(new Error('mock inference server has no TCP address'))
return
}
listening = true
resolveStart({
activeStreamingRequests: () => activeStreamingRequests,
close: () =>
new Promise(resolveClose => {
let settled = false
const settle = () => {
if (settled) return
settled = true
resolveClose()
}
const timeout = setTimeout(() => {
server.closeAllConnections?.()
settle()
}, 2_000)
server.close(() => {
clearTimeout(timeout)
settle()
})
server.closeAllConnections?.()
}),
requestSummary: () => requests.map(request => ({ ...request })),
streamingCompletionRequests: () => streamingCompletionRequests,
transportErrors: () => transportErrors.map(error => ({ ...error })),
waitForStreaming: async (timeoutMs = 30_000, baseline = 0) => {
const deadline = Date.now() + timeoutMs
while (activeStreamingRequests === 0 && streamingCompletionRequests <= baseline && Date.now() < deadline) {
await sleep(20)
}
if (activeStreamingRequests === 0 && streamingCompletionRequests <= baseline) {
throw new Error('streaming response did not start')
}
},
url: `http://127.0.0.1:${address.port}`
})
})
})
}
function writeSandboxConfig(home, mockUrl) {
mkdirSync(home, { recursive: true })
writeFileSync(
join(home, 'config.yaml'),
`model:\n default: short-session-model\n provider: custom:short-session\nauxiliary:\n title_generation:\n enabled: false\nproviders:\n short-session:\n api: ${mockUrl}/v1\n transport: chat_completions\n default_model: short-session-model\n key_env: SHORT_SESSION_API_KEY\n`
)
writeFileSync(join(home, '.env'), 'SHORT_SESSION_API_KEY=local-diagnostic-only\n')
}
async function waitFor(cdp, expression, timeoutMs, label, ErrorType = Error) {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
try {
if (await cdp.eval(expression)) {
return
}
} catch {
// Renderer is still loading.
}
await sleep(Math.min(250, Math.max(1, deadline - Date.now())))
}
throw new ErrorType(`timed out waiting for ${label}`)
}
async function screenshot(cdp, path) {
try {
await cdp.send('Page.enable')
const shot = await cdp.send('Page.captureScreenshot', { format: 'png', fromSurface: true })
writeFileSync(path, Buffer.from(shot.data, 'base64'))
} catch {
// A frozen renderer may not service screenshot capture.
}
}
function reliablePid(pid) {
return Number.isSafeInteger(pid) && pid > 1 ? pid : null
}
function processRows() {
const result = spawnSync('ps', ['-axo', 'pid=,ppid=,%cpu=,%mem=,state=,etime=,command='], { encoding: 'utf8' })
if (result.error || result.status !== 0) {
const detail =
result.error?.message ?? (String(result.stderr ?? '').trim() || `exit status ${result.status ?? 'unknown'}`)
throw new Error(`process discovery failed: ${detail}`)
}
return String(result.stdout ?? '')
.split(/\r?\n/)
.map(line => {
const match = line.match(/^\s*(\d+)\s+(\d+)\s+([\d.]+)\s+([\d.]+)\s+(\S+)\s+(\S+)\s+(.*)$/)
return match
? {
command: match[7],
cpu: Number(match[3]),
elapsed: match[6],
memory: Number(match[4]),
pid: Number(match[1]),
ppid: Number(match[2]),
state: match[5]
}
: null
})
.filter(Boolean)
}
function processTree(rootPid) {
if (!reliablePid(rootPid)) {
return []
}
let rows
let discoveryError = null
try {
rows = processRows()
} catch (error) {
rows = []
discoveryError = error instanceof Error ? error.message : String(error)
}
const selected = new Set([rootPid])
let changed = true
while (changed) {
changed = false
for (const row of rows) {
if (!selected.has(row.pid) && selected.has(row.ppid)) {
selected.add(row.pid)
changed = true
}
}
}
const tree = rows.filter(row => selected.has(row.pid))
const rootMissing = !tree.some(row => row.pid === rootPid)
if (rootMissing) {
tree.unshift({
command: '<synthetic-root>',
cpu: 0,
elapsed: '',
memory: 0,
pid: rootPid,
ppid: 0,
state: '?',
synthetic: true
})
}
if (discoveryError) {
tree.discoveryError = discoveryError
}
if (rootMissing) {
tree.rootMissing = true
}
return tree
}
function redactCommand(command) {
return command
.replace(/([a-z][a-z0-9+.-]*:\/\/)[^\s/@]+(?::[^\s/@]*)?@/gi, '$1[REDACTED]@')
.replace(/\b([A-Za-z_][A-Za-z0-9_]*(?:TOKEN|SECRET|PASSWORD|KEY|AUTH|COOKIE)[A-Za-z0-9_]*)=\S+/gi, '$1=[REDACTED]')
.replace(/(--?\S*(?:token|secret|password|key|auth|cookie)\S*)(?:=|\s+)\S+/gi, '$1=[REDACTED]')
}
function processSnapshot(path, rootPid) {
const tree = processTree(rootPid)
const rows = tree.map(row => ({ ...row, command: redactCommand(row.command) }))
writeFileSync(
path,
`${JSON.stringify({ rootPid, rows, discoveryError: tree.discoveryError ?? null, rootMissing: tree.rootMissing ?? false }, null, 2)}\n`
)
return rows
}
function sampleOne(path, pid) {
if (process.platform !== 'darwin' || !reliablePid(pid)) {
return
}
const result = spawnSync('sample', [String(pid), '5', '1'], { encoding: 'utf8', timeout: 8_000 })
writeFileSync(path, `${result.stdout ?? ''}${result.stderr ?? ''}`)
}
function sampleTree(runDir, rootPid, prefix) {
const rows = processTree(rootPid)
sampleOne(join(runDir, `${prefix}-main.sample.txt`), rootPid)
const renderer = rows
.filter(row => row.pid !== rootPid && /(?:^|\s)--type=renderer(?:\s|$)/.test(row.command))
.sort((a, b) => b.cpu - a.cpu)[0]
if (renderer) {
sampleOne(join(runDir, `${prefix}-renderer-${renderer.pid}.sample.txt`), renderer.pid)
}
}
function captureDiagnostics(runDir, rootPid, prefix) {
try {
processSnapshot(join(runDir, `${prefix}-processes.txt`), rootPid)
} catch {
// Diagnostics are best effort and must not replace the original result.
}
try {
sampleTree(runDir, rootPid, prefix)
} catch {
// Diagnostics are best effort and must not replace the original result.
}
}
function liveCaptured(captured) {
let current
try {
current = new Map(processRows().map(row => [row.pid, row]))
} catch (error) {
const live = captured.filter(original => pidIsLive(original.pid))
live.discoveryError = error instanceof Error ? error.message : String(error)
return live
}
return captured.filter(original => {
if (original.synthetic) {
return pidIsLive(original.pid)
}
const row = current.get(original.pid)
return row && !row.state.startsWith('Z') && row.command === original.command
})
}
function pidIsLive(pid) {
try {
process.kill(pid, 0)
return true
} catch (error) {
return error?.code === 'EPERM'
}
}
async function stopProcessTree(rootPid) {
const captured = processTree(rootPid)
const processDiscoveryErrors = new Set()
if (captured.discoveryError) {
processDiscoveryErrors.add(captured.discoveryError)
}
const currentLive = () => {
const live = liveCaptured(captured)
if (live.discoveryError) {
processDiscoveryErrors.add(live.discoveryError)
}
return live
}
for (const { pid } of [...captured].reverse()) {
try {
process.kill(pid, 'SIGTERM')
} catch {
// It already exited.
}
}
const deadline = Date.now() + 3_000
while (Date.now() < deadline && currentLive().length > 0) {
await sleep(100)
}
const remaining = currentLive()
for (const { pid } of remaining.reverse()) {
try {
process.kill(pid, 'SIGKILL')
} catch {
// It exited between the liveness check and signal.
}
}
const killDeadline = Date.now() + 2_000
while (Date.now() < killDeadline && currentLive().length > 0) {
await sleep(100)
}
return {
captured: captured.map(row => row.pid),
processDiscoveryErrors: [...processDiscoveryErrors],
remainingAfterKill: currentLive().map(row => row.pid)
}
}
function withWatchdog(task, onTimeout) {
let timer
return Promise.race([
task,
new Promise((_, reject) => {
timer = setTimeout(() => {
onTimeout()
reject(new ReproductionError(`outer watchdog exceeded ${OUTER_WATCHDOG_MS}ms`))
}, OUTER_WATCHDOG_MS)
})
]).finally(() => clearTimeout(timer))
}
function withTimeout(task, timeoutMs, label, ErrorType = Error) {
let timer
return Promise.race([
task,
new Promise((_, reject) => {
timer = setTimeout(() => reject(new ErrorType(`${label} exceeded ${timeoutMs}ms`)), timeoutMs)
})
]).finally(() => clearTimeout(timer))
}
async function waitForResponsive(cdp, expression, timeoutMs, label, evaluationTimeoutMs = FREEZE_MS) {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
try {
if (
await withTimeout(cdp.eval(expression), evaluationTimeoutMs, `${label} renderer evaluation`, ReproductionError)
) {
return
}
} catch (error) {
if (error instanceof ReproductionError) {
throw error
}
// Match the initial renderer-readiness polling: CDP can fail transiently while a document is replaced.
}
await sleep(Math.min(250, Math.max(1, deadline - Date.now())))
}
throw new Error(`timed out waiting for ${label} while the renderer remained responsive`)
}
async function waitForPredicate(predicate, timeoutMs, label) {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (await predicate()) {
return
}
await sleep(Math.min(100, Math.max(1, deadline - Date.now())))
}
throw new Error(`timed out waiting for ${label}`)
}
async function verifyInteractiveSurfaces(cdp, timed, measure, label) {
const sentinel = `short-session-sentinel-${label}`
const composerPainted = await timed(`composer.paint.${label}`, async () => {
const focused = await cdp.eval(
`(() => { const el = document.querySelector('[data-slot="composer-rich-input"]'); if (!el || el.contentEditable !== 'true') return false; el.focus(); return true })()`
)
if (!focused) {
return false
}
await cdp.send('Input.insertText', { text: sentinel })
return cdp.eval(
`document.querySelector('[data-slot="composer-rich-input"]')?.textContent?.includes(${JSON.stringify(sentinel)}) === true`
)
})
if (!composerPainted) {
throw new Error(`composer did not paint sentinel at ${label}`)
}
const version = await timed(`version.ipc.${label}`, () => cdp.eval('window.hermesDesktop.getVersion()'))
if (typeof version?.appVersion !== 'string' || version.appVersion.length === 0) {
throw new Error(`version IPC returned no appVersion at ${label}: ${JSON.stringify(version)}`)
}
await timed(`about.open.${label}`, () => cdp.eval("location.hash = '#/settings?tab=about'; true"))
await measure(`about.ready.${label}`, () =>
waitForResponsive(
cdp,
`document.body.textContent.includes(${JSON.stringify(version.appVersion)}) && !!document.querySelector('button[aria-label]')`,
FREEZE_MS,
`About settings at ${label}`
)
)
const aboutClosed = await timed(`about.close.${label}`, () =>
cdp.eval(
`(() => { const close = document.querySelector('div[role="presentation"] > div > div:first-child button[aria-label]'); if (!close) return false; close.click(); return true })()`
)
)
if (!aboutClosed) {
throw new Error(`About settings close control unavailable at ${label}`)
}
await measure(`about.closed.${label}`, () =>
waitForResponsive(cdp, "!location.hash.includes('/settings')", FREEZE_MS, `About settings close at ${label}`)
)
const interactive = await timed(`transcript.interactive.${label}`, () =>
cdp.eval(
`(() => { const viewport = document.querySelector('[data-slot="aui_thread-viewport"]'); const row = document.querySelector('[data-message-id]'); if (!viewport || !row) return false; const before = viewport.scrollTop; viewport.scrollTop = Math.min(viewport.scrollHeight, before + 40); viewport.dispatchEvent(new Event('scroll', { bubbles: true })); return getComputedStyle(row).pointerEvents !== 'none' })()`
)
)
if (!interactive) {
throw new Error(`transcript was not interactive at ${label}`)
}
return { sentinel, version }
}
function nativeWindowVisibilitySource() {
return [
'import AppKit',
'import CoreGraphics',
'import Foundation',
'import Darwin',
'guard CommandLine.arguments.count >= 3,',
' let processId = Int32(CommandLine.arguments[1]),',
' let application = NSRunningApplication(processIdentifier: pid_t(processId)) else {',
' fputs("target application was not found", stderr)',
' exit(2)',
'}',
'let process = pid_t(processId)',
'let visible = CommandLine.arguments[2] == "visible"',
'var changed: Bool',
'if visible {',
' changed = application.unhide() || application.activate(options: [.activateIgnoringOtherApps, .activateAllWindows])',
'} else {',
' let _ = application.activate(options: [.activateIgnoringOtherApps, .activateAllWindows])',
' usleep(100_000)',
' changed = application.hide()',
' if !changed, let source = CGEventSource(stateID: .hidSystemState),',
' let down = CGEvent(keyboardEventSource: source, virtualKey: 4, keyDown: true),',
' let up = CGEvent(keyboardEventSource: source, virtualKey: 4, keyDown: false) {',
' down.flags = .maskCommand',
' up.flags = .maskCommand',
' down.postToPid(process)',
' up.postToPid(process)',
' changed = true',
' }',
'}',
'if !changed {',
' fputs("native visibility request was rejected", stderr)',
' exit(3)',
'}'
].join(String.fromCharCode(10))
}
function compileNativeWindowVisibilityHelper(helperPath) {
const sourcePath = `${helperPath}.swift`
writeFileSync(sourcePath, nativeWindowVisibilitySource())
try {
const result = spawnSync('/usr/bin/swiftc', [sourcePath, '-o', helperPath], {
encoding: 'utf8',
timeout: 30_000
})
if (result.status !== 0) {
const detail = result.error?.message || result.stderr || result.stdout || String(result.status)
throw new Error(`native macOS visibility helper compilation failed: ${detail}`)
}
} finally {
rmSync(sourcePath, { force: true })
}
return helperPath
}
function setNativeWindowVisibility(helperPath, pid, visible) {
if (process.platform !== 'darwin' || !helperPath || !Number.isSafeInteger(pid) || pid <= 1) {
throw new Error(`native macOS visibility requires a helper and valid app pid, got ${pid}`)
}
const result = spawnSync(helperPath, [String(pid), visible ? 'visible' : 'hidden'], {
encoding: 'utf8',
timeout: 10_000
})
if (result.status !== 0) {
const detail = result.error?.message || result.stderr || result.stdout || String(result.status)
throw new Error(`native macOS visibility change failed: ${detail}`)
}
}
async function runRealChatChecks(cdp, timed, measure, mock, runDir, appPid, nativeVisibilityHelper, getOperationCount) {
const requestCountBefore = mock.streamingCompletionRequests()
for (let exchange = 1; exchange <= EXCHANGE_COUNT; exchange += 1) {
const streamingRequestsBefore = mock.streamingCompletionRequests()
const beforeAssistant = await timed(`real-chat.assistant-count.${exchange}`, () =>
cdp.eval(
// Settled = no streaming marker anywhere in the row's subtree. The
// marker moved off the message root onto a hidden leaf inside it — a
// per-flip attribute write on the root widened style recalc across the
// whole message subtree — so this counts the descendant instead of the
// root's own attribute. Same rows, same gate.
//
// Counted by subtraction rather than with
// `:not(:has([data-message-streaming="true"]))`, which makes the engine
// walk every row's subtree on each evaluation — and this probe runs
// inside the very latency window it is measuring, so that cost lands in
// the number. At most one marker per row carries the attribute, so
// roots - streaming is exactly the settled count.
`document.querySelectorAll('[data-slot="aui_assistant-message-root"]').length - document.querySelectorAll('[data-message-streaming="true"]').length`
)
)
const composer = await timed(`real-chat.composer-focus.${exchange}`, () =>
cdp.eval(
`(() => { const el = document.querySelector('[data-slot="composer-rich-input"]'); if (!el || el.contentEditable !== 'true') return false; el.focus(); return true })()`
)
)
if (!composer) {
throw new Error(`real chat composer unavailable at exchange ${exchange}`)
}
const prompt = `Deterministic real chat exchange ${exchange}`
await timed(`real-chat.insert.${exchange}`, () => cdp.send('Input.insertText', { text: prompt }))
const inserted = await timed(`real-chat.inserted.${exchange}`, () =>
cdp.eval(
`document.querySelector('[data-slot="composer-rich-input"]')?.textContent?.includes(${JSON.stringify(prompt)}) === true`
)
)
if (!inserted) {
throw new Error(`real chat prompt did not reach the composer at exchange ${exchange}`)
}
await measure(`real-chat.submit-ready.${exchange}`, () =>
waitForResponsive(
cdp,
`!!document.querySelector('[data-slot="composer-root"] button[type="submit"]:not(:disabled)')`,
FREEZE_MS,
`real chat submit control ${exchange}`
)
)
const submitted = await timed(`real-chat.submit.${exchange}`, () =>
cdp.eval(
`(() => { const button = document.querySelector('[data-slot="composer-root"] button[type="submit"]:not(:disabled)'); if (!button) return false; window.setTimeout(() => button.click(), 0); return true })()`
)
)
if (!submitted) {
throw new Error(`real chat submit control unavailable at exchange ${exchange}`)
}
try {
await measure(`real-chat.stream-start.${exchange}`, () => mock.waitForStreaming(30_000, streamingRequestsBefore))
} catch (error) {
const state = await timed(`real-chat.dispatch-probe.${exchange}`, () =>
cdp.eval(`({
assistantMessages: document.querySelectorAll('[data-slot="aui_assistant-message-root"]').length,
assistantText: document.querySelector('[data-slot="aui_assistant-message-root"]')?.textContent ?? null,
composerText: document.querySelector('[data-slot="composer-rich-input"]')?.textContent ?? null,
harness: window.__SHORT_SESSION_HANG_REPRO__.summary(),
submitDisabled: document.querySelector('[data-slot="composer-root"] button[type="submit"]')?.disabled ?? null,
userMessages: document.querySelectorAll('[data-slot="aui_user-message-root"]').length
})`)
)
throw new Error(
`${error instanceof Error ? error.message : String(error)}; mock=${JSON.stringify(mock.requestSummary())}; dispatch probe responded with state: ${JSON.stringify(state)}`
)
}
await measure(`real-chat.native-hide.${exchange}`, () =>
setNativeWindowVisibility(nativeVisibilityHelper, appPid, false)
)
await sleep(NATIVE_VISIBILITY_PAUSE_MS)
await measure(`real-chat.native-restore.${exchange}`, () =>
setNativeWindowVisibility(nativeVisibilityHelper, appPid, true)
)
try {
await measure(`real-chat.mock-request.${exchange}`, () =>
waitForPredicate(
() => mock.streamingCompletionRequests() >= requestCountBefore + exchange,
FREEZE_MS,
`mock inference request ${exchange}`
)
)
} catch (error) {
const state = await timed(`real-chat.dispatch-probe.${exchange}`, () =>
cdp.eval(`({
assistantMessages: document.querySelectorAll('[data-slot="aui_assistant-message-root"]').length,
composerText: document.querySelector('[data-slot="composer-rich-input"]')?.textContent ?? null,
harness: window.__SHORT_SESSION_HANG_REPRO__.summary(),
userMessages: document.querySelectorAll('[data-slot="aui_user-message-root"]').length
})`)
)
throw new Error(
`${error instanceof Error ? error.message : String(error)}; dispatch probe responded with state: ${JSON.stringify(state)}`
)
}
await measure(`real-chat.assistant-response.${exchange}`, () =>
waitForResponsive(
cdp,
// Same count-by-subtraction as the pre-send probe above: no `:has()`
// subtree walk inside the responsiveness measurement window.
`document.querySelectorAll('[data-slot="aui_assistant-message-root"]').length - document.querySelectorAll('[data-message-streaming="true"]').length > ${beforeAssistant}`,
STREAM_RESPONSE_TIMEOUT_MS,
`real assistant response ${exchange}`,
STREAM_RESPONSE_EVALUATION_TIMEOUT_MS
)
)
if (mock.streamingCompletionRequests() < requestCountBefore + exchange) {
throw new Error(`mock inference request count did not advance for exchange ${exchange}`)
}
}
const requestDelta = mock.streamingCompletionRequests() - requestCountBefore
if (requestDelta !== EXCHANGE_COUNT) {
throw new Error(`expected exactly ${EXCHANGE_COUNT} mock completion requests, observed ${requestDelta}`)
}
const streamHeartbeat = await timed('renderer.heartbeat.stream', () =>
cdp.eval('window.__SHORT_SESSION_HANG_REPRO__.summary()')
)
await timed('renderer.heartbeat.checkpoint', () => cdp.eval('window.__SHORT_SESSION_HANG_REPRO__.checkpoint()'))
const postStreamOperationStart = getOperationCount()
const surfaces = await verifyInteractiveSurfaces(cdp, timed, measure, 'real-chat-exchange-5')
await withTimeout(screenshot(cdp, join(runDir, 'real-chat-exchange-5.png')), 2_000, 'real chat screenshot').catch(
() => {}
)
return {
assistantResponses: EXCHANGE_COUNT,
exchanges: EXCHANGE_COUNT,
messageRecords: EXCHANGE_COUNT * 2,
mockCompletionRequests: requestDelta,
postStreamOperationStart,
surfaces,
streamMaxGapMs: Number(streamHeartbeat.maxGapMs || 0)
}
}
async function runRendererChecks(cdp, label, runDir, mock, appPid, journalContract) {
const operations = []
const measure = async (name, body) => {
const started = performance.now()
const value = await Promise.resolve().then(body)
const latencyMs = performance.now() - started
operations.push({ name, latencyMs })
return value
}
const timed = (name, body) =>
measure(name, () => withTimeout(Promise.resolve().then(body), FREEZE_MS, name, ReproductionError))
await cdp.send('Runtime.enable')
await cdp.send('Profiler.enable')
await cdp.send('Profiler.start')
if (journalContract.storageKey !== LEGACY_STORAGE_KEY) {
throw new Error(
`journal storage-key contract mismatch: target=${journalContract.storageKey ?? 'missing'} harness=${LEGACY_STORAGE_KEY}`
)
}
if (journalContract.migrationKey !== null && journalContract.migrationKey !== LEGACY_MIGRATION_KEY) {
throw new Error(
`journal migration-key contract mismatch: target=${journalContract.migrationKey} harness=${LEGACY_MIGRATION_KEY}`
)
}
const checkpoints = []
const fixtureManifest = null
const nativeVisibilityHelper = compileNativeWindowVisibilityHelper(join(runDir, 'native-window-visibility'))
const journalSeed = await measure('journal.seed.legacy', () =>
withTimeout(
cdp.eval(`(() => {
const payload = 'j'.repeat(${LEGACY_SEED_PAYLOAD_CHARS})
const entries = Object.fromEntries(Array.from({ length: ${LEGACY_SEED_ENTRY_COUNT} }, (_, index) => {
const assistantId = 'legacy-seed-a-' + index
return [
'legacy-seed-' + index,
{
messages: [
{ id: 'legacy-seed-u-' + index, role: 'user', parts: [{ type: 'text', text: 'legacy seed prompt ' + index }] },
{ id: assistantId, role: 'assistant', parts: [{ type: 'text', text: payload }], pending: true }
],
streamId: assistantId,
turnStartedAt: Date.now(),
updatedAt: Date.now()
}
]
}))
const raw = JSON.stringify({ entries, version: 1 })
localStorage.removeItem(${JSON.stringify(LEGACY_MIGRATION_KEY)})
localStorage.setItem(${JSON.stringify(LEGACY_STORAGE_KEY)}, raw)
return { bytes: raw.length, entries: Object.keys(entries).length }
})()`),
JOURNAL_SEED_TIMEOUT_MS,
'journal.seed.legacy'
)
)
if (journalSeed.bytes >= MAX_LEGACY_SEED_BYTES) {
throw new Error(`legacy journal seed exceeded migration limit: ${journalSeed.bytes} bytes`)
}
if (journalContract.legacyStoreLimit !== null && journalSeed.bytes >= journalContract.legacyStoreLimit) {
throw new Error(
`legacy journal seed exceeds target migration limit: seed=${journalSeed.bytes} target=${journalContract.legacyStoreLimit}`
)
}
await timed('harness.reset', () => cdp.eval('window.__SHORT_SESSION_HANG_REPRO__.reset()'))
const realChat = await runRealChatChecks(
cdp,
timed,
measure,
mock,
runDir,
appPid,
nativeVisibilityHelper,
() => operations.length
)
const heartbeat = await timed('renderer.heartbeat.summary', () =>
cdp.eval('window.__SHORT_SESSION_HANG_REPRO__.summary()')
)
const profile = await cdp.send('Profiler.stop')
writeFileSync(join(runDir, 'renderer.cpuprofile'), JSON.stringify(profile.profile))
const maxOperationMs = operations
.slice(realChat.postStreamOperationStart)
.reduce((max, operation) => Math.max(max, operation.latencyMs), 0)
const maxGapMs = Number(heartbeat.maxGapMs || 0)
const reproduced = maxOperationMs > FREEZE_MS || maxGapMs > FREEZE_MS
return {
checkpoints,
fixtureManifest,
hardFailure: reproduced,
maxGapMs,
maxOperationMs,
operations,
outcome: reproduced ? 'reproduced' : 'not-reproduced',
journalSeed,
realChat,
syntheticScenario: 'disabled; real-chat-only'
}
}
async function withTemporarySandbox(label, body) {
const sandbox = mkdtempSync(join(tmpdir(), `hermes-short-session-${label}-`))
try {
return await body(sandbox)
} finally {
rmSync(sandbox, { force: true, recursive: true })
}
}
async function allocateDebugPort() {
const server = createServer()
try {
await new Promise((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', resolve)
})
const address = server.address()
if (!address || typeof address === 'string') {
throw new Error('temporary debug-port allocation returned no numeric address')
}
return address.port
} finally {
if (server.listening) {
await new Promise(resolve => server.close(() => resolve()))
}
}
}
function resultForError(error) {
const reproduced = error instanceof ReproductionError
return {
error: error instanceof Error ? (error.stack ?? error.message) : String(error),
hardFailure: true,
lifecycleSignals: [],
maxGapMs: null,
maxOperationMs: null,
operations: [],
outcome: reproduced ? 'reproduced' : 'harness-error'
}
}
async function executeRun(target, index, warmup, mock, output) {
return withTemporarySandbox(target.label, sandbox =>
executeRunInSandbox(target, index, warmup, mock, output, sandbox)
)
}
function runDirForAttempt(output, label, index, warmup, attempt = 1) {
const base = join(output, label, warmup ? 'warmup' : `run-${index + 1}`)
return attempt > 1 ? `${base}-attempt-${attempt}` : base
}
function promoteAttemptArtifacts(output, label, index, warmup, attempt) {
if (attempt <= 1) {
return
}
cpSync(runDirForAttempt(output, label, index, warmup, attempt), runDirForAttempt(output, label, index, warmup), {
force: true,
recursive: true
})
}
async function executeRunInSandboxAttempt(target, index, warmup, mock, output, sandbox, attempt) {
const runDir = runDirForAttempt(output, target.label, index, warmup, attempt)
const hermesHome = join(sandbox, 'hermes-home')
const userData = join(sandbox, 'electron-user-data')
const desktopLog = join(hermesHome, 'logs', 'desktop.log')
const stdoutPath = join(runDir, 'electron.stdout.log')
const stderrPath = join(runDir, 'electron.stderr.log')
const eventsPath = join(runDir, 'events.jsonl')
const port = await allocateDebugPort()
mkdirSync(runDir, { recursive: true })
mkdirSync(userData, { recursive: true })
writeSandboxConfig(hermesHome, mock.url)
const electron = require('electron')
const stdoutLog = createWriteStream(stdoutPath)
const stderrLog = createWriteStream(stderrPath)
const stdoutFlushed = finished(stdoutLog).then(
() => null,
error => error
)
const stderrFlushed = finished(stderrLog).then(
() => null,
error => error
)
const child = spawn(
electron,
[target.targetDesktop, `--user-data-dir=${userData}`, `--remote-debugging-port=${port}`],
{
cwd: target.targetDesktop,
env: sanitizedEnv({
HERMES_DESKTOP_APP_NAME: `HermesShortSession-${target.label}-${process.pid}-${index}-${warmup ? 'w' : 'm'}-${attempt}`,
HERMES_DESKTOP_HERMES_ROOT: target.targetRoot,
HERMES_DESKTOP_IGNORE_EXISTING: '1',
HERMES_DESKTOP_USER_DATA_DIR: userData,
HERMES_HOME: hermesHome,
SHORT_SESSION_API_KEY: 'local-diagnostic-only'
}),
stdio: ['ignore', 'pipe', 'pipe']
}
)
let exited = null
let spawnFailed = false
let stopping = false
let resolveSpawn
let rejectSpawn
const spawnReady = new Promise((resolve, reject) => {
resolveSpawn = resolve
rejectSpawn = reject
})
child.once('spawn', () => resolveSpawn())
child.once('error', error => {
spawnFailed = true
exited = exited ?? { code: null, signal: null }
appendFileSync(
eventsPath,
`${JSON.stringify({ at: new Date().toISOString(), error: error.message, type: 'spawn-error' })}\n`
)
rejectSpawn(error)
})
if (child.stdout) {
child.stdout.pipe(stdoutLog)
} else {
stdoutLog.end()
}
if (child.stderr) {
child.stderr.pipe(stderrLog)
} else {
stderrLog.end()
}
child.once('exit', (code, signal) => {
exited = { code, signal }
appendFileSync(
eventsPath,
`${JSON.stringify({ at: new Date().toISOString(), code, signal, type: stopping ? 'teardown-exit' : 'unexpected-exit' })}\n`
)
})
let cdp
let result
try {
await withTimeout(spawnReady, FREEZE_MS, 'Electron spawn')
const targetInfo = await withTimeout(discoverTarget({ port, timeoutMs: 60_000 }), 65_000, 'CDP target discovery')
cdp = await withTimeout(CDP.open(targetInfo.webSocketDebuggerUrl), 10_000, 'CDP connection')
await waitFor(cdp, '!!window.__SHORT_SESSION_HANG_REPRO__', 60_000, 'short-session renderer harness')
await waitFor(
cdp,
`document.querySelector('[data-slot="composer-rich-input"]')?.contentEditable === 'true'`,
60_000,
'interactive composer'
)
result = await withWatchdog(
runRendererChecks(cdp, `${target.label}-${index + 1}`, runDir, mock, child.pid, target.journalContract),
() => {
captureDiagnostics(runDir, child.pid, 'hard-timeout')
}
)
} catch (error) {
result = resultForError(error)
captureDiagnostics(runDir, child.pid, 'failure')
if (cdp) {
await withTimeout(screenshot(cdp, join(runDir, 'failure.png')), 2_000, 'failure screenshot').catch(() => {})
}
} finally {
cdp?.close()
const logText = existsSync(desktopLog) ? readFileSync(desktopLog, 'utf8') : ''
const lifecycleSignals = logText
.split(/\r?\n/)
.filter(line => /webContents became unresponsive|render-process-gone/i.test(line))
const lifecycleReproduced = lifecycleSignals.length > 0 || Boolean(exited && !spawnFailed)
result = { ...(result ?? resultForError(new Error('run produced no result'))), lifecycleSignals }
if (lifecycleReproduced) {
result.hardFailure = true
result.outcome = 'reproduced'
}
stopping = true
const teardown = await stopProcessTree(child.pid)
const logFlushErrors = await withTimeout(
Promise.all([stdoutFlushed, stderrFlushed]),
FREEZE_MS,
'Electron log flush'
).catch(error => [error])
writeFileSync(join(runDir, 'teardown.json'), `${JSON.stringify(teardown, null, 2)}\n`)
if (existsSync(desktopLog)) {
copyFileSync(desktopLog, join(runDir, 'desktop.log'))
}
if (teardown.remainingAfterKill.length > 0) {
result = {
...(result ?? {}),
error: `${result?.error ? `${result.error}\n` : ''}process teardown leaked PIDs ${teardown.remainingAfterKill.join(', ')}`,
hardFailure: true,
outcome: result?.outcome === 'reproduced' ? 'reproduced' : 'harness-error'
}
}
if (teardown.processDiscoveryErrors.length > 0) {
result = {
...(result ?? {}),
error: `${result?.error ? `${result.error}\n` : ''}${teardown.processDiscoveryErrors.join('\n')}`,
hardFailure: true,
outcome: result?.outcome === 'reproduced' ? 'reproduced' : 'harness-error'
}
}
const logFlushError = logFlushErrors.find(Boolean)
if (logFlushError) {
result = {
...(result ?? {}),
error: `${result?.error ? `${result.error}\n` : ''}Electron log flush failed: ${logFlushError instanceof Error ? logFlushError.message : String(logFlushError)}`,
hardFailure: true,
outcome: result?.outcome === 'reproduced' ? 'reproduced' : 'harness-error'
}
}
}
writeFileSync(join(runDir, 'result.json'), `${JSON.stringify(result, null, 2)}\n`)
appendFileSync(eventsPath, `${JSON.stringify({ at: new Date().toISOString(), result, type: 'run-complete' })}\n`)
return result
}
function isDebugStartupFailure(result) {
return !result.lifecycleSignals?.length && /CDP (?:target discovery|connection)/i.test(result.error ?? '')
}
async function executeRunInSandbox(target, index, warmup, mock, output, sandbox) {
for (let attempt = 1; attempt <= 2; attempt += 1) {
const result = await executeRunInSandboxAttempt(target, index, warmup, mock, output, sandbox, attempt)
if (attempt === 1 && isDebugStartupFailure(result)) {
rmSync(join(sandbox, 'electron-user-data'), { force: true, recursive: true })
continue
}
promoteAttemptArtifacts(output, target.label, index, warmup, attempt)
return result
}
throw new Error('unreachable debug startup retry state')
}
function classify(results, warmup) {
const invalid = [warmup, ...results].some(result => result.outcome === 'harness-error')
const reproduced = results.filter(result => result.outcome === 'reproduced').length
const reproducedThreshold = Math.ceil(results.length * 0.8)
return {
classification: invalid
? 'invalid'
: reproduced >= reproducedThreshold
? 'reproduced'
: reproduced === 0
? 'not-reproduced'
: 'intermittent',
invalid,
reproduced,
reproducedThreshold,
total: results.length
}
}
function pairedSoftSignal(baseline, candidate) {
if (baseline.length === 0 || candidate.length === 0) {
return { material: false, materialPairs: 0, materialThreshold: 0, reason: 'insufficient-runs', thresholdPct: 30 }
}
if ([...baseline, ...candidate].some(result => result.outcome !== 'not-reproduced')) {
return { material: false, materialPairs: 0, materialThreshold: 0, reason: 'hard-or-invalid-run', thresholdPct: 30 }
}
let materialPairs = 0
for (let i = 0; i < Math.min(baseline.length, candidate.length); i += 1) {
const base = Math.max(1, baseline[i].maxGapMs || 0, baseline[i].maxOperationMs || 0)
const next = Math.max(candidate[i].maxGapMs || 0, candidate[i].maxOperationMs || 0)
if (next >= base * 1.3) {
materialPairs += 1
}
}
const materialThreshold = Math.ceil(Math.min(baseline.length, candidate.length) * 0.8)
return { material: materialPairs >= materialThreshold, materialPairs, materialThreshold, thresholdPct: 30 }
}
function validateArtifactBundle(output, repetitions) {
const required = ['environment.json', 'summary.json', 'baseline-build.log', 'candidate-build.log']
for (const label of ['baseline', 'candidate']) {
for (const runName of ['warmup', ...Array.from({ length: repetitions }, (_, index) => `run-${index + 1}`)]) {
for (const artifact of ['events.jsonl', 'result.json', 'teardown.json']) {
required.push(join(label, runName, artifact))
}
}
}
for (const relativePath of required) {
const path = join(output, relativePath)
if (!existsSync(path) || readFileSync(path).byteLength === 0) {
throw new Error(`missing or empty required diagnostic artifact: ${relativePath}`)
}
}
const summary = JSON.parse(readFileSync(join(output, 'summary.json'), 'utf8'))
validateSummary(summary, repetitions)
}
function validateSummary(summary, repetitions) {
if (!Number.isInteger(repetitions) || repetitions < 1 || repetitions > 20) {
throw new Error('invalid summary repetitions')
}
let invalid = false
for (const label of ['baseline', 'candidate']) {
if (!summary[label]?.warmup || summary[label].runs?.length !== repetitions) {
throw new Error(`invalid ${label} summary shape`)
}
for (const result of [summary[label].warmup, ...summary[label].runs]) {
if (!['harness-error', 'not-reproduced', 'reproduced'].includes(result.outcome)) {
throw new Error(`invalid ${label} run outcome`)
}
const expectedHardFailure = result.outcome !== 'not-reproduced'
if (result.hardFailure !== expectedHardFailure) {
throw new Error(`inconsistent ${label} run outcome and hardFailure`)
}
}
const derived = classify(summary[label].runs, summary[label].warmup)
for (const field of ['classification', 'invalid', 'reproduced', 'reproducedThreshold', 'total']) {
if (summary[label][field] !== derived[field]) {
throw new Error(`inconsistent ${label} summary ${field}`)
}
}
invalid ||= derived.invalid
}
if (summary.invalid !== invalid) {
throw new Error('inconsistent summary invalid state')
}
}
async function main() {
const options = parseArgs(process.argv.slice(2))
if (!existsSync(HARNESS_SOURCE)) {
throw new Error(`renderer harness missing: ${HARNESS_SOURCE}`)
}
validateRef(options.baseline)
validateRef(options.candidate)
const baselineSha = resolveRef(options.baseline)
const candidateSha = resolveRef(options.candidate)
const mergeBase = run('git', ['merge-base', '--', baselineSha, candidateSha])
const ancestor = spawnSync('git', ['merge-base', '--is-ancestor', '--', baselineSha, candidateSha], {
cwd: REPO_ROOT,
encoding: 'utf8'
})
if (mergeBase !== baselineSha || ancestor.status !== 0) {
throw new Error(
`baseline must be the exact merge-base and an ancestor of candidate; baseline=${baselineSha} merge-base=${mergeBase} candidate=${candidateSha}`
)
}
const baselineMetadata = readTargetMetadata(baselineSha)
const candidateMetadata = readTargetMetadata(candidateSha)
const harnessHead = run('git', ['rev-parse', '--verify', '--end-of-options', 'HEAD^{commit}'])
const harnessMetadata = readTargetMetadata(harnessHead)
if (
JSON.stringify(baselineMetadata) !== JSON.stringify(candidateMetadata) ||
JSON.stringify(baselineMetadata) !== JSON.stringify(harnessMetadata)
) {
throw new Error(
`shared dependency mismatch; harness checkout, baseline, and candidate Electron/lockfile metadata must match:\nharness ${JSON.stringify(harnessMetadata)}\nbaseline ${JSON.stringify(baselineMetadata)}\ncandidate ${JSON.stringify(candidateMetadata)}`
)
}
const plan = {
baseline: { ref: options.baseline, sha: baselineSha },
candidate: { ref: options.candidate, sha: candidateSha },
harness: { head: harnessHead, metadata: harnessMetadata },
harnessSha256: sha256(readFileSync(HARNESS_SOURCE)),
mergeBase,
metadata: baselineMetadata,
repetitions: options.repetitions,
runner: { arch: process.arch, platform: process.platform, versions: process.versions }
}
if (options.dryRun) {
console.log(JSON.stringify(plan, null, 2))
return
}
if (process.platform !== 'darwin' || process.arch !== 'arm64') {
throw new Error(
`the short-session hang diagnostic requires macOS arm64; got ${process.platform}-${process.arch} (use --dry-run for preflight)`
)
}
mkdirSync(options.output, { recursive: true })
writeFileSync(join(options.output, 'environment.json'), `${JSON.stringify(plan, null, 2)}\n`)
const ephemeralRoot = mkdtempSync(join(tmpdir(), 'hermes-short-session-ab-'))
const prepared = []
const mock = await startMockInference()
try {
prepared.push(prepareTarget('baseline', baselineSha, ephemeralRoot, options.output))
prepared.push(prepareTarget('candidate', candidateSha, ephemeralRoot, options.output))
const byLabel = Object.fromEntries(prepared.map(target => [target.label, target]))
const warmups = {}
for (const label of ['baseline', 'candidate']) {
warmups[label] = await executeRun(byLabel[label], 0, true, mock, options.output)
}
const measured = { baseline: [], candidate: [] }
for (let index = 0; index < options.repetitions; index += 1) {
const order = index % 2 === 0 ? ['baseline', 'candidate'] : ['candidate', 'baseline']
for (const label of order) {
measured[label].push(await executeRun(byLabel[label], index, false, mock, options.output))
}
}
const baselineClassification = classify(measured.baseline, warmups.baseline)
const candidateClassification = classify(measured.candidate, warmups.candidate)
const invalid = baselineClassification.invalid || candidateClassification.invalid
const summary = {
...plan,
invalid,
baseline: { ...plan.baseline, ...baselineClassification, runs: measured.baseline, warmup: warmups.baseline },
candidate: {
...plan.candidate,
...candidateClassification,
runs: measured.candidate,
warmup: warmups.candidate
},
softSignal: invalid
? { material: false, materialPairs: 0, materialThreshold: 0, reason: 'invalid-run', thresholdPct: 30 }
: pairedSoftSignal(measured.baseline, measured.candidate),
mockRequests: mock.requestSummary(),
mockTransportErrors: mock.transportErrors()
}
writeFileSync(join(options.output, 'summary.json'), `${JSON.stringify(summary, null, 2)}\n`)
validateArtifactBundle(options.output, options.repetitions)
console.log(JSON.stringify(summary, null, 2))
// A warm-up reproduction can be the one-shot v1 migration or first-paint
// cost. It is retained in the summary, but only a warm-up harness error or
// a measured hard failure makes the A/B job fail.
if (
warmups.baseline.outcome === 'harness-error' ||
warmups.candidate.outcome === 'harness-error' ||
measured.baseline.some(result => result.hardFailure) ||
measured.candidate.some(result => result.hardFailure)
) {
process.exitCode = 1
}
} finally {
await mock.close()
if (!options.keepWorktrees) {
for (const target of prepared.reverse()) {
try {
run('git', ['worktree', 'remove', '--force', target.targetRoot])
} catch (error) {
console.error(`warning: failed to remove ${target.targetRoot}: ${error.message}`)
}
}
rmSync(ephemeralRoot, { force: true, recursive: true })
} else {
console.log(`kept ephemeral worktrees at ${ephemeralRoot}`)
}
}
}
if (resolve(process.argv[1] ?? '') === fileURLToPath(import.meta.url)) {
main().catch(error => {
console.error(error instanceof Error ? (error.stack ?? error.message) : String(error))
process.exitCode = 2
})
}
export {
ReproductionError,
classify,
pairedSoftSignal,
resultForError,
validateArtifactBundle,
validateSummary,
waitFor,
waitForPredicate,
waitForResponsive,
withTemporarySandbox,
withTimeout
}