Files
orca/src/main/git/runner-command-exec.test.ts
T
Neil fb48a9771b fix(gh): reap the whole gh/glab process tree at the deadline on POSIX (#18258)
`gh` and `glab` on PATH are routinely shims — mise, asdf, volta, or a
hand-written wrapper — so a timed-out invocation has a chain to stop, not
one process. `execFileCapture`'s POSIX kill path signals only the direct
child; the descendants are orphaned to init and keep running. #18234 is
exactly that shape: `bash ~/.local/bin/gh` -> `mise x gh` -> `gh`, where
the reporter found the tail reparented to `systemd --user` and still at
100% CPU nearly two hours later. The 15s deadline #18239 added bounds
Orca's semaphore slot and its promise; it does not bound the CPU burn.

Route both CLIs through `execFileCaptureToTermination`, the primitive
git's barrier path already uses: POSIX children spawn `detached`, the
deadline signals `-pgid` and escalates to SIGKILL, and the promise waits
for verified termination. Windows behaviour is unchanged (`taskkill /t`
either way).

Switching primitives also swapped execFile's hard maxBuffer failure for
`runProcess`'s silent clipping, which would have turned an oversized gh
response into a shorter valid-looking one. `ProcessResult` now reports
truncation and the capture rejects on it, restoring the old contract and
closing the same latent gap on git's barrier path.
2026-09-02 15:12:21 -07:00

935 lines
31 KiB
TypeScript

import { EventEmitter } from 'node:events'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { execFileMock, execFileSyncMock, spawnMock } = vi.hoisted(() => ({
execFileMock: vi.fn(),
execFileSyncMock: vi.fn(),
spawnMock: vi.fn()
}))
vi.mock('node:child_process', () => ({
execFile: execFileMock,
execFileSync: execFileSyncMock,
spawn: spawnMock
}))
import {
commandExecFileAsync,
ghExecFileAsync,
gitExecFileAsync,
glabExecFileAsync,
gitStreamStdout,
translateWslOutputPaths,
wslAwareSpawn
} from './runner'
import {
GitAdmissionScheduler,
_resetGitAdmissionForTests
} from './command-runner/git-subprocess-admission'
afterEach(() => _resetGitAdmissionForTests())
type MockChildProcess = EventEmitter & {
stdout: EventEmitter
stderr: EventEmitter
pid: number
kill: ReturnType<typeof vi.fn>
unref?: ReturnType<typeof vi.fn>
}
function createMockChildProcess(pid: number): MockChildProcess {
const child = new EventEmitter() as MockChildProcess
child.stdout = new EventEmitter()
child.stderr = new EventEmitter()
child.pid = pid
child.kill = vi.fn()
return child
}
/**
* Spawn stand-in for the gh/glab deadline tests: the CLI hangs, while the `ps`
* quiescence probe the tree termination runs answers immediately.
*/
function mockWedgedCliSpawn(child: MockChildProcess): void {
spawnMock.mockImplementation((program: string) => {
if (program !== 'ps') {
return child
}
const probe = createMockChildProcess(9100)
queueMicrotask(() => probe.emit('close', 0, null))
return probe
})
}
/** Signals succeed; the existence probe reports the group already gone. */
function mockProcessGroupSignals(): ReturnType<typeof vi.spyOn> {
return vi.spyOn(process, 'kill').mockImplementation(((_pid: number, signal?: unknown) => {
if (signal === 0) {
throw Object.assign(new Error('ESRCH'), { code: 'ESRCH' })
}
return true
}) as typeof process.kill)
}
function createMockTaskkillProcess(): MockChildProcess {
const child = createMockChildProcess(9000)
child.unref = vi.fn()
return child
}
async function withPlatform<T>(platform: NodeJS.Platform, fn: () => Promise<T>): Promise<T> {
const original = process.platform
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
try {
return await fn()
} finally {
Object.defineProperty(process, 'platform', { configurable: true, value: original })
}
}
describe('commandExecFileAsync Windows command shims', () => {
const originalComSpec = process.env.ComSpec
beforeEach(() => {
process.env.ComSpec = 'C:\\Windows\\System32\\cmd.exe'
execFileMock.mockReset()
execFileSyncMock.mockReset()
spawnMock.mockReset()
})
afterEach(() => {
vi.useRealTimers()
if (originalComSpec === undefined) {
delete process.env.ComSpec
} else {
process.env.ComSpec = originalComSpec
}
})
it('kills aborted Windows .cmd shim executions as a process tree', async () => {
await withPlatform('win32', async () => {
const command = createMockChildProcess(1234)
const taskkill = createMockTaskkillProcess()
spawnMock.mockImplementation((cmd: string) => (cmd === 'taskkill' ? taskkill : command))
const controller = new AbortController()
const promise = commandExecFileAsync('C:\\tools\\pnpm.cmd', ['--version'], {
cwd: 'C:\\repo',
signal: controller.signal
})
const rejection = expect(promise).rejects.toMatchObject({ name: 'AbortError' })
controller.abort()
await rejection
expect(spawnMock).toHaveBeenCalledWith(
'taskkill',
['/pid', '1234', '/t', '/f'],
expect.objectContaining({ stdio: 'ignore', windowsHide: true })
)
expect(command.kill).not.toHaveBeenCalled()
})
})
it('kills timed-out Windows .cmd shim executions as a process tree', async () => {
vi.useFakeTimers()
await withPlatform('win32', async () => {
const command = createMockChildProcess(1234)
const taskkill = createMockTaskkillProcess()
spawnMock.mockImplementation((cmd: string) => (cmd === 'taskkill' ? taskkill : command))
const promise = commandExecFileAsync('C:\\tools\\pnpm.cmd', ['store', 'prune'], {
cwd: 'C:\\repo',
timeout: 1000
})
const rejection = expect(promise).rejects.toThrow('C:\\tools\\pnpm.cmd timed out.')
await vi.advanceTimersByTimeAsync(1000)
await rejection
expect(spawnMock).toHaveBeenCalledWith(
'taskkill',
['/pid', '1234', '/t', '/f'],
expect.objectContaining({ stdio: 'ignore', windowsHide: true })
)
expect(command.kill).not.toHaveBeenCalled()
expect(command.stdout.listenerCount('data')).toBe(0)
expect(command.stderr.listenerCount('data')).toBe(0)
expect(command.listenerCount('error')).toBe(0)
expect(command.listenerCount('close')).toBe(0)
})
})
it('kills over-buffer Windows .cmd shim executions as a process tree', async () => {
await withPlatform('win32', async () => {
const command = createMockChildProcess(1234)
const taskkill = createMockTaskkillProcess()
spawnMock.mockImplementation((cmd: string) => (cmd === 'taskkill' ? taskkill : command))
const promise = commandExecFileAsync('C:\\tools\\pnpm.cmd', ['store', 'prune'], {
cwd: 'C:\\repo',
maxBuffer: 2
})
const rejection = expect(promise).rejects.toThrow(
'C:\\tools\\pnpm.cmd stdout exceeded maxBuffer.'
)
command.stdout.emit('data', Buffer.from('too much output'))
await rejection
expect(spawnMock).toHaveBeenCalledWith(
'taskkill',
['/pid', '1234', '/t', '/f'],
expect.objectContaining({ stdio: 'ignore', windowsHide: true })
)
expect(command.kill).not.toHaveBeenCalled()
expect(command.stdout.listenerCount('data')).toBe(0)
expect(command.stderr.listenerCount('data')).toBe(0)
expect(command.listenerCount('error')).toBe(0)
expect(command.listenerCount('close')).toBe(0)
})
})
it('removes listeners after successful Windows .cmd shim executions', async () => {
await withPlatform('win32', async () => {
const command = createMockChildProcess(1234)
spawnMock.mockReturnValue(command)
const promise = commandExecFileAsync('C:\\tools\\pnpm.cmd', ['--version'], {
cwd: 'C:\\repo'
})
command.stdout.emit('data', Buffer.from('9.1.0\n'))
command.stderr.emit('data', Buffer.from('notice\n'))
command.emit('close', 0)
await expect(promise).resolves.toEqual({ stdout: '9.1.0\n', stderr: 'notice\n' })
expect(command.stdout.listenerCount('data')).toBe(0)
expect(command.stderr.listenerCount('data')).toBe(0)
expect(command.listenerCount('error')).toBe(0)
expect(command.listenerCount('close')).toBe(0)
})
})
})
describe('runner execFile timeout handling', () => {
beforeEach(() => {
execFileMock.mockReset()
spawnMock.mockReset()
execFileSyncMock.mockReset()
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('rejects command executions when execFile never calls back after timeout', async () => {
const child = createMockChildProcess(1234)
execFileMock.mockReturnValue(child)
const promise = commandExecFileAsync('git', ['status'], {
cwd: '/repo',
timeout: 1000
})
const rejection = expect(promise).rejects.toThrow(/git(?:\.exe)? timed out\./i)
await vi.advanceTimersByTimeAsync(1000)
await rejection
expect(child.kill).toHaveBeenCalled()
})
it('rejects git executions when execFile never calls back after timeout', async () => {
const child = createMockChildProcess(1234)
execFileMock.mockReturnValue(child)
const promise = gitExecFileAsync(['status'], {
cwd: '/repo',
timeout: 1000
})
const rejection = expect(promise).rejects.toThrow('git timed out.')
await vi.waitFor(() => expect(execFileMock).toHaveBeenCalledOnce())
await vi.advanceTimersByTimeAsync(1000)
await rejection
expect(child.kill).toHaveBeenCalled()
})
it.skipIf(process.platform === 'win32')(
'keeps a barrier Git abort pending until the process group closes',
async () => {
const child = createMockChildProcess(1234)
spawnMock.mockImplementation((command: string) => {
if (command !== 'ps') {
return child
}
const probe = createMockChildProcess(4321)
queueMicrotask(() => probe.emit('close', 0, null))
return probe
})
const processKill = vi.spyOn(process, 'kill').mockImplementation(() => true)
const controller = new AbortController()
try {
const pending = gitExecFileAsync(['status'], {
cwd: '/repo',
signal: controller.signal,
terminationBarrier: true
})
let settled = false
void pending.then(
() => {
settled = true
},
() => {
settled = true
}
)
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled())
controller.abort()
child.emit('close', 0, null)
await vi.advanceTimersByTimeAsync(1_999)
expect(settled).toBe(false)
await vi.advanceTimersByTimeAsync(1)
expect(processKill).toHaveBeenCalledWith(-1234, 'SIGKILL')
await expect(pending).rejects.toMatchObject({ name: 'AbortError' })
} finally {
processKill.mockRestore()
}
}
)
// Why the group and not the child (#18234): `gh` and `glab` on PATH are often
// shims, so the deadline has a chain to reap. Signalling only the direct child
// leaves the rest of it running under init long after the deadline passed.
it('signals the whole gh process group when gh never calls back', async () => {
const child = createMockChildProcess(1234)
mockWedgedCliSpawn(child)
const processKill = mockProcessGroupSignals()
try {
const promise = ghExecFileAsync(['api', 'repos/stablyai/orca/issues/5388'], {
cwd: '/repo'
})
const rejection = expect(promise).rejects.toThrow('gh timed out.')
await vi.advanceTimersByTimeAsync(30_000)
expect(spawnMock.mock.calls[0][2].detached).toBe(true)
await vi.advanceTimersByTimeAsync(2_000)
await rejection
expect(processKill).toHaveBeenCalledWith(-1234, undefined)
} finally {
processKill.mockRestore()
}
})
it('signals the whole glab process group when glab never calls back', async () => {
const child = createMockChildProcess(1234)
mockWedgedCliSpawn(child)
const processKill = mockProcessGroupSignals()
try {
const promise = glabExecFileAsync(['api', 'projects/stablyai%2Forca/issues'], {
cwd: '/repo'
})
const rejection = expect(promise).rejects.toThrow('glab timed out.')
await vi.advanceTimersByTimeAsync(30_000)
await vi.advanceTimersByTimeAsync(2_000)
await rejection
expect(processKill).toHaveBeenCalledWith(-1234, undefined)
} finally {
processKill.mockRestore()
}
})
it('aborts glab retry backoff instead of starting another attempt', async () => {
const controller = new AbortController()
const transient = Object.assign(new Error('glab failed'), {
stderr: 'HTTP 503 Service Unavailable'
})
spawnMock.mockImplementationOnce(() => {
const child = createMockChildProcess(1234)
queueMicrotask(() => {
child.stderr.emit('data', Buffer.from(transient.stderr))
child.emit('exit', 1, null)
child.emit('close', 1, null)
})
return child
})
const promise = glabExecFileAsync(['api', 'projects'], {
cwd: '/repo',
signal: controller.signal
})
const rejection = expect(promise).rejects.toMatchObject({ name: 'AbortError' })
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1))
controller.abort()
await rejection
expect(spawnMock).toHaveBeenCalledTimes(1)
})
it('kills an active gh execution when its caller aborts', async () => {
const child = createMockChildProcess(1234)
mockWedgedCliSpawn(child)
const processKill = mockProcessGroupSignals()
try {
const controller = new AbortController()
const promise = ghExecFileAsync(['api', 'repos/stablyai/orca/issues/5388'], {
cwd: '/repo',
signal: controller.signal
})
const rejection = expect(promise).rejects.toMatchObject({ name: 'AbortError' })
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled())
controller.abort()
await vi.advanceTimersByTimeAsync(2_000)
await rejection
expect(processKill).toHaveBeenCalledWith(-1234, undefined)
} finally {
processKill.mockRestore()
}
})
it('honors explicit gh timeouts', async () => {
const child = createMockChildProcess(1234)
mockWedgedCliSpawn(child)
const processKill = mockProcessGroupSignals()
try {
const promise = ghExecFileAsync(['api', 'repos/stablyai/orca/issues/5388'], {
cwd: '/repo',
timeout: 1234
})
const rejection = expect(promise).rejects.toThrow('gh timed out.')
await vi.advanceTimersByTimeAsync(1233)
expect(processKill).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
await vi.advanceTimersByTimeAsync(2_000)
await rejection
expect(processKill).toHaveBeenCalledWith(-1234, undefined)
} finally {
processKill.mockRestore()
}
})
it('runs gh non-interactively while preserving explicit env', async () => {
let capturedEnv: NodeJS.ProcessEnv | undefined
spawnMock.mockImplementation((_cmd, _args, opts) => {
capturedEnv = opts.env
const child = createMockChildProcess(1234)
queueMicrotask(() => {
child.stdout.emit('data', Buffer.from('ok'))
child.emit('exit', 0, null)
child.emit('close', 0, null)
})
return child
})
await ghExecFileAsync(['api', 'user'], {
cwd: '/repo',
env: { ...process.env, GH_PROMPT_DISABLED: '0', ORCA_TEST_ENV: 'kept' },
timeout: 1234
})
expect(capturedEnv?.GH_PROMPT_DISABLED).toBe('0')
expect(capturedEnv?.ORCA_TEST_ENV).toBe('kept')
})
// Issue #5308: git read-path calls must be forced non-interactive so a
// credential / SSH host-key prompt fails fast instead of blocking forever on
// stdin and wedging the serve runtime for all clients.
it('runs git non-interactively so a prompt fails fast instead of hanging', async () => {
const child = createMockChildProcess(1234)
let capturedEnv: NodeJS.ProcessEnv | undefined
execFileMock.mockImplementation((_cmd, _args, opts, cb) => {
capturedEnv = opts.env
cb(null, '', '')
return child
})
await gitExecFileAsync(['worktree', 'list', '--porcelain', '-z'], {
cwd: '/home5/Brian',
env: { ...process.env, GIT_ASKPASS: undefined, SSH_ASKPASS: undefined }
})
expect(capturedEnv?.GIT_TERMINAL_PROMPT).toBe('0')
expect(capturedEnv?.GIT_ASKPASS).toBe('')
expect(capturedEnv?.SSH_ASKPASS).toBe('')
expect(capturedEnv?.GIT_SSH_COMMAND).toContain('BatchMode=yes')
})
it('probes core.sshCommand for opted-in network git calls', async () => {
const child = createMockChildProcess(1234)
const calls: { args: string[]; env: NodeJS.ProcessEnv }[] = []
execFileMock.mockImplementation((_cmd, args, opts, cb) => {
calls.push({ args, env: opts.env })
cb(null, args[0] === 'config' ? 'ssh -F ~/.ssh/github-work -i ~/.ssh/work_key\n' : '', '')
return child
})
await gitExecFileAsync(['fetch', 'origin'], {
cwd: '/repo',
env: {},
useConfiguredSshCommandForNetwork: true
})
expect(calls[0]?.args).toEqual(['config', '--get', 'core.sshCommand'])
expect(calls[0]?.env.GIT_TERMINAL_PROMPT).toBe('0')
expect(calls[0]?.env.GIT_SSH_COMMAND).toBeUndefined()
expect(calls[1]?.args).toEqual(['fetch', 'origin'])
expect(calls[1]?.env.GIT_SSH_COMMAND).toBe(
'ssh -F ~/.ssh/github-work -i ~/.ssh/work_key -o BatchMode=yes'
)
})
it('admits the core.sshCommand probe before spawning it', async () => {
const scheduler = new GitAdmissionScheduler({ generalCap: 1, generalHeadroom: 0 })
_resetGitAdmissionForTests(scheduler)
const blocker = await scheduler.acquire({ args: ['status'], cwd: '/repo', tier: 'status' })
const calls: string[][] = []
execFileMock.mockImplementation((_cmd, args, _opts, cb) => {
const child = createMockChildProcess(1234 + calls.length)
calls.push(args)
cb(null, '', '')
queueMicrotask(() => child.emit('close', 0, null))
return child
})
const pending = gitExecFileAsync(['fetch', '--no-write-fetch-head', 'origin'], {
cwd: '/repo',
env: {},
useConfiguredSshCommandForNetwork: true
})
await Promise.resolve()
expect(execFileMock).not.toHaveBeenCalled()
blocker.release()
await pending
expect(calls).toEqual([
['config', '--get', 'core.sshCommand'],
['fetch', '--no-write-fetch-head', 'origin']
])
})
it('replaces configured BatchMode for opted-in mergeable OpenSSH commands', async () => {
const child = createMockChildProcess(1234)
let capturedEnv: NodeJS.ProcessEnv | undefined
execFileMock.mockImplementation((_cmd, args, opts, cb) => {
if (args[0] === 'config') {
cb(null, 'ssh -o BatchMode=no -i ~/.ssh/personal\n', '')
} else {
capturedEnv = opts.env
cb(null, '', '')
}
return child
})
await gitExecFileAsync(['fetch', 'origin'], {
cwd: '/repo',
env: {},
useConfiguredSshCommandForNetwork: true
})
expect(capturedEnv?.GIT_SSH_COMMAND).toBe('ssh -i ~/.ssh/personal -o BatchMode=yes')
})
it('merges quoted ssh.exe command shapes for opted-in network calls', async () => {
const child = createMockChildProcess(1234)
let capturedEnv: NodeJS.ProcessEnv | undefined
execFileMock.mockImplementation((_cmd, args, opts, cb) => {
if (args[0] === 'config') {
cb(null, '"C:/Program Files/Git/usr/bin/ssh.exe" -F ~/.ssh/config\n', '')
} else {
capturedEnv = opts.env
cb(null, '', '')
}
return child
})
await gitExecFileAsync(['fetch', 'origin'], {
cwd: '/repo',
env: {},
useConfiguredSshCommandForNetwork: true
})
expect(capturedEnv?.GIT_SSH_COMMAND).toBe(
"'C:/Program Files/Git/usr/bin/ssh.exe' -F ~/.ssh/config -o BatchMode=yes"
)
})
it('merges unquoted Windows ssh.exe paths for opted-in network calls', async () => {
const child = createMockChildProcess(1234)
let capturedEnv: NodeJS.ProcessEnv | undefined
execFileMock.mockImplementation((_cmd, args, opts, cb) => {
if (args[0] === 'config') {
cb(null, `${String.raw`C:\Git\usr\bin\ssh.exe -i C:\Users\me\.ssh\work_key`}\n`, '')
} else {
capturedEnv = opts.env
cb(null, '', '')
}
return child
})
await gitExecFileAsync(['fetch', 'origin'], {
cwd: '/repo',
env: {},
useConfiguredSshCommandForNetwork: true
})
expect(capturedEnv?.GIT_SSH_COMMAND).toBe(
String.raw`'C:\Git\usr\bin\ssh.exe' -i 'C:\Users\me\.ssh\work_key' -o BatchMode=yes`
)
})
it('passes through unmergeable core.sshCommand wrappers without generic fallback', async () => {
const child = createMockChildProcess(1234)
let capturedEnv: NodeJS.ProcessEnv | undefined
execFileMock.mockImplementation((_cmd, args, opts, cb) => {
if (args[0] === 'config') {
cb(null, '/usr/local/bin/work-ssh-wrapper --account work\n', '')
} else {
capturedEnv = opts.env
cb(null, '', '')
}
return child
})
await gitExecFileAsync(['fetch', 'origin'], {
cwd: '/repo',
env: {},
useConfiguredSshCommandForNetwork: true
})
expect(capturedEnv?.GIT_TERMINAL_PROMPT).toBe('0')
expect(capturedEnv?.GIT_ASKPASS).toBe('')
expect(capturedEnv?.SSH_ASKPASS).toBe('')
expect(capturedEnv?.GIT_SSH_COMMAND).toBeUndefined()
})
it('passes through shell-expanding OpenSSH configs without changing expansion semantics', async () => {
const child = createMockChildProcess(1234)
let capturedEnv: NodeJS.ProcessEnv | undefined
execFileMock.mockImplementation((_cmd, args, opts, cb) => {
if (args[0] === 'config') {
cb(null, 'ssh -i "$HOME/.ssh/work_key"\n', '')
} else {
capturedEnv = opts.env
cb(null, '', '')
}
return child
})
await gitExecFileAsync(['fetch', 'origin'], {
cwd: '/repo',
env: {},
useConfiguredSshCommandForNetwork: true
})
expect(capturedEnv?.GIT_TERMINAL_PROMPT).toBe('0')
expect(capturedEnv?.GIT_SSH_COMMAND).toBeUndefined()
})
it('falls back to generic batch-mode SSH when opted-in config is unset', async () => {
const child = createMockChildProcess(1234)
let capturedEnv: NodeJS.ProcessEnv | undefined
execFileMock.mockImplementation((_cmd, args, opts, cb) => {
if (args[0] === 'config') {
cb(Object.assign(new Error('missing'), { code: 1 }), '', '')
} else {
capturedEnv = opts.env
cb(null, '', '')
}
return child
})
await gitExecFileAsync(['fetch', 'origin'], {
cwd: '/repo',
env: {},
useConfiguredSshCommandForNetwork: true
})
expect(capturedEnv?.GIT_SSH_COMMAND).toBe('ssh -o BatchMode=yes')
})
it('preserves explicit GIT_SSH_COMMAND and skips the opted-in config probe', async () => {
const child = createMockChildProcess(1234)
let capturedEnv: NodeJS.ProcessEnv | undefined
execFileMock.mockImplementation((_cmd, _args, opts, cb) => {
capturedEnv = opts.env
cb(null, '', '')
return child
})
await gitExecFileAsync(['fetch', 'origin'], {
cwd: '/repo',
env: { GIT_SSH_COMMAND: 'custom-ssh -o IdentityAgent=none' },
useConfiguredSshCommandForNetwork: true
})
expect(execFileMock).toHaveBeenCalledTimes(1)
expect(capturedEnv?.GIT_SSH_COMMAND).toBe('custom-ssh -o IdentityAgent=none')
expect(capturedEnv?.GIT_TERMINAL_PROMPT).toBe('0')
})
it('routes git through the selected WSL distro login shell when requested', async () => {
await withPlatform('win32', async () => {
const child = createMockChildProcess(1234)
execFileMock.mockImplementation((_cmd, _args, _opts, cb) => {
cb(null, 'ok', '')
return child
})
await gitExecFileAsync(['status', '--short'], {
cwd: String.raw`C:\repo`,
wslDistro: 'Ubuntu'
})
expect(execFileMock).toHaveBeenCalledWith(
'wsl.exe',
['-d', 'Ubuntu', '--exec', 'sh', '-lc', expect.any(String)],
// Why a concrete directory (#16463): `undefined` makes CreateProcessW inherit
// Orca's own cwd, a deletable WSL UNC path when it was launched from a
// worktree. The Linux directory still rides inside the command (/mnt/c/repo,
// asserted below).
expect.objectContaining({ cwd: expect.any(String) }),
expect.any(Function)
)
// A read also warms the direct-git environment probe in the background, so
// pick the git call rather than assuming it is the first spawn.
const gitCall = execFileMock.mock.calls.find((call) => String(call[1]?.[5]).includes("'git'"))
const shellCommand = gitCall?.[1]?.[5] as string
expect(shellCommand).toContain('getent passwd')
expect(shellCommand).toContain('exec "$_orca_wsl_shell" -ilc')
expect(shellCommand).toContain('/mnt/c/repo')
expect(shellCommand).toContain("'git'")
expect(shellCommand).toContain('status')
expect(shellCommand).toContain('--short')
})
})
it('routes fixed commands through an explicitly selected WSL distro', async () => {
await withPlatform('win32', async () => {
const child = createMockChildProcess(1234)
execFileMock.mockImplementation((_cmd, _args, _opts, cb) => {
cb(null, 'hostname github.com\n', '')
return child
})
await commandExecFileAsync('ssh', ['-G', '--', 'github-work'], {
cwd: String.raw`C:\repo`,
timeout: 5_000,
wslDistro: 'Ubuntu'
})
expect(execFileMock).toHaveBeenCalledWith(
'wsl.exe',
['-d', 'Ubuntu', '--exec', 'bash', '-c', expect.any(String)],
// Why a concrete directory (#16463): `undefined` makes CreateProcessW inherit
// Orca's own cwd, a deletable WSL UNC path when it was launched from a
// worktree. The Linux directory still rides inside the command (/mnt/c/repo,
// asserted below).
expect.objectContaining({ cwd: expect.any(String) }),
expect.any(Function)
)
const shellCommand = execFileMock.mock.calls[0]?.[1]?.[5] as string
expect(shellCommand).toContain('/mnt/c/repo')
expect(shellCommand).toContain("'ssh' '-G' '--' 'github-work'")
})
})
it('forwards synthesized network SSH policy into the selected WSL distro', async () => {
await withPlatform('win32', async () => {
const child = createMockChildProcess(1234)
let capturedEnv: NodeJS.ProcessEnv | undefined
execFileMock.mockImplementation((_cmd, args, opts, cb) => {
const shellCommand = args[5] as string
if (shellCommand.includes("'config'")) {
cb(Object.assign(new Error('missing'), { code: 1 }), '', '')
} else {
capturedEnv = opts.env
cb(null, '', '')
}
return child
})
await gitExecFileAsync(['fetch', 'origin'], {
cwd: String.raw`C:\repo`,
env: {},
wslDistro: 'Ubuntu',
useConfiguredSshCommandForNetwork: true
})
expect(capturedEnv?.GIT_SSH_COMMAND).toBe('ssh -o BatchMode=yes')
expect((capturedEnv?.WSLENV ?? '').split(':')).toContain('GIT_SSH_COMMAND')
})
})
it('forwards synthesized network SSH policy when a UNC cwd selects WSL', async () => {
await withPlatform('win32', async () => {
const child = createMockChildProcess(1234)
let capturedEnv: NodeJS.ProcessEnv | undefined
execFileMock.mockImplementation((_cmd, args, opts, cb) => {
const shellCommand = args[5] as string
if (shellCommand.includes("'config'")) {
cb(Object.assign(new Error('missing'), { code: 1 }), '', '')
} else {
capturedEnv = opts.env
cb(null, '', '')
}
return child
})
await gitExecFileAsync(['fetch', 'origin'], {
cwd: String.raw`\\wsl.localhost\Ubuntu\home\me\repo`,
env: {},
useConfiguredSshCommandForNetwork: true
})
expect(capturedEnv?.GIT_SSH_COMMAND).toBe('ssh -o BatchMode=yes')
expect((capturedEnv?.WSLENV ?? '').split(':')).toContain('GIT_SSH_COMMAND')
})
})
it('quotes WSL-routed executables before entering the shell', async () => {
await withPlatform('win32', async () => {
const child = createMockChildProcess(1234)
spawnMock.mockReturnValue(child)
wslAwareSpawn('codex; touch /tmp/pwned', ['--version'], {
cwd: String.raw`C:\repo`,
stdio: ['pipe', 'pipe', 'pipe'],
wslDistro: 'Ubuntu',
useWslLoginShell: true
})
const shellCommand = spawnMock.mock.calls[0]?.[1]?.[5] as string
expect(shellCommand).toContain(String.raw`'\''codex; touch /tmp/pwned'\'' '\''--version'\''`)
})
})
})
describe('gitStreamStdout', () => {
beforeEach(() => {
spawnMock.mockReset()
})
it('streams chunks to onStdout and resolves cleanly on a zero exit', async () => {
const child = createMockChildProcess(1234)
spawnMock.mockReturnValue(child)
const chunks: string[] = []
const promise = gitStreamStdout(['status', '--porcelain=v2'], {
cwd: '/repo',
onStdout: (chunk) => {
chunks.push(chunk)
}
})
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce())
child.stdout.emit('data', Buffer.from('? a.txt\n'))
child.stdout.emit('data', Buffer.from('? b.txt\n'))
child.emit('close', 0)
await expect(promise).resolves.toEqual({ stoppedEarly: false })
expect(chunks).toEqual(['? a.txt\n', '? b.txt\n'])
expect(child.kill).not.toHaveBeenCalled()
})
it('kills git early and resolves stoppedEarly when onStdout requests a stop', async () => {
const child = createMockChildProcess(1234)
spawnMock.mockReturnValue(child)
let calls = 0
const promise = gitStreamStdout(['status'], {
cwd: '/repo',
// Stop after the first chunk — mirrors a parser hitting its entry limit.
onStdout: () => {
calls += 1
return true
}
})
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce())
child.stdout.emit('data', Buffer.from('? a.txt\n'))
await expect(promise).resolves.toEqual({ stoppedEarly: true })
expect(child.kill).toHaveBeenCalled()
expect(calls).toBe(1)
child.emit('close', null, 'SIGTERM')
})
it('rejects when stdout exceeds the maxBuffer backstop', async () => {
const child = createMockChildProcess(1234)
spawnMock.mockReturnValue(child)
const promise = gitStreamStdout(['status'], {
cwd: '/repo',
maxBuffer: 4,
onStdout: () => {}
})
const rejection = expect(promise).rejects.toThrow('git stdout exceeded maxBuffer.')
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce())
child.stdout.emit('data', Buffer.from('way too much'))
await rejection
expect(child.kill).toHaveBeenCalled()
child.emit('close', null, 'SIGTERM')
})
it('rejects on a non-zero exit with stderr context', async () => {
const child = createMockChildProcess(1234)
spawnMock.mockReturnValue(child)
const promise = gitStreamStdout(['status'], { cwd: '/repo', onStdout: () => {} })
const rejection = expect(promise).rejects.toThrow('git exited with 128')
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce())
child.stderr.emit('data', Buffer.from('fatal: not a git repository'))
child.emit('close', 128)
await rejection
})
it('rejects (not crashes) when the onStdout callback throws', async () => {
const child = createMockChildProcess(1234)
spawnMock.mockReturnValue(child)
const promise = gitStreamStdout(['status'], {
cwd: '/repo',
onStdout: () => {
throw new Error('parser blew up')
}
})
const rejection = expect(promise).rejects.toThrow('parser blew up')
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce())
child.stdout.emit('data', Buffer.from('? a.txt\n'))
await rejection
expect(child.kill).toHaveBeenCalled()
child.emit('close', null, 'SIGTERM')
})
it('handles a late spawn error after cancellation', async () => {
const child = createMockChildProcess(0)
spawnMock.mockReturnValue(child)
const controller = new AbortController()
const promise = gitStreamStdout(['status'], {
cwd: '/repo',
signal: controller.signal,
onStdout: () => {}
})
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce())
controller.abort()
await expect(promise).rejects.toMatchObject({ name: 'AbortError' })
expect(() => child.emit('error', new Error('spawn ENOENT'))).not.toThrow()
})
})
describe('translateWslOutputPaths', () => {
it('translates WSL output paths with an explicit distro for Windows cwd routing', () => {
expect(
translateWslOutputPaths('worktree /mnt/c/Users/me/repo-feature\n', 'C:\\Users\\me\\repo', {
wslDistro: 'Ubuntu'
})
).toBe('worktree C:\\Users\\me\\repo-feature\n')
})
})