Files
aiturk-hermes-ide/apps/desktop/scripts/run-short-session-hang-repro.mjs
T

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
}