Files
orca/tests/tools/benchmarks/terminal-perf-bench.mjs
T
JinjingandOrca a07427e970 fix(ssh, relay): keep remote sessions alive through reconnects and backpressure (#11999)
* fix(ssh,relay): stop remote connections from being killed by backoff and frame caps

Three independent connection killers found in the SSH/remote freeze audit.

FINDING A - the reconnect ladder never escalated for post-handshake drops.
scheduleReconnect() used the single published state.reconnectAttempt for both
the delay index and the give-up test, and runReconnectAttempt() zeroed it
before connecting (ssh.ts gates the relay redeploy on 0-at-connected). Every
post-handshake drop therefore re-entered at 1000ms forever, ~3600 relay
redeploys/hour, and 'reconnection-failed' was unreachable for a flapping host.
New SshReconnectLadder splits the delay index (advanced by every retry) from
the failure streak (advanced only by a failed handshake), so flaps back off
while give-up semantics stay byte-identical to shipped.

FINDING B - notify() closed the client whenever a frame exceeded the producer
frame capacity, conflating a permanently un-sendable frame with transient
backpressure. A 5000-event fs.changed is 425KB against a 49KB cap, so the
watcher flood killed the link and re-killed on every reattach+replay. notify()
now drops and logs once per generation; fs.changed is chunked to each sink's
capacity with a control-lane overflow marker as the resync fallback; agent-hook
envelopes shed lastAssistantMessage/interactivePrompt/subagents to fit.

FINDING B2 - sendResponse routed >1MB responses to a lane whose admission
ignores the frame cap and closed the client on rejection, so a large
fs.listFiles dropped the SSH host. It now substitutes a JSON-RPC error so the
request fails instead of the connection.

Also moves fs.streamEnd/fs.streamError to the control lane so a terminal frame
cannot be dropped by the producer-lane check.

Co-authored-by: Orca <help@stably.ai>

* fix(relay): stop the overflow marker from re-killing the link it protects

Round-1 review fixes on the P0 freeze work.

The control-lane overflow marker could reinstate the exact failure this P0
removes: dispatcher-client-writer closes the client when control-lane
admission fails, and admitControl is the only lane that returns an error, so
one marker per failing batch accumulated to the 256-frame/1MB bound and
dropped the link. Markers are now deduped to one outstanding per
(client, root), cleared on settle.

Chunking also defeated the renderer's per-payload directory dedupe -- events
are now stable-grouped by parent directory so one directory lands in one
chunk -- and the halving walk overshot the byte minimum ~1.7x while the fast
path paid three JSON encodes; both are fixed by publishing first and sizing
from a measured bytes-per-event estimate.

Agent-hook shedding now surrenders the blocking interactive prompt LAST
rather than first, so a degraded envelope cannot strand a pane at
state=waiting with no answerable question card.

The dropped-notification log now distinguishes over-capacity from producer
queue backpressure and no longer lets the first dropped method silence every
other producer for the life of the connection.

* fix(relay,ssh): keep status delivery and terminal frames from trading one freeze for another

Round-2 review fixes.

The round-0 change from close-on-rejection to silent drop removed the only
redelivery path for agent.hook envelopes: they are fire-and-forget and the
per-pane cache only replays on handler install, so a saturated link stranded
a pane on a stale Working spinner until reconnect. Closing used to guarantee
delivery by forcing that replay. Envelopes now publish per client and pend
for bounded latest-wins redelivery when the producer queue rejects them.

Shed fields are now named on the wire. The subagent roster is not cosmetic --
the renderer replaces rather than merges it, and hibernation gates on its
length -- so an unmarked shed could sleep a live pane.

fs.streamEnd rode the control lane because it must not be dropped, but that
lane kills rather than drops. The stream's concurrency slot is now held until
the terminal frame settles rather than until the fd closes, capping queued
terminal frames well under the control budget; overflow costs one refused
read instead of the connection.

The watcher chunk walk now stops while producer retention sits past its
reserve and degrades to a resync, so a 5000-event flood cannot fill the queue
that interactive PTY traffic shares and stall every remote terminal.

The reconnect ladder caps its flap-path delay so delay plus handshake timeout
cannot cross the relay grace floor and let the remote daemon kill live PTYs.

Also: the suppression key no longer embeds a NUL byte, which had made the
file binary to git and grep; producerEnvelopeBudget no longer reports
infinite capacity for a departed client; the drop logger no longer encodes a
frame it will not log; and an over-capacity response substitution no longer
settles as if the result had been delivered.

* fix(relay,ssh): restore relay-shed status fields and scope backpressure per client

Round 3 + 4 review fixes.

Watcher chunking is now gated on the *client's* retention reserve rather than
the dispatcher-wide one, so one stalled peer no longer forces a healthy client
into a full file-tree resync. The relay-lost redeploy ladder no longer burns its
6-attempt budget while the SSH transport itself is down: it holds at the 15s step
with a non-terminal status and rearms, so a laptop that slept past the ladder
comes back instead of landing on a terminal "give up" banner.

The shedFields wire marker had no consumer, so an agent-hook envelope whose
subagent roster was dropped to fit the frame read as "roster cleared" on the Orca
side: live child rows blanked and a done pane became hibernation-eligible while
its teammates were still running. ingestRemote now restores shed fields from the
cached payload (interactivePrompt deliberately excluded — a stale answerable
question card is worse than none).

Also: stream terminal-frame slots are counted per client, since the control queue
they protect is per client; the chunking fast path no longer logs a drop for a
batch it goes on to deliver in full; -32010 is now RelayErrorCode.ResponseOverCapacity.

Test debt from the review: pending-pane eviction, per-client stream isolation, and
the reconnect budget are now asserted rather than assumed; four fragile exact-byte
pins dropped in favour of the tier comparisons that carry the requirement.

* fix(relay,ssh): restore relay-shed status fields and scope backpressure

- Oversized relay responses now fail their request instead of closing the connection,
  preventing one frame from killing every pane on the host
- Restore subagent state for correct hibernation; don't resurrect stale prose
  across turns
- Account for relay re-establishment and PTY reattach time in SSH flap delay caps
- Only log drops of final unsendable envelopes, not temporary rejections during
  measurement probes
- Fix watcher overflow marker release race when notification admission rejects
  without settlement; use precise byte counting for event batching

* Restore relay-shed fields with digest validation and scoped backpressure

Validate that shed subagent rosters match their wire digest and turn identity before
restoration, preventing stale roster resurrection. Compact interactive prompts for waiting
states instead of dropping them. Demote control-queue overflow to non-fatal rejection so
clients can retry on capacity recovery, keeping the link alive during transient backpressure.

* fix(relay): correct ResponseOverCapacity error code

ResponseOverCapacity should use -33008 to stay in the -33xxx range
for relay protocol errors, not -32010.

* fix(relay): close client when pty.replay overflows control queue

Replay is never retried, so it uses the control lane where overflow
is fatal — the writer closes the client and reconnect reloads history
rather than stranding a short buffer.

* fix(relay): prevent infinite redeploy on flapping SSH transports

Charge reconnect attempts when connection restores mid-backoff, preventing
infinite loop on transports that flap between states. Refactor control overflow
handling to use entry property instead of WeakSet marker for clarity.

---------

Co-authored-by: Orca <help@stably.ai>
2026-08-01 14:27:16 -07:00

623 lines
22 KiB
JavaScript

#!/usr/bin/env node
// Terminal interaction latency benchmark: tab creation, tab switching, and
// workspace switching against the real Electron dev app over CDP. Produces
// median/p95 phase timings so Windows terminal slowness can be attributed to
// store work, PTY spawn, shell startup, or renderer paint — not guessed at.
import { execFileSync } from 'node:child_process'
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import {
collectRendererDiagnostics,
connectToApp,
createGpuUserDataDirectory,
installRendererProbe,
launchDevApp,
pickFreePort,
stopDevApp,
waitForStoreReady
} from '../../config/scripts/windows-apphang-repro/electron-dev-session.mjs'
import {
pollUntil,
rendererActionTimeoutMs,
runWithTimeout,
setupTimeoutMs
} from '../../config/scripts/windows-apphang-repro/repro-timing.mjs'
import { safeRemoveLocalDirectory } from '../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.mjs'
const rootDir = path.resolve(fileURLToPath(new URL('../..', import.meta.url)))
const scenarioTimeoutMs = 300_000
const defaultIterations = 8
const defaultSwitches = 24
const defaultCycles = 12
function parseArgs() {
const args = {
label: 'run',
iterations: defaultIterations,
switches: defaultSwitches,
cycles: defaultCycles,
shell: null,
scenarios: ['tab-create', 'tab-switch', 'workspace-switch'],
reportPath: null,
keep: false
}
for (const arg of process.argv.slice(2)) {
if (arg === '--help' || arg === '-h') {
printHelp()
process.exit(0)
}
if (arg === '--keep') {
args.keep = true
continue
}
const [name, value] = arg.split('=', 2)
if (name === '--label') {
args.label = value?.trim() || 'run'
continue
}
if (name === '--iterations') {
args.iterations = parsePositiveInt(name, value)
continue
}
if (name === '--switches') {
args.switches = parsePositiveInt(name, value)
continue
}
if (name === '--cycles') {
args.cycles = parsePositiveInt(name, value)
continue
}
if (name === '--shell') {
args.shell = value?.trim() || null
continue
}
if (name === '--scenarios') {
const scenarios = (value ?? '')
.split(',')
.map((entry) => entry.trim())
.filter(Boolean)
const known = new Set(['tab-create', 'tab-switch', 'workspace-switch'])
if (scenarios.length === 0 || scenarios.some((scenario) => !known.has(scenario))) {
throw new Error(
`Unsupported --scenarios=${value}. Use tab-create,tab-switch,workspace-switch.`
)
}
args.scenarios = scenarios
continue
}
if (name === '--report') {
args.reportPath = value?.trim() || null
continue
}
throw new Error(`Unknown argument: ${arg}`)
}
return args
}
function printHelp() {
console.log(`Usage:
node tests/tools/benchmarks/terminal-perf-bench.mjs [options]
Options:
--label=NAME Label recorded in the report filename/JSON. Default: run.
--iterations=N Terminal tab create/close iterations. Default: ${defaultIterations}.
--switches=N Tab switch alternations. Default: ${defaultSwitches}.
--cycles=N Workspace switch cycles. Default: ${defaultCycles}.
--shell=PATH Shell override for created tabs (e.g. cmd.exe). Default: app default.
--scenarios=a,b Subset of tab-create,tab-switch,workspace-switch.
--report=PATH Report JSON path. Default: tests/tools/benchmarks/results/terminal-perf-<label>-<ts>.json.
--keep Keep the temp fixture and user data dir.`)
}
function parsePositiveInt(name, value) {
const parsed = Number.parseInt(value ?? '', 10)
if (!Number.isInteger(parsed) || parsed <= 0 || String(parsed) !== value) {
throw new Error(`${name} requires a positive integer.`)
}
return parsed
}
function git(cwd, ...cmd) {
execFileSync('git', cmd, { cwd, stdio: 'pipe' })
}
/** Local (native-path) git repo with two external worktrees — three workspace
* cards total. Native paths keep the benchmark portable and representative of
* the default local workflow, unlike the WSL-specific apphang fixture. */
function createLocalRepoFixture() {
const baseDir = mkdtempSync(path.join(os.tmpdir(), 'orca-termperf-'))
const repoPath = path.join(baseDir, 'repo')
mkdirSync(repoPath, { recursive: true })
git(repoPath, 'init', '--initial-branch=main')
git(repoPath, 'config', 'user.email', 'bench@orca.local')
git(repoPath, 'config', 'user.name', 'Orca Bench')
writeFileSync(path.join(repoPath, 'README.md'), '# terminal perf fixture\n')
git(repoPath, 'add', '.')
git(repoPath, 'commit', '-m', 'init', '--no-gpg-sign')
const worktreePaths = []
for (const name of ['wt-one', 'wt-two']) {
const worktreePath = path.join(baseDir, name)
git(repoPath, 'worktree', 'add', worktreePath, '-b', name)
worktreePaths.push(worktreePath)
}
return { baseDir, repoPath, worktreePaths }
}
async function setupWorkspaces(page, fixture) {
return await runWithTimeout(
'fixture registration in Orca',
() =>
page.evaluate(
async ({ repoPath, importedWorktreePaths }) => {
const store = window.__store
if (!store) {
throw new Error('window.__store is unavailable.')
}
await store.getState().fetchSettings?.()
const addResult = await window.api.repos.add({ path: repoPath, kind: 'git' })
if ('error' in addResult) {
throw new Error(addResult.error)
}
await store.getState().fetchRepos()
const state = store.getState()
const repo =
state.repos.find((candidate) => candidate.path === repoPath) ?? addResult.repo
await state.updateRepo(repo.id, {
externalWorktreeVisibility: 'show',
externalWorktreeVisibilityPromptDismissedAt: Date.now(),
importedExternalWorktreePaths: importedWorktreePaths,
externalWorktreeInboxBaselinePaths: importedWorktreePaths
})
await store.getState().fetchWorktrees(repo.id, { requireAuthoritative: true })
const nextState = store.getState()
nextState.setSidebarOpen(true)
nextState.setGroupBy('none')
nextState.setSortBy('recent')
nextState.setShowActiveOnly(false)
nextState.setActiveView('terminal')
const worktrees = nextState.worktreesByRepo[repo.id] ?? []
return {
repoId: repo.id,
worktrees: worktrees.map((worktree) => ({
id: worktree.id,
path: worktree.path,
displayName: worktree.displayName,
isMainWorktree: worktree.isMainWorktree
}))
}
},
{ repoPath: fixture.repoPath, importedWorktreePaths: fixture.worktreePaths }
),
setupTimeoutMs
)
}
async function clickWorktreeCard(page, worktreeId) {
const rect = await runWithTimeout(
`locate worktree card ${worktreeId}`,
() =>
page.evaluate((id) => {
const rows = Array.from(document.querySelectorAll('[data-worktree-id]'))
const row = rows.find((candidate) => candidate.getAttribute('data-worktree-id') === id)
if (!row) {
return null
}
row.scrollIntoView({ block: 'center', inline: 'nearest' })
const surface = row.querySelector('[data-worktree-card-surface="true"]') ?? row
const bounds = surface.getBoundingClientRect()
if (bounds.width <= 0 || bounds.height <= 0) {
return null
}
return { x: bounds.left + bounds.width / 2, y: bounds.top + bounds.height / 2 }
}, worktreeId),
rendererActionTimeoutMs
)
if (!rect) {
throw new Error(`Could not find rendered worktree card for ${worktreeId}`)
}
await runWithTimeout(
`click worktree card ${worktreeId}`,
() => page.mouse.click(rect.x, rect.y),
rendererActionTimeoutMs
)
}
async function activateWorktree(page, worktreeId) {
await clickWorktreeCard(page, worktreeId)
await pollUntil(
`active worktree ${worktreeId}`,
() =>
page.evaluate((id) => {
const state = window.__store?.getState?.()
return state?.activeWorktreeId === id && state.activeTabType === 'terminal'
}, worktreeId),
Boolean,
30_000
)
}
/** Runs entirely in the renderer so phase timestamps come from one
* performance.now() clock with no CDP round-trip skew. */
async function runTabCreateScenario(page, worktreeId, iterations, shell) {
return await runWithTimeout(
'tab-create scenario',
() =>
page.evaluate(
async ({ worktreeId, iterations, shell }) => {
const store = window.__store
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const raf2 = () =>
new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))
const longtasks = []
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
longtasks.push(entry.duration)
}
})
observer.observe({ entryTypes: ['longtask'] })
const samples = []
for (let index = 0; index < iterations; index++) {
const t0 = performance.now()
const tab = store.getState().createTab(worktreeId, undefined, shell ?? undefined)
const tCreated = performance.now()
const readPane = () => {
const manager = window.__paneManagers?.get(tab.id)
return manager?.getActivePane?.() ?? manager?.getPanes?.()?.[0] ?? null
}
let ptyId = null
while (!ptyId && performance.now() - t0 < 60_000) {
ptyId = readPane()?.container?.dataset?.ptyId ?? null
if (!ptyId) {
await sleep(5)
}
}
const tPty = performance.now()
let sawOutput = false
while (!sawOutput && performance.now() - t0 < 60_000) {
const content = readPane()?.serializeAddon?.serialize?.() ?? ''
sawOutput = content.trim().length > 0
if (!sawOutput) {
await sleep(10)
}
}
const tOutput = performance.now()
await raf2()
const tPainted = performance.now()
samples.push({
index,
storeCreateMs: tCreated - t0,
ptyBindMs: tPty - t0,
firstOutputMs: tOutput - t0,
paintSettleMs: tPainted - t0,
timedOut: !ptyId || !sawOutput
})
store.getState().closeTab(tab.id)
await sleep(400)
}
observer.disconnect()
return { samples, longtasks }
},
{ worktreeId, iterations, shell }
),
scenarioTimeoutMs
)
}
/** K tabs are created up-front (PTY bound), then activation alternates across
* them. switchMs = setActiveTab call → active pane manager/pane for the new
* tab present → two RAFs (paint settled). */
async function runTabSwitchScenario(page, worktreeId, switches, shell) {
return await runWithTimeout(
'tab-switch scenario',
() =>
page.evaluate(
async ({ worktreeId, switches, shell }) => {
const store = window.__store
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const raf2 = () =>
new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))
const tabCount = 4
const tabs = []
for (let index = 0; index < tabCount; index++) {
const tab = store.getState().createTab(worktreeId, undefined, shell ?? undefined)
tabs.push(tab.id)
const started = performance.now()
let bound = false
while (!bound && performance.now() - started < 60_000) {
const manager = window.__paneManagers?.get(tab.id)
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()?.[0] ?? null
bound = Boolean(pane?.container?.dataset?.ptyId)
if (!bound) {
await sleep(10)
}
}
}
await sleep(500)
const longtasks = []
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
longtasks.push(entry.duration)
}
})
observer.observe({ entryTypes: ['longtask'] })
const samples = []
for (let index = 0; index < switches; index++) {
const targetTabId = tabs[index % tabs.length]
if (store.getState().activeTabId === targetTabId) {
continue
}
const t0 = performance.now()
store.getState().setActiveTab(targetTabId)
const tStore = performance.now()
let visible = false
while (!visible && performance.now() - t0 < 10_000) {
const manager = window.__paneManagers?.get(targetTabId)
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()?.[0] ?? null
visible = Boolean(pane?.container?.isConnected)
if (!visible) {
await sleep(2)
}
}
const tVisible = performance.now()
await raf2()
const tPainted = performance.now()
samples.push({
index,
targetTabId,
storeMs: tStore - t0,
paneVisibleMs: tVisible - t0,
paintSettleMs: tPainted - t0,
timedOut: !visible
})
await sleep(120)
}
observer.disconnect()
for (const tabId of tabs) {
store.getState().closeTab(tabId)
}
return { samples, longtasks }
},
{ worktreeId, switches, shell }
),
scenarioTimeoutMs
)
}
async function runWorkspaceSwitchScenario(page, worktreeIds, cycles) {
const samples = []
for (let index = 0; index < cycles; index++) {
const worktreeId = worktreeIds[index % worktreeIds.length]
const t0 = Date.now()
await clickWorktreeCard(page, worktreeId)
await pollUntil(
`workspace activation ${worktreeId}`,
() =>
page.evaluate((id) => {
const state = window.__store?.getState?.()
return state?.activeWorktreeId === id && state.activeTabType === 'terminal'
}, worktreeId),
Boolean,
30_000,
10
)
const activationMs = Date.now() - t0
const pty = await pollUntil(
`workspace pty ${worktreeId}`,
() =>
page.evaluate((id) => {
const state = window.__store?.getState?.()
const tabId =
state?.activeWorktreeId === id && state.activeTabType === 'terminal'
? state.activeTabId
: (state?.activeTabIdByWorktree?.[id] ?? null)
const manager = tabId ? window.__paneManagers?.get(tabId) : null
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()?.[0] ?? null
return pane?.container?.dataset?.ptyId ?? null
}, worktreeId),
Boolean,
30_000,
10
)
const ptyBindMs = Date.now() - t0
await runWithTimeout(
'paint settle',
() =>
page.evaluate(
() =>
new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))
),
rendererActionTimeoutMs
)
const paintSettleMs = Date.now() - t0
samples.push({ index, worktreeId, ptyId: pty, activationMs, ptyBindMs, paintSettleMs })
}
return { samples }
}
function summarize(values) {
const sorted = values.filter((value) => Number.isFinite(value)).sort((a, b) => a - b)
if (sorted.length === 0) {
return null
}
const at = (fraction) =>
sorted[Math.min(sorted.length - 1, Math.round(fraction * (sorted.length - 1)))]
return {
count: sorted.length,
median: Math.round(at(0.5)),
p95: Math.round(at(0.95)),
max: Math.round(sorted.at(-1))
}
}
function summarizeScenario(name, result) {
if (!result) {
return null
}
const fields = {}
const keys = new Set()
// Why: a timed-out sample carries its timeout ceiling as the measurement,
// which would masquerade as a giant latency in median/p95/max. Timeouts are
// reported through the separate timedOut count instead.
const completedSamples = result.samples.filter((sample) => !sample.timedOut)
for (const sample of completedSamples) {
for (const [key, value] of Object.entries(sample)) {
if (typeof value === 'number' && key !== 'index') {
keys.add(key)
}
}
}
for (const key of keys) {
fields[key] = summarize(completedSamples.map((sample) => sample[key]))
}
const longtasks = result.longtasks ?? []
return {
name,
samples: result.samples.length,
timedOut: result.samples.filter((sample) => sample.timedOut).length,
fields,
longtaskCount: longtasks.length,
longtaskMaxMs: longtasks.length ? Math.round(Math.max(...longtasks)) : 0
}
}
async function main() {
const args = parseArgs()
const startedAt = Date.now()
// Why: the report and clocks exist before any setup so a launch/setup
// failure still produces a report and reaches the cleanup below.
const report = {
label: args.label,
startedAt: new Date(startedAt).toISOString(),
platform: `${process.platform} ${os.release()}`,
shell: args.shell ?? 'default',
args: { iterations: args.iterations, switches: args.switches, cycles: args.cycles },
scenarios: {},
summaries: [],
finalDiagnostics: null,
cleanupErrors: []
}
let fixture = null
let userDataDir = null
let launched = null
let browser = null
let page = null
try {
fixture = createLocalRepoFixture()
const cdpPort = await pickFreePort()
userDataDir = createGpuUserDataDirectory('bench')
console.log(`[terminal-perf] fixture=${fixture.baseDir} userData=${userDataDir} cdp=${cdpPort}`)
launched = launchDevApp({ cdpPort, userDataDir })
const connected = await connectToApp(cdpPort)
browser = connected.browser
page = connected.page
await waitForStoreReady(page)
await installRendererProbe(page)
const setup = await setupWorkspaces(page, fixture)
const worktrees = setup.worktrees
if (worktrees.length < 2) {
throw new Error(`Expected >=2 worktrees, got ${worktrees.length}`)
}
const primary = worktrees.find((worktree) => worktree.isMainWorktree) ?? worktrees[0]
await activateWorktree(page, primary.id)
if (args.scenarios.includes('tab-create')) {
console.log(`[terminal-perf] scenario=tab-create iterations=${args.iterations}`)
report.scenarios['tab-create'] = await runTabCreateScenario(
page,
primary.id,
args.iterations,
args.shell
)
}
if (args.scenarios.includes('tab-switch')) {
console.log(`[terminal-perf] scenario=tab-switch switches=${args.switches}`)
report.scenarios['tab-switch'] = await runTabSwitchScenario(
page,
primary.id,
args.switches,
args.shell
)
}
if (args.scenarios.includes('workspace-switch')) {
console.log(`[terminal-perf] scenario=workspace-switch cycles=${args.cycles}`)
const targets = worktrees.slice(0, 3).map((worktree) => worktree.id)
report.scenarios['workspace-switch'] = await runWorkspaceSwitchScenario(
page,
targets,
args.cycles
)
}
report.finalDiagnostics = await collectRendererDiagnostics(page)
} finally {
report.elapsedMs = Date.now() - startedAt
for (const [name, result] of Object.entries(report.scenarios)) {
const summary = summarizeScenario(name, result)
if (summary) {
report.summaries.push(summary)
}
}
if (browser) {
await browser.close().catch(() => undefined)
}
if (launched) {
// Why: a shutdown failure must not abort the rest of teardown — the
// report still gets written and temp dirs still get removed.
try {
await stopDevApp(launched.child)
} catch (error) {
report.cleanupErrors.push(error instanceof Error ? error.message : String(error))
}
report.appLogsTail = launched.logs.slice(-80)
// Main-process phase attribution (requires ORCA_PTY_SPAWN_TIMING=1 in the
// benchmark's environment; launchDevApp inherits it into the app).
report.ptySpawnTimings = launched.logs
.filter((entry) => entry.line.includes('[pty-spawn-timing]'))
.map((entry) => entry.line.slice(entry.line.indexOf('[pty-spawn-timing]')))
}
if (!args.keep) {
if (fixture) {
safeRemoveLocalDirectory(fixture.baseDir, report.cleanupErrors)
}
if (userDataDir) {
safeRemoveLocalDirectory(userDataDir, report.cleanupErrors)
}
}
const stamp = new Date(startedAt).toISOString().replace(/[:.]/g, '-')
const reportPath = path.resolve(
args.reportPath ??
path.join(
rootDir,
'tests',
'tools',
'benchmarks',
'results',
`terminal-perf-${args.label}-${stamp}.json`
)
)
mkdirSync(path.dirname(reportPath), { recursive: true })
writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`)
console.log(`[terminal-perf] report=${reportPath}`)
for (const summary of report.summaries) {
console.log(
`[terminal-perf] ${summary.name}: samples=${summary.samples} timedOut=${summary.timedOut} longtasks=${summary.longtaskCount} (max ${summary.longtaskMaxMs}ms)`
)
for (const [field, stats] of Object.entries(summary.fields)) {
if (stats) {
console.log(
`[terminal-perf] ${field}: median=${stats.median}ms p95=${stats.p95}ms max=${stats.max}ms`
)
}
}
}
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error))
process.exit(1)
})