Files
orca/src/shared/ripgrep-process-availability.ts
T
Neil be5d10c830 Cancel abandoned relay search work (#22997)
* fix: cancel abandoned relay searches

* fix: restore catalog entries required by the current CI baseline

* fix(i18n): make AI the recipient of diff notes
2026-09-26 14:37:34 -07:00

227 lines
7.8 KiB
TypeScript

import { constants } from 'node:fs'
import { access, stat } from 'node:fs/promises'
import { spawnProcess, type ChildProcessHandle } from './child-process/run-process'
import { abortSignalReason, throwIfSignalAborted } from './abort-signal-reason'
const RIPGREP_CWD_CHECK_TIMEOUT_MS = 1000
const RIPGREP_FAILURE_PROBE_TIMEOUT_MS = 5000
export class RipgrepUnavailableError extends Error {
constructor() {
super('ripgrep is unavailable')
this.name = 'RipgrepUnavailableError'
}
}
/** rg could not be launched even though ripgrep itself is installed; retryable, never install guidance. */
export class RipgrepLaunchFailureError extends Error {
constructor(message: string) {
super(message)
this.name = 'RipgrepLaunchFailureError'
}
}
// Why: fork/exec pressure (out of processes, fds, or memory) is not evidence that ripgrep is missing.
const TRANSIENT_SPAWN_ERROR_CODES: ReadonlySet<string> = new Set([
'EAGAIN',
'EMFILE',
'ENFILE',
'ENOMEM',
'ETXTBSY'
])
export function isTransientRipgrepSpawnError(error: unknown): boolean {
const code = (error as { code?: unknown } | null | undefined)?.code
return typeof code === 'string' && TRANSIENT_SPAWN_ERROR_CODES.has(code)
}
function ignoreRipgrepSpawnError(): void {}
export function killSpawnedRipgrepProcess(child: ChildProcessHandle): boolean {
// Why: killing a failed-spawn handle can signal the relay's own process group.
if (Object.hasOwn(child, 'pid') && child.pid === undefined) {
return false
}
return child.kill()
}
export function absorbPendingRipgrepSpawnError(
child: ChildProcessHandle,
state: { errorObserved: boolean; unavailableExitObserved: boolean }
): void {
if (
state.errorObserved ||
(!state.unavailableExitObserved && !(Object.hasOwn(child, 'pid') && child.pid === undefined))
) {
return
}
// Why: concurrent-pass cleanup can win before Node delivers the queued spawn error.
child.once('error', ignoreRipgrepSpawnError)
}
export async function isRipgrepSpawnCwdUsable(cwd: string): Promise<boolean> {
let timeout: ReturnType<typeof setTimeout> | null = null
const timedOut = new Promise<boolean>((resolve) => {
timeout = setTimeout(() => resolve(false), RIPGREP_CWD_CHECK_TIMEOUT_MS)
timeout.unref?.()
})
const checked = Promise.all([stat(cwd), access(cwd, constants.X_OK)]).then(
([entry]) => entry.isDirectory(),
() => false
)
try {
return await Promise.race([checked, timedOut])
} finally {
if (timeout) {
clearTimeout(timeout)
}
}
}
function probeRipgrepVersion(
command: string,
env: NodeJS.ProcessEnv,
signal?: AbortSignal
): Promise<boolean> {
if (signal?.aborted) {
return Promise.reject(abortSignalReason(signal))
}
return new Promise((resolve, reject) => {
let child: ChildProcessHandle
try {
// windowsHide: a probe must never flash a console window on Windows.
child = spawnProcess({ program: command, args: ['--version'], env, stdio: 'ignore' })
} catch {
resolve(false)
return
}
let settled = false
// Why kill on timeout: a `rg --version` that hangs -- a stalled network mount, or antivirus
// holding a just-installed rg.exe -- would otherwise leave a live process and a ref'd handle
// behind for the relay's lifetime, once per launch failure.
const settle = (available: boolean, kill = false, error?: Error): void => {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
signal?.removeEventListener('abort', onAbort)
child.off('close', onClose)
child.off('error', onError)
// A queued spawn error or synchronous kill failure can arrive after cleanup.
child.once('error', ignoreRipgrepSpawnError)
if (kill) {
try {
killSpawnedRipgrepProcess(child)
} catch {
// A refused kill must not strand the probe's settlement.
}
}
if (error) {
reject(error)
} else {
resolve(available)
}
}
const onAbort = (): void => {
if (signal) {
settle(false, true, abortSignalReason(signal))
}
}
const onError = (): void => settle(false)
const onClose = (code: number | null): void => settle(code === 0)
child.once('error', onError)
child.once('close', onClose)
const timeout = setTimeout(() => settle(false, true), RIPGREP_FAILURE_PROBE_TIMEOUT_MS)
timeout.unref?.()
signal?.addEventListener('abort', onAbort, { once: true })
if (signal?.aborted) {
onAbort()
}
})
}
/**
* Why a launch failure needs classifying: spawn reports an unreachable cwd as ENOENT, the same as
* a missing binary. Telling a user to install ripgrep because their workspace moved sends them
* down the wrong path, and resolving an empty result hides the move entirely.
*
* `candidates` are the ripgreps worth asking about, in order -- the command that actually failed
* first, then the host's PATH one. Probing only PATH would misclassify the normal remote setup,
* where Orca uploaded a bundled binary precisely because the host has no `rg` of its own: the
* probe would fail and a moved workspace would be reported as a missing ripgrep. Nulls are
* skipped, and a host with no working ripgrep at all keeps 'ripgrep-unavailable', because only
* that verdict engages the git/readdir fallback chain.
*/
export async function classifyRipgrepLaunchFailure(
cwd: string,
candidates: readonly (string | null)[],
env: NodeJS.ProcessEnv,
signal?: AbortSignal
): Promise<'cwd-unreachable' | 'ripgrep-unavailable'> {
throwIfSignalAborted(signal)
const usable = await isRipgrepSpawnCwdUsable(cwd)
throwIfSignalAborted(signal)
if (usable) {
return 'ripgrep-unavailable'
}
for (const command of new Set(candidates.filter((entry) => entry !== null))) {
throwIfSignalAborted(signal)
if (await probeRipgrepVersion(command, env, signal)) {
return 'cwd-unreachable'
}
}
return 'ripgrep-unavailable'
}
/**
* Exit code the WSL wrapper uses when it cannot enter the search root. Why a dedicated code:
* ripgrep exits 1 for "no matches", so without this an unreachable workspace would report an
* empty listing as a successful scan. Picked above ripgrep's own 0/1/2 and clear of the shell's
* 126/127 and 128+signal range.
*/
export const RIPGREP_MISSING_CWD_EXIT_CODE = 97
export function isRipgrepMissingCwdExit(code: number | null): boolean {
return code === RIPGREP_MISSING_CWD_EXIT_CODE
}
export function ripgrepMissingCwdError(cwd: string): Error {
return new Error(`Search root is not reachable: ${cwd}`)
}
// ENOTDIR and some resource failures throw before a ChildProcess can emit an error.
export async function classifySynchronousRipgrepSpawnFailure(
error: unknown,
cwd: string
): Promise<Error> {
// Why pass an already-classified error straight through: this only diagnoses raw spawn errnos.
// A caller that threw a verdict of its own has more context than a cwd probe does.
if (error instanceof RipgrepUnavailableError || error instanceof RipgrepLaunchFailureError) {
return error
}
if (isTransientRipgrepSpawnError(error)) {
const code = error instanceof Error && 'code' in error ? String(error.code) : 'unknown'
return new RipgrepLaunchFailureError(`rg failed to start (${code})`)
}
if (!(await isRipgrepSpawnCwdUsable(cwd))) {
return ripgrepMissingCwdError(cwd)
}
return error instanceof Error ? error : new Error(String(error))
}
export function isRipgrepUnavailableExit(
child: ChildProcessHandle,
code: number | null,
signal: NodeJS.Signals | null,
options: { classifyNativeLauncherExit?: boolean } = {}
): boolean {
if (signal) {
return false
}
if ((Object.hasOwn(child, 'pid') && child.pid === undefined) || (code !== null && code < 0)) {
return true
}
return Boolean(options.classifyNativeLauncherExit && code !== null && code > 2)
}