Files
orca/src/main/ipc/filesystem-list-files-git-fallback.ts
T
Brennan BensonandMerge Sim b5a85890ac perf(git): bound git subprocess execution with an atomic admission scheduler (#16874)
* perf(git): bound git subprocess execution with an atomic admission scheduler

Field traces (#16038, #11363) show Windows freeze storms driven by unbounded
concurrent git children (12+ at once, 50-65s status convoys for 25+ minutes).
Admit every main-process git child against atomic per-budget base+headroom
counters (general / network / per-route), with reserved interactive capacity,
ordering-only aging, close-bound permit release, a 120s fail-safe read timeout
that feeds scheduler backoff, tier plumbing through every option carrier, and
coalesced+jittered visibility pollers. Killswitch: ORCA_GIT_ADMISSION_DISABLED=1.

Storm harness A/B: max concurrent children 65 -> 6, interactive p95 791ms -> 88ms;
output-parity battery byte-identical with admission on vs off.

* test(git): run the admission output-parity battery on every platform

Parity needs real git, not the storm harness's PATH stub, so it must not share
that file's POSIX gate - Windows is the platform where parity evidence matters.

* fix(git): preserve interactive admission invariants

* perf(git): keep admission queue drains linear

* fix(git): close final admission gaps

* perf(git): bound eligible route selection

* fix(merge): remove unrelated stale snapshot changes

* fix(git): preserve refresh lifecycle authority

* test(git): align admission lifetime contracts

* fix(git): harden admission across runtime paths

* fix(git): restore freshness for bulk status reads

* test(git): repoint delete-dialog source pins after admission plumbing

The hydration effect now orders its targets through
orderDeleteWorktreeStatusHydrationTargets and passes includeLineStats
alongside the abort signal, so both literal anchors stopped matching.
The invariants are unchanged and still pinned: dropping the signal, the
main-worktree/folder filter, or getState-instead-of-subscribe each
still reddens this test.

* Fix git admission tier propagation and lock ordering

Decode optional Git status tiers permissively and default runtime RPC status reads to the status lane while preserving renderer caller intent.

Acquire the FETCH_HEAD mutex before atomic admission so same-repository fetch waiters hold no global or route permits.

Preserve automatic pull-request refresh reasons, keep explicit hosted-review refreshes interactive, remove the dead candidate tier, and keep relay scheduling unchanged.

Use tier-aware status lease keys because a shared lease cannot be safely promoted after its admission request is queued or granted.

* test: align expectations with admission plumbing

* refactor(child-process): move the process contract types to process-spec

run-process.ts crossed its line cap after gaining the termination observer;
the public types and defaults move out with re-exports so no caller changes.

* chore: restore pnpm-lock.yaml to main (unintended local drift)

---------

Co-authored-by: Merge Sim <sim@local>
2026-08-30 14:19:05 -07:00

336 lines
10 KiB
TypeScript

import type { ChildProcess } from 'node:child_process'
import { gitSpawnAfterWindowsEnvironmentReady } from '../git/runner'
import {
isWslLinkedWorktreeGitRoutingCandidate,
prepareWslLinkedWorktreeGitRouting
} from '../git/wsl-linked-worktree-git-routing'
import {
buildGitLsFilesArgsForQuickOpen,
shouldExcludeQuickOpenRelPath,
shouldIncludeQuickOpenPath
} from '../../shared/quick-open-filter'
import {
createQuickOpenReaddirBudget,
expandQuickOpenGitFileListing,
listQuickOpenFilesWithReaddir,
parseQuickOpenGitLsFilesEntry
} from '../../shared/quick-open-readdir-walk'
import { fileListingCancellationError } from '../../shared/file-listing-cancellation'
/**
* Fallback file lister using git ls-files. Used when rg is not available.
*
* Why two git ls-files calls: the first lists tracked + untracked-but-not-ignored
* files (mirrors rg --files --hidden with gitignore respect). The second
* surfaces ignored files (mirrors the second rg call with --no-ignore-vcs).
*/
async function isInsideGitWorkTree(
rootPath: string,
localGitOptions: { wslDistro?: string },
signal?: AbortSignal
): Promise<boolean> {
if (signal?.aborted) {
throw fileListingCancellationError(signal)
}
if (isWslLinkedWorktreeGitRoutingCandidate(rootPath, localGitOptions.wslDistro)) {
try {
await prepareWslLinkedWorktreeGitRouting(rootPath, localGitOptions.wslDistro, { signal })
} catch (error) {
if (signal?.aborted) {
throw fileListingCancellationError(signal)
}
throw error
}
}
if (signal?.aborted) {
throw fileListingCancellationError(signal)
}
const child = await gitSpawnAfterWindowsEnvironmentReady(['rev-parse', '--is-inside-work-tree'], {
cwd: rootPath,
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}),
...(signal ? { signal } : {}),
stdio: ['ignore', 'ignore', 'ignore']
})
return new Promise((resolve, reject) => {
let done = false
let timer: ReturnType<typeof setTimeout>
const cleanup = (): void => {
clearTimeout(timer)
child.off('error', handleError)
child.off('close', handleClose)
signal?.removeEventListener('abort', handleAbort)
}
const finish = (isGitRepo: boolean): void => {
if (done) {
return
}
done = true
cleanup()
resolve(isGitRepo)
}
const cancel = (): void => {
if (done) {
return
}
done = true
child.kill()
cleanup()
reject(fileListingCancellationError(signal))
}
const handleError = (): void => finish(false)
const handleClose = (code: number | null, signal: NodeJS.Signals | null): void =>
finish(code === 0 && signal === null)
const handleAbort = (): void => cancel()
child.once('error', handleError)
child.once('close', handleClose)
signal?.addEventListener('abort', handleAbort, { once: true })
timer = setTimeout(() => {
child.kill()
finish(false)
}, 10_000)
if (signal?.aborted) {
cancel()
}
})
}
export async function listFilesWithGit(
rootPath: string,
excludePathPrefixes: readonly string[],
localGitOptions: { wslDistro?: string },
signal?: AbortSignal,
maxResults?: number
): Promise<string[]> {
const isGitWorkTree = await isInsideGitWorkTree(rootPath, localGitOptions, signal)
if (signal?.aborted) {
throw fileListingCancellationError(signal)
}
if (!isGitWorkTree) {
return listQuickOpenFilesWithReaddir(rootPath, {
excludePathPrefixes,
budget: createQuickOpenReaddirBudget(),
maxResults,
signal
})
}
const gitPaths = new Set<string>()
const directoryPaths = new Set<string>()
const directFileCandidates = new Set<string>()
const { primary, ignoredPass } = buildGitLsFilesArgsForQuickOpen(excludePathPrefixes)
const children: {
child: ChildProcess
isDone: () => boolean
reject: (error: Error) => void
resolve: () => void
}[] = []
const scanController = new AbortController()
const runGitLsFiles = async (args: string[]): Promise<void> => {
// Why: git ls-files outputs paths relative to cwd, so we set cwd to
// rootPath and use the output directly — no prefix stripping needed.
const child = await gitSpawnAfterWindowsEnvironmentReady(['ls-files', ...args], {
cwd: rootPath,
admissionTier: 'interactive',
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}),
signal: scanController.signal,
stdio: ['ignore', 'pipe', 'pipe']
})
return new Promise((resolve, reject) => {
let buf = ''
let done = false
const processPath = (path: string): boolean => {
if (!path) {
return false
}
if (path.endsWith('/')) {
directoryPaths.add(path)
} else {
gitPaths.add(path)
if (maxResults !== undefined) {
// Why: this duplicate classification exists only to stop bounded
// scans; unbounded scans must not retain a second repo-sized set.
const parsed = parseQuickOpenGitLsFilesEntry(path)
const relPath = parsed.path.replace(/\/+$/, '')
if (
!parsed.isGitlink &&
!parsed.isUntrackedDir &&
shouldIncludeQuickOpenPath(relPath) &&
!shouldExcludeQuickOpenRelPath(relPath, excludePathPrefixes)
) {
directFileCandidates.add(relPath)
}
}
}
// Why: collapsed directories and gitlinks may be discarded during the
// later filesystem classification, so they cannot consume the stop cap.
return maxResults !== undefined && directFileCandidates.size >= maxResults
}
let timer: ReturnType<typeof setTimeout>
const cleanup = (): void => {
clearTimeout(timer)
// Why: child.kill() is advisory. If git ignores it, detach our
// closures so repeated Quick Open attempts do not retain old scans.
child.stdout!.off('data', handleStdoutData)
child.stderr!.off('data', handleStderrData)
child.off('error', handleError)
child.off('close', handleClose)
}
const rejectPass = (err: Error): void => {
if (done) {
return
}
done = true
buf = ''
cleanup()
reject(err)
}
const resolvePass = (): void => {
if (done) {
return
}
done = true
cleanup()
resolve()
}
children.push({
child,
isDone: () => done,
reject: rejectPass,
resolve: resolvePass
})
const handleStdoutData = (chunk: string): void => {
buf += chunk
let start = 0
let nulIdx = buf.indexOf('\0', start)
while (nulIdx !== -1) {
if (processPath(buf.substring(start, nulIdx))) {
buf = ''
finishAtLimit()
return
}
start = nulIdx + 1
nulIdx = buf.indexOf('\0', start)
}
buf = start < buf.length ? buf.substring(start) : ''
}
const handleStderrData = (): void => {
/* drain */
}
const handleError = (err: Error): void => {
rejectPass(err)
}
const handleClose = (code: number | null, signal: NodeJS.Signals | null): void => {
if (done) {
return
}
if (signal) {
rejectPass(new Error(`git ls-files killed by ${signal}`))
return
}
if (buf && processPath(buf)) {
buf = ''
finishAtLimit()
return
}
if (code === 0) {
resolvePass()
return
}
rejectPass(new Error(`git ls-files exited with code ${code}`))
}
child.stdout!.setEncoding('utf-8')
child.stdout!.on('data', handleStdoutData)
child.stderr!.on('data', handleStderrData)
child.once('error', handleError)
child.once('close', handleClose)
timer = setTimeout(() => {
buf = ''
child.kill()
rejectPass(new Error('git ls-files timed out'))
}, 10000)
if (scanController.signal.aborted) {
child.kill()
rejectPass(fileListingCancellationError(scanController.signal))
}
})
}
const killSurvivors = (reason = 'git ls-files canceled after sibling failure'): void => {
// Why: Promise.all rejects on the first failed pass; cancel the sibling so
// a stuck git process cannot keep scanning after Quick Open has failed.
for (const entry of children) {
if (entry.isDone()) {
continue
}
if (entry.child.exitCode === null && entry.child.signalCode === null) {
entry.child.kill()
}
entry.reject(new Error(reason))
}
}
function finishAtLimit(): void {
for (const entry of children) {
if (entry.isDone()) {
continue
}
entry.resolve()
if (entry.child.exitCode === null && entry.child.signalCode === null) {
entry.child.kill()
}
}
}
const onAbort = (): void => {
scanController.abort(signal?.reason)
killSurvivors('git ls-files cancelled')
}
signal?.addEventListener('abort', onAbort, { once: true })
try {
const runIgnoredPass = () =>
// Why: ignored files are supplementary — a failed or timed-out ignored
// pass must not discard the primary listing the user actually needs.
runGitLsFiles(ignoredPass).catch((err: Error) => {
if (!scanController.signal.aborted) {
console.warn('[quick-open] git ignored-file pass failed; keeping primary results:', err)
}
})
if (maxResults === undefined) {
await Promise.all([runGitLsFiles(primary), runIgnoredPass()])
} else {
// Why: give ordinary source files first claim on a bounded autocomplete
// inventory; a large ignored tree must not win a parallel-output race.
await runGitLsFiles(primary)
if (directFileCandidates.size < maxResults) {
await runIgnoredPass()
}
}
} catch (err) {
scanController.abort(err)
killSurvivors()
if (signal?.aborted) {
throw fileListingCancellationError(signal)
}
throw err
} finally {
signal?.removeEventListener('abort', onAbort)
}
const files = await expandQuickOpenGitFileListing({
rootPath,
gitPaths,
directoryPaths,
excludePathPrefixes,
signal,
maxResults
})
// Why: directory placeholders are expanded after Git exits; restore Git's
// path order so empty queries and fuzzy-score ties remain stable.
return files.sort().slice(0, maxResults)
}