Files
orca/src/main/ipc/filesystem-auth.ts
T
Neil b7209b5ae9 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 }`.
2026-09-29 22:01:08 -07:00

239 lines
8.0 KiB
TypeScript

import { resolve, dirname, basename } from 'node:path'
import { realpathSync } from 'node:fs'
import { realpath } from 'node:fs/promises'
import type { Store } from '../persistence'
import { getAllowedRoots } from './filesystem-allowed-roots'
import { isDescendantOrEqual, isENOENT, normalizeExistingPath } from './filesystem-path-containment'
import {
ensureAuthorizedRootsCache,
isPathAllowedByCanonicalRegisteredRoot,
isRegisteredWorktreePath
} from './registered-worktree-roots-cache'
// 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 =
'Access denied: path resolves outside allowed directories. If this blocks a legitimate workflow, please file a GitHub issue.'
// Why: authorized external paths accumulate all session; LRU-bound the set. Safe to evict because every caller re-authorizes before operating.
export const AUTHORIZED_EXTERNAL_PATHS_MAX = 4096
const authorizedExternalPaths = new Set<string>()
function rememberAuthorizedExternalPath(path: string): void {
// Delete-then-add makes re-authorized paths most-recent so LRU eviction sheds only the oldest untouched entries.
authorizedExternalPaths.delete(path)
authorizedExternalPaths.add(path)
while (authorizedExternalPaths.size > AUTHORIZED_EXTERNAL_PATHS_MAX) {
const oldest = authorizedExternalPaths.keys().next().value
if (oldest === undefined) {
break
}
authorizedExternalPaths.delete(oldest)
}
}
export function authorizeExternalPath(targetPath: string): void {
const resolvedTarget = resolve(targetPath)
rememberAuthorizedExternalPath(resolvedTarget)
try {
// Why: macOS canonicalizes /tmp to /private/tmp during read authorization.
rememberAuthorizedExternalPath(realpathSync(resolvedTarget))
} catch {}
}
/**
* One allowed-root list shared by every check in a single authorization.
*
* Lazy so a path already covered by an external grant still builds nothing at all, the way it did
* before the list was hoisted out of the individual checks.
*/
type AllowedRootsSnapshot = { get: () => readonly string[] }
function createAllowedRootsSnapshot(store: Store): AllowedRootsSnapshot {
let roots: readonly string[] | undefined
return { get: () => (roots ??= getAllowedRoots(store)) }
}
export function isPathAllowed(
targetPath: string,
store: Store,
allowedRoots?: AllowedRootsSnapshot
): boolean {
const resolvedTarget = resolve(targetPath)
if (authorizedExternalPaths.has(resolvedTarget)) {
return true
}
for (const authorizedPath of authorizedExternalPaths) {
if (isDescendantOrEqual(resolvedTarget, authorizedPath)) {
return true
}
}
return (allowedRoots?.get() ?? getAllowedRoots(store)).some((root) =>
isDescendantOrEqual(resolvedTarget, root)
)
}
export type ResolveAuthorizedPathOptions = {
/**
* Canonicalize the parent but preserve the leaf so delete/rename target the symlink itself, not its destination (which may live outside allowed roots).
*/
preserveSymlink?: boolean
}
export async function resolveAuthorizedPath(
targetPath: string,
store: Store,
options: ResolveAuthorizedPathOptions = {}
): Promise<string> {
const resolvedTarget = resolve(targetPath)
// Why: the roots depend only on store state, not on the candidate path, so one snapshot serves
// every authorization below; each candidate is still checked against it in full.
const allowedRoots = createAllowedRootsSnapshot(store)
if (!(await isPathAllowedIncludingRegisteredWorktrees(resolvedTarget, store, { allowedRoots }))) {
throw new Error(PATH_ACCESS_DENIED_MESSAGE)
}
if (options.preserveSymlink) {
// Canonicalize the parent so ancestor symlinks can't redirect outside allowed roots, but keep the leaf so delete/rename act on the link itself.
let realParent: string
try {
realParent = await realpath(dirname(resolvedTarget))
} catch (error) {
if (isENOENT(error)) {
return resolveAuthorizedMissingPath(resolvedTarget, store, allowedRoots)
}
throw error
}
const candidateTarget = resolve(realParent, basename(resolvedTarget))
if (
!(await isPathAllowedIncludingRegisteredWorktrees(candidateTarget, store, {
canonicalSourcePath: resolvedTarget,
allowedRoots
}))
) {
throw new Error(PATH_ACCESS_DENIED_MESSAGE)
}
return candidateTarget
}
try {
// Why: Windows/WSL realpath can return UNC-shaped paths; re-resolve to compare against this module's allow-list roots.
const realTarget = resolve(await realpath(resolvedTarget))
if (
!(await isPathAllowedIncludingRegisteredWorktrees(realTarget, store, {
canonicalSourcePath: resolvedTarget,
allowedRoots
}))
) {
throw new Error(PATH_ACCESS_DENIED_MESSAGE)
}
return realTarget
} catch (error) {
if (!isENOENT(error)) {
throw error
}
return resolveAuthorizedMissingPath(resolvedTarget, store, allowedRoots)
}
}
async function resolveAuthorizedMissingPath(
resolvedTarget: string,
store: Store,
allowedRoots: AllowedRootsSnapshot
): Promise<string> {
let existingAncestor = resolvedTarget
const missingSegments: string[] = []
while (true) {
try {
const realAncestor = await realpath(existingAncestor)
const candidateTarget = resolve(realAncestor, ...missingSegments)
if (
!(await isPathAllowedIncludingRegisteredWorktrees(candidateTarget, store, {
canonicalSourcePath: resolvedTarget,
allowedRoots
}))
) {
throw new Error(PATH_ACCESS_DENIED_MESSAGE)
}
return candidateTarget
} catch (error) {
if (!isENOENT(error)) {
throw error
}
const parent = dirname(existingAncestor)
if (parent === existingAncestor) {
throw error
}
// Why: create/copy make missing parents after auth; canonicalize nearest existing ancestor to catch symlink escapes without rejecting nested paths.
missingSegments.unshift(basename(existingAncestor))
existingAncestor = parent
}
}
}
async function isPathAllowedIncludingRegisteredWorktrees(
targetPath: string,
store: Store,
options: { canonicalSourcePath?: string; allowedRoots?: AllowedRootsSnapshot } = {}
): Promise<boolean> {
if (isPathAllowed(targetPath, store, options.allowedRoots)) {
return true
}
if (isRegisteredWorktreePath(targetPath, store)) {
return true
}
if (
await isPathAllowedByCanonicalAllowedRoot(
targetPath,
options.canonicalSourcePath,
store,
options.allowedRoots
)
) {
return true
}
if (
await isPathAllowedByCanonicalRegisteredRoot(targetPath, options.canonicalSourcePath, store)
) {
return true
}
await ensureAuthorizedRootsCache(store)
// Why: linked worktrees are already git-trusted; reuse the cached root index so reads don't spawn `git worktree list` each time.
return (
isRegisteredWorktreePath(targetPath, store) ||
(await isPathAllowedByCanonicalRegisteredRoot(targetPath, options.canonicalSourcePath, store))
)
}
async function isPathAllowedByCanonicalAllowedRoot(
targetPath: string,
sourcePath: string | undefined,
store: Store,
allowedRoots?: AllowedRootsSnapshot
): Promise<boolean> {
if (!sourcePath) {
return false
}
for (const root of allowedRoots?.get() ?? getAllowedRoots(store)) {
const resolvedRoot = resolve(root)
if (!isDescendantOrEqual(sourcePath, resolvedRoot)) {
continue
}
// Why: macOS resolves /var→/private/var; canonicalize only the matched root, not the whole repo set.
const canonicalRoot = await normalizeExistingPath(resolvedRoot)
if (isDescendantOrEqual(targetPath, canonicalRoot)) {
return true
}
}
return false
}