1717 lines
58 KiB
JavaScript
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
|
|
}
|