mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 16:02:56 +00:00
* 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>
336 lines
10 KiB
TypeScript
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)
|
|
}
|