mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 16:02:56 +00:00
* perf(git): stop blocking main on the open-on-remote git cascade
`getRemoteFileUrl` ran up to 6 sequential `gitExecFileSync` calls on the Electron
main thread — `remote get-url`, then `getDefaultBaseRef`'s `symbolic-ref` plus up
to four `rev-parse --verify` probes — each with its own 15s timeout and no yield
between them.
A complete async twin already existed (`getDefaultBaseRefAsync` ->
`resolveDefaultBaseRefViaExec`, sharing DEFAULT_BASE_REF_PROBES), so the sync
cascade is deleted rather than converted. `getRemoteUrl`, `getRemoteFileUrl` and
`getRemoteCommitUrl` become async; all four downstream callers were already async
(`filesystem-git-url-handlers` inside `ipcMain.handle`, `runtime-git-diff-commands`
async methods) and the provider contract already typed both wrappers
`Promise<string | null>`, so no new async plumbing was needed.
Removes 3 of the 10 `gitExecFileSync` sites and the confusing name collision with
the unrelated async `getDefaultBaseRef` in hosted-review-creation-git-state.
The base-ref regression tests keep their coverage, repointed at the public async
`getBaseRefDefault`.
* perf(git): resolve the repo root in one sync spawn instead of two
getGitRepoRoot ran `rev-parse --is-inside-work-tree` and then `rev-parse
--show-toplevel` as separate blocking spawns. Each sync git call holds the main
thread for up to its whole 15s timeout, so the spawn count is the cost — and this
function is called twice per "Add Project" on a linked worktree, once directly and
once through getLinkedWorktreeMainRepoRoot's self-recursion.
Combined into one invocation. Safe only here: in a bare repo the combined form
exits non-zero, and both that throw and the plain `false` already land on the same
marker-scan fallback. probeGitRepo deliberately does NOT combine — it has to read
`false` cleanly to go on and detect a bare repo, which the combined form's exit 128
would misread as indeterminate.
* perf(git): rebuild only the repos whose authorized roots actually changed
One worktree create called `invalidateAuthorizedRootsCache()`, which dirties every
registered owner. The next authorization-requiring IPC then rebuilt by listing EVERY
repo — and the rebuild never consulted `dirty` when choosing what to list, so `dirty`
gated only whether a rebuild ran, not its scope. At 58 repos that is 58
`git worktree list` spawns, roughly ten seconds of git wall-clock through an
admission budget of four, to rediscover roots one repo changed.
Both halves were needed; scoping the invalidation alone changed nothing.
- `markAuthorizedRootsOwnerDirty` dirties a single owner, reusing the per-owner
primitives `registerWorktreeRootsForRepo` already used. It leaves `baseRevision`
and the per-repo revision map alone — that pair is the global side-effect-token
fence, and bumping it would retire in-flight tokens for untouched repos.
- `rebuildAuthorizedRootsCache(store, onlyDirty)` re-lists only owners that are
dirty, have no listing yet, or still hold recovered roots (those are retired by
comparison against a fresh listing, so skipping them would strand them as
authorized). Only `ensureAuthorizedRootsCache` passes `onlyDirty`; an explicit
rebuild keeps re-listing everything because callers use it to force a refresh —
`filesystem-auth.test.ts` pins that contract.
`invalidateAuthorizedRootsCacheForRepo` wraps the primitive and falls back to the
global form for an unknown owner or a missing store, rather than silently skipping an
invalidation and leaving a stale allowlist. Applied to the worktree-create path.
Changes that can alter the owner SET (store swap, host/WSL re-routing, nested-repo
import, folder->git upgrade) stay global. Removal paths are not converted yet.
The allowlist contents are unchanged and the failure direction is a false denial
rather than a false allow. The relist predicate is split into its own module so it is
testable alone and the cache file stays inside its line budget without a suppression.
* test(perf): measure what git orchestration actually costs the main thread
The existing churn probe (ORCA_MAIN_THREAD_DIAGNOSTICS=1) reported spawn-initiation
cost for git/gh/glab only — its 7 call sites all sit inside git/command-runner — so
it was blind to `spawnProcess`/`runProcess`, the repo's own mandated wrapper, and to
the blocking `execFileSync('ps')` per PTY resize. That understated total churn across
115 main call sites.
- `spawn-observer.ts`: a settable seam, since shared code cannot import src/main.
Unregistered in the daemon/relay/CLI, where it costs one boolean check.
- `spawnProcess` brackets `nodeSpawn` and reports; exec-file-capture's own report is
removed because it routes through runProcess and would double-count.
- `posix-pty-foreground-group` now reports its full blocking duration. Note this
lands on the daemon, not main, whenever the daemon hosts the PTY.
- `ORCA_UNMINIFIED_MAIN=1` build flag, because a minified main bundle cannot
attribute CPU-profile self time to real function names. Defaults unchanged.
- `main-thread-git-cost.spec.ts` + `analyze-main-cpuprofile.mjs`: sweeps concurrency
against real registered repos, captures the churn lines and a V8 CPU profile of
main per phase.
What it found, which is why this is worth keeping: at the width-4 admission ceiling
(~90 git:status/s) main sees ZERO event-loop gaps over 50ms and a worst gap of 23ms,
and is 85% idle. Git orchestration does not stall the main thread. Of the cost it
does incur, spawn-init is 58%, parse 5%, stdout drain 4%.
* test(perf): name the inspector params type the anti-slop gate requires
The broad `object` parameter trips anti-slop(no-object-parameters); the only
Profiler call that passes params sends `{ interval }`.
226 lines
9.3 KiB
TypeScript
226 lines
9.3 KiB
TypeScript
/**
|
|
* Diagnostic-only measurement: how much of main's event loop does git
|
|
* subprocess orchestration actually consume, and which phase dominates?
|
|
*
|
|
* Why a separate spec from terminal-multi-workspace-typing-latency: that bench
|
|
* measures typing latency UNDER churn, so its numbers mix PTY relay, renderer
|
|
* paint and git. This one runs git churn alone against a hidden window and
|
|
* takes a V8 CPU profile OF THE MAIN PROCESS for each phase, which is the only
|
|
* way to split spawn initiation from stdout drain from porcelain parsing — the
|
|
* churn probe reports spawn initiation only.
|
|
*
|
|
* Run: ORCA_GIT_COST_BENCH=1 ORCA_BACKGROUND_LAUNCH=1 \
|
|
* npx playwright test tests/e2e/main-thread-git-cost.spec.ts \
|
|
* --config tests/playwright.config.ts --project electron-headless --workers=1
|
|
*/
|
|
import { expect, test } from './helpers/orca-app'
|
|
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import path from 'node:path'
|
|
import {
|
|
createGitChurnRepos,
|
|
registerGitChurnRepos,
|
|
startGitChurnLoad,
|
|
stopGitChurnLoad,
|
|
type GitChurnStats
|
|
} from './git-churn-load'
|
|
|
|
type MainThreadReport = {
|
|
t: number
|
|
maxGapMs?: number
|
|
gapsOver50Ms?: number
|
|
gapsOver250Ms?: number
|
|
spawnCount?: number
|
|
spawns?: Record<string, { count: number; blockMsTotal: number; blockMsMax: number }>
|
|
marker?: string
|
|
wallMs: number
|
|
}
|
|
|
|
type Phase = { label: string; repos: number; concurrency: number; seconds: number }
|
|
|
|
const FILES_PER_REPO = Number(process.env.ORCA_GIT_COST_FILES ?? 400)
|
|
const PHASE_SECONDS = Number(process.env.ORCA_GIT_COST_PHASE_SECONDS ?? 20)
|
|
const MAX_REPOS = Number(process.env.ORCA_GIT_COST_REPOS ?? 24)
|
|
|
|
const PHASES: Phase[] = [
|
|
{ label: 'idle', repos: 0, concurrency: 0, seconds: PHASE_SECONDS },
|
|
{ label: 'c1', repos: MAX_REPOS, concurrency: 1, seconds: PHASE_SECONDS },
|
|
{ label: 'c4', repos: MAX_REPOS, concurrency: 4, seconds: PHASE_SECONDS },
|
|
{ label: 'c8', repos: MAX_REPOS, concurrency: 8, seconds: PHASE_SECONDS },
|
|
{ label: 'c16', repos: MAX_REPOS, concurrency: 16, seconds: PHASE_SECONDS },
|
|
{ label: 'c32', repos: MAX_REPOS, concurrency: 32, seconds: PHASE_SECONDS }
|
|
]
|
|
|
|
test.use({
|
|
orcaAppExtraEnv: { ORCA_MAIN_THREAD_DIAGNOSTICS: '1', ORCA_BACKGROUND_LAUNCH: '1' }
|
|
})
|
|
|
|
test.describe('main-thread git orchestration cost', () => {
|
|
test.skip(
|
|
process.env.ORCA_GIT_COST_BENCH !== '1',
|
|
'Diagnostic bench; set ORCA_GIT_COST_BENCH=1 to run'
|
|
)
|
|
test.setTimeout((PHASES.reduce((sum, phase) => sum + phase.seconds, 0) + 600) * 1000)
|
|
|
|
test('measures spawn / drain / parse split under git churn', async ({
|
|
orcaPage,
|
|
electronApp
|
|
}, testInfo) => {
|
|
const reports: MainThreadReport[] = []
|
|
const stderr = electronApp.process().stderr
|
|
expect(stderr, 'electron stderr must be piped for the churn probe').toBeTruthy()
|
|
let buffer = ''
|
|
stderr?.setEncoding('utf-8')
|
|
stderr?.on('data', (chunk: string) => {
|
|
buffer += chunk
|
|
let index = buffer.indexOf('\n')
|
|
while (index !== -1) {
|
|
const line = buffer.slice(0, index).trimEnd()
|
|
buffer = buffer.slice(index + 1)
|
|
index = buffer.indexOf('\n')
|
|
const match = /^\[main-thread\] (\{.*\})$/.exec(line)
|
|
if (!match) {
|
|
continue
|
|
}
|
|
try {
|
|
reports.push({ ...JSON.parse(match[1]), wallMs: Date.now() })
|
|
} catch {
|
|
// ignore malformed probe line
|
|
}
|
|
}
|
|
})
|
|
|
|
const churnRoot = mkdtempSync(path.join(tmpdir(), 'orca-git-cost-'))
|
|
const profileDir = path.join(testInfo.outputDir, 'main-profiles')
|
|
mkdirSync(profileDir, { recursive: true })
|
|
const results: Record<string, unknown>[] = []
|
|
try {
|
|
const repos = createGitChurnRepos(churnRoot, MAX_REPOS, FILES_PER_REPO)
|
|
const registration = await registerGitChurnRepos(
|
|
orcaPage,
|
|
repos.map((repo) => repo.path)
|
|
)
|
|
expect(
|
|
registration,
|
|
`churn repos were not registered: ${registration.failures.join('; ')}`
|
|
).toMatchObject({ registered: repos.length })
|
|
|
|
for (const phase of PHASES) {
|
|
const profilePath = path.join(profileDir, `main-${phase.label}.cpuprofile`)
|
|
await startMainProfiler(electronApp)
|
|
const startedAt = Date.now()
|
|
if (phase.concurrency > 0) {
|
|
await startGitChurnLoad(
|
|
orcaPage,
|
|
repos.slice(0, phase.repos).map((repo) => repo.path),
|
|
{ concurrency: phase.concurrency, admissionTier: 'status' }
|
|
)
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, phase.seconds * 1000))
|
|
const churn: GitChurnStats | null =
|
|
phase.concurrency > 0 ? await stopGitChurnLoad(orcaPage) : null
|
|
const endedAt = Date.now()
|
|
const wallMs = await stopMainProfiler(electronApp, profilePath)
|
|
// Skip the first window: it straddles the previous phase's tail.
|
|
const phaseReports = reports.filter(
|
|
(report) =>
|
|
!report.marker && report.wallMs >= startedAt + 5_000 && report.wallMs <= endedAt + 1_000
|
|
)
|
|
results.push({
|
|
phase: phase.label,
|
|
repos: phase.repos,
|
|
concurrency: phase.concurrency,
|
|
windowMs: endedAt - startedAt,
|
|
profileWallMs: wallMs,
|
|
reportCount: phaseReports.length,
|
|
maxGapMs: Math.max(0, ...phaseReports.map((report) => report.maxGapMs ?? 0)),
|
|
gapsOver50Ms: sum(phaseReports.map((report) => report.gapsOver50Ms ?? 0)),
|
|
gapsOver250Ms: sum(phaseReports.map((report) => report.gapsOver250Ms ?? 0)),
|
|
spawnCount: sum(phaseReports.map((report) => report.spawnCount ?? 0)),
|
|
spawns: mergeSpawns(phaseReports),
|
|
churn,
|
|
profilePath
|
|
})
|
|
console.log(`[git-cost] ${JSON.stringify(results.at(-1))}`)
|
|
}
|
|
} finally {
|
|
await stopGitChurnLoad(orcaPage).catch(() => null)
|
|
rmSync(churnRoot, { recursive: true, force: true })
|
|
}
|
|
const summaryPath = path.join(profileDir, 'summary.json')
|
|
writeFileSync(summaryPath, JSON.stringify({ filesPerRepo: FILES_PER_REPO, results }, null, 2))
|
|
console.log(`[git-cost] summary=${summaryPath}`)
|
|
expect(readFileSync(summaryPath, 'utf8').length).toBeGreaterThan(0)
|
|
})
|
|
})
|
|
|
|
function sum(values: number[]): number {
|
|
return values.reduce((total, value) => total + value, 0)
|
|
}
|
|
|
|
function mergeSpawns(
|
|
reports: MainThreadReport[]
|
|
): Record<string, { count: number; blockMsTotal: number; blockMsMax: number }> {
|
|
const merged: Record<string, { count: number; blockMsTotal: number; blockMsMax: number }> = {}
|
|
for (const report of reports) {
|
|
for (const [key, stats] of Object.entries(report.spawns ?? {})) {
|
|
const entry = (merged[key] ??= { 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 merged
|
|
}
|
|
|
|
/** V8 CPU profile of the MAIN process, taken from inside main via node:inspector. */
|
|
async function startMainProfiler(electronApp: {
|
|
evaluate: <R, A>(fn: (electron: unknown, arg: A) => R | Promise<R>, arg: A) => Promise<R>
|
|
}): Promise<void> {
|
|
await electronApp.evaluate(async (_electron, intervalUs: number) => {
|
|
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: diagnostic-only scratch globals owned by this bench.
|
|
const scope = globalThis as unknown as Record<string, unknown>
|
|
const inspector = process.getBuiltinModule('node:inspector')
|
|
const session = new inspector.Session()
|
|
session.connect()
|
|
type InspectorParams = { interval?: number }
|
|
const post = (method: string, params?: InspectorParams): Promise<Record<string, unknown>> =>
|
|
new Promise((resolve, reject) => {
|
|
session.post(method, params, (error, result) =>
|
|
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: node:inspector types the callback payload as unknown; every Profiler reply is an object.
|
|
error ? reject(error) : resolve(result as Record<string, unknown>)
|
|
)
|
|
})
|
|
await post('Profiler.enable')
|
|
await post('Profiler.setSamplingInterval', { interval: intervalUs })
|
|
await post('Profiler.start')
|
|
scope.__orcaGitCostProfiler = { session, post, startedAt: Date.now() }
|
|
}, 500)
|
|
}
|
|
|
|
async function stopMainProfiler(
|
|
electronApp: {
|
|
evaluate: <R, A>(fn: (electron: unknown, arg: A) => R | Promise<R>, arg: A) => Promise<R>
|
|
},
|
|
outPath: string
|
|
): Promise<number> {
|
|
return electronApp.evaluate(async (_electron, target: string) => {
|
|
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: same bench-owned global installed above.
|
|
const scope = globalThis as unknown as Record<string, unknown>
|
|
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the shape startMainProfiler stored on this same global, in this same process.
|
|
const handle = scope.__orcaGitCostProfiler as {
|
|
session: { disconnect: () => void }
|
|
post: (method: string, params?: { interval?: number }) => Promise<Record<string, unknown>>
|
|
startedAt: number
|
|
}
|
|
const { profile } = await handle.post('Profiler.stop')
|
|
const wallMs = Date.now() - handle.startedAt
|
|
process
|
|
.getBuiltinModule('node:fs')
|
|
.writeFileSync(target, JSON.stringify(profile), { mode: 0o600 })
|
|
handle.session.disconnect()
|
|
delete scope.__orcaGitCostProfiler
|
|
return wallMs
|
|
}, outPath)
|
|
}
|