mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 16:03:26 +00:00
perf(ai-vault): stop the session projection from stalling workspace switches
Clicking a workspace card blocked the renderer for ~1s with the AI Vault
panel open. A CPU profile put 1.7s of self time in isSessionInWorktreePath
and 1.66s in isPathInsideOrEqual.
resolveAiVaultSessionWorktreeInfo rebuilt the full candidate list inside the
per-session call, then filtered it with no early exit and sorted it. At
1124 worktrees x 500 sessions that is ~1.1M containment checks and ~2M path
normalizations per switch, all on the click path.
a1a78da878 (#10841) is what made it visible: it added an unconditional
.normalize('NFC') to normalizeRuntimePathForComparison, the innermost
function in that loop. Bisect puts the step at 344ms -> 420ms, and deleting
just that call on main reproduces the 420 -> 348 delta. The NFC fold is a
correctness fix worth keeping, so this removes the fan-out instead.
- cache the candidate list per (worktrees, repos) identity pair
- precompute each candidate's matcher via
createNormalizedPathInsideOrEqualMatcher, so the session cwd is
normalized once per session instead of once per candidate
- replace filter+filter+sort with a single max pass
The matcher is what #10881 added for exactly this shape; the AI Vault
fan-outs were still paying its cost without adopting it.
Projection cost on a real 1124-worktree profile: 423ms -> 13ms. Verified
byte-identical output against the previous implementation across 4501
session/worktree combinations.
Tests cover NFD/NFC matching in both directions through the projection,
the sibling-prefix boundary, nested-workspace and prior-path tie-breaks,
and a timing regression for the quadratic shape.
This commit is contained in:
@@ -202,6 +202,142 @@ describe('resolveAiVaultSessionWorktreeInfo', () => {
|
||||
worktreeId: worktree.id
|
||||
})
|
||||
})
|
||||
|
||||
it('matches an NFD workspace path to an NFC transcript cwd', () => {
|
||||
// macOS yields NFD on disk while Claude Code records cwd in NFC (#10832).
|
||||
// The projection precomputes its matchers, so the fold has to survive that.
|
||||
const nfcPath = '/repo/프로젝트'
|
||||
const worktree = makeWorktree({
|
||||
id: `repo-1::${nfcPath.normalize('NFD')}`,
|
||||
displayName: '',
|
||||
path: nfcPath.normalize('NFD')
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveAiVaultSessionWorktreeInfo({
|
||||
session: { ...baseSession, cwd: `${nfcPath}/src` },
|
||||
worktrees: [worktree],
|
||||
activeWorktreeId: null
|
||||
})
|
||||
).toMatchObject({
|
||||
status: 'active',
|
||||
worktreeId: worktree.id
|
||||
})
|
||||
})
|
||||
|
||||
it('matches an NFD transcript cwd to an NFC workspace path', () => {
|
||||
const nfcPath = '/repo/café'
|
||||
const worktree = makeWorktree({
|
||||
id: `repo-1::${nfcPath}`,
|
||||
displayName: '',
|
||||
path: nfcPath
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveAiVaultSessionWorktreeInfo({
|
||||
session: { ...baseSession, cwd: `${nfcPath}/src`.normalize('NFD') },
|
||||
worktrees: [worktree],
|
||||
activeWorktreeId: null
|
||||
})
|
||||
).toMatchObject({
|
||||
status: 'active',
|
||||
worktreeId: worktree.id
|
||||
})
|
||||
})
|
||||
|
||||
it('does not treat a sibling sharing a path prefix as the owner', () => {
|
||||
// Guards the candidate matcher's boundary: '/repo/orca-sibling' must not
|
||||
// match under '/repo/orca'.
|
||||
const worktree = makeWorktree()
|
||||
const sibling = makeWorktree({
|
||||
id: 'repo-1::/repo/orca-sibling',
|
||||
displayName: 'sibling',
|
||||
path: '/repo/orca-sibling'
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveAiVaultSessionWorktreeInfo({
|
||||
session: { ...baseSession, cwd: '/repo/orca-sibling/src' },
|
||||
worktrees: [worktree, sibling],
|
||||
activeWorktreeId: null
|
||||
})
|
||||
).toMatchObject({ worktreeId: sibling.id })
|
||||
})
|
||||
|
||||
it('prefers the deepest owning worktree when workspaces nest', () => {
|
||||
// The single-pass max must reproduce the old sort: longest comparison path
|
||||
// wins, and 'current-path' breaks a tie against 'prior-path'.
|
||||
const outer = makeWorktree({
|
||||
id: 'repo-1::/repo/orca',
|
||||
displayName: 'outer',
|
||||
path: '/repo/orca'
|
||||
})
|
||||
const inner = makeWorktree({
|
||||
id: 'repo-1::/repo/orca/packages/app',
|
||||
displayName: 'inner',
|
||||
path: '/repo/orca/packages/app'
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveAiVaultSessionWorktreeInfo({
|
||||
session: { ...baseSession, cwd: '/repo/orca/packages/app/src' },
|
||||
worktrees: [outer, inner],
|
||||
activeWorktreeId: null
|
||||
})
|
||||
).toMatchObject({ worktreeId: inner.id })
|
||||
})
|
||||
|
||||
it('prefers a current path over another worktree holding it as a prior path', () => {
|
||||
const current = makeWorktree({
|
||||
id: 'repo-1::/repo/orca',
|
||||
displayName: 'current',
|
||||
path: '/repo/orca'
|
||||
})
|
||||
const renamed = makeWorktree({
|
||||
id: 'repo-1::/repo/orca-renamed',
|
||||
displayName: 'renamed',
|
||||
path: '/repo/orca-renamed',
|
||||
priorWorktreeIds: ['repo-1::/repo/orca']
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveAiVaultSessionWorktreeInfo({
|
||||
session: baseSession,
|
||||
worktrees: [renamed, current],
|
||||
activeWorktreeId: null
|
||||
})
|
||||
).toMatchObject({ worktreeId: current.id })
|
||||
})
|
||||
|
||||
it('resolves a large workspace set without rebuilding candidates per session', () => {
|
||||
// Regression for the ~1s workspace-switch stall (#10841): resolving each
|
||||
// session used to rebuild and re-normalize every candidate, making the
|
||||
// panel O(sessions x worktrees) on path normalization. Times a fan-out
|
||||
// rather than counting calls so it fails on any return to that shape.
|
||||
const worktrees = Array.from({ length: 1200 }, (_, i) =>
|
||||
makeWorktree({
|
||||
id: `repo-1::/repo/w${i}`,
|
||||
displayName: `w${i}`,
|
||||
path: `/repo/w${i}`
|
||||
})
|
||||
)
|
||||
const sessions = Array.from({ length: 400 }, (_, i) => ({
|
||||
...baseSession,
|
||||
id: `codex:session-${i}`,
|
||||
cwd: `/repo/w${i % worktrees.length}/src`
|
||||
}))
|
||||
|
||||
const startedAt = performance.now()
|
||||
const resolved = sessions.map((session) =>
|
||||
resolveAiVaultSessionWorktreeInfo({ session, worktrees, activeWorktreeId: null })
|
||||
)
|
||||
const elapsedMs = performance.now() - startedAt
|
||||
|
||||
expect(resolved.every((info) => info?.status === 'active')).toBe(true)
|
||||
// Was ~350ms+ before the fix and ~10ms after; the bound is loose enough to
|
||||
// stay quiet on a slow CI box while still catching the quadratic shape.
|
||||
expect(elapsedMs).toBeLessThan(150)
|
||||
})
|
||||
})
|
||||
|
||||
describe('canJumpToAiVaultSessionWorktree', () => {
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
type ExecutionHostId
|
||||
} from '../../../../shared/execution-host'
|
||||
import {
|
||||
isPathInsideOrEqual,
|
||||
createNormalizedPathInsideOrEqualMatcher,
|
||||
isRuntimePathAbsolute,
|
||||
normalizeRuntimePathForComparison
|
||||
} from '../../../../shared/cross-platform-path'
|
||||
@@ -41,11 +41,28 @@ type WorktreeCandidate = {
|
||||
hostId: ExecutionHostId
|
||||
status: Exclude<AiVaultSessionWorktreeStatus, 'current'>
|
||||
source: 'current-path' | 'prior-path'
|
||||
/** Precomputed so a fan-out over sessions does not re-normalize per session. */
|
||||
matches: (normalizedSessionCwd: string) => boolean
|
||||
comparisonPathLength: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Candidate list for one (worktrees, repos) pair.
|
||||
*
|
||||
* Why cache: resolving a session walks every candidate, so a panel with N
|
||||
* sessions rebuilt the same list N times — 1000+ allocations and path
|
||||
* normalizations per session. The list only depends on its two inputs, both of
|
||||
* which are referentially stable in the store, so one WeakMap hop replaces it.
|
||||
*/
|
||||
const candidateCache = new WeakMap<object, WeakMap<object, WorktreeCandidate[]>>()
|
||||
|
||||
// Why a shared constant: a `repos = []` default would allocate a fresh array per
|
||||
// call, so every lookup would miss the cache keyed on that array's identity.
|
||||
const NO_REPOS: readonly Pick<Repo, 'id' | 'connectionId' | 'executionHostId'>[] = []
|
||||
|
||||
export function resolveAiVaultSessionWorktreeInfo({
|
||||
session,
|
||||
repos = [],
|
||||
repos = NO_REPOS,
|
||||
worktrees,
|
||||
activeWorktreeId
|
||||
}: {
|
||||
@@ -59,12 +76,24 @@ export function resolveAiVaultSessionWorktreeInfo({
|
||||
}
|
||||
|
||||
const sessionHostId = normalizeExecutionHostId(session.executionHostId)
|
||||
const candidates = buildWorktreeCandidates(worktrees, repos)
|
||||
.filter((candidate) => isSessionInWorktreePath(candidate.path, session.cwd!))
|
||||
.filter((candidate) => !sessionHostId || candidate.hostId === sessionHostId)
|
||||
.sort(compareWorktreeCandidates)
|
||||
// Normalize the session cwd once, then test it against precomputed matchers.
|
||||
const normalizedSessionCwd = normalizeRuntimePathForComparison(session.cwd)
|
||||
|
||||
// Single pass picking the max, rather than filter+filter+sort: the result is
|
||||
// just the best candidate, and sorting 1000+ entries per session dominated.
|
||||
let best: WorktreeCandidate | undefined
|
||||
for (const candidate of getWorktreeCandidates(worktrees, repos)) {
|
||||
if (sessionHostId && candidate.hostId !== sessionHostId) {
|
||||
continue
|
||||
}
|
||||
if (!candidate.matches(normalizedSessionCwd)) {
|
||||
continue
|
||||
}
|
||||
if (!best || compareWorktreeCandidates(candidate, best) < 0) {
|
||||
best = candidate
|
||||
}
|
||||
}
|
||||
|
||||
const best = candidates[0]
|
||||
if (!best) {
|
||||
return {
|
||||
status: 'unavailable',
|
||||
@@ -138,7 +167,7 @@ export function resolveAiVaultSessionWorktreeDisplay(args: {
|
||||
|
||||
export function useAiVaultSessionWorktreeMap({
|
||||
sessions,
|
||||
repos = [],
|
||||
repos = NO_REPOS,
|
||||
worktrees,
|
||||
activeWorktreeId
|
||||
}: {
|
||||
@@ -164,38 +193,59 @@ export function useAiVaultSessionWorktreeMap({
|
||||
)
|
||||
}
|
||||
|
||||
function getWorktreeCandidates(
|
||||
worktrees: readonly Worktree[],
|
||||
repos: readonly Pick<Repo, 'id' | 'connectionId' | 'executionHostId'>[]
|
||||
): WorktreeCandidate[] {
|
||||
let byRepos = candidateCache.get(worktrees)
|
||||
if (!byRepos) {
|
||||
byRepos = new WeakMap()
|
||||
candidateCache.set(worktrees, byRepos)
|
||||
}
|
||||
let candidates = byRepos.get(repos)
|
||||
if (!candidates) {
|
||||
candidates = buildWorktreeCandidates(worktrees, repos)
|
||||
byRepos.set(repos, candidates)
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
function buildWorktreeCandidates(
|
||||
worktrees: readonly Worktree[],
|
||||
repos: readonly Pick<Repo, 'id' | 'connectionId' | 'executionHostId'>[]
|
||||
): WorktreeCandidate[] {
|
||||
const candidates: WorktreeCandidate[] = []
|
||||
const repoById = new Map(repos.map((repo) => [repo.id, repo]))
|
||||
const push = (
|
||||
worktree: Worktree,
|
||||
candidatePath: string,
|
||||
hostId: ExecutionHostId,
|
||||
source: WorktreeCandidate['source']
|
||||
): void => {
|
||||
candidates.push({
|
||||
worktree,
|
||||
path: candidatePath,
|
||||
hostId,
|
||||
status: worktree.isArchived ? 'archived' : 'active',
|
||||
source,
|
||||
matches: createWorktreePathMatcher(candidatePath),
|
||||
comparisonPathLength: normalizeRuntimePathForComparison(candidatePath).length
|
||||
})
|
||||
}
|
||||
for (const worktree of worktrees) {
|
||||
const repo = repoById.get(worktree.repoId)
|
||||
const hostId =
|
||||
normalizeExecutionHostId(worktree.hostId) ??
|
||||
(repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID)
|
||||
if (hasUsablePath(worktree.path)) {
|
||||
candidates.push({
|
||||
worktree,
|
||||
path: worktree.path,
|
||||
hostId,
|
||||
status: worktree.isArchived ? 'archived' : 'active',
|
||||
source: 'current-path'
|
||||
})
|
||||
push(worktree, worktree.path, hostId, 'current-path')
|
||||
}
|
||||
for (const priorWorktreeId of worktree.priorWorktreeIds ?? []) {
|
||||
const parsed = splitWorktreeIdForFilesystem(priorWorktreeId)
|
||||
if (!parsed || parsed.repoId !== worktree.repoId || !hasUsablePath(parsed.worktreePath)) {
|
||||
continue
|
||||
}
|
||||
candidates.push({
|
||||
worktree,
|
||||
path: parsed.worktreePath,
|
||||
hostId,
|
||||
status: worktree.isArchived ? 'archived' : 'active',
|
||||
source: 'prior-path'
|
||||
})
|
||||
push(worktree, parsed.worktreePath, hostId, 'prior-path')
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
@@ -206,18 +256,26 @@ function hasUsablePath(pathValue: string): boolean {
|
||||
return Boolean(trimmed && isRuntimePathAbsolute(trimmed))
|
||||
}
|
||||
|
||||
function isSessionInWorktreePath(worktreePath: string, sessionCwd: string): boolean {
|
||||
if (isPathInsideOrEqual(worktreePath, sessionCwd)) {
|
||||
return true
|
||||
}
|
||||
/**
|
||||
* Builds the containment test for one worktree path, so the per-session loop
|
||||
* only normalizes the session cwd. Keeps the WSL fallback: a UNC worktree path
|
||||
* must also match sessions recorded under the Linux-side path.
|
||||
*/
|
||||
function createWorktreePathMatcher(
|
||||
worktreePath: string
|
||||
): (normalizedSessionCwd: string) => boolean {
|
||||
const direct = createNormalizedPathInsideOrEqualMatcher(worktreePath)
|
||||
const wslPath = parseWslUncPath(worktreePath)
|
||||
return wslPath ? isPathInsideOrEqual(wslPath.linuxPath, sessionCwd) : false
|
||||
if (!wslPath) {
|
||||
return direct
|
||||
}
|
||||
const viaLinuxPath = createNormalizedPathInsideOrEqualMatcher(wslPath.linuxPath)
|
||||
return (normalizedSessionCwd) =>
|
||||
direct(normalizedSessionCwd) || viaLinuxPath(normalizedSessionCwd)
|
||||
}
|
||||
|
||||
function compareWorktreeCandidates(left: WorktreeCandidate, right: WorktreeCandidate): number {
|
||||
const lengthDifference =
|
||||
normalizeRuntimePathForComparison(right.path).length -
|
||||
normalizeRuntimePathForComparison(left.path).length
|
||||
const lengthDifference = right.comparisonPathLength - left.comparisonPathLength
|
||||
if (lengthDifference !== 0) {
|
||||
return lengthDifference
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Replays the AI Vault worktree projection — the work the CPU profile blamed
|
||||
* for the ~1s stall on workspace switch — against whatever source is checked
|
||||
* out right now.
|
||||
*
|
||||
* Bisecting through the real app costs ~6min/step (dev server boot on an 11GB
|
||||
* profile) and boots flakily. This runs the same hot functions over the same
|
||||
* real-scale inputs in a couple of seconds, so it can bisect ~500 commits.
|
||||
*
|
||||
* It compiles the checked-out .ts sources with esbuild rather than importing a
|
||||
* fixed copy, so each bisect step measures that commit's actual code.
|
||||
*
|
||||
* node tools/benchmarks/ai-vault-worktree-projection-bench.mjs \
|
||||
* --fixture /tmp/orca-bisect/fixture --budget 250
|
||||
*
|
||||
* Exit 0 = under budget, 1 = over, 125 = could not measure (skip).
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
function arg(name, fallback) {
|
||||
const i = process.argv.indexOf(`--${name}`)
|
||||
return i === -1 ? fallback : process.argv[i + 1]
|
||||
}
|
||||
|
||||
// --repo lets a bisect run this script from a fixed location against a
|
||||
// worktree that is being checked out underneath it.
|
||||
const REPO_ROOT = path.resolve(arg('repo', path.resolve(import.meta.dirname, '../..')))
|
||||
|
||||
const fixtureDir = arg('fixture', '/tmp/orca-bisect/fixture')
|
||||
const budgetMs = Number(arg('budget', 250))
|
||||
const iterations = Number(arg('iterations', 3))
|
||||
const sessionCount = Number(arg('sessions', 500))
|
||||
|
||||
const HOT_MODULE = 'src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts'
|
||||
|
||||
let tempDir
|
||||
try {
|
||||
const hotPath = path.join(REPO_ROOT, HOT_MODULE)
|
||||
readFileSync(hotPath) // throws on commits predating the module
|
||||
|
||||
// Inside the repo, not tmpdir: the bundle keeps `react` external, so it has
|
||||
// to sit somewhere that can resolve the repo's node_modules.
|
||||
tempDir = mkdtempSync(path.join(REPO_ROOT, 'node_modules', '.aivault-bench-'))
|
||||
const bundlePath = path.join(tempDir, 'hot.mjs')
|
||||
|
||||
// Bundle from the working tree so the measured code is this commit's code.
|
||||
// React is aliased to a stub rather than left external: esbuild turns an
|
||||
// external ESM import into `require()`, which an .mjs bundle cannot do. The
|
||||
// functions under test are pure data work and never call a hook.
|
||||
// CJS, so a Proxy can stand in for every named export transitive deps pull
|
||||
// from react (Fragment, createElement, Children, ...) without listing them.
|
||||
const shimPath = path.join(tempDir, 'react-shim.cjs')
|
||||
writeFileSync(
|
||||
shimPath,
|
||||
'const hooks = {\n' +
|
||||
' useMemo: (fn) => fn(),\n' +
|
||||
' useCallback: (fn) => fn,\n' +
|
||||
' useRef: (v) => ({ current: v }),\n' +
|
||||
' useState: (v) => [v, () => {}],\n' +
|
||||
' useEffect: () => {},\n' +
|
||||
' useLayoutEffect: () => {},\n' +
|
||||
' createContext: () => ({ Provider: null, Consumer: null }),\n' +
|
||||
' createElement: () => null,\n' +
|
||||
' Fragment: null\n' +
|
||||
'}\n' +
|
||||
'module.exports = new Proxy(hooks, {\n' +
|
||||
' get: (target, prop) => (prop in target ? target[prop] : () => null)\n' +
|
||||
'})\n'
|
||||
)
|
||||
|
||||
execFileSync(
|
||||
path.join(REPO_ROOT, 'node_modules/.bin/esbuild'),
|
||||
[
|
||||
hotPath,
|
||||
'--bundle',
|
||||
'--format=esm',
|
||||
// node, not neutral: transitive deps (react-i18next -> html-parse-stringify)
|
||||
// only resolve with node main-field semantics.
|
||||
'--platform=node',
|
||||
'--log-level=silent',
|
||||
`--alias:react=${shimPath}`,
|
||||
`--outfile=${bundlePath}`
|
||||
],
|
||||
{ cwd: REPO_ROOT, stdio: ['ignore', 'ignore', 'pipe'] }
|
||||
)
|
||||
|
||||
const hot = await import(pathToFileURL(bundlePath).href)
|
||||
const resolve = hot.resolveAiVaultSessionWorktreeDisplay ?? hot.resolveAiVaultSessionWorktreeInfo
|
||||
if (typeof resolve !== 'function') {
|
||||
throw new Error('no resolve export in this revision')
|
||||
}
|
||||
|
||||
const worktrees = JSON.parse(readFileSync(path.join(fixtureDir, 'worktrees.json'), 'utf8'))
|
||||
const repos = JSON.parse(readFileSync(path.join(fixtureDir, 'repos.json'), 'utf8'))
|
||||
|
||||
// Sessions live under real workspace paths so containment checks match the
|
||||
// way they do in the app; a synthetic path would short-circuit differently.
|
||||
const sessions = Array.from({ length: sessionCount }, (_, i) => {
|
||||
const wt = worktrees[(i * 7) % worktrees.length]
|
||||
return {
|
||||
id: `session-${i}`,
|
||||
cwd: i % 9 === 0 ? `${wt.path}/src/nested/dir` : wt.path,
|
||||
executionHostId: null,
|
||||
title: `session ${i}`
|
||||
}
|
||||
})
|
||||
|
||||
const activeWorktreeId = worktrees[0]?.id ?? null
|
||||
|
||||
const runOnce = () => {
|
||||
const t0 = performance.now()
|
||||
let resolved = 0
|
||||
for (const session of sessions) {
|
||||
const info = resolve({ session, repos, worktrees, activeWorktreeId })
|
||||
if (info) {
|
||||
resolved += 1
|
||||
}
|
||||
}
|
||||
return { ms: performance.now() - t0, resolved }
|
||||
}
|
||||
|
||||
runOnce() // warm JIT
|
||||
const runs = Array.from({ length: iterations }, runOnce)
|
||||
const times = runs.map((r) => r.ms).sort((a, b) => a - b)
|
||||
const median = times[Math.floor(times.length / 2)]
|
||||
|
||||
const report = {
|
||||
sha: execFileSync('git', ['-C', REPO_ROOT, 'rev-parse', '--short', 'HEAD'], {
|
||||
encoding: 'utf8'
|
||||
}).trim(),
|
||||
worktrees: worktrees.length,
|
||||
repos: repos.length,
|
||||
sessions: sessions.length,
|
||||
resolved: runs[0].resolved,
|
||||
medianMs: +median.toFixed(1),
|
||||
minMs: +times[0].toFixed(1),
|
||||
maxMs: +times.at(-1).toFixed(1),
|
||||
budgetMs
|
||||
}
|
||||
report.pass = report.medianMs <= budgetMs
|
||||
|
||||
console.log(JSON.stringify(report))
|
||||
process.exit(report.pass ? 0 : 1)
|
||||
} catch (error) {
|
||||
console.error(`[bench] cannot measure: ${String(error?.message ?? error).slice(0, 200)}`)
|
||||
process.exit(125)
|
||||
} finally {
|
||||
if (tempDir) {
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Measures how long a workspace-card click takes to become VISIBLE.
|
||||
*
|
||||
* The existing e2e spec (tests/e2e/worktree-switch-responsiveness.spec.ts) only
|
||||
* measures the synchronous click task. That stays fast even when the symptom is
|
||||
* present: `markSidebarWorktreeActiveImmediately` mutates the DOM in the click
|
||||
* handler, so the attribute flips in ~1ms. What the user sees is the next
|
||||
* painted FRAME, and that frame is blocked when the async activation path jams
|
||||
* the main thread. So the number that matters is first-paint-after-click.
|
||||
*
|
||||
* Attaches over CDP to an already-running dev app:
|
||||
*
|
||||
* ORCA_DEV_USER_DATA_PATH=... REMOTE_DEBUGGING_PORT=9455 pn dev
|
||||
* node tools/benchmarks/workspace-switch-paint-latency.mjs --port 9455
|
||||
*
|
||||
* Exits non-zero when the median first-paint exceeds --budget (default 200ms),
|
||||
* which makes it usable directly as a `git bisect run` predicate.
|
||||
*/
|
||||
|
||||
import { writeFileSync, mkdirSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { chromium } from '@stablyai/playwright-test'
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
port: 9455,
|
||||
switches: 6,
|
||||
budgetMs: 200,
|
||||
settleMs: 2500,
|
||||
out: null,
|
||||
json: false
|
||||
}
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const [flag, inlineValue] = argv[i].split('=')
|
||||
const value = inlineValue ?? argv[i + 1]
|
||||
const consume = () => {
|
||||
if (inlineValue === undefined) {
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
switch (flag) {
|
||||
case '--port':
|
||||
args.port = Number(value)
|
||||
consume()
|
||||
break
|
||||
case '--switches':
|
||||
args.switches = Number(value)
|
||||
consume()
|
||||
break
|
||||
case '--budget':
|
||||
args.budgetMs = Number(value)
|
||||
consume()
|
||||
break
|
||||
case '--settle':
|
||||
args.settleMs = Number(value)
|
||||
consume()
|
||||
break
|
||||
case '--out':
|
||||
args.out = value
|
||||
consume()
|
||||
break
|
||||
case '--json':
|
||||
args.json = true
|
||||
break
|
||||
case '--help':
|
||||
console.log(
|
||||
'Usage: workspace-switch-paint-latency.mjs [--port 9455] [--switches 6] [--budget 200] [--settle 2500] [--out file.json] [--json]'
|
||||
)
|
||||
process.exit(0)
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
|
||||
function quantile(sorted, q) {
|
||||
if (sorted.length === 0) {
|
||||
return null
|
||||
}
|
||||
const pos = (sorted.length - 1) * q
|
||||
const lo = Math.floor(pos)
|
||||
const hi = Math.ceil(pos)
|
||||
if (lo === hi) {
|
||||
return sorted[lo]
|
||||
}
|
||||
return sorted[lo] + (sorted[hi] - sorted[lo]) * (pos - lo)
|
||||
}
|
||||
|
||||
function summarize(values) {
|
||||
const clean = values.filter((v) => typeof v === 'number' && Number.isFinite(v))
|
||||
if (clean.length === 0) {
|
||||
return null
|
||||
}
|
||||
const sorted = [...clean].sort((a, b) => a - b)
|
||||
return {
|
||||
n: sorted.length,
|
||||
min: +sorted[0].toFixed(1),
|
||||
p50: +quantile(sorted, 0.5).toFixed(1),
|
||||
p90: +quantile(sorted, 0.9).toFixed(1),
|
||||
max: +sorted.at(-1).toFixed(1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Installed in the renderer before each click. Records, from the real
|
||||
* pointerdown, every animation frame plus long tasks, so we can tell a jammed
|
||||
* main thread (no frames for ~1s) apart from slow-but-smooth work.
|
||||
*/
|
||||
const PROBE_SOURCE = `(targetId) => {
|
||||
const probe = {
|
||||
t0: null,
|
||||
targetId,
|
||||
frames: [],
|
||||
longTasks: [],
|
||||
attrFlipMs: null,
|
||||
storeCommitMs: null,
|
||||
renderedCommitMs: null,
|
||||
done: false
|
||||
}
|
||||
window.__switchProbe = probe
|
||||
|
||||
const surfaceOf = (id) => {
|
||||
const row = [...document.querySelectorAll('[data-worktree-id]')].find(
|
||||
(el) => el.dataset.worktreeId === id
|
||||
)
|
||||
return row ? row.querySelector('[data-worktree-card-surface]') : null
|
||||
}
|
||||
const isActive = (id) => {
|
||||
const s = surfaceOf(id)
|
||||
return Boolean(s && s.getAttribute('data-worktree-card-active'))
|
||||
}
|
||||
const renderedId = () =>
|
||||
document
|
||||
.querySelector('[data-rendered-active-worktree-id]')
|
||||
?.getAttribute('data-rendered-active-worktree-id') ?? null
|
||||
|
||||
try {
|
||||
const po = new PerformanceObserver((list) => {
|
||||
for (const e of list.getEntries()) {
|
||||
if (probe.t0 === null) continue
|
||||
probe.longTasks.push({
|
||||
startMs: +(e.startTime - probe.t0).toFixed(1),
|
||||
durationMs: +e.duration.toFixed(1)
|
||||
})
|
||||
}
|
||||
})
|
||||
po.observe({ entryTypes: ['longtask'] })
|
||||
probe.observer = po
|
||||
} catch {}
|
||||
|
||||
// Why: pointerdown is the earliest thing the user's click produces, so it is
|
||||
// the honest zero point for "time until I saw something happen".
|
||||
const onPointerDown = () => {
|
||||
if (probe.t0 !== null) return
|
||||
probe.t0 = performance.now()
|
||||
const tick = () => {
|
||||
const now = performance.now() - probe.t0
|
||||
probe.frames.push(+now.toFixed(1))
|
||||
if (probe.attrFlipMs === null && isActive(probe.targetId)) probe.attrFlipMs = +now.toFixed(1)
|
||||
if (probe.storeCommitMs === null) {
|
||||
const st = window.__store?.getState?.()
|
||||
if (st && st.activeWorktreeId === probe.targetId) probe.storeCommitMs = +now.toFixed(1)
|
||||
}
|
||||
if (probe.renderedCommitMs === null && renderedId() === probe.targetId) {
|
||||
probe.renderedCommitMs = +now.toFixed(1)
|
||||
}
|
||||
if (!probe.done) requestAnimationFrame(tick)
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
}
|
||||
window.addEventListener('pointerdown', onPointerDown, { capture: true, once: true })
|
||||
probe.cleanup = () => {
|
||||
probe.done = true
|
||||
try { probe.observer?.disconnect() } catch {}
|
||||
window.removeEventListener('pointerdown', onPointerDown, { capture: true })
|
||||
}
|
||||
return true
|
||||
}`
|
||||
|
||||
/** Press Escape while any full-screen overlay is intercepting pointer events. */
|
||||
async function dismissOverlays(page) {
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
const blocked = await page.evaluate(() =>
|
||||
[...document.querySelectorAll('body *')].some((el) => {
|
||||
const r = el.getBoundingClientRect()
|
||||
if (r.width < window.innerWidth * 0.9 || r.height < window.innerHeight * 0.9) {
|
||||
return false
|
||||
}
|
||||
const s = getComputedStyle(el)
|
||||
return s.position === 'fixed' && s.pointerEvents !== 'none' && Number(s.zIndex) >= 40
|
||||
})
|
||||
)
|
||||
if (!blocked) {
|
||||
return
|
||||
}
|
||||
await page.keyboard.press('Escape')
|
||||
await page.waitForTimeout(200)
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const browser = await chromium.connectOverCDP(`http://127.0.0.1:${args.port}`)
|
||||
const contexts = browser.contexts()
|
||||
const pages = contexts.flatMap((c) => c.pages())
|
||||
let page = null
|
||||
for (const candidate of pages) {
|
||||
const hasStore = await candidate
|
||||
.evaluate(
|
||||
() => Boolean(window.__store) && Boolean(document.querySelector('[data-worktree-id]'))
|
||||
)
|
||||
.catch(() => false)
|
||||
if (hasStore) {
|
||||
page = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!page) {
|
||||
throw new Error(
|
||||
'No renderer page with window.__store + a mounted sidebar. Is the app past startup, and is the sidebar open?'
|
||||
)
|
||||
}
|
||||
|
||||
// Normalize sidebar state so the same cards are reachable across commits.
|
||||
// Why pin the sort: 'recent' reorders the list after every switch, which
|
||||
// would move the card out from under the cursor mid-run.
|
||||
const worktreeIds = await page.evaluate(() => {
|
||||
const state = window.__store.getState()
|
||||
state.setActiveView('terminal')
|
||||
state.setSidebarOpen(true)
|
||||
state.setGroupBy?.('none')
|
||||
state.setSortBy?.('name')
|
||||
state.setShowActiveOnly?.(false)
|
||||
state.setFilterRepoIds?.([])
|
||||
const viewportH = window.innerHeight
|
||||
// Only cards fully inside the viewport are clickable: the list is
|
||||
// virtualized, so off-screen rows are unmounted or positioned outside.
|
||||
return [...document.querySelectorAll('[data-worktree-id]')]
|
||||
.filter((el) => {
|
||||
const surface = el.querySelector('[data-worktree-card-surface]')
|
||||
if (!surface) {
|
||||
return false
|
||||
}
|
||||
const r = surface.getBoundingClientRect()
|
||||
return r.height > 0 && r.top >= 0 && r.bottom <= viewportH
|
||||
})
|
||||
.map((el) => el.dataset.worktreeId)
|
||||
.filter((id, i, all) => id && all.indexOf(id) === i)
|
||||
})
|
||||
|
||||
if (worktreeIds.length < 2) {
|
||||
throw new Error(
|
||||
`Need >=2 workspace cards visible in the viewport, found ${worktreeIds.length}. Is the sidebar open?`
|
||||
)
|
||||
}
|
||||
|
||||
// Alternate between two cards, neither of which is the one already active at
|
||||
// start — clicking the active card short-circuits and measures nothing.
|
||||
const activeAtStart = await page.evaluate(() => window.__store.getState().activeWorktreeId)
|
||||
const pair = worktreeIds.filter((id) => id !== activeAtStart).slice(0, 2)
|
||||
if (pair.length < 2) {
|
||||
throw new Error('Need two visible non-active cards to alternate between')
|
||||
}
|
||||
console.error(`alternating between:\n ${pair[0]}\n ${pair[1]}`)
|
||||
|
||||
const samples = []
|
||||
for (let i = 0; i < args.switches; i += 1) {
|
||||
const targetId = pair[i % 2]
|
||||
|
||||
// Why: page.evaluate() treats a string as an expression to evaluate and
|
||||
// drops the argument, so the probe has to be applied inline.
|
||||
const surface = page
|
||||
.locator(`[data-worktree-id="${targetId}"] [data-worktree-card-surface]`)
|
||||
.first()
|
||||
|
||||
// Why: a transient popover (`fixed inset-0 z-50`) swallows the click, and
|
||||
// the run would then measure a click that never reached the card.
|
||||
await dismissOverlays(page)
|
||||
|
||||
await page.evaluate(`(${PROBE_SOURCE})(${JSON.stringify(targetId)})`)
|
||||
|
||||
// Real CDP input events, not element.click(): the click path has
|
||||
// pointer-drag suppression that a synthetic click would bypass. Going
|
||||
// through the locator also re-resolves position at click time, which
|
||||
// matters because the virtualized list reorders between switches.
|
||||
try {
|
||||
await surface.click({ timeout: 10_000 })
|
||||
} catch (error) {
|
||||
console.error(`skip switch ${i + 1}: ${String(error).split('\n')[0]}`)
|
||||
continue
|
||||
}
|
||||
|
||||
await page.waitForTimeout(args.settleMs)
|
||||
|
||||
const sample = await page.evaluate(() => {
|
||||
const p = window.__switchProbe
|
||||
p.cleanup?.()
|
||||
const frames = p.frames
|
||||
let maxGap = 0
|
||||
let prev = 0
|
||||
for (const f of frames) {
|
||||
maxGap = Math.max(maxGap, f - prev)
|
||||
prev = f
|
||||
}
|
||||
return {
|
||||
firstPaintMs: frames.length ? frames[0] : null,
|
||||
maxFrameGapMs: +maxGap.toFixed(1),
|
||||
frameCount: frames.length,
|
||||
attrFlipMs: p.attrFlipMs,
|
||||
storeCommitMs: p.storeCommitMs,
|
||||
renderedCommitMs: p.renderedCommitMs,
|
||||
longTaskCount: p.longTasks.length,
|
||||
longTaskTotalMs: +p.longTasks.reduce((a, t) => a + t.durationMs, 0).toFixed(1),
|
||||
worstLongTaskMs: p.longTasks.reduce((a, t) => Math.max(a, t.durationMs), 0)
|
||||
}
|
||||
})
|
||||
sample.targetId = targetId
|
||||
// Why: a click that never activated would report a fast "first paint" and
|
||||
// silently make every commit look good during a bisect.
|
||||
if (sample.attrFlipMs === null && sample.renderedCommitMs === null) {
|
||||
throw new Error(
|
||||
`switch ${i + 1} clicked card ${targetId} but it never became active — the measurement is not valid`
|
||||
)
|
||||
}
|
||||
samples.push(sample)
|
||||
console.error(
|
||||
`switch ${i + 1}/${args.switches} -> firstPaint=${sample.firstPaintMs}ms ` +
|
||||
`maxFrameGap=${sample.maxFrameGapMs}ms attrFlip=${sample.attrFlipMs}ms ` +
|
||||
`rendered=${sample.renderedCommitMs}ms longTasks=${sample.longTaskCount}/${sample.longTaskTotalMs}ms`
|
||||
)
|
||||
}
|
||||
|
||||
// Why: the first switch after attach pays one-off lazy work (chunk loads,
|
||||
// cold caches) that a warm app never pays again. Keep it in the raw samples
|
||||
// but exclude it from the verdict so bisect steps compare like with like.
|
||||
const scored = samples.length > 1 ? samples.slice(1) : samples
|
||||
|
||||
const report = {
|
||||
port: args.port,
|
||||
switches: samples.length,
|
||||
budgetMs: args.budgetMs,
|
||||
firstPaintMs: summarize(scored.map((s) => s.firstPaintMs)),
|
||||
maxFrameGapMs: summarize(scored.map((s) => s.maxFrameGapMs)),
|
||||
attrFlipMs: summarize(scored.map((s) => s.attrFlipMs)),
|
||||
renderedCommitMs: summarize(scored.map((s) => s.renderedCommitMs)),
|
||||
worstLongTaskMs: summarize(scored.map((s) => s.worstLongTaskMs)),
|
||||
longTaskTotalMs: summarize(scored.map((s) => s.longTaskTotalMs)),
|
||||
samples
|
||||
}
|
||||
|
||||
const medianFirstPaint = report.firstPaintMs?.p50 ?? Infinity
|
||||
const medianFrameGap = report.maxFrameGapMs?.p50 ?? Infinity
|
||||
// The visible symptom is a stall, which shows up either as a late first frame
|
||||
// or as a long gap between frames right after the click.
|
||||
const verdictMs = Math.max(medianFirstPaint, medianFrameGap)
|
||||
report.verdictMs = +verdictMs.toFixed(1)
|
||||
report.pass = verdictMs <= args.budgetMs
|
||||
|
||||
if (args.out) {
|
||||
mkdirSync(path.dirname(args.out), { recursive: true })
|
||||
writeFileSync(args.out, JSON.stringify(report, null, 2))
|
||||
}
|
||||
if (args.json) {
|
||||
console.log(JSON.stringify(report, null, 2))
|
||||
} else {
|
||||
console.log('')
|
||||
console.log(
|
||||
`first paint after click p50=${report.firstPaintMs?.p50}ms max=${report.firstPaintMs?.max}ms`
|
||||
)
|
||||
console.log(
|
||||
`max frame gap p50=${report.maxFrameGapMs?.p50}ms max=${report.maxFrameGapMs?.max}ms`
|
||||
)
|
||||
console.log(`attribute flip (JS) p50=${report.attrFlipMs?.p50}ms`)
|
||||
console.log(
|
||||
`main pane rendered p50=${report.renderedCommitMs?.p50}ms max=${report.renderedCommitMs?.max}ms`
|
||||
)
|
||||
console.log(
|
||||
`worst long task p50=${report.worstLongTaskMs?.p50}ms max=${report.worstLongTaskMs?.max}ms`
|
||||
)
|
||||
console.log('')
|
||||
console.log(
|
||||
`${report.pass ? 'PASS' : 'FAIL'} verdict=${report.verdictMs}ms budget=${args.budgetMs}ms`
|
||||
)
|
||||
}
|
||||
|
||||
await browser.close()
|
||||
process.exit(report.pass ? 0 : 1)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error?.stack ?? String(error))
|
||||
// 125 tells `git bisect run` to skip rather than mark the commit bad.
|
||||
process.exit(125)
|
||||
})
|
||||
Reference in New Issue
Block a user