mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 16:02:56 +00:00
perf(git): relist only the repo whose worktrees changed, and stop blocking main on sync git (#23998)
* 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 }`.
This commit is contained in:
@@ -196,13 +196,20 @@ function createMainBootstrapPlugin() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnostic escape hatch: an unminified main bundle so a V8 CPU profile of the
|
||||
* main process attributes self time to real function names. Release builds never
|
||||
* set this, and `pnpm build` does not read it.
|
||||
*/
|
||||
const MAIN_MINIFY: 'oxc' | false = process.env.ORCA_UNMINIFIED_MAIN === '1' ? false : 'oxc'
|
||||
|
||||
export const electronViteConfig: UserConfig = {
|
||||
main: {
|
||||
build: {
|
||||
// Why: 'esbuild' makes rolldown disable its own minifier and re-print every
|
||||
// chunk through esbuild, which is undeclared here and only resolves via
|
||||
// pnpm hoisting. 'oxc' is rolldown's in-process minifier.
|
||||
minify: 'oxc',
|
||||
minify: MAIN_MINIFY,
|
||||
// Why: 'hidden' emits .js.map with no sourceMappingURL, so the shipped
|
||||
// bundle never references maps that packaging strips out. Release CI
|
||||
// uploads them so minified crash traces stay decodable.
|
||||
|
||||
@@ -27,10 +27,8 @@ export async function execFileCaptureToTermination(
|
||||
options: ExecFileCaptureOptions,
|
||||
termination?: WslProcessGroupTermination
|
||||
): Promise<{ stdout: string | Buffer; stderr: string | Buffer }> {
|
||||
// Why measured here: runProcess spawns inside its promise executor, which runs
|
||||
// synchronously, so this brackets exactly the main-thread block execFileCapture
|
||||
// reports for its own spawns.
|
||||
const spawnStartedAt = performance.now()
|
||||
// Spawn cost is reported by spawnProcess's observer, which runProcess goes
|
||||
// through; recording it again here would double-count every capture.
|
||||
const pending = runProcess({
|
||||
program: command,
|
||||
args,
|
||||
@@ -43,7 +41,6 @@ export async function execFileCaptureToTermination(
|
||||
onChildTerminated: options.onChildTerminated,
|
||||
...(options.stdin === undefined ? {} : { input: options.stdin })
|
||||
})
|
||||
recordSubprocessSpawn(command, args, performance.now() - spawnStartedAt)
|
||||
const result = await pending
|
||||
const stdout = options.encoding === 'buffer' ? Buffer.from(result.stdout) : result.stdout
|
||||
const cleanStderr = termination?.stripControlOutput(result.stderr) ?? result.stderr
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { GitAdmissionTier } from '../../shared/rpc-contract/git-admission-tier-params'
|
||||
import { gitExecFileAsync, gitExecFileSync } from './runner'
|
||||
import { gitExecFileAsync } from './runner'
|
||||
|
||||
export type LocalGitExecOptions = {
|
||||
wslDistro?: string
|
||||
@@ -43,44 +43,10 @@ async function resolveDefaultBaseRefFromProbes(
|
||||
return null
|
||||
}
|
||||
|
||||
function hasGitRef(path: string, ref: string): boolean {
|
||||
try {
|
||||
gitExecFileSync(['rev-parse', '--verify', ref], { cwd: path })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function gitRefToDefaultBaseRef(ref: string): string {
|
||||
return ref.replace(/^refs\/remotes\//, '')
|
||||
}
|
||||
|
||||
function getVerifiedOriginHeadBaseRef(path: string): string | null {
|
||||
try {
|
||||
const ref = gitExecFileSync(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], {
|
||||
cwd: path
|
||||
}).trim()
|
||||
return ref && hasGitRef(path, ref) ? gitRefToDefaultBaseRef(ref) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve the default base ref without inventing a fallback branch. */
|
||||
export function getDefaultBaseRef(path: string): string | null {
|
||||
const originHeadBaseRef = getVerifiedOriginHeadBaseRef(path)
|
||||
if (originHeadBaseRef) {
|
||||
return originHeadBaseRef
|
||||
}
|
||||
for (const { ref, returnAs } of DEFAULT_BASE_REF_PROBES) {
|
||||
if (hasGitRef(path, ref)) {
|
||||
return returnAs
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function getBaseRefDefault(
|
||||
path: string,
|
||||
options: LocalGitExecOptions = {}
|
||||
|
||||
@@ -78,14 +78,19 @@ export function getGitRepoRoot(path: string): string {
|
||||
if (!existsSync(path) || !statSync(path).isDirectory()) {
|
||||
return path
|
||||
}
|
||||
const insideWorkTree = gitExecFileSync(['rev-parse', '--is-inside-work-tree'], {
|
||||
cwd: path
|
||||
}).trim()
|
||||
if (insideWorkTree === 'true') {
|
||||
const root = gitExecFileSync(['rev-parse', '--show-toplevel'], {
|
||||
cwd: path
|
||||
}).trim()
|
||||
return normalizeGitRepoRootForInputPath(path, root)
|
||||
// One spawn, not two: each sync git call blocks main for up to its whole 15s
|
||||
// timeout, so the spawn count is the cost. Safe to combine only here — a bare
|
||||
// repo makes the combined form exit non-zero, and both that throw and the
|
||||
// plain `false` land on the same marker-scan fallback below. `probeGitRepo`
|
||||
// must NOT combine: it has to read `false` cleanly to go on and detect bare.
|
||||
const [insideWorkTree, toplevel] = gitExecFileSync(
|
||||
['rev-parse', '--is-inside-work-tree', '--show-toplevel'],
|
||||
{ cwd: path }
|
||||
)
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
if (insideWorkTree === 'true' && toplevel) {
|
||||
return normalizeGitRepoRootForInputPath(path, toplevel)
|
||||
}
|
||||
} catch {
|
||||
// Fall through to preserving the original path.
|
||||
|
||||
+14
-14
@@ -6,7 +6,7 @@ import path from 'node:path'
|
||||
|
||||
import {
|
||||
buildSearchBaseRefsArgv,
|
||||
getDefaultBaseRef,
|
||||
getBaseRefDefault,
|
||||
getBranchConflictKind,
|
||||
getRemoteCount,
|
||||
parseAndFilterSearchRefDetails,
|
||||
@@ -537,7 +537,7 @@ describe('searchBaseRefs (widened glob)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getDefaultBaseRef (regression — unchanged behavior)', () => {
|
||||
describe('getBaseRefDefault (regression — unchanged behavior)', async () => {
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -549,52 +549,52 @@ describe('getDefaultBaseRef (regression — unchanged behavior)', () => {
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('returns origin/main when both origin/main and upstream/main exist (origin wins)', () => {
|
||||
it('returns origin/main when both origin/main and upstream/main exist (origin wins)', async () => {
|
||||
const sha = getHeadSha(tmpDir)
|
||||
createRemoteRef(tmpDir, 'origin/main', sha)
|
||||
createRemoteRef(tmpDir, 'upstream/main', sha)
|
||||
|
||||
const result = getDefaultBaseRef(tmpDir)
|
||||
const result = await getBaseRefDefault(tmpDir)
|
||||
|
||||
expect(result).toBe('origin/main')
|
||||
})
|
||||
|
||||
it('returns the target of origin/HEAD when set', () => {
|
||||
it('returns the target of origin/HEAD when set', async () => {
|
||||
const sha = getHeadSha(tmpDir)
|
||||
createRemoteRef(tmpDir, 'origin/main', sha)
|
||||
git(tmpDir, ['symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main'])
|
||||
|
||||
const result = getDefaultBaseRef(tmpDir)
|
||||
const result = await getBaseRefDefault(tmpDir)
|
||||
|
||||
expect(result).toBe('origin/main')
|
||||
})
|
||||
|
||||
it('falls through from a stale origin/HEAD target to an existing primary ref', () => {
|
||||
it('falls through from a stale origin/HEAD target to an existing primary ref', async () => {
|
||||
const sha = getHeadSha(tmpDir)
|
||||
createRemoteRef(tmpDir, 'origin/main', sha)
|
||||
git(tmpDir, ['symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/master'])
|
||||
|
||||
const result = getDefaultBaseRef(tmpDir)
|
||||
const result = await getBaseRefDefault(tmpDir)
|
||||
|
||||
expect(result).toBe('origin/main')
|
||||
})
|
||||
|
||||
it('falls through from a stale origin/HEAD primary target to another existing default ref', () => {
|
||||
it('falls through from a stale origin/HEAD primary target to another existing default ref', async () => {
|
||||
const sha = getHeadSha(tmpDir)
|
||||
createRemoteRef(tmpDir, 'origin/master', sha)
|
||||
git(tmpDir, ['symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main'])
|
||||
|
||||
const result = getDefaultBaseRef(tmpDir)
|
||||
const result = await getBaseRefDefault(tmpDir)
|
||||
|
||||
expect(result).toBe('origin/master')
|
||||
})
|
||||
|
||||
it('does NOT fall through to upstream/main when origin/* is absent', () => {
|
||||
it('does NOT fall through to upstream/main when origin/* is absent', async () => {
|
||||
// Why: default probe order is origin-only by design; upstream-aware defaulting is deferred.
|
||||
const sha = getHeadSha(tmpDir)
|
||||
createRemoteRef(tmpDir, 'upstream/main', sha)
|
||||
|
||||
const result = getDefaultBaseRef(tmpDir)
|
||||
const result = await getBaseRefDefault(tmpDir)
|
||||
|
||||
// initRepo creates a local `main`, so with no origin/* we expect it — not `upstream/main`.
|
||||
expect(result).toBe('main')
|
||||
@@ -602,7 +602,7 @@ describe('getDefaultBaseRef (regression — unchanged behavior)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveDefaultBaseRefViaExec', () => {
|
||||
describe('resolveDefaultBaseRefViaExec', async () => {
|
||||
it('falls through from a stale origin/HEAD target to the probe list', async () => {
|
||||
const calls: string[][] = []
|
||||
const exec = async (argv: string[]): Promise<{ stdout: string }> => {
|
||||
@@ -649,7 +649,7 @@ describe('resolveDefaultBaseRefViaExec', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRemoteCount', () => {
|
||||
describe('getRemoteCount', async () => {
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
+10
-11
@@ -3,12 +3,11 @@ import { parseGitRevListAheadBehindCounts } from '../../shared/git-rev-list-outp
|
||||
import { buildHostedRemoteCommitUrl, buildHostedRemoteFileUrl } from './hosted-remote-url'
|
||||
import {
|
||||
DEFAULT_BASE_REF_PROBE_TIMEOUT_MS,
|
||||
getDefaultBaseRef,
|
||||
getDefaultBaseRefAsync,
|
||||
gitExecOptions,
|
||||
type LocalGitExecOptions
|
||||
} from './repo-default-base-ref'
|
||||
import { gitExecFileAsync, gitExecFileSync } from './runner'
|
||||
import { gitExecFileAsync } from './runner'
|
||||
|
||||
export {
|
||||
isGitRepo,
|
||||
@@ -18,7 +17,6 @@ export {
|
||||
} from './repo-detection'
|
||||
export {
|
||||
DEFAULT_BASE_REF_PROBES,
|
||||
getDefaultBaseRef,
|
||||
getBaseRefDefault,
|
||||
resolveDefaultBaseRefViaExec,
|
||||
resolveDefaultBaseRefWithLocalGit
|
||||
@@ -43,9 +41,10 @@ export function getRepoName(path: string): string {
|
||||
}
|
||||
|
||||
/** Get the remote origin URL, or null if not set. */
|
||||
export function getRemoteUrl(path: string): string | null {
|
||||
export async function getRemoteUrl(path: string): Promise<string | null> {
|
||||
try {
|
||||
return gitExecFileSync(['remote', 'get-url', 'origin'], { cwd: path }).trim()
|
||||
const { stdout } = await gitExecFileAsync(['remote', 'get-url', 'origin'], { cwd: path })
|
||||
return stdout.trim()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
@@ -168,16 +167,16 @@ export async function getDefaultRemote(
|
||||
}
|
||||
|
||||
/** Build a hosted file URL when the origin belongs to a supported provider. */
|
||||
export function getRemoteFileUrl(
|
||||
export async function getRemoteFileUrl(
|
||||
repoPath: string,
|
||||
relativePath: string,
|
||||
line: number
|
||||
): string | null {
|
||||
const remoteUrl = getRemoteUrl(repoPath)
|
||||
): Promise<string | null> {
|
||||
const remoteUrl = await getRemoteUrl(repoPath)
|
||||
if (!remoteUrl) {
|
||||
return null
|
||||
}
|
||||
const defaultBaseRef = getDefaultBaseRef(repoPath)
|
||||
const defaultBaseRef = await getDefaultBaseRefAsync(repoPath)
|
||||
if (!defaultBaseRef) {
|
||||
return null
|
||||
}
|
||||
@@ -190,7 +189,7 @@ export function getRemoteFileUrl(
|
||||
}
|
||||
|
||||
/** Build a hosted commit URL when the origin belongs to a supported provider. */
|
||||
export function getRemoteCommitUrl(repoPath: string, sha: string): string | null {
|
||||
const remoteUrl = getRemoteUrl(repoPath)
|
||||
export async function getRemoteCommitUrl(repoPath: string, sha: string): Promise<string | null> {
|
||||
const remoteUrl = await getRemoteUrl(repoPath)
|
||||
return remoteUrl ? buildHostedRemoteCommitUrl(remoteUrl, sha) : null
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
// Compatibility exports for runtime command modules that historically imported these seams from
|
||||
// filesystem-auth. The implementations remain owned by their focused modules.
|
||||
export { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache'
|
||||
export { invalidateAuthorizedRootsCacheForRepo } from './registered-worktree-roots-scoped-invalidation'
|
||||
export { isENOENT } from './filesystem-path-containment'
|
||||
|
||||
export const PATH_ACCESS_DENIED_MESSAGE =
|
||||
|
||||
@@ -51,7 +51,8 @@ vi.mock('./repos/repos-changed-notification', () => ({
|
||||
notifyReposChanged: vi.fn()
|
||||
}))
|
||||
vi.mock('./registered-worktree-roots-cache', () => ({
|
||||
invalidateAuthorizedRootsCache: vi.fn()
|
||||
invalidateAuthorizedRootsCache: vi.fn(),
|
||||
invalidateAuthorizedRootsCacheForRepo: vi.fn()
|
||||
}))
|
||||
vi.mock('../worktree-root-preparation', () => ({
|
||||
prepareLocalWorktreeRootForRepo: vi.fn(async () => {})
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Which owners an authorized-roots rebuild has to re-list.
|
||||
*
|
||||
* Split out of registered-worktree-roots-cache so the policy is testable on its own
|
||||
* and the cache module stays within its line budget.
|
||||
*/
|
||||
/** The owner fields the policy reads; `undefined` means no owner record yet. */
|
||||
export type RelistCandidate =
|
||||
| { dirty: boolean; listed: unknown; recovered: { size: number } }
|
||||
| undefined
|
||||
|
||||
/**
|
||||
* `onlyDirty` is the ensure path, where a single-repo invalidation must not relist
|
||||
* every repo. An explicit rebuild keeps re-listing everything, because callers use
|
||||
* it to force a refresh.
|
||||
*
|
||||
* A clean owner still holding recovered roots is always re-listed: those roots are
|
||||
* retired by comparing against a fresh listing, so skipping it would strand them
|
||||
* as authorized.
|
||||
*/
|
||||
export function shouldRelistOwner(owner: RelistCandidate, onlyDirty: boolean): boolean {
|
||||
if (!onlyDirty || owner === undefined) {
|
||||
return true
|
||||
}
|
||||
return owner.dirty || owner.listed == null || owner.recovered.size > 0
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
pruneCreatedWorktreeRoots
|
||||
} from './registered-worktree-root-probes'
|
||||
import { isDescendantOrEqual, normalizeExistingPath } from './filesystem-path-containment'
|
||||
import { shouldRelistOwner } from './registered-worktree-root-relist-policy'
|
||||
import {
|
||||
getLocalWorktreeRootOwners,
|
||||
resolveWorktreeRootOwner
|
||||
@@ -38,6 +39,12 @@ function advanceOwner(owner: RegisteredOwner): void {
|
||||
registeredWorktreeRootsRevisionByRepo.set(owner.repoId, owner.revision)
|
||||
}
|
||||
|
||||
function resolveOwnerForRepo(store: Store, repo: Repo | string): RegisteredOwner | undefined {
|
||||
const repos = synchronizeOwners(store)
|
||||
const key = resolveWorktreeRootOwner(repos, repo, currentOwners)
|
||||
return key === undefined ? undefined : registeredOwners.get(key)
|
||||
}
|
||||
|
||||
function synchronizeOwners(store: Store): Repo[] {
|
||||
const repos = store.getRepos()
|
||||
const owners = getLocalWorktreeRootOwners(repos)
|
||||
@@ -88,15 +95,22 @@ export function invalidateAuthorizedRootsCache(): void {
|
||||
registeredWorktreeRootsRevisionByRepo.clear()
|
||||
}
|
||||
|
||||
export async function rebuildAuthorizedRootsCache(store: Store): Promise<void> {
|
||||
/** `onlyDirty` is the ensure path; an explicit rebuild still re-lists every repo. */
|
||||
export async function rebuildAuthorizedRootsCache(store: Store, onlyDirty = false): Promise<void> {
|
||||
synchronizeOwners(store)
|
||||
const generation = invalidationGeneration
|
||||
const pending = [...currentOwners].map(([key, repo]) => ({
|
||||
key,
|
||||
repo,
|
||||
owner: registeredOwners.get(key),
|
||||
revision: registeredOwners.get(key)?.revision
|
||||
}))
|
||||
const pending = [...currentOwners]
|
||||
.map(([key, repo]) => ({
|
||||
key,
|
||||
repo,
|
||||
owner: registeredOwners.get(key),
|
||||
revision: registeredOwners.get(key)?.revision
|
||||
}))
|
||||
// Why only dirty owners: `dirty` used to gate whether a rebuild ran, not what it
|
||||
// listed, so a single-repo invalidation still spawned `git worktree list` for
|
||||
// every registered repo. An owner with no listing yet (`listed === null`) is
|
||||
// always included, so a first rebuild is unchanged.
|
||||
.filter((entry) => shouldRelistOwner(entry.owner, onlyDirty))
|
||||
const listings = await listWorktreeRootsWithConcurrency(pending.map((entry) => entry.repo))
|
||||
const results = pending.map((entry, index) => ({ ...entry, ...listings[index] }))
|
||||
const isCurrent = (entry: (typeof pending)[number]): boolean =>
|
||||
@@ -128,14 +142,33 @@ export async function rebuildAuthorizedRootsCache(store: Store): Promise<void> {
|
||||
registeredWorktreeRootsDirty = [...registeredOwners.values()].some((owner) => owner.dirty)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dirties exactly one owner so the next ensure-path rebuild re-lists only that repo.
|
||||
* Returns false when the repo has no known owner, leaving the decision to the caller.
|
||||
*
|
||||
* Deliberately 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. `advanceOwner` still records this repo's new revision.
|
||||
*/
|
||||
export function markAuthorizedRootsOwnerDirty(store: Store, repo: Repo | string): boolean {
|
||||
const owner = resolveOwnerForRepo(store, repo)
|
||||
if (!owner) {
|
||||
return false
|
||||
}
|
||||
owner.listed = null
|
||||
owner.dirty = true
|
||||
advanceOwner(owner)
|
||||
refreshRegisteredWorktreeRoots()
|
||||
registeredWorktreeRootsDirty = true
|
||||
return true
|
||||
}
|
||||
|
||||
export function registerWorktreeRootsForRepo(
|
||||
store: Store,
|
||||
repo: Repo | string,
|
||||
worktreeRoots: string[]
|
||||
): void {
|
||||
const repos = synchronizeOwners(store)
|
||||
const key = resolveWorktreeRootOwner(repos, repo, currentOwners)
|
||||
const owner = key === undefined ? undefined : registeredOwners.get(key)
|
||||
const owner = resolveOwnerForRepo(store, repo)
|
||||
if (!owner) {
|
||||
return
|
||||
}
|
||||
@@ -152,9 +185,7 @@ export function registerCreatedWorktreeRoot(
|
||||
repo: Repo | string,
|
||||
worktreeRoot: string
|
||||
): void {
|
||||
const repos = synchronizeOwners(store)
|
||||
const key = resolveWorktreeRootOwner(repos, repo, currentOwners)
|
||||
const owner = key === undefined ? undefined : registeredOwners.get(key)
|
||||
const owner = resolveOwnerForRepo(store, repo)
|
||||
if (!owner) {
|
||||
return
|
||||
}
|
||||
@@ -189,7 +220,7 @@ export async function ensureAuthorizedRootsCache(store: Store): Promise<void> {
|
||||
// Follow one superseded refresh; continuous catalog churn must not pin authorization forever.
|
||||
for (let attempt = 0; registeredWorktreeRootsDirty && attempt < 2; attempt++) {
|
||||
if (!registeredWorktreeRootsRefresh) {
|
||||
registeredWorktreeRootsRefresh = rebuildAuthorizedRootsCache(store).finally(() => {
|
||||
registeredWorktreeRootsRefresh = rebuildAuthorizedRootsCache(store, true).finally(() => {
|
||||
registeredWorktreeRootsRefresh = null
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { resolve } from 'node:path'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Store } from '../persistence'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ graph: vi.fn(), stat: vi.fn(), realpath: vi.fn() }))
|
||||
vi.mock('node:fs', () => ({ realpathSync: (path: string) => path, statSync: mocks.stat }))
|
||||
vi.mock('node:fs/promises', () => ({ stat: mocks.stat, realpath: mocks.realpath }))
|
||||
vi.mock('../repo-worktrees', () => ({
|
||||
listRepoWorktreeGraph: mocks.graph,
|
||||
isRepoRoot: vi.fn(() => false)
|
||||
}))
|
||||
vi.mock('./worktree-logic', () => ({
|
||||
computeWorkspaceRoot: vi.fn(),
|
||||
getWorktreePathSettings: vi.fn()
|
||||
}))
|
||||
vi.mock('../project-runtime-git-options', () => ({
|
||||
getWorktreeMirrorDistroForRuntime: vi.fn(),
|
||||
resolveLocalProjectRuntimesForRepos: vi.fn()
|
||||
}))
|
||||
import {
|
||||
invalidateAuthorizedRootsCache,
|
||||
rebuildAuthorizedRootsCache
|
||||
} from './registered-worktree-roots-cache'
|
||||
import { invalidateAuthorizedRootsCacheForRepo } from './registered-worktree-roots-scoped-invalidation'
|
||||
|
||||
const REPO_COUNT = 12
|
||||
const repos: Repo[] = Array.from({ length: REPO_COUNT }, (_, index) => ({
|
||||
id: `repo-${index}`,
|
||||
path: resolve(`/scoped-invalidation-${index}`),
|
||||
displayName: `repo-${index}`,
|
||||
badgeColor: '#000',
|
||||
addedAt: 0
|
||||
}))
|
||||
|
||||
function fixture(): Store {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the authorization cache only reads these four store methods; filesystem and graph boundaries are mocked.
|
||||
return {
|
||||
getRepos: () => repos,
|
||||
getProjectGroups: () => [],
|
||||
getFolderWorkspaces: () => [],
|
||||
getSettings: () => ({})
|
||||
} as unknown as Store
|
||||
}
|
||||
|
||||
function listedRepoPaths(): string[] {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this file installs the listRepoWorktreeGraph mock, so every call's first argument is the Repo it was handed.
|
||||
return mocks.graph.mock.calls.map((call) => (call[0] as Repo).path)
|
||||
}
|
||||
|
||||
describe('authorized-roots invalidation scope', () => {
|
||||
let store: Store
|
||||
|
||||
beforeEach(async () => {
|
||||
mocks.graph.mockReset()
|
||||
mocks.graph.mockImplementation((repo: Repo) =>
|
||||
Promise.resolve([{ path: resolve(repo.path, 'wt') }])
|
||||
)
|
||||
mocks.stat.mockReset()
|
||||
mocks.stat.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
|
||||
store = fixture()
|
||||
// Prime every owner so a later scoped invalidation has clean state to dirty.
|
||||
invalidateAuthorizedRootsCache()
|
||||
await rebuildAuthorizedRootsCache(store)
|
||||
mocks.graph.mockClear()
|
||||
})
|
||||
|
||||
/**
|
||||
* The regression this pins: the global form dirties every owner, so one worktree
|
||||
* mutation cost a `git worktree list` per registered repo — ~58 spawns and seconds
|
||||
* of git wall-clock on a real fleet, to rediscover roots only one repo changed.
|
||||
*/
|
||||
it('relists only the mutated repo', async () => {
|
||||
invalidateAuthorizedRootsCacheForRepo(store, repos[3])
|
||||
await rebuildAuthorizedRootsCache(store, true)
|
||||
|
||||
expect(listedRepoPaths()).toEqual([repos[3].path])
|
||||
})
|
||||
|
||||
it('still relists every repo for a change of unknown scope', async () => {
|
||||
invalidateAuthorizedRootsCache()
|
||||
await rebuildAuthorizedRootsCache(store, true)
|
||||
|
||||
expect(listedRepoPaths()).toHaveLength(REPO_COUNT)
|
||||
})
|
||||
|
||||
it('re-lists everything for an explicit rebuild, which callers use to force a refresh', async () => {
|
||||
await rebuildAuthorizedRootsCache(store)
|
||||
|
||||
expect(listedRepoPaths()).toHaveLength(REPO_COUNT)
|
||||
})
|
||||
|
||||
it('falls back to a global relist when the repo has no known owner', async () => {
|
||||
// An unregistered repo must not silently skip invalidation and leave a stale
|
||||
// allowlist, so the global form is the safe fallback.
|
||||
invalidateAuthorizedRootsCacheForRepo(store, {
|
||||
...repos[0],
|
||||
id: 'unregistered',
|
||||
path: resolve('/scoped-invalidation-unregistered')
|
||||
})
|
||||
await rebuildAuthorizedRootsCache(store, true)
|
||||
|
||||
expect(listedRepoPaths()).toHaveLength(REPO_COUNT)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import type { Store } from '../persistence'
|
||||
import {
|
||||
invalidateAuthorizedRootsCache,
|
||||
markAuthorizedRootsOwnerDirty
|
||||
} from './registered-worktree-roots-cache'
|
||||
|
||||
/**
|
||||
* Scoped counterpart to `invalidateAuthorizedRootsCache` for a change whose blast
|
||||
* radius is provably one repo — every worktree create and removal.
|
||||
*
|
||||
* Why this exists: the global form dirties every registered owner, so the next
|
||||
* authorization-requiring IPC rebuilds by listing EVERY repo. 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 only one repo changed.
|
||||
*
|
||||
* Falls back to the global form rather than silently skipping an invalidation, which
|
||||
* would leave a stale allowlist. Callers whose change can alter the owner SET rather
|
||||
* than one repo's roots (store swap, execution-host/WSL re-routing, nested-repo
|
||||
* import, folder->git upgrade) must keep using the global form.
|
||||
*/
|
||||
export function invalidateAuthorizedRootsCacheForRepo(
|
||||
store: Store | null | undefined,
|
||||
repo: Repo | string
|
||||
): void {
|
||||
if (!store || !markAuthorizedRootsOwnerDirty(store, repo)) {
|
||||
invalidateAuthorizedRootsCache()
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,8 @@ vi.mock('../git/repo', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('./registered-worktree-roots-cache', () => ({
|
||||
invalidateAuthorizedRootsCache: vi.fn()
|
||||
invalidateAuthorizedRootsCache: vi.fn(),
|
||||
invalidateAuthorizedRootsCacheForRepo: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
|
||||
@@ -35,7 +35,10 @@ vi.mock('../git/repo', () => ({
|
||||
filterBaseRefSearchOutput: vi.fn().mockReturnValue([])
|
||||
}))
|
||||
|
||||
vi.mock('./registered-worktree-roots-cache', () => ({ invalidateAuthorizedRootsCache: vi.fn() }))
|
||||
vi.mock('./registered-worktree-roots-cache', () => ({
|
||||
invalidateAuthorizedRootsCache: vi.fn(),
|
||||
invalidateAuthorizedRootsCacheForRepo: vi.fn()
|
||||
}))
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({ getSshGitProvider: vi.fn() }))
|
||||
vi.mock('./ssh', () => ({ getActiveMultiplexer: vi.fn() }))
|
||||
|
||||
|
||||
@@ -43,7 +43,8 @@ vi.mock('../git/repo', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('./registered-worktree-roots-cache', () => ({
|
||||
invalidateAuthorizedRootsCache: vi.fn()
|
||||
invalidateAuthorizedRootsCache: vi.fn(),
|
||||
invalidateAuthorizedRootsCacheForRepo: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
|
||||
@@ -15,7 +15,8 @@ vi.mock('../../worktree-root-preparation', () => ({
|
||||
prepareLocalWorktreeRootForRepo: vi.fn(async () => {})
|
||||
}))
|
||||
vi.mock('../registered-worktree-roots-cache', () => ({
|
||||
invalidateAuthorizedRootsCache: vi.fn()
|
||||
invalidateAuthorizedRootsCache: vi.fn(),
|
||||
invalidateAuthorizedRootsCacheForRepo: vi.fn()
|
||||
}))
|
||||
vi.mock('./repo-added-telemetry', () => ({ emitRepoAdded: vi.fn() }))
|
||||
vi.mock('./repos-changed-notification', () => ({ notifyReposChanged: vi.fn() }))
|
||||
|
||||
@@ -7,7 +7,8 @@ vi.mock('../../worktree-remote', () => ({
|
||||
notifyWorktreesChanged: vi.fn()
|
||||
}))
|
||||
vi.mock('../../registered-worktree-roots-cache', () => ({
|
||||
invalidateAuthorizedRootsCache: vi.fn()
|
||||
invalidateAuthorizedRootsCache: vi.fn(),
|
||||
invalidateAuthorizedRootsCacheForRepo: vi.fn()
|
||||
}))
|
||||
vi.mock('./worktree-removal-ownership', () => ({
|
||||
removeWorktreeMetadataAndTransientState: vi.fn(),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { notifySpawnObserver } from '../../shared/child-process/spawn-observer'
|
||||
|
||||
const PROCESS_TABLE_LOOKUP_TIMEOUT_MS = 250
|
||||
const PROCESS_TABLE_QUERY_TIMEOUT_MS = PROCESS_TABLE_LOOKUP_TIMEOUT_MS / 2
|
||||
@@ -26,11 +27,18 @@ function hasUsableTty(tty: string): boolean {
|
||||
}
|
||||
|
||||
function runPs(pid: number): string {
|
||||
return execFileSync('ps', ['-p', String(pid), '-o', 'pid=,tpgid=,tty='], {
|
||||
encoding: 'utf8',
|
||||
timeout: PROCESS_TABLE_QUERY_TIMEOUT_MS,
|
||||
maxBuffer: PROCESS_TABLE_MAX_BYTES
|
||||
})
|
||||
const args = ['-p', String(pid), '-o', 'pid=,tpgid=,tty=']
|
||||
const startedAt = performance.now()
|
||||
try {
|
||||
return execFileSync('ps', args, {
|
||||
encoding: 'utf8',
|
||||
timeout: PROCESS_TABLE_QUERY_TIMEOUT_MS,
|
||||
maxBuffer: PROCESS_TABLE_MAX_BYTES
|
||||
})
|
||||
} finally {
|
||||
// Diagnostics only: execFileSync blocks the caller for the child's whole life.
|
||||
notifySpawnObserver('ps', args, performance.now() - startedAt)
|
||||
}
|
||||
}
|
||||
|
||||
let ownRowCache: { pid: number; row: string } | null = null
|
||||
|
||||
@@ -31,7 +31,8 @@ vi.mock('../ipc/worktree-logic', async (importOriginal) => {
|
||||
})
|
||||
|
||||
vi.mock('../ipc/registered-worktree-roots-cache', () => ({
|
||||
invalidateAuthorizedRootsCache: vi.fn()
|
||||
invalidateAuthorizedRootsCache: vi.fn(),
|
||||
invalidateAuthorizedRootsCacheForRepo: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../git/repo', async (importOriginal) => {
|
||||
|
||||
@@ -19,7 +19,8 @@ vi.mock('../ipc/worktree-logic', async (importOriginal) => {
|
||||
return { ...actual, computeWorktreePath: vi.fn(), ensurePathWithinWorkspace: vi.fn() }
|
||||
})
|
||||
vi.mock('../ipc/registered-worktree-roots-cache', () => ({
|
||||
invalidateAuthorizedRootsCache: vi.fn()
|
||||
invalidateAuthorizedRootsCache: vi.fn(),
|
||||
invalidateAuthorizedRootsCacheForRepo: vi.fn()
|
||||
}))
|
||||
vi.mock('../git/repo', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>
|
||||
|
||||
@@ -20,7 +20,8 @@ vi.mock('../ipc/worktree-logic', async (importOriginal) => {
|
||||
})
|
||||
|
||||
vi.mock('../ipc/registered-worktree-roots-cache', () => ({
|
||||
invalidateAuthorizedRootsCache: vi.fn()
|
||||
invalidateAuthorizedRootsCache: vi.fn(),
|
||||
invalidateAuthorizedRootsCacheForRepo: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../git/repo', async (importOriginal) => {
|
||||
|
||||
@@ -30,7 +30,8 @@ vi.mock('../ipc/worktree-logic', async (importOriginal) => {
|
||||
})
|
||||
|
||||
vi.mock('../ipc/registered-worktree-roots-cache', () => ({
|
||||
invalidateAuthorizedRootsCache: vi.fn()
|
||||
invalidateAuthorizedRootsCache: vi.fn(),
|
||||
invalidateAuthorizedRootsCacheForRepo: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../git/repo', async (importOriginal) => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { createRuntimeFolderWorktree } from './runtime-folder-worktree-create'
|
||||
import { createRuntimeLocalManagedWorktree } from './runtime-local-worktree-create'
|
||||
import type { PreparationRearmHolder } from '../worktree-create-preparation'
|
||||
import { prepareRuntimeLocalWorktreeSetup } from './runtime-local-worktree-setup'
|
||||
import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth'
|
||||
import { invalidateAuthorizedRootsCacheForRepo } from '../ipc/filesystem-auth'
|
||||
import { startRuntimeLocalWorktreeTerminals } from './runtime-local-worktree-terminal-startup'
|
||||
|
||||
export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWorktreeTerminalProvisioningHost {
|
||||
@@ -211,7 +211,11 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork
|
||||
// to authorize paths. Without invalidating it here, CLI-created worktrees
|
||||
// are not recognized and all git operations fail with "Access denied:
|
||||
// unknown repository or worktree path".
|
||||
invalidateAuthorizedRootsCache()
|
||||
// Scoped to this repo: the global form dirties every owner, so the next
|
||||
// authorization-requiring IPC relists EVERY registered repo — ~58 `git worktree
|
||||
// list` spawns, ~10s of git wall-clock through an admission budget of 4, to
|
||||
// rediscover roots only this repo changed.
|
||||
invalidateAuthorizedRootsCacheForRepo(this.store, repo)
|
||||
|
||||
this.notifyWorktreesChanged(repo.id)
|
||||
const {
|
||||
|
||||
@@ -413,12 +413,14 @@ vi.mock('../../ipc/worktree-logic', async (importOriginal) => {
|
||||
vi.mock('../../ipc/filesystem-auth', () => ({
|
||||
resolveAuthorizedPath: vi.fn(async (pathValue: string) => pathValue),
|
||||
invalidateAuthorizedRootsCache: invalidateAuthorizedRootsCacheMock,
|
||||
invalidateAuthorizedRootsCacheForRepo: invalidateAuthorizedRootsCacheMock,
|
||||
isENOENT: (error: unknown) =>
|
||||
Boolean(error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT')
|
||||
}))
|
||||
|
||||
vi.mock('../../ipc/registered-worktree-roots-cache', () => ({
|
||||
invalidateAuthorizedRootsCache: invalidateAuthorizedRootsCacheMock
|
||||
invalidateAuthorizedRootsCache: invalidateAuthorizedRootsCacheMock,
|
||||
invalidateAuthorizedRootsCacheForRepo: invalidateAuthorizedRootsCacheMock
|
||||
}))
|
||||
|
||||
// Why: the real check also matches relay-rebuilt errors, which carry only the ENOENT message.
|
||||
|
||||
@@ -29,7 +29,8 @@ vi.mock('../ipc/worktree-logic', async (importOriginal) => {
|
||||
return { ...actual, computeWorktreePath: vi.fn(), ensurePathWithinWorkspace: vi.fn() }
|
||||
})
|
||||
vi.mock('../ipc/registered-worktree-roots-cache', () => ({
|
||||
invalidateAuthorizedRootsCache: vi.fn()
|
||||
invalidateAuthorizedRootsCache: vi.fn(),
|
||||
invalidateAuthorizedRootsCacheForRepo: vi.fn()
|
||||
}))
|
||||
vi.mock('../git/repo', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>
|
||||
|
||||
@@ -33,7 +33,10 @@ vi.mock('./runtime-local-worktree-setup', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('../ipc/filesystem-auth', () => ({ invalidateAuthorizedRootsCache: vi.fn() }))
|
||||
vi.mock('../ipc/filesystem-auth', () => ({
|
||||
invalidateAuthorizedRootsCache: vi.fn(),
|
||||
invalidateAuthorizedRootsCacheForRepo: vi.fn()
|
||||
}))
|
||||
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
|
||||
|
||||
@@ -35,7 +35,12 @@ import { getDevInstanceIdentity, shouldApplyPreReadyAppName } from './dev-instan
|
||||
import { enableRendererHeapHeadroom } from './renderer-heap-headroom'
|
||||
import { isStartupDiagnosticsEnabled, logStartupDiagnostic } from './startup-diagnostics'
|
||||
import { startEventLoopStallProbe } from './event-loop-stall-probe'
|
||||
import { startMainThreadChurnProbe } from '../diagnostics/main-thread-churn-probe'
|
||||
import {
|
||||
isMainThreadDiagnosticsEnabled,
|
||||
recordSubprocessSpawn,
|
||||
startMainThreadChurnProbe
|
||||
} from '../diagnostics/main-thread-churn-probe'
|
||||
import { setSpawnObserver } from '../../shared/child-process/spawn-observer'
|
||||
import { settledDiffCache } from '../git/source-control/git-read-cache-invalidation'
|
||||
import { reserveServeStdoutForReadiness } from '../server/serve-stdout-boundary'
|
||||
import { createServeDesktopActivationGate } from './serve-desktop-activation'
|
||||
@@ -222,6 +227,11 @@ function initializeMainProcessPreflight(options: MainProcessPreflightOptions): b
|
||||
// Self-gated on ORCA_MAIN_THREAD_DIAGNOSTICS; runs the whole session to catch steady-state churn (issue #7576).
|
||||
// Why the diff-cache counters ride along: a stamp the filesystem reports unstably makes the cache
|
||||
// look exactly like a cold start, and only the hit/miss/unprovable split tells the two apart.
|
||||
if (isMainThreadDiagnosticsEnabled()) {
|
||||
// Why here too: the probe's own call sites only cover src/main/git, so without
|
||||
// this every spawnProcess/runProcess child (rg, ps, pty helpers) is invisible.
|
||||
setSpawnObserver(recordSubprocessSpawn)
|
||||
}
|
||||
startMainThreadChurnProbe({ extraStats: () => ({ diffCache: settledDiffCache.stats() }) })
|
||||
// Why: acquire AFTER configureDevUserDataPath — Electron derives lock identity from `userData`, so dev/packaged lock in separate namespaces.
|
||||
// Why dev locks too: two processes on one profile corrupt its stores (PR #1326 / #1312); parallel `pnpm dev` needs ORCA_DEV_USER_DATA_PATH per copy.
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { resolveSpawn } from './spawn-resolution'
|
||||
import { forceTerminateProcessTree, signalProcessTree } from './process-tree-termination'
|
||||
|
||||
import { hasSpawnObserver, notifySpawnObserver } from './spawn-observer'
|
||||
import { createOutputSink } from './bounded-output-sink'
|
||||
import { createChildTerminationReporter } from './child-termination-reporter'
|
||||
|
||||
@@ -50,11 +51,19 @@ const BARRIER_UNVERIFIED_EXIT_GRACE_MS = 10_000
|
||||
*/
|
||||
export function spawnProcess(spec: ProcessSpec): ChildProcessWithoutNullStreams {
|
||||
const resolved = resolveSpawn(spec, process.platform)
|
||||
return nodeSpawn(
|
||||
// Diagnostics only: uv_spawn runs synchronously on the calling thread, so this
|
||||
// brackets the main-thread block for every child started through the wrapper.
|
||||
const spawnStartedAt = hasSpawnObserver() ? performance.now() : null
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: resolveSpawn never sets stdio to 'ignore'/'inherit' for a stream slot, so stdin/stdout/stderr are always pipes.
|
||||
const child = nodeSpawn(
|
||||
resolved.file,
|
||||
[...resolved.args],
|
||||
resolved.options
|
||||
) as ChildProcessWithoutNullStreams
|
||||
if (spawnStartedAt !== null) {
|
||||
notifySpawnObserver(resolved.file, resolved.args, performance.now() - spawnStartedAt)
|
||||
}
|
||||
return child
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { setSpawnObserver } from './spawn-observer'
|
||||
import { runProcess, spawnProcess } from './run-process'
|
||||
|
||||
afterEach(() => {
|
||||
setSpawnObserver(null)
|
||||
})
|
||||
|
||||
describe('spawn observer', () => {
|
||||
it('reports the resolved program for a spawnProcess child', async () => {
|
||||
const seen: { command: string; args: readonly string[]; blockMs: number }[] = []
|
||||
setSpawnObserver((command, args, blockMs) => seen.push({ command, args, blockMs }))
|
||||
const child = spawnProcess({ program: process.execPath, args: ['-e', 'process.exit(0)'] })
|
||||
await new Promise((resolve) => child.on('exit', resolve))
|
||||
expect(seen).toHaveLength(1)
|
||||
expect(seen[0].command).toBe(process.execPath)
|
||||
expect(seen[0].args).toEqual(['-e', 'process.exit(0)'])
|
||||
expect(seen[0].blockMs).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it('reports exactly once for runProcess, which spawns through spawnProcess', async () => {
|
||||
let calls = 0
|
||||
setSpawnObserver(() => {
|
||||
calls += 1
|
||||
})
|
||||
await runProcess({ program: process.execPath, args: ['-e', 'process.exit(0)'] })
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
|
||||
it('stays silent with no observer registered', async () => {
|
||||
let calls = 0
|
||||
setSpawnObserver(null)
|
||||
await runProcess({ program: process.execPath, args: ['-e', 'process.exit(0)'] })
|
||||
setSpawnObserver(() => {
|
||||
calls += 1
|
||||
})
|
||||
expect(calls).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Diagnostic seam for counting every child process started through this
|
||||
* package. `src/main/diagnostics/main-thread-churn-probe` registers itself here
|
||||
* under ORCA_MAIN_THREAD_DIAGNOSTICS=1; with nothing registered every spawn
|
||||
* pays one undefined check, so production behaviour is unchanged.
|
||||
*
|
||||
* Why a seam rather than a direct import: run-process.ts also runs in the
|
||||
* terminal daemon, the relay and the CLI, none of which may depend on main.
|
||||
*/
|
||||
export type SpawnObserver = (command: string, args: readonly string[], blockMs: number) => void
|
||||
|
||||
let observer: SpawnObserver | null = null
|
||||
|
||||
export function setSpawnObserver(next: SpawnObserver | null): void {
|
||||
observer = next
|
||||
}
|
||||
|
||||
export function notifySpawnObserver(
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
blockMs: number
|
||||
): void {
|
||||
observer?.(command, args, blockMs)
|
||||
}
|
||||
|
||||
export function hasSpawnObserver(): boolean {
|
||||
return observer !== null
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Bucket a MAIN-process .cpuprofile by self time, resolved through out/main's
|
||||
* source maps, so a minified frame like `_Le` reads as the file it came from.
|
||||
*
|
||||
* Why buckets and not just a flame list: the question this answers is whether
|
||||
* main-thread git cost is spawn initiation + stdout drain (which a utility
|
||||
* process removes) or result parsing + IPC (which it does not).
|
||||
*
|
||||
* Why the nearest-mapping fallback: esbuild emits source-less segments, and an
|
||||
* exact-position lookup returns null for them — which silently dumped a third
|
||||
* of the busy samples into an "unknown bundle" bucket on the first pass.
|
||||
*
|
||||
* Usage: node tests/tools/benchmarks/analyze-main-cpuprofile.mjs <file.cpuprofile> [--top 30]
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { TraceMap, decodedMappings } from '@jridgewell/trace-mapping'
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const profilePath = args.find((arg) => !arg.startsWith('--'))
|
||||
if (!profilePath) {
|
||||
throw new Error('Usage: analyze-main-cpuprofile.mjs <file.cpuprofile> [--top N]')
|
||||
}
|
||||
const topIndex = args.indexOf('--top')
|
||||
const top = topIndex === -1 ? 30 : Number(args[topIndex + 1])
|
||||
|
||||
const profile = JSON.parse(readFileSync(profilePath, 'utf8'))
|
||||
const indexCache = new Map()
|
||||
|
||||
/** Per generated line: columns that carry a source, ascending, for a lower-bound scan. */
|
||||
function mappingIndexFor(url) {
|
||||
if (indexCache.has(url)) {
|
||||
return indexCache.get(url)
|
||||
}
|
||||
let built = null
|
||||
if (url.startsWith('file://')) {
|
||||
const filePath = new URL(url).pathname
|
||||
try {
|
||||
const map = new TraceMap(JSON.parse(readFileSync(`${filePath}.map`, 'utf8')))
|
||||
const lines = decodedMappings(map)
|
||||
built = {
|
||||
dir: dirname(filePath),
|
||||
sources: map.sources,
|
||||
names: map.names,
|
||||
lines: lines.map((segments) => segments.filter((segment) => segment.length >= 4))
|
||||
}
|
||||
} catch {
|
||||
built = null
|
||||
}
|
||||
}
|
||||
indexCache.set(url, built)
|
||||
return built
|
||||
}
|
||||
|
||||
function sourceRelative(dir, source) {
|
||||
const absolute = resolve(dir, source)
|
||||
const marker = absolute.indexOf('/src/')
|
||||
return marker === -1 ? absolute : absolute.slice(marker + 1)
|
||||
}
|
||||
|
||||
/** Source-relative origin of a frame, e.g. "src/main/git/status/porcelain.ts:parseEntry". */
|
||||
function originOf(frame) {
|
||||
if (!frame.url) {
|
||||
return frame.functionName ? `<internal:${frame.functionName}>` : '<unknown>'
|
||||
}
|
||||
const index = mappingIndexFor(frame.url)
|
||||
if (!index) {
|
||||
return frame.url
|
||||
}
|
||||
const segments = index.lines[frame.lineNumber] ?? []
|
||||
let chosen = null
|
||||
for (const segment of segments) {
|
||||
if (segment[0] > frame.columnNumber) {
|
||||
break
|
||||
}
|
||||
chosen = segment
|
||||
}
|
||||
if (!chosen) {
|
||||
return `${frame.url}#unmapped:${frame.lineNumber}:${frame.columnNumber}`
|
||||
}
|
||||
const file = sourceRelative(index.dir, index.sources[chosen[1]] ?? '?')
|
||||
const name =
|
||||
(chosen.length >= 5 ? index.names[chosen[4]] : null) ?? frame.functionName ?? '(anon)'
|
||||
return `${file}:${name}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Which cost phase a frame belongs to, matched on the V8 function name first.
|
||||
*
|
||||
* Why function name and not the resolved source file: the source map's
|
||||
* greatest-lower-bound lookup lands on the *preceding* mapped segment often
|
||||
* enough that file attribution alone put env builders in the diagnostics probe.
|
||||
* With ORCA_UNMINIFIED_MAIN=1 the function name is the real one, so it is the
|
||||
* trustworthy key; the file is kept only as a display hint.
|
||||
*
|
||||
* `spawn` (no url) is the native process_wrap binding — the synchronous
|
||||
* posix_spawn that actually holds the main thread.
|
||||
*/
|
||||
const NAME_RULES = [
|
||||
[/^\((?:idle|program)\)$/, 'idle/program'],
|
||||
[/^\(garbage collector\)$/, 'gc'],
|
||||
// Env construction exists only to hand a git child its environment, so it is
|
||||
// spawn preparation: it moves wherever the spawn moves.
|
||||
[
|
||||
/^(?:spawn|spawnSync|normalizeSpawnArguments|validateArgumentNullCheck|validateTimeout|ChildProcess|setupChannel|execFile|execFileSync|spawnWithSignal)$/,
|
||||
'spawn-init'
|
||||
],
|
||||
[
|
||||
/Env$|^untranslatedGitOutputEnv$|^resolveSpawn$|^spawnProcess$|^runProcess$|^getSpawnArgsForWindows$/,
|
||||
'spawn-init'
|
||||
],
|
||||
[
|
||||
/^(?:onStreamRead|Socket|readableAddChunk|emitReadable|onread|write|flow|resume_|StringDecoder|utf8Write|text|createOutputSink)$/,
|
||||
'stdout-drain'
|
||||
],
|
||||
[/^(?:parse|attachLineStats|createInputIdentity|runGetStatus|detect|read)/, 'git-parse'],
|
||||
[/Porcelain|Numstat|ChangedEntry|StatusEntry/i, 'git-parse'],
|
||||
[
|
||||
/^(?:hydrateRepo|getWorktreeRootOwnerKey|getLocalWorktreeRootOwners|collectRepoIdsWithRegisteredWorktreeMeta|resolveRegisteredWorktreePath|getRepos|normalizeString|join|resolve|relative|isAbsolute)/,
|
||||
'store+path-orchestration'
|
||||
],
|
||||
[/^(?:sendReply|_invokeHandler|ipc)/i, 'ipc']
|
||||
]
|
||||
|
||||
const URL_RULES = [
|
||||
[/child_process|spawn-resolution|run-process\.ts/, 'spawn-init'],
|
||||
[
|
||||
/string_decoder|node:internal\/streams|node:stream|node:net|bounded-output-sink/,
|
||||
'stdout-drain'
|
||||
],
|
||||
[/src\/main\/git\/|src\/shared\/git|porcelain/, 'git-parse'],
|
||||
[/electron\/js2c|src\/main\/ipc\//, 'ipc'],
|
||||
[/node:internal\/fs|node:fs/, 'fs'],
|
||||
[/src\//, 'other-orca'],
|
||||
[/node:/, 'other-node']
|
||||
]
|
||||
|
||||
function bucketOf(label) {
|
||||
const name = label.includes(' @') ? label.slice(0, label.indexOf(' @')) : label
|
||||
for (const [pattern, bucket] of NAME_RULES) {
|
||||
if (pattern.test(name)) {
|
||||
return bucket
|
||||
}
|
||||
}
|
||||
for (const [pattern, bucket] of URL_RULES) {
|
||||
if (pattern.test(label)) {
|
||||
return bucket
|
||||
}
|
||||
}
|
||||
return 'other'
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the frame lives, for display. Bundle frames keep their generated
|
||||
* position rather than a mapped file: with the mappings this bundle carries, a
|
||||
* greatest-lower-bound source lookup names the wrong file often enough to
|
||||
* mislead, and the (unminified) function name already identifies the code.
|
||||
*/
|
||||
function displayLocation(frame) {
|
||||
if (!frame.url) {
|
||||
return '<native>'
|
||||
}
|
||||
if (!frame.url.startsWith('file://')) {
|
||||
return frame.url
|
||||
}
|
||||
const mapped = originOf(frame)
|
||||
const leaf = frame.url.split('/').at(-1)
|
||||
return `${leaf}:${frame.lineNumber} ~${typeof mapped === 'string' ? mapped.split('#')[0].split('/').at(-1) : mapped}`
|
||||
}
|
||||
|
||||
const wallUs = profile.endTime - profile.startTime
|
||||
const sampleUs = wallUs / profile.samples.length
|
||||
const selfBySite = new Map()
|
||||
for (const node of profile.nodes) {
|
||||
if (!node.hitCount) {
|
||||
continue
|
||||
}
|
||||
const label = `${node.callFrame.functionName || '(anon)'} @${displayLocation(node.callFrame)}`
|
||||
selfBySite.set(label, (selfBySite.get(label) ?? 0) + node.hitCount)
|
||||
}
|
||||
const totalHits = [...selfBySite.values()].reduce((sum, hits) => sum + hits, 0)
|
||||
|
||||
const byBucket = new Map()
|
||||
for (const [label, hits] of selfBySite) {
|
||||
const bucket = bucketOf(label)
|
||||
byBucket.set(bucket, (byBucket.get(bucket) ?? 0) + hits)
|
||||
}
|
||||
|
||||
const ms = (hits) => Math.round((hits * sampleUs) / 100) / 10
|
||||
const busyHits = totalHits - (byBucket.get('idle/program') ?? 0)
|
||||
|
||||
console.log(`profile: ${profilePath}`)
|
||||
console.log(
|
||||
`wall ${(wallUs / 1000).toFixed(0)}ms samples ${profile.samples.length} sampleInterval ${sampleUs.toFixed(0)}us`
|
||||
)
|
||||
console.log(
|
||||
`busy (non-idle) main-thread time: ${ms(busyHits)}ms = ${((busyHits / totalHits) * 100).toFixed(1)}% of wall`
|
||||
)
|
||||
console.log('\nbuckets (self time):')
|
||||
for (const [bucket, hits] of [...byBucket].sort((a, b) => b[1] - a[1])) {
|
||||
const shareOfBusy =
|
||||
bucket === 'idle/program' ? '' : ` ${((hits / busyHits) * 100).toFixed(1)}% of busy`
|
||||
console.log(
|
||||
` ${bucket.padEnd(24)} ${String(ms(hits)).padStart(8)}ms ${((hits / totalHits) * 100).toFixed(2)}% of wall${shareOfBusy}`
|
||||
)
|
||||
}
|
||||
console.log(`\ntop ${top} self-time sites:`)
|
||||
for (const [label, hits] of [...selfBySite].sort((a, b) => b[1] - a[1]).slice(0, top)) {
|
||||
console.log(
|
||||
` ${String(ms(hits)).padStart(8)}ms ${((hits / busyHits) * 100).toFixed(1)}%busy [${bucketOf(label)}] ${label}`
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user