mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 16:03:26 +00:00
Recover a working local forge CLI when an earlier PATH launcher is broken. Bound executable probes and reuse the verified selection for native operations without replaying authentication or user requests. Fixes #22975 Co-authored-by: Aashish <145881415+aashish254@users.noreply.github.com>
240 lines
7.9 KiB
TypeScript
240 lines
7.9 KiB
TypeScript
import { execFile, type ChildProcess, type ExecFileOptions } from 'node:child_process'
|
|
import { recordSubprocessSpawn } from '../../diagnostics/main-thread-churn-probe'
|
|
import { endSubprocessStdin } from '../../../shared/subprocess-stdin-write'
|
|
import { runProcess } from '../../../shared/child-process/run-process'
|
|
import { resolveSelectedLocalCommand } from '../../ipc/command-path-resolver'
|
|
import type { WslProcessGroupTermination } from '../wsl-process-group-termination'
|
|
import { createAbortError } from './abort-error'
|
|
import { killSpawnedCommandTree } from './spawned-command-tree-kill'
|
|
import { DEFAULT_GIT_MAX_BUFFER } from './git-exec-options'
|
|
import type { GitAdmissionTier } from './git-exec-options'
|
|
|
|
type ExecFileCaptureOptions = Omit<ExecFileOptions, 'timeout'> & {
|
|
timeout?: number
|
|
stdin?: string
|
|
terminationBarrier?: boolean
|
|
onChildTerminated?: () => void
|
|
admissionTier?: GitAdmissionTier
|
|
createTimeoutError?: () => Error
|
|
/** Called once when the deadline — not an abort — is what ended the process. */
|
|
onDeadlineKill?: () => void
|
|
}
|
|
|
|
const GIT_TERMINATION_BARRIER_FALLBACK_TIMEOUT_MS = 2_147_000_000
|
|
|
|
export async function execFileCaptureToTermination(
|
|
command: string,
|
|
args: string[],
|
|
options: ExecFileCaptureOptions,
|
|
termination?: WslProcessGroupTermination
|
|
): Promise<{ stdout: string | Buffer; stderr: string | Buffer }> {
|
|
// Spawn cost is reported by spawnProcess's observer, which runProcess goes
|
|
// through; recording it again here would double-count every capture.
|
|
const pending = runProcess({
|
|
program: resolveSelectedLocalCommand(command, {
|
|
env: options.env,
|
|
cwd: typeof options.cwd === 'string' ? options.cwd : undefined
|
|
}),
|
|
args,
|
|
cwd: typeof options.cwd === 'string' ? options.cwd : undefined,
|
|
env: options.env,
|
|
timeoutMs: options.timeout ?? GIT_TERMINATION_BARRIER_FALLBACK_TIMEOUT_MS,
|
|
maxOutputBytes: options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER,
|
|
signal: options.signal,
|
|
terminationBarrier: termination ?? true,
|
|
onChildTerminated: options.onChildTerminated,
|
|
...(options.stdin === undefined ? {} : { input: options.stdin })
|
|
})
|
|
const result = await pending
|
|
const stdout = options.encoding === 'buffer' ? Buffer.from(result.stdout) : result.stdout
|
|
const cleanStderr = termination?.stripControlOutput(result.stderr) ?? result.stderr
|
|
const stderr = options.encoding === 'buffer' ? Buffer.from(cleanStderr) : cleanStderr
|
|
if (
|
|
result.code === 0 &&
|
|
!result.timedOut &&
|
|
!result.outputTruncated &&
|
|
!options.signal?.aborted
|
|
) {
|
|
return { stdout, stderr }
|
|
}
|
|
if (result.timedOut && !options.signal?.aborted) {
|
|
options.onDeadlineKill?.()
|
|
}
|
|
const error = result.timedOut
|
|
? (options.createTimeoutError?.() ?? new Error(`${command} timed out.`))
|
|
: new Error(
|
|
options.signal?.aborted
|
|
? 'The operation was aborted.'
|
|
: result.outputTruncated
|
|
? // Why fail instead of returning the clipped text: callers parse this
|
|
// as JSON or JSONL, where a clipped answer reads as a shorter valid
|
|
// one. execFile's own maxBuffer overrun errored for the same reason.
|
|
`${command} produced more than ${options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER} bytes of output.`
|
|
: cleanStderr.trim() || `${command} exited with ${result.code}.`
|
|
)
|
|
if (options.signal?.aborted) {
|
|
error.name = 'AbortError'
|
|
}
|
|
throw Object.assign(error, {
|
|
code: result.code,
|
|
killed: result.timedOut || result.signal !== null || options.signal?.aborted === true,
|
|
signal: result.signal,
|
|
stdout,
|
|
stderr
|
|
})
|
|
}
|
|
|
|
function emptyExecFileOutput(options: ExecFileCaptureOptions): string | Buffer {
|
|
return options.encoding === 'buffer' ? Buffer.alloc(0) : ''
|
|
}
|
|
|
|
function isExecFileResultObject(
|
|
value: unknown
|
|
): value is { stdout: string | Buffer; stderr: string | Buffer } {
|
|
return (
|
|
value !== null &&
|
|
typeof value === 'object' &&
|
|
!Buffer.isBuffer(value) &&
|
|
'stdout' in value &&
|
|
'stderr' in value
|
|
)
|
|
}
|
|
|
|
export function execFileCapture(
|
|
command: string,
|
|
args: string[],
|
|
options: ExecFileCaptureOptions
|
|
): Promise<{ stdout: string | Buffer; stderr: string | Buffer }> {
|
|
return new Promise((resolve, reject) => {
|
|
if (options.signal?.aborted) {
|
|
options.onChildTerminated?.()
|
|
reject(createAbortError())
|
|
return
|
|
}
|
|
|
|
let settled = false
|
|
let terminating = false
|
|
let child: ChildProcess | null = null
|
|
let timer: NodeJS.Timeout | null = null
|
|
let terminationReported = false
|
|
const reportChildTerminated = (): void => {
|
|
if (terminationReported) {
|
|
return
|
|
}
|
|
terminationReported = true
|
|
options.onChildTerminated?.()
|
|
}
|
|
const cleanup = (): void => {
|
|
if (timer) {
|
|
clearTimeout(timer)
|
|
timer = null
|
|
}
|
|
options.signal?.removeEventListener('abort', onAbort)
|
|
}
|
|
const finish = (
|
|
error: Error | null,
|
|
stdout: string | Buffer = emptyExecFileOutput(options),
|
|
stderr: string | Buffer = emptyExecFileOutput(options)
|
|
): void => {
|
|
if (settled) {
|
|
return
|
|
}
|
|
settled = true
|
|
cleanup()
|
|
if (error) {
|
|
const enriched = error as Error & { stdout?: string | Buffer; stderr?: string | Buffer }
|
|
enriched.stdout ??= stdout
|
|
enriched.stderr ??= stderr
|
|
reject(enriched)
|
|
return
|
|
}
|
|
resolve({ stdout, stderr })
|
|
}
|
|
const onAbort = (): void => {
|
|
if (settled || terminating) {
|
|
return
|
|
}
|
|
terminating = true
|
|
const abortError = createAbortError()
|
|
if (!child) {
|
|
terminating = false
|
|
finish(abortError)
|
|
return
|
|
}
|
|
void killSpawnedCommandTree(child).then(() => {
|
|
terminating = false
|
|
finish(abortError)
|
|
})
|
|
}
|
|
|
|
try {
|
|
const spawnStartedAt = performance.now()
|
|
// Why: our abort listener owns tree cleanup; Node's signal handler could kill wsl.exe before taskkill sees its children.
|
|
child = execFile(
|
|
command,
|
|
args,
|
|
{
|
|
cwd: options.cwd,
|
|
// Why: git.exe is console-subsystem and Orca's main process owns no
|
|
// console, so every spawn without this flashes a conhost that takes
|
|
// foreground. Git runs on nearly every interaction (#14543).
|
|
windowsHide: true,
|
|
encoding: options.encoding,
|
|
maxBuffer: options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER,
|
|
env: options.env
|
|
},
|
|
(error, stdout, stderr) => {
|
|
if (terminating) {
|
|
return
|
|
}
|
|
if (!error && stderr === undefined && isExecFileResultObject(stdout)) {
|
|
finish(null, stdout.stdout, stdout.stderr)
|
|
return
|
|
}
|
|
finish(error, stdout, stderr)
|
|
}
|
|
)
|
|
recordSubprocessSpawn(command, args, performance.now() - spawnStartedAt)
|
|
} catch (error) {
|
|
reportChildTerminated()
|
|
finish(error instanceof Error ? error : new Error(String(error)))
|
|
return
|
|
}
|
|
|
|
child.once('error', (error) => {
|
|
if (!child?.pid) {
|
|
reportChildTerminated()
|
|
}
|
|
if (!terminating) {
|
|
finish(error)
|
|
}
|
|
})
|
|
child.once('close', reportChildTerminated)
|
|
|
|
if (options.stdin !== undefined) {
|
|
endSubprocessStdin(child.stdin, options.stdin)
|
|
}
|
|
|
|
// Why: Node's timeout waits forever on signal-ignoring CLIs; enforce our own deadline with bounded tree cleanup.
|
|
if (options.timeout && options.timeout > 0) {
|
|
timer = setTimeout(() => {
|
|
if (settled || terminating) {
|
|
return
|
|
}
|
|
terminating = true
|
|
const timeoutError = options.createTimeoutError?.() ?? new Error(`${command} timed out.`)
|
|
if (!child) {
|
|
terminating = false
|
|
finish(timeoutError)
|
|
return
|
|
}
|
|
void killSpawnedCommandTree(child).then(() => {
|
|
terminating = false
|
|
finish(timeoutError)
|
|
})
|
|
}, options.timeout)
|
|
}
|
|
options.signal?.addEventListener('abort', onAbort, { once: true })
|
|
})
|
|
}
|