mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 16:03:17 +00:00
* Suppress Git Credential Manager OAuth popup on git clone (fixes #7652)
Orca's git runner disables the interactive credential prompt on every git
call that goes through gitExecFileAsync/gitStreamStdout, but the two raw
'git clone' spawns (desktop repos:clone and the runtime clone path) passed
no env, so they inherited process.env with no guard. On Windows a clone
that needs GitHub auth then makes Git Credential Manager pop its
'Connect to GitHub' OAuth window, and in a network-restricted intranet the
browser/device flow never completes while git's credential retry re-pops it.
Apply nonInteractiveGitEnv() to both clone spawns so the prompt is
suppressed (GCM_INTERACTIVE=never, credential.interactive=false,
GIT_TERMINAL_PROMPT=0). The credential *helper* is kept, so cached-token
clones for private repos still work; only the interactive fallback popup is
disabled and the clone fails fast with a clear error instead.
* Suppress GCM OAuth popup in agent terminals and setup hooks too (#7652)
The clone-spawn fix stopped Orca's own managed git from popping Git
Credential Manager, but git run in terminals and setup scripts inherited
process.env with no guard. That is the more likely source of the reported
loop: agents are told to run 'git pull --rebase'/'git fetch'/retry 'git
push' (preamble + conflict/push-failure prompts), and each retry re-pops
GCM's 'Connect to GitHub' window in a network-restricted intranet.
Apply the credential-prompt guard to:
- setup/archive/hook scripts (hooks.ts non-WSL exec env), which run
unattended on worktree create/archive.
- the shared PTY host env (buildPtyHostEnv), via a small
applyTerminalGitCredentialPromptGuard helper. Agent terminals are
guarded unconditionally (they cannot dismiss a GUI popup); user
terminals are guarded by default via the new
terminalSuppressGitCredentialPrompt setting so power users can opt out.
The credential helper is kept, so cached gh auth still works; only the
interactive fallback prompt is disabled. Verified end-to-end in a real
Orca terminal (GIT_TERMINAL_PROMPT=0 + GCM_INTERACTIVE=never by default;
absent when the opt-out is set).
* Scope user-terminal credential guard to Windows, add settings toggle, forward guard into WSL (#7652)
* Retrigger PR checks (Actions dropped the synchronize dispatch for 57e7ce249)
* Keep shell locale out of the terminal/hook credential guard (#7652 review fix)
* Fix Fable review findings: guard WSL hook branch, wire settings search, catalog keyword keys, sparse-env askpass, one-shot agent classification (#7652)
* fix(terminal): harden Git credential popup guard
* test(pty): cover SSH credential guard setting
* fix(git): guard remote clones and setup runners
* fix(git): scope credential guards to unattended work
---------
Co-authored-by: Brennan Benson <brennanbenson@Brennans-MacBook-Pro.local>
619 lines
23 KiB
JavaScript
619 lines
23 KiB
JavaScript
#!/usr/bin/env node
|
|
// Cold-park reveal-cost benchmark: measures the reveal latency a user pays when
|
|
// returning to a terminal whose hidden view was cold-parked (React subtree
|
|
// unmounted, xterm/buffers/WebGL released) versus a warm reveal where the tab
|
|
// was still in the hot-retain working set. The stock terminal-perf-bench
|
|
// workspace-switch scenario cycles 3 worktrees fast enough that parking never
|
|
// fires, so it only ever measures warm reveals. This bench drives the
|
|
// ORCA_E2E_TERMINAL_PARKING_DELAY_MS override to shrink the 30s hysteresis and
|
|
// window.__terminalParkingDebug.parkedTabIds() to *confirm* a tab parked before
|
|
// timing its reveal — so the cold vs warm delta is the real cost of parking.
|
|
//
|
|
// Output phases per reveal (ms from the switch-back click):
|
|
// activationMs store activeWorktree flips to the target terminal
|
|
// ptyBindMs the revealed pane has a bound ptyId (xterm remounted+attached)
|
|
// paintSettleMs two RAFs after ptyBind (first painted frame settled)
|
|
//
|
|
// Arms:
|
|
// cold — tab confirmed parked before reveal (park delay shrunk, wait to park)
|
|
// warm — tab revealed before the park delay elapses (hot-retain hit)
|
|
// off — parking disabled entirely (kill switch), reveal after the same idle
|
|
|
|
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
|
|
import os from 'node:os'
|
|
import path from 'node:path'
|
|
import process from 'node:process'
|
|
import { execFileSync } from 'node:child_process'
|
|
import { fileURLToPath } from 'node:url'
|
|
import {
|
|
collectRendererDiagnostics,
|
|
connectToApp,
|
|
installRendererProbe,
|
|
launchDevApp,
|
|
pickFreePort,
|
|
stopDevApp,
|
|
waitForStoreReady
|
|
} from '../../config/scripts/windows-apphang-repro/electron-dev-session.mjs'
|
|
import { createCompletedOnboardingProfile } from '../../config/scripts/windows-apphang-repro/wsl-workspace-fixture.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
|
|
|
|
// Short enough that a tab parks within a few seconds of being hidden, long
|
|
// enough that the warm arm can reliably reveal before it elapses.
|
|
const PARK_DELAY_MS = 1_500
|
|
const WARM_REVEAL_AFTER_MS = 300
|
|
const COLD_PARK_CONFIRM_TIMEOUT_MS = 15_000
|
|
|
|
function parseArgs() {
|
|
const args = { label: 'run', cycles: 8, scrollbackLines: 0, reportPath: null, keep: false }
|
|
for (const arg of process.argv.slice(2)) {
|
|
if (arg === '--help' || arg === '-h') {
|
|
console.log(
|
|
'Usage: node tools/benchmarks/terminal-cold-park-reveal-bench.mjs [--label=name] [--cycles=N] [--scrollback-lines=N] [--report=path] [--keep]'
|
|
)
|
|
process.exit(0)
|
|
}
|
|
if (arg === '--keep') {
|
|
args.keep = true
|
|
continue
|
|
}
|
|
const [name, value] = arg.split('=', 2)
|
|
if (name === '--label') {
|
|
args.label = value?.trim() || 'run'
|
|
} else if (name === '--cycles') {
|
|
args.cycles = Math.max(1, Number(value) || 8)
|
|
} else if (name === '--scrollback-lines') {
|
|
args.scrollbackLines = Math.max(0, Number(value) || 0)
|
|
} else if (name === '--report') {
|
|
args.reportPath = value
|
|
}
|
|
}
|
|
return args
|
|
}
|
|
|
|
// Why: the daemon binds a Unix socket at <userData>/daemon/daemon-v20.sock.
|
|
// macOS sun_path caps at ~104 chars; the default os.tmpdir() (/var/folders/…)
|
|
// blows past it, the daemon fails EINVAL, terminals fall back to non-snapshot
|
|
// PTYs, and cold parking (snapshot-backed only) can never engage. Root the
|
|
// userData + fixture under a short ~/.ocpb path so the socket fits.
|
|
const shortRoot = path.join(os.homedir(), '.ocpb')
|
|
|
|
function createShortUserDataDirectory() {
|
|
mkdirSync(shortRoot, { recursive: true })
|
|
const userDataDir = mkdtempSync(path.join(shortRoot, 'ud-'))
|
|
createCompletedOnboardingProfile(userDataDir)
|
|
return userDataDir
|
|
}
|
|
|
|
function git(cwd, ...cmd) {
|
|
execFileSync('git', cmd, { cwd, stdio: 'pipe' })
|
|
}
|
|
|
|
function createLocalRepoFixture() {
|
|
mkdirSync(shortRoot, { recursive: true })
|
|
const baseDir = mkdtempSync(path.join(shortRoot, 'fx-'))
|
|
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'), '# cold-park 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((c) => c.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((w) => ({
|
|
id: w.id,
|
|
path: w.path,
|
|
displayName: w.displayName,
|
|
isMainWorktree: w.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((c) => c.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 activateWorktreeTerminal(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
|
|
)
|
|
}
|
|
|
|
/** Wait until the active terminal pane of `worktreeId` has a bound ptyId — i.e.
|
|
* its terminal actually exists and is warm before we start hiding it. */
|
|
async function waitForBoundTerminal(page, worktreeId) {
|
|
return await pollUntil(
|
|
`pty bound ${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
|
|
)
|
|
}
|
|
|
|
/** Fill the active pane's terminal with ~lineCount lines of output so the
|
|
* parked snapshot (and thus the cold reveal replay) reflects a busy TUI-sized
|
|
* buffer rather than an empty shell. Writes to the pty and waits for the
|
|
* xterm buffer to grow. Returns the observed serialized length. */
|
|
async function primeScrollback(page, worktreeId, lineCount) {
|
|
const ptyId = await 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)
|
|
if (!ptyId) {
|
|
throw new Error(`No pty to prime for ${worktreeId}`)
|
|
}
|
|
// A single command that emits many numbered 80-col lines. seq is POSIX-ish;
|
|
// fall back handled by awk for portability.
|
|
const cmd = `awk 'BEGIN{for(i=0;i<${lineCount};i++)printf "%04d %s\\n", i, "cold-park-scrollback-priming-line-padding-to-eighty-cols-000000"}'\n`
|
|
await page.evaluate(({ id, c }) => window.api.pty.write(id, c), { id: ptyId, c: cmd })
|
|
return await pollUntil(
|
|
`scrollback primed ${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
|
|
const content = pane?.serializeAddon?.serialize?.() ?? ''
|
|
return content.length
|
|
}, worktreeId),
|
|
(len) => Number.isFinite(len) && len > lineCount * 40,
|
|
20_000,
|
|
100
|
|
).catch(() => 0)
|
|
}
|
|
|
|
function activeTabIdFor(page, worktreeId) {
|
|
return page.evaluate((id) => {
|
|
const state = window.__store?.getState?.()
|
|
return (
|
|
(state?.activeWorktreeId === id && state.activeTabType === 'terminal'
|
|
? state.activeTabId
|
|
: (state?.activeTabIdByWorktree?.[id] ?? null)) ?? null
|
|
)
|
|
}, worktreeId)
|
|
}
|
|
|
|
/** Times a reveal of `targetId` (must currently be hidden). Returns the three
|
|
* phase timings measured from the switch-back click. */
|
|
async function timeReveal(page, targetId) {
|
|
const t0 = Date.now()
|
|
await clickWorktreeCard(page, targetId)
|
|
await pollUntil(
|
|
`reveal activation ${targetId}`,
|
|
() =>
|
|
page.evaluate((id) => {
|
|
const state = window.__store?.getState?.()
|
|
return state?.activeWorktreeId === id && state.activeTabType === 'terminal'
|
|
}, targetId),
|
|
Boolean,
|
|
30_000,
|
|
5
|
|
)
|
|
const activationMs = Date.now() - t0
|
|
await pollUntil(
|
|
`reveal pty ${targetId}`,
|
|
() =>
|
|
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?.isConnected ? (pane?.container?.dataset?.ptyId ?? null) : null
|
|
}, targetId),
|
|
Boolean,
|
|
30_000,
|
|
5
|
|
)
|
|
const ptyBindMs = Date.now() - t0
|
|
await runWithTimeout(
|
|
'reveal paint settle',
|
|
() =>
|
|
page.evaluate(
|
|
() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
|
|
),
|
|
rendererActionTimeoutMs
|
|
)
|
|
const paintSettleMs = Date.now() - t0
|
|
return { activationMs, ptyBindMs, paintSettleMs }
|
|
}
|
|
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
|
|
/** Parking signal that does not depend on the e2e debug handle: a parked tab's
|
|
* TerminalPane unmounts, so its manager leaves window.__paneManagers AND the
|
|
* overlay slot renders null (no [data-tab-id] container). Prefer the debug
|
|
* handle when present, fall back to manager/DOM absence. */
|
|
function isTabParked(page, tabId) {
|
|
return page.evaluate((id) => {
|
|
const handleIds = window.__terminalParkingDebug?.parkedTabIds?.()
|
|
if (Array.isArray(handleIds)) {
|
|
return { source: 'handle', parked: handleIds.includes(id) }
|
|
}
|
|
// Fallback (no debug handle): a parked tab's manager leaves __paneManagers.
|
|
const managerGone = !(window.__paneManagers && window.__paneManagers.has(id))
|
|
return { source: 'manager', parked: managerGone }
|
|
}, tabId)
|
|
}
|
|
|
|
async function debugSnapshot(page, tabId, worktreeId) {
|
|
return page.evaluate(
|
|
({ id, wtId }) => {
|
|
const state = window.__store?.getState?.()
|
|
const tabsByWorktree = state?.tabsByWorktree ?? {}
|
|
const tab = Object.values(tabsByWorktree)
|
|
.flat()
|
|
.find((t) => t?.id === id)
|
|
const wtTabs = tabsByWorktree[wtId] ?? []
|
|
return {
|
|
handlePresent: typeof window.__terminalParkingDebug?.parkedTabIds === 'function',
|
|
parkDelayMs: window.__terminalParkingDebug?.parkDelayMs ?? null,
|
|
parkedHandleIds: window.__terminalParkingDebug?.parkedTabIds?.() ?? null,
|
|
managerPresent: Boolean(window.__paneManagers?.has(id)),
|
|
managerKeys: window.__paneManagers ? Array.from(window.__paneManagers.keys()) : null,
|
|
ptyId: tab?.ptyId ?? null,
|
|
worktreeTabCount: wtTabs.length,
|
|
worktreeTabPtyIds: wtTabs.map((t) => t?.ptyId ?? null),
|
|
activeTabIdByWorktree: state?.activeTabIdByWorktree?.[wtId] ?? null,
|
|
parkingEnabled: state?.settings?.terminalHiddenViewParking !== false
|
|
}
|
|
},
|
|
{ id: tabId, wtId: worktreeId }
|
|
)
|
|
}
|
|
|
|
/** One cold cycle: focus target, hide it (switch to primary), wait until the
|
|
* tab parks (debug handle or manager-absence), then time the reveal. */
|
|
async function runColdCycle(page, primaryId, targetId, wantDebug, scrollbackLines) {
|
|
await activateWorktreeTerminal(page, targetId)
|
|
await waitForBoundTerminal(page, targetId)
|
|
if (scrollbackLines > 0) {
|
|
await primeScrollback(page, targetId, scrollbackLines)
|
|
}
|
|
const targetTabId = await activeTabIdFor(page, targetId)
|
|
await activateWorktreeTerminal(page, primaryId)
|
|
const parked = await pollUntil(
|
|
`cold-park ${targetTabId}`,
|
|
() => isTabParked(page, targetTabId),
|
|
(result) => result?.parked === true,
|
|
COLD_PARK_CONFIRM_TIMEOUT_MS,
|
|
50
|
|
).then(
|
|
() => true,
|
|
() => false
|
|
)
|
|
const diag = wantDebug ? await debugSnapshot(page, targetTabId, targetId) : null
|
|
const timings = await timeReveal(page, targetId)
|
|
return { ...timings, parkedConfirmed: parked, diag }
|
|
}
|
|
|
|
/** One warm cycle: focus target, hide it briefly (under the park delay), reveal
|
|
* before it can park. Asserts it did NOT park. */
|
|
async function runWarmCycle(page, primaryId, targetId, scrollbackLines) {
|
|
await activateWorktreeTerminal(page, targetId)
|
|
await waitForBoundTerminal(page, targetId)
|
|
if (scrollbackLines > 0) {
|
|
await primeScrollback(page, targetId, scrollbackLines)
|
|
}
|
|
const targetTabId = await activeTabIdFor(page, targetId)
|
|
await activateWorktreeTerminal(page, primaryId)
|
|
await sleep(WARM_REVEAL_AFTER_MS)
|
|
const parked = await isTabParked(page, targetTabId)
|
|
const timings = await timeReveal(page, targetId)
|
|
return { ...timings, parkedConfirmed: parked?.parked === true }
|
|
}
|
|
|
|
function summarize(values) {
|
|
const sorted = values.filter(Number.isFinite).sort((a, b) => a - b)
|
|
if (!sorted.length) {
|
|
return null
|
|
}
|
|
const at = (f) => sorted[Math.min(sorted.length - 1, Math.round(f * (sorted.length - 1)))]
|
|
return {
|
|
count: sorted.length,
|
|
median: Math.round(at(0.5)),
|
|
p95: Math.round(at(0.95)),
|
|
max: sorted.at(-1)
|
|
}
|
|
}
|
|
|
|
function summarizeArm(name, samples) {
|
|
const fields = {}
|
|
for (const key of ['activationMs', 'ptyBindMs', 'paintSettleMs']) {
|
|
fields[key] = summarize(samples.map((s) => s[key]))
|
|
}
|
|
return {
|
|
name,
|
|
samples: samples.length,
|
|
parkedConfirmed: samples.filter((s) => s.parkedConfirmed).length,
|
|
fields
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
const args = parseArgs()
|
|
const startedAt = Date.now()
|
|
const report = {
|
|
label: args.label,
|
|
startedAt: new Date(startedAt).toISOString(),
|
|
platform: `${process.platform} ${os.release()}`,
|
|
parkDelayMs: PARK_DELAY_MS,
|
|
args: { cycles: args.cycles, scrollbackLines: args.scrollbackLines },
|
|
arms: {},
|
|
summaries: [],
|
|
cleanupErrors: []
|
|
}
|
|
let fixture = null
|
|
let userDataDir = null
|
|
let launched = null
|
|
let browser = null
|
|
let page = null
|
|
try {
|
|
fixture = createLocalRepoFixture()
|
|
const cdpPort = await pickFreePort()
|
|
userDataDir = createShortUserDataDirectory()
|
|
// Shrink the 30s cold-park hysteresis + 5-min hot-retain so parking is
|
|
// observable in a benchmark run. Inherited by launchDevApp into the app.
|
|
process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS = String(PARK_DELAY_MS)
|
|
console.log(
|
|
`[cold-park] fixture=${fixture.baseDir} userData=${userDataDir} cdp=${cdpPort} parkDelay=${PARK_DELAY_MS}ms`
|
|
)
|
|
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)
|
|
let worktrees = setup.worktrees
|
|
// Why: worktree registration/fetch can lag the setup evaluate return; poll
|
|
// the store until the two external worktrees materialize before asserting.
|
|
if (worktrees.length < 2) {
|
|
// Why: the initial fetchWorktrees can return before the external-worktree
|
|
// scan surfaces the imported paths. Re-drive the fetch each poll (not just
|
|
// re-read the store) so a missed first scan self-heals.
|
|
worktrees = await pollUntil(
|
|
'worktrees registered',
|
|
() =>
|
|
page.evaluate(async (repoId) => {
|
|
await window.__store
|
|
?.getState?.()
|
|
?.fetchWorktrees?.(repoId, { requireAuthoritative: true })
|
|
.catch(() => undefined)
|
|
const state = window.__store?.getState?.()
|
|
const list = state?.worktreesByRepo?.[repoId] ?? []
|
|
return list.map((w) => ({
|
|
id: w.id,
|
|
path: w.path,
|
|
displayName: w.displayName,
|
|
isMainWorktree: w.isMainWorktree
|
|
}))
|
|
}, setup.repoId),
|
|
(list) => Array.isArray(list) && list.length >= 2,
|
|
30_000,
|
|
500
|
|
)
|
|
}
|
|
if (worktrees.length < 2) {
|
|
throw new Error(`Expected >=2 worktrees, got ${worktrees.length}`)
|
|
}
|
|
const primary = worktrees.find((w) => w.isMainWorktree) ?? worktrees[0]
|
|
const target = worktrees.find((w) => w.id !== primary.id)
|
|
await activateWorktreeTerminal(page, primary.id)
|
|
await waitForBoundTerminal(page, primary.id)
|
|
|
|
// Confirm the parking debug handle is present (exposeStore gate). It
|
|
// registers on first pane mount, so poll rather than read once.
|
|
const debugReady = await pollUntil(
|
|
'parking debug handle',
|
|
() => page.evaluate(() => typeof window.__terminalParkingDebug?.parkedTabIds === 'function'),
|
|
Boolean,
|
|
10_000,
|
|
100
|
|
).then(
|
|
() => true,
|
|
() => false
|
|
)
|
|
report.parkingDebugHandlePresent = debugReady
|
|
|
|
const cold = []
|
|
const warm = []
|
|
console.log(`[cold-park] running ${args.cycles} cold + ${args.cycles} warm cycles`)
|
|
for (let i = 0; i < args.cycles; i++) {
|
|
warm.push(
|
|
await runWithTimeout(
|
|
`warm cycle ${i}`,
|
|
() => runWarmCycle(page, primary.id, target.id, args.scrollbackLines),
|
|
scenarioTimeoutMs
|
|
)
|
|
)
|
|
const coldResult = await runWithTimeout(
|
|
`cold cycle ${i}`,
|
|
() => runColdCycle(page, primary.id, target.id, i === 0, args.scrollbackLines),
|
|
scenarioTimeoutMs
|
|
)
|
|
if (i === 0 && coldResult.diag) {
|
|
report.firstColdDiag = coldResult.diag
|
|
console.log(`[cold-park] first-cold diag: ${JSON.stringify(coldResult.diag)}`)
|
|
}
|
|
cold.push(coldResult)
|
|
}
|
|
report.arms.cold = cold
|
|
report.arms.warm = warm
|
|
report.summaries.push(summarizeArm('cold', cold))
|
|
report.summaries.push(summarizeArm('warm', warm))
|
|
report.finalDiagnostics = await collectRendererDiagnostics(page)
|
|
} finally {
|
|
report.elapsedMs = Date.now() - startedAt
|
|
if (browser) {
|
|
await browser.close().catch(() => undefined)
|
|
}
|
|
if (launched) {
|
|
try {
|
|
await stopDevApp(launched.child)
|
|
} catch (error) {
|
|
report.cleanupErrors.push(error instanceof Error ? error.message : String(error))
|
|
}
|
|
report.appLogsTail = launched.logs.slice(-60)
|
|
}
|
|
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,
|
|
'tools',
|
|
'benchmarks',
|
|
'results',
|
|
`cold-park-${args.label}-${stamp}.json`
|
|
)
|
|
)
|
|
mkdirSync(path.dirname(reportPath), { recursive: true })
|
|
writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`)
|
|
console.log(`[cold-park] report=${reportPath}`)
|
|
const coldArm = report.summaries.find((s) => s.name === 'cold')
|
|
if (coldArm && coldArm.parkedConfirmed < coldArm.samples) {
|
|
console.log(
|
|
`[cold-park] WARNING: only ${coldArm.parkedConfirmed}/${coldArm.samples} cold reveals were confirmed parked — treat unconfirmed samples with caution`
|
|
)
|
|
}
|
|
for (const summary of report.summaries) {
|
|
console.log(
|
|
`[cold-park] ${summary.name}: samples=${summary.samples} parkedConfirmed=${summary.parkedConfirmed}/${summary.samples}`
|
|
)
|
|
for (const [field, stats] of Object.entries(summary.fields)) {
|
|
if (stats) {
|
|
console.log(
|
|
`[cold-park] ${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)
|
|
})
|