Files
stablyai--orca/tools/benchmarks/main-thread-jank-bench.mjs
2026-07-13 13:05:33 +08:00

420 lines
14 KiB
JavaScript

#!/usr/bin/env node
/**
* Orca main-thread jank / subprocess-churn benchmark (issue #7576).
*
* Reproduces the reporter's setup: a running app with an active git worktree
* and the Source Control sidebar open, measured at steady state. The app is
* launched with ORCA_MAIN_THREAD_DIAGNOSTICS=1 so the main process emits one
* `[main-thread] {json}` stderr line every 5s containing:
* - worst event-loop stall in the window (maxGapMs) and stall counts
* - subprocess spawns since the last report, keyed by command
* ("git status", "gh api", …) with the synchronous spawn-initiation cost
* that held the main thread (blockMsTotal/blockMsMax)
* On macOS the bench also tails the unified log for the exact
* "Performance Diagnostics" warnings from the issue and records which process
* emitted them, so spawn churn and OS warnings can be correlated by timestamp.
*
* Usage:
* node tools/benchmarks/main-thread-jank-bench.mjs --label baseline
* [--duration-s 120] [--warmup-s 20] [--fixture-dir <path>]
* [--exe <path-to-packaged-Orca>] [--headless] [--no-log-stream]
*
* The window must stay VISIBLE during the run: git-status polling is gated on
* document visibility, so a hidden/minimized window measures nothing.
* --headless exists only for smoke-testing the harness itself.
*
* Prereq (when not using --exe): `pnpm build:electron-vite` so out/ exists.
* Results: tools/benchmarks/results/main-thread-jank-<label>-<timestamp>.json
*/
import { spawn, spawnSync } from 'node:child_process'
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { join, resolve } from 'node:path'
const scriptDir = import.meta.dirname
const repoRoot = resolve(scriptDir, '..', '..')
const require = createRequire(import.meta.url)
function parseArgs(argv) {
const args = {
label: 'run',
durationS: 120,
warmupS: 20,
fixtureDir: null,
exe: null,
headless: false,
logStream: process.platform === 'darwin'
}
for (let i = 2; i < argv.length; i++) {
const next = () => argv[++i]
switch (argv[i]) {
case '--label':
args.label = next()
break
case '--duration-s':
args.durationS = Number(next())
break
case '--warmup-s':
args.warmupS = Number(next())
break
case '--fixture-dir':
args.fixtureDir = next()
break
case '--exe':
args.exe = next()
break
case '--headless':
args.headless = true
break
case '--no-log-stream':
args.logStream = false
break
default:
throw new Error(`Unknown argument: ${argv[i]}`)
}
}
return args
}
/**
* Seed a userData fixture whose persisted state restores straight into the
* churn-producing configuration: one git repo as the active worktree with the
* Source Control sidebar open (engages the 3s interactive git-status poll).
* The branch is pushed to a local `origin` but has NO configured upstream —
* the shape Orca worktrees commonly have, which forces the effective-upstream
* probe (the most spawn-heavy status path) on every poll tick.
*/
function ensureFixture(fixtureDir) {
mkdirSync(fixtureDir, { recursive: true })
const repoPath = join(fixtureDir, 'bench-repo')
if (!existsSync(join(repoPath, '.git'))) {
mkdirSync(repoPath, { recursive: true })
run('git', ['init', repoPath])
run('git', ['-C', repoPath, 'config', 'user.email', 'bench@example.com'])
run('git', ['-C', repoPath, 'config', 'user.name', 'Bench'])
writeFileSync(join(repoPath, 'README.md'), '# bench\n')
run('git', ['-C', repoPath, 'add', '.'])
run('git', ['-C', repoPath, 'commit', '-m', 'init', '--no-gpg-sign'])
}
const originPath = join(fixtureDir, 'bench-origin.git')
if (!existsSync(originPath)) {
run('git', ['init', '--bare', originPath])
run('git', ['-C', repoPath, 'remote', 'add', 'origin', originPath])
run('git', ['-C', repoPath, 'checkout', '-b', 'bench/feature'])
// Push WITHOUT -u: same-name origin ref exists but no tracking config.
run('git', ['-C', repoPath, 'push', 'origin', 'bench/feature'])
}
// A dirty file so every status poll parses a non-empty porcelain diff.
writeFileSync(join(repoPath, 'dirty.txt'), `${Date.now()}\n`)
const repoId = 'bench-repo'
const worktreeId = `${repoId}::${repoPath}`
const tabId = 'bench-tab-00000'
const state = {
schemaVersion: 1,
repos: [
{
id: repoId,
path: repoPath,
displayName: 'Bench Repo',
badgeColor: '#000000',
addedAt: 1,
externalWorktreeVisibility: 'show'
}
],
settings: {
telemetry: {
installId: 'main-thread-jank-bench',
optedIn: false,
existedBeforeTelemetryRelease: true
}
},
ui: {
lastActiveRepoId: repoId,
lastActiveWorktreeId: worktreeId,
rightSidebarOpen: true,
rightSidebarTab: 'source-control'
},
workspaceSession: {
activeRepoId: repoId,
activeWorktreeId: worktreeId,
activeTabId: tabId,
tabsByWorktree: {
[worktreeId]: [
{
id: tabId,
ptyId: 'bench-pty-00000',
worktreeId,
title: 'Terminal 1',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
},
terminalLayoutsByTabId: {
[tabId]: { root: null, activeLeafId: null, expandedLeafId: null }
},
activeTabIdByWorktree: { [worktreeId]: tabId },
activeWorktreeIdsOnShutdown: [worktreeId],
defaultTerminalTabsAppliedByWorktreeId: { [worktreeId]: true }
}
}
writeFileSync(join(fixtureDir, 'orca-data.json'), JSON.stringify(state, null, 2))
return repoPath
}
function run(command, args) {
const result = spawnSync(command, args, { stdio: 'ignore' })
if (result.status !== 0) {
throw new Error(`${command} ${args.join(' ')} failed with ${result.status}`)
}
}
function parseMainThreadLine(line) {
const match = /^\[main-thread\] (\{.*\})$/.exec(line)
if (!match) {
return null
}
try {
return JSON.parse(match[1])
} catch {
return null
}
}
/**
* Tail the macOS unified log for the exact warning from issue #7576. Events
* are kept with process identity + wall-clock timestamp so they can be lined
* up against the probe's report windows.
*/
function startPerfDiagnosticsLogStream(events) {
const child = spawn(
'log',
[
'stream',
'--style',
'ndjson',
'--predicate',
// The issue's "Performance Diagnostics:" prefix is the log category as
// rendered by the CLI; the message body carries the actual warning.
'eventMessage CONTAINS "should not be called on the main thread" OR eventMessage CONTAINS "Performance Diagnostics"'
],
{ stdio: ['ignore', 'pipe', 'ignore'] }
)
let buffer = ''
child.stdout.setEncoding('utf-8')
child.stdout.on('data', (chunk) => {
buffer += chunk
let newlineIndex = buffer.indexOf('\n')
while (newlineIndex !== -1) {
const line = buffer.slice(0, newlineIndex)
buffer = buffer.slice(newlineIndex + 1)
newlineIndex = buffer.indexOf('\n')
if (!line.startsWith('{')) {
continue
}
try {
const entry = JSON.parse(line)
events.push({
timestamp: entry.timestamp ?? null,
process: entry.processImagePath ?? entry.process ?? null,
pid: entry.processID ?? null,
message: String(entry.eventMessage ?? '').slice(0, 200)
})
} catch {
// ignore malformed ndjson lines
}
}
})
child.on('error', () => {
console.warn('[log-stream] failed to start `log stream`; continuing without it')
})
return child
}
function killProcessTree(proc) {
if (proc.exitCode !== null || proc.signalCode !== null) {
return
}
if (process.platform === 'win32') {
spawnSync('taskkill', ['/PID', String(proc.pid), '/T', '/F'], { stdio: 'ignore' })
} else {
try {
proc.kill('SIGKILL')
} catch {
// already gone
}
}
}
function aggregate(reports) {
const perCommand = {}
let spawnCount = 0
let maxGapMs = 0
let gapsOver50Ms = 0
let gapsOver250Ms = 0
for (const report of reports) {
spawnCount += report.spawnCount ?? 0
maxGapMs = Math.max(maxGapMs, report.maxGapMs ?? 0)
gapsOver50Ms += report.gapsOver50Ms ?? 0
gapsOver250Ms += report.gapsOver250Ms ?? 0
for (const [command, stats] of Object.entries(report.spawns ?? {})) {
const entry = (perCommand[command] ??= { count: 0, blockMsTotal: 0, blockMsMax: 0 })
entry.count += stats.count
entry.blockMsTotal = Math.round((entry.blockMsTotal + stats.blockMsTotal) * 100) / 100
entry.blockMsMax = Math.max(entry.blockMsMax, stats.blockMsMax)
}
}
return { spawnCount, maxGapMs, gapsOver50Ms, gapsOver250Ms, perCommand }
}
async function main() {
const args = parseArgs(process.argv)
const fixtureDir = resolve(
args.fixtureDir ?? join(repoRoot, '.bench-fixtures', 'main-thread-jank')
)
const repoPath = ensureFixture(fixtureDir)
console.log(`[fixture] userData=${fixtureDir} repo=${repoPath}`)
const env = {
...process.env,
ORCA_STARTUP_DIAGNOSTICS: '1',
ORCA_MAIN_THREAD_DIAGNOSTICS: '1',
ORCA_E2E_USER_DATA_DIR: fixtureDir
}
if (args.headless) {
env.ORCA_E2E_HEADLESS = '1'
console.warn(
'[bench] --headless: visibility-gated polling will NOT engage; harness smoke test only'
)
}
const command = args.exe ?? require('electron')
const commandArgs = args.exe ? [] : [repoRoot]
const startedAtWallMs = Date.now()
const child = spawn(command, commandArgs, { env, stdio: ['ignore', 'ignore', 'pipe'] })
const perfDiagnosticsEvents = []
const logStreamChild = args.logStream
? startPerfDiagnosticsLogStream(perfDiagnosticsEvents)
: null
const allReports = []
const markers = []
const startupEvents = []
let buffer = ''
child.stderr.setEncoding('utf-8')
child.stderr.on('data', (chunk) => {
buffer += chunk
let newlineIndex = buffer.indexOf('\n')
while (newlineIndex !== -1) {
const line = buffer.slice(0, newlineIndex).trimEnd()
buffer = buffer.slice(newlineIndex + 1)
newlineIndex = buffer.indexOf('\n')
const report = parseMainThreadLine(line)
if (report) {
// Marker lines (e.g. updater-check-attempt) timestamp one-off
// activities for correlation; they are not 5s report windows.
if (report.marker) {
markers.push({ ...report, wallMs: Date.now() })
} else {
allReports.push({ ...report, wallMs: Date.now() })
}
continue
}
if (line.startsWith('[startup] ')) {
startupEvents.push({ line, wallMs: Date.now() })
}
}
})
let exitedEarly = false
child.on('exit', () => {
exitedEarly = true
})
const totalMs = (args.warmupS + args.durationS) * 1000
console.log(
`[bench] pid=${child.pid} warming up ${args.warmupS}s, then measuring ${args.durationS}s…`
)
await new Promise((resolvePromise) => setTimeout(resolvePromise, totalMs))
const measureStartWallMs = startedAtWallMs + args.warmupS * 1000
const reports = allReports.filter((report) => report.wallMs >= measureStartWallMs)
killProcessTree(child)
if (logStreamChild) {
killProcessTree(logStreamChild)
}
if (exitedEarly) {
console.error('[bench] app exited before the measurement window completed')
}
const totals = aggregate(reports)
const measuredMinutes = args.durationS / 60
const result = {
label: args.label,
capturedAt: new Date(startedAtWallMs).toISOString(),
platform: process.platform,
config: { ...args, fixtureDir },
outcome: exitedEarly ? 'early-exit' : 'ok',
measuredDurationS: args.durationS,
reportCount: reports.length,
totals: {
...totals,
spawnsPerMinute: Math.round((totals.spawnCount / Math.max(measuredMinutes, 0.01)) * 10) / 10
},
perfDiagnostics: {
enabled: Boolean(logStreamChild),
totalCount: perfDiagnosticsEvents.length,
byProcess: perfDiagnosticsEvents.reduce((acc, event) => {
const key = event.process ?? 'unknown'
acc[key] = (acc[key] ?? 0) + 1
return acc
}, {}),
events: perfDiagnosticsEvents.slice(0, 200)
},
reports,
markers,
startupEvents: startupEvents.map((event) => event.line)
}
const resultsDir = join(scriptDir, 'results')
mkdirSync(resultsDir, { recursive: true })
const outPath = join(
resultsDir,
`main-thread-jank-${args.label}-${new Date(startedAtWallMs).toISOString().replace(/[:.]/g, '-')}.json`
)
writeFileSync(outPath, JSON.stringify(result, null, 2))
console.log(`\n[bench] label=${args.label} outcome=${result.outcome}`)
console.log(` reports: ${reports.length} (5s windows over ${args.durationS}s)`)
console.log(
` spawns: ${totals.spawnCount} total, ${result.totals.spawnsPerMinute}/min` +
` worst event-loop stall: ${totals.maxGapMs}ms` +
` stalls >50ms: ${totals.gapsOver50Ms}, >250ms: ${totals.gapsOver250Ms}`
)
const topCommands = Object.entries(totals.perCommand).sort((a, b) => b[1].count - a[1].count)
for (const [commandKey, stats] of topCommands.slice(0, 10)) {
console.log(
` ${commandKey}: ${stats.count} spawns, block ${stats.blockMsTotal}ms total / ${stats.blockMsMax}ms max`
)
}
if (logStreamChild) {
const byProcessSummary = perfDiagnosticsEvents.length
? ` (${Object.entries(result.perfDiagnostics.byProcess)
.map(([proc, count]) => `${proc.split('/').pop()}: ${count}`)
.join(', ')})`
: ''
console.log(
` macOS Performance Diagnostics log entries: ${perfDiagnosticsEvents.length}${byProcessSummary}`
)
}
console.log(` results: ${outPath}`)
}
main().catch((error) => {
console.error(error)
process.exit(1)
})