mirror of
https://github.com/stablyai/orca.git
synced 2026-09-26 08:02:38 +00:00
* retry transient ripgrep spawn failures instead of missing binary errors Fork/exec pressure (EAGAIN, EMFILE, ENFILE, ENOMEM, ETXTBSY) should not trigger ripgrep-not-found guidance. Add bounded retries (max 2x) for transient spawn failures in Quick Open and file listing, respecting cancellation signals. Introduce RipgrepLaunchFailureError to distinguish fork/exec pressure from unavailable ripgrep installations. * Handle cancellation during transient spawn failure retry window When a query is cancelled after a transient ripgrep spawn failure but before the retry decision resumes, the cancellation must be reported to the caller rather than proceeding with a retry attempt.
340 lines
12 KiB
TypeScript
340 lines
12 KiB
TypeScript
/**
|
|
* Ripgrep-based file listing for Quick Open.
|
|
* Why a full rewrite vs. the older execFile+maxBuffer version: on a home-dir
|
|
* worktree over SSH, rg descended into every dotfile cache, hit the timeout,
|
|
* and silently resolved with a partial list — Quick Open then showed "No
|
|
* matching files" even though the file existed on disk. This implementation:
|
|
* - streams via spawn (no maxBuffer failure mode)
|
|
* - prunes traversal at rg level using the shared blocklist globs
|
|
* - runs a second --no-ignore-vcs pass for ignored files
|
|
* - honors excludePathPrefixes for nested linked worktrees
|
|
* - rejects (not resolves) on timeout / spawn error / signal exit so
|
|
* the UI shows a load error instead of a false-empty list
|
|
* - treats rg exit code 2 with parseable stdout as success (permission
|
|
* denied on a single subdir is expected on home-dir roots)
|
|
*/
|
|
import { spawn, type ChildProcess } from 'node:child_process'
|
|
import { fileListingCancellationError } from '../shared/file-listing-cancellation'
|
|
import {
|
|
buildRgArgsForQuickOpen,
|
|
normalizeQuickOpenRgLine,
|
|
shouldExcludeQuickOpenRelPath,
|
|
shouldIncludeQuickOpenPath
|
|
} from '../shared/quick-open-filter'
|
|
import {
|
|
absorbPendingRipgrepSpawnError,
|
|
isRipgrepUnavailableAfterLaunchFailure,
|
|
isRipgrepUnavailableExit,
|
|
isTransientRipgrepSpawnError,
|
|
killSpawnedRipgrepProcess,
|
|
RipgrepLaunchFailureError,
|
|
RipgrepUnavailableError
|
|
} from '../shared/ripgrep-process-availability'
|
|
import { QuickOpenPathRanker } from '../shared/quick-open-path-search'
|
|
|
|
export const LIST_FILES_TIMEOUT_MS = 25_000
|
|
|
|
export function listFilesWithRg(
|
|
rootPath: string,
|
|
excludePathPrefixes: readonly string[] = [],
|
|
options: { signal?: AbortSignal; maxResults?: number; searchQuery?: string } = {}
|
|
): Promise<string[]> {
|
|
const { signal, maxResults, searchQuery } = options
|
|
if (signal?.aborted) {
|
|
return Promise.reject(fileListingCancellationError(signal))
|
|
}
|
|
return new Promise((resolve, reject) => {
|
|
const files = new Set<string>()
|
|
let rankedPaths: string[] | null = null
|
|
let done = false
|
|
const children: {
|
|
child: ChildProcess
|
|
isDone: () => boolean
|
|
reject: (error: Error) => void
|
|
}[] = []
|
|
|
|
const { primary, ignoredPass } = buildRgArgsForQuickOpen({
|
|
// Why: rg only applies root-relative exclude globs as traversal pruning
|
|
// when the search target is relative to cwd. Absolute targets still
|
|
// emit root-relative-looking paths for filters, but they do not prune.
|
|
searchRoot: '.',
|
|
excludePathPrefixes,
|
|
forceSlashSeparator: true
|
|
})
|
|
|
|
const processLine = (rawLine: string, attemptRanker: QuickOpenPathRanker | null): boolean => {
|
|
const relPath = normalizeQuickOpenRgLine(rawLine, { kind: 'cwd-relative' })
|
|
if (relPath === null) {
|
|
return false
|
|
}
|
|
// Why: correctness backstop. The rg globs prune most blocklisted dirs,
|
|
// but a glob edge case could still surface e.g. a .git/ or .npm/ hit.
|
|
if (!shouldIncludeQuickOpenPath(relPath)) {
|
|
return true
|
|
}
|
|
if (shouldExcludeQuickOpenRelPath(relPath, excludePathPrefixes)) {
|
|
return true
|
|
}
|
|
if (attemptRanker) {
|
|
attemptRanker.consider(relPath)
|
|
return true
|
|
}
|
|
files.add(relPath)
|
|
if (maxResults !== undefined && files.size >= maxResults) {
|
|
finishAtLimit()
|
|
}
|
|
return true
|
|
}
|
|
|
|
const runPassOnce = (args: string[]): Promise<void> =>
|
|
new Promise((passResolve, passReject) => {
|
|
const attemptRanker =
|
|
searchQuery === undefined ? null : new QuickOpenPathRanker(searchQuery, maxResults ?? 16)
|
|
let passBuf = ''
|
|
let passDone = false
|
|
let passFileCount = 0
|
|
let processErrorObserved = false
|
|
let unavailableExitObserved = false
|
|
let launchFailureCheck: Promise<void> | null = null
|
|
// --no-messages: permission-denied noise on the remote (e.g. .ssh,
|
|
// root-owned mounts) would otherwise flood stderr.
|
|
// cwd: rootPath — root-relative exclude globs like `!packages/app/**`
|
|
// are evaluated against rg's working directory, not the absolute
|
|
// search target. Without cwd, nested-worktree exclusions silently
|
|
// stop working.
|
|
let child: ChildProcess
|
|
try {
|
|
child = spawn('rg', ['--no-messages', ...args], {
|
|
cwd: rootPath,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
windowsHide: true
|
|
})
|
|
} catch (error) {
|
|
throw isTransientRipgrepSpawnError(error)
|
|
? new RipgrepLaunchFailureError(
|
|
`rg failed to start (${(error as NodeJS.ErrnoException).code})`
|
|
)
|
|
: error
|
|
}
|
|
let timer: ReturnType<typeof setTimeout> | null = null
|
|
const cleanup = (): void => {
|
|
if (timer) {
|
|
clearTimeout(timer)
|
|
timer = null
|
|
}
|
|
child.stdout!.off('data', handleStdoutData)
|
|
child.stderr!.off('data', handleStderrData)
|
|
child.off('error', handleError)
|
|
child.off('close', handleClose)
|
|
absorbPendingRipgrepSpawnError(child, {
|
|
errorObserved: processErrorObserved,
|
|
unavailableExitObserved
|
|
})
|
|
}
|
|
const rejectPass = (error: Error): void => {
|
|
if (passDone) {
|
|
return
|
|
}
|
|
passDone = true
|
|
passBuf = ''
|
|
cleanup()
|
|
passReject(error)
|
|
}
|
|
const resolvePass = (): void => {
|
|
if (passDone) {
|
|
return
|
|
}
|
|
passDone = true
|
|
cleanup()
|
|
if (attemptRanker) {
|
|
rankedPaths = attemptRanker.result().paths
|
|
}
|
|
passResolve()
|
|
}
|
|
const rejectLaunchFailure = (error: Error): void => {
|
|
if (launchFailureCheck) {
|
|
return
|
|
}
|
|
launchFailureCheck = isRipgrepUnavailableAfterLaunchFailure(rootPath).then(
|
|
(unavailable) => {
|
|
rejectPass(unavailable ? new RipgrepUnavailableError() : error)
|
|
}
|
|
)
|
|
}
|
|
children.push({
|
|
child,
|
|
isDone: () => passDone,
|
|
reject: rejectPass
|
|
})
|
|
|
|
timer = setTimeout(() => {
|
|
// Discard residual buffer on abnormal exit — a truncated byte
|
|
// sequence could look like a valid path.
|
|
killSpawnedRipgrepProcess(child)
|
|
rejectPass(new Error('rg list timed out'))
|
|
}, LIST_FILES_TIMEOUT_MS)
|
|
|
|
function handleStdoutData(chunk: string): void {
|
|
passBuf += chunk
|
|
let start = 0
|
|
let idx = passBuf.indexOf('\n', start)
|
|
while (idx !== -1) {
|
|
if (processLine(passBuf.substring(start, idx), attemptRanker)) {
|
|
passFileCount++
|
|
}
|
|
if (done) {
|
|
return
|
|
}
|
|
start = idx + 1
|
|
idx = passBuf.indexOf('\n', start)
|
|
}
|
|
passBuf = start < passBuf.length ? passBuf.substring(start) : ''
|
|
}
|
|
function handleStderrData(): void {
|
|
/* drain to prevent backpressure stalls */
|
|
}
|
|
function handleError(err: NodeJS.ErrnoException): void {
|
|
processErrorObserved = true
|
|
if (isTransientRipgrepSpawnError(err)) {
|
|
rejectPass(new RipgrepLaunchFailureError(`rg failed to start (${err.code})`))
|
|
return
|
|
}
|
|
if (isRipgrepUnavailableExit(child, null, null)) {
|
|
passBuf = ''
|
|
rejectLaunchFailure(err)
|
|
return
|
|
}
|
|
rejectPass(err)
|
|
}
|
|
function handleClose(code: number | null, signal: NodeJS.Signals | null): void {
|
|
if (passDone) {
|
|
return
|
|
}
|
|
if (
|
|
isRipgrepUnavailableExit(child, code, signal, {
|
|
classifyNativeLauncherExit: true
|
|
})
|
|
) {
|
|
unavailableExitObserved = true
|
|
passBuf = ''
|
|
rejectLaunchFailure(new Error(`rg exited with code ${code}`))
|
|
return
|
|
}
|
|
// Why signal != null is a failure: the only way spawn gets a signal
|
|
// is if the process was killed (timeout, OOM, external SIGKILL).
|
|
// Trusting its stdout could surface a truncated list as a success.
|
|
if (signal) {
|
|
rejectPass(new Error(`rg killed by ${signal}`))
|
|
return
|
|
}
|
|
// Flush residual line only on clean exit.
|
|
if (passBuf) {
|
|
if (processLine(passBuf, attemptRanker)) {
|
|
passFileCount++
|
|
}
|
|
}
|
|
// exit 0 = matches found, 1 = no files (still success for --files).
|
|
// exit 2 is documented as "a subdirectory could not be searched"
|
|
// (e.g. EACCES on .ssh), but rg also returns 2 for fatal errors
|
|
// (bad flag, invalid glob). Only trust exit 2 when rg emitted at
|
|
// least one parseable path — otherwise treat it as a real failure.
|
|
if (code === 0 || code === 1) {
|
|
resolvePass()
|
|
} else if (code === 2 && passFileCount > 0) {
|
|
resolvePass()
|
|
} else {
|
|
rejectPass(new Error(`rg 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)
|
|
})
|
|
|
|
const runPass = (args: string[]): Promise<void> =>
|
|
runPassOnce(args).catch((error: unknown) => {
|
|
if (!(error instanceof RipgrepLaunchFailureError) || signal?.aborted || done) {
|
|
throw error
|
|
}
|
|
return runPassOnce(args)
|
|
})
|
|
|
|
const killSurvivors = (reason: string): void => {
|
|
// Why: when one pass rejects, Promise.all surfaces the error immediately
|
|
// but the sibling rg keeps running up to LIST_FILES_TIMEOUT_MS. Kill it
|
|
// so repeated Quick Open opens don't pile up orphan rg processes on the
|
|
// remote.
|
|
for (const entry of children) {
|
|
if (entry.isDone()) {
|
|
continue
|
|
}
|
|
if (entry.child.exitCode === null && entry.child.signalCode === null) {
|
|
killSpawnedRipgrepProcess(entry.child)
|
|
}
|
|
entry.reject(new Error(reason))
|
|
}
|
|
}
|
|
|
|
function finishAtLimit(): void {
|
|
if (done) {
|
|
return
|
|
}
|
|
done = true
|
|
signal?.removeEventListener('abort', onAbort)
|
|
killSurvivors('rg list reached bounded result limit')
|
|
resolve(Array.from(files).slice(0, maxResults))
|
|
}
|
|
|
|
// Why: a cancelled scan (workspace switch, superseded request) must stop
|
|
// its rg children immediately instead of letting them walk the tree to
|
|
// completion and flood the relay with stdout it will only discard.
|
|
const onAbort = (): void => {
|
|
if (done) {
|
|
return
|
|
}
|
|
done = true
|
|
killSurvivors('rg list cancelled')
|
|
reject(fileListingCancellationError(signal))
|
|
}
|
|
signal?.addEventListener('abort', onAbort, { once: true })
|
|
|
|
const passes =
|
|
searchQuery !== undefined
|
|
? runPass(ignoredPass)
|
|
: (() => {
|
|
const primaryPass = runPass(primary)
|
|
return maxResults === undefined
|
|
? children[0]?.child.pid === undefined
|
|
? primaryPass.then(() => runPass(ignoredPass))
|
|
: Promise.all([primaryPass, runPass(ignoredPass)])
|
|
: // Why: deterministic primary-first budgeting prevents a large ignored
|
|
// tree from starving ordinary source paths on a remote host.
|
|
primaryPass.then(() =>
|
|
files.size < maxResults ? runPass(ignoredPass) : Promise.resolve()
|
|
)
|
|
})()
|
|
|
|
passes
|
|
.then(() => {
|
|
if (done) {
|
|
return
|
|
}
|
|
done = true
|
|
signal?.removeEventListener('abort', onAbort)
|
|
resolve(rankedPaths ?? Array.from(files))
|
|
})
|
|
.catch((err) => {
|
|
if (done) {
|
|
return
|
|
}
|
|
done = true
|
|
signal?.removeEventListener('abort', onAbort)
|
|
killSurvivors('rg list canceled after sibling failure')
|
|
reject(err instanceof Error ? err : new Error(String(err)))
|
|
})
|
|
})
|
|
}
|