mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 17:47:40 +00:00
Extract subprocess execution and process contracts into @orca/process-host with explicit public entry points, independent checks, and one Node 18 compatible CommonJS runtime. Migrate application consumers and integrate package builds, dependency selection, import enforcement, and runtime resource staging. Preserve daemon startup ownership and profile isolation, and qualify development rebuild recovery and concurrent output publication. Remove unnecessary aliases, test forwarding exports, and a duplicate output scan. Validation: full typechecks, changed-code quality, package and artifact checks, native Windows builds/types plus 306 runtime tests, and independent native reviews. Matched full E2E controls on original main and unchanged reviewed head both pass all 17 applicable tests (nine skips). Earlier intermittent E2E failures are documented in the PR; no assertions were weakened. The final combined-tree audit preserves both branches with no import-boundary violations.
228 lines
7.8 KiB
TypeScript
228 lines
7.8 KiB
TypeScript
import { constants } from 'node:fs'
|
|
import { access, stat } from 'node:fs/promises'
|
|
import { spawnProcess } from '@orca/process-host'
|
|
import type { ChildProcessHandle } from '@orca/process-host/process-spec'
|
|
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)
|
|
}
|