[Perf-SS] Remove redundant native Ripgrep probes (#13462)

This commit is contained in:
Neil
2026-08-10 17:25:26 -07:00
committed by GitHub
parent 3d8c8c3d4c
commit 2ade803522
18 changed files with 1198 additions and 211 deletions
@@ -1,17 +1,19 @@
import { execFile as execFileCallback } from 'node:child_process'
import { execFile as execFileCallback, spawn, type SpawnOptions } from 'node:child_process'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { promisify } from 'node:util'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Store } from '../persistence'
import type * as GitRunner from '../git/runner'
const { checkRgAvailableMock } = vi.hoisted(() => ({
checkRgAvailableMock: vi.fn()
const { wslAwareSpawnMock } = vi.hoisted(() => ({
wslAwareSpawnMock: vi.fn()
}))
vi.mock('./rg-availability', () => ({
checkRgAvailable: checkRgAvailableMock
vi.mock('../git/runner', async (importOriginal) => ({
...(await importOriginal<typeof GitRunner>()),
wslAwareSpawn: wslAwareSpawnMock
}))
import { listQuickOpenFiles } from './filesystem-list-files'
@@ -50,6 +52,16 @@ async function initRepo(repoPath: string): Promise<void> {
describe('filesystem-list-files real git fallback', () => {
let tempDir: string | null = null
beforeEach(() => {
wslAwareSpawnMock.mockImplementation(
(_command: string, _args: string[], options: SpawnOptions & { cwd?: string }) =>
spawn('orca-definitely-missing-rg', [], {
cwd: options.cwd,
stdio: options.stdio
})
)
})
afterEach(async () => {
if (tempDir) {
await rm(tempDir, { recursive: true, force: true })
@@ -59,7 +71,6 @@ describe('filesystem-list-files real git fallback', () => {
})
it('returns real paths for UTF-8 filenames from the git fallback', async () => {
checkRgAvailableMock.mockResolvedValue(false)
tempDir = await mkdtemp(join(tmpdir(), 'orca-quick-open-git-fallback-'))
const repoPath = join(tempDir, 'repo')
await execFile('git', ['init', '-q', repoPath])
@@ -71,7 +82,6 @@ describe('filesystem-list-files real git fallback', () => {
})
it('fills nested git repos from gitlink and untracked embedded-repo entries', async () => {
checkRgAvailableMock.mockResolvedValue(false)
tempDir = await mkdtemp(join(tmpdir(), 'orca-quick-open-monorepo-'))
const repoPath = join(tempDir, 'parent')
const appPath = join(repoPath, 'packages', 'app')
@@ -117,7 +127,6 @@ describe('filesystem-list-files real git fallback', () => {
})
it('walks a non-git root instead of returning an empty git fallback result', async () => {
checkRgAvailableMock.mockResolvedValue(false)
tempDir = await mkdtemp(join(tmpdir(), 'orca-quick-open-non-git-'))
await writeRel(tempDir, 'folder/file.ts')
@@ -127,7 +136,6 @@ describe('filesystem-list-files real git fallback', () => {
})
it('bounds a non-git readdir fallback without treating the limit as an error', async () => {
checkRgAvailableMock.mockResolvedValue(false)
tempDir = await mkdtemp(join(tmpdir(), 'orca-quick-open-bounded-non-git-'))
await writeRel(tempDir, 'a.ts')
await writeRel(tempDir, 'b.ts')
@@ -139,7 +147,6 @@ describe('filesystem-list-files real git fallback', () => {
})
it('rejects abnormal git ls-files failures instead of resolving an empty list', async () => {
checkRgAvailableMock.mockResolvedValue(false)
tempDir = await mkdtemp(join(tmpdir(), 'orca-quick-open-bad-index-'))
const repoPath = join(tempDir, 'repo')
await initRepo(repoPath)
@@ -151,7 +158,6 @@ describe('filesystem-list-files real git fallback', () => {
})
it('resolves an empty repo as an empty list', async () => {
checkRgAvailableMock.mockResolvedValue(false)
tempDir = await mkdtemp(join(tmpdir(), 'orca-quick-open-empty-repo-'))
const repoPath = join(tempDir, 'repo')
await initRepo(repoPath)
@@ -1,16 +1,21 @@
import { spawn, type ChildProcess, type SpawnOptions } from 'node:child_process'
import { EventEmitter } from 'node:events'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Store } from '../persistence'
import type * as GitRunner from '../git/runner'
const {
listFilesWithGitMock,
resolveAuthorizedPathMock,
checkRgAvailableMock,
getLocalGitOptionsForRegisteredWorktreeMock
getLocalGitOptionsForRegisteredWorktreeMock,
wslAwareSpawnMock
} = vi.hoisted(() => ({
listFilesWithGitMock: vi.fn(),
resolveAuthorizedPathMock: vi.fn(),
checkRgAvailableMock: vi.fn(),
getLocalGitOptionsForRegisteredWorktreeMock: vi.fn()
getLocalGitOptionsForRegisteredWorktreeMock: vi.fn(),
wslAwareSpawnMock: vi.fn()
}))
vi.mock('./filesystem-list-files-git-fallback', () => ({
@@ -29,14 +34,39 @@ vi.mock('./local-worktree-runtime-options', () => ({
getLocalGitOptionsForRegisteredWorktree: getLocalGitOptionsForRegisteredWorktreeMock
}))
vi.mock('../git/runner', async (importOriginal) => ({
...(await importOriginal<typeof GitRunner>()),
wslAwareSpawn: wslAwareSpawnMock
}))
import { listQuickOpenFiles } from './filesystem-list-files'
function createStartedRipgrepProcess(): ChildProcess {
const child = new EventEmitter() as ChildProcess
;(child as unknown as Record<string, unknown>).stdout = new EventEmitter()
;(
(child as unknown as Record<string, unknown>).stdout as EventEmitter & {
setEncoding: () => void
}
).setEncoding = vi.fn()
;(child as unknown as Record<string, unknown>).stderr = new EventEmitter()
;(child as unknown as Record<string, unknown>).kill = vi.fn()
;(child as unknown as Record<string, unknown>).exitCode = null
;(child as unknown as Record<string, unknown>).signalCode = null
Object.defineProperty(child, 'pid', { value: 1 })
return child
}
describe('filesystem-list-files ripgrep guidance', () => {
beforeEach(() => {
vi.clearAllMocks()
resolveAuthorizedPathMock.mockImplementation(async (path) => path)
checkRgAvailableMock.mockResolvedValue(false)
checkRgAvailableMock.mockResolvedValue(true)
getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({})
wslAwareSpawnMock.mockImplementation(
(_command: string, _args: string[], options: SpawnOptions & { cwd?: string }) =>
spawn('orca-definitely-missing-rg', [], { stdio: options.stdio })
)
})
it('turns only a readdir budget failure into install guidance', async () => {
@@ -51,6 +81,31 @@ describe('filesystem-list-files ripgrep guidance', () => {
)
})
it('keeps the WSL preflight and falls back before starting real rg', async () => {
getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' })
checkRgAvailableMock.mockResolvedValue(false)
listFilesWithGitMock.mockResolvedValue(['src/index.ts'])
await expect(listQuickOpenFiles('C:\\repo', {} as Store)).resolves.toEqual(['src/index.ts'])
expect(checkRgAvailableMock).toHaveBeenCalledWith('C:\\repo', 'Ubuntu')
expect(wslAwareSpawnMock).not.toHaveBeenCalled()
})
it("falls back when a native launcher exits outside ripgrep's contract", async () => {
const primary = createStartedRipgrepProcess()
const ignored = createStartedRipgrepProcess()
let callIndex = 0
wslAwareSpawnMock.mockImplementation(() => (++callIndex === 1 ? primary : ignored))
listFilesWithGitMock.mockResolvedValue(['src/index.ts'])
const listing = listQuickOpenFiles('/workspace', {} as Store)
await Promise.resolve()
primary.emit('close', 127, null)
await expect(listing).resolves.toEqual(['src/index.ts'])
expect(listFilesWithGitMock).toHaveBeenCalledTimes(1)
})
it('keeps cancellation and Git errors unchanged', async () => {
const cancellation = new Error('File listing cancelled')
listFilesWithGitMock.mockRejectedValueOnce(cancellation)
+136 -23
View File
@@ -36,6 +36,7 @@ import { listQuickOpenFiles } from './filesystem-list-files'
import { EventEmitter } from 'node:events'
import type { Store } from '../persistence'
import type { ChildProcess } from 'node:child_process'
import { FileListingCancelledError } from '../../shared/file-listing-cancellation'
const SHA1 = '0123456789abcdef0123456789abcdef01234567'
@@ -55,10 +56,24 @@ function createMockProcess(): ChildProcess {
;(p as unknown as Record<string, unknown>).kill = vi.fn()
;(p as unknown as Record<string, unknown>).exitCode = null
;(p as unknown as Record<string, unknown>).signalCode = null
Object.defineProperty(p, 'pid', { configurable: true, value: 1 })
return p
}
function createMissingRipgrepProcess(): ChildProcess {
const child = createMockProcess()
Object.defineProperty(child, 'pid', { value: undefined })
void Promise.resolve().then(() => child.emit('close', -2, null))
return child
}
async function flushMicrotasks(): Promise<void> {
for (let index = 0; index < 12; index++) {
await Promise.resolve()
}
}
function isIgnoredRgPass(args: string[]): boolean {
return args.includes('--no-ignore-vcs')
}
@@ -93,6 +108,8 @@ describe('filesystem-list-files', () => {
expect(p1.kill).toHaveBeenCalled()
expect(p2.kill).not.toHaveBeenCalled()
expect(spawnMock).toHaveBeenCalledTimes(1)
expect(checkRgAvailableMock).not.toHaveBeenCalled()
expect(spawnMock.mock.calls[0]?.[1]).not.toContain('--version')
})
it('merges normal files and ignored files and filters correctly', async () => {
@@ -108,6 +125,8 @@ describe('filesystem-list-files', () => {
const storeMock = {} as unknown as Store
const promise = listQuickOpenFiles('/mock/root', storeMock)
await flushMicrotasks()
expect(spawnMock).toHaveBeenCalledTimes(2)
// Simulate stdout output for normal files
setTimeout(() => {
@@ -192,6 +211,24 @@ describe('filesystem-list-files', () => {
await expect(promise).resolves.toEqual(['src/index.ts'])
})
it('does not mistake a WSL-routed executable exit 127 for missing rg', async () => {
const p1 = createMockProcess()
const p2 = createMockProcess()
Object.defineProperty(p1, 'pid', { value: 1 })
Object.defineProperty(p2, 'pid', { value: 2 })
getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' })
spawnMock.mockImplementation((_cmd, args: string[]) => (isIgnoredRgPass(args) ? p2 : p1))
const promise = listQuickOpenFiles('C:\\repo', {} as unknown as Store)
setTimeout(() => {
p1.emit('close', 127, null)
p2.emit('close', 0, null)
}, 0)
await expect(promise).rejects.toThrow('rg exited with code 127')
expect(spawnMock.mock.calls.some((call) => call[0] === 'git')).toBe(false)
})
it('rejects rg failures instead of resolving a false-empty list', async () => {
const p1 = createMockProcess()
const p2 = createMockProcess()
@@ -330,16 +367,90 @@ describe('filesystem-list-files', () => {
expect(result).toEqual(['valid.ts'])
})
describe('git ls-files fallback', () => {
it('falls back to git ls-files when rg is not available', async () => {
checkRgAvailableMock.mockResolvedValue(false)
it('lets cancellation win a native-unavailable race before Git starts', async () => {
const first = createMockProcess()
Object.defineProperty(first, 'pid', { value: undefined })
spawnMock.mockReturnValue(first)
const controller = new AbortController()
const cancellation = new FileListingCancelledError('superseded')
const promise = listQuickOpenFiles(
'/mock/root',
{} as unknown as Store,
undefined,
controller.signal
)
await flushMicrotasks()
controller.abort(cancellation)
first.emit('close', -2, null)
await expect(promise).rejects.toBe(cancellation)
expect(spawnMock).toHaveBeenCalledTimes(1)
expect(spawnMock.mock.calls.some((call) => call[0] === 'git')).toBe(false)
const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' })
expect(() => first.emit('error', error)).not.toThrow()
expect(first.listenerCount('error')).toBe(0)
})
describe('git ls-files fallback', () => {
it('kills only the admitted pass when ignored rg fails before spawn', async () => {
const primary = createMockProcess()
const missingIgnored = createMockProcess()
const revParse = createMockProcess()
const gitPrimary = createMockProcess()
const gitIgnored = createMockProcess()
Object.defineProperty(missingIgnored, 'pid', { value: undefined })
let gitPassIndex = 0
spawnMock.mockImplementation((cmd: string, args: string[]) => {
if (cmd === 'rg') {
return isIgnoredRgPass(args) ? missingIgnored : primary
}
if (args.includes('rev-parse')) {
return revParse
}
if (args.includes('ls-files')) {
return gitPassIndex++ === 0 ? gitPrimary : gitIgnored
}
return createMockProcess()
})
const promise = listQuickOpenFiles('/mock/root', {} as unknown as Store)
await flushMicrotasks()
expect(spawnMock.mock.calls.filter((call) => call[0] === 'rg')).toHaveLength(2)
missingIgnored.emit('close', -2, null)
await vi.waitFor(() => expect(primary.kill).toHaveBeenCalled())
expect(missingIgnored.kill).not.toHaveBeenCalled()
const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' })
expect(() => missingIgnored.emit('error', error)).not.toThrow()
await vi.waitFor(() =>
expect(
spawnMock.mock.calls.some((call) => (call[1] as string[]).includes('rev-parse'))
).toBe(true)
)
revParse.emit('close', 0, null)
await vi.waitFor(() =>
expect(
spawnMock.mock.calls.filter((call) => (call[1] as string[]).includes('ls-files'))
).toHaveLength(2)
)
gitPrimary.emit('close', 0, null)
gitIgnored.emit('close', 0, null)
await expect(promise).resolves.toEqual([])
expect(missingIgnored.listenerCount('error')).toBe(0)
})
it('falls back to git ls-files when rg is not available', async () => {
let callIndex = 0
const revParseProc = createMockProcess()
const gitP1 = createMockProcess()
const gitP2 = createMockProcess()
spawnMock.mockImplementation((cmd: string, args: string[]) => {
if (cmd === 'rg') {
return createMissingRipgrepProcess()
}
if (cmd === 'git' && args.includes('rev-parse')) {
return revParseProc
}
@@ -378,9 +489,10 @@ describe('filesystem-list-files', () => {
const result = await promise
// Verify rg was never called
// The primary real command doubles as the availability check.
const rgCalls = spawnMock.mock.calls.filter((call) => call[0] === 'rg')
expect(rgCalls.length).toBe(0)
expect(rgCalls.length).toBe(1)
expect(rgCalls.every((call) => !(call[1] as string[]).includes('--version'))).toBe(true)
// Verify git ls-files was called
const gitCalls = spawnMock.mock.calls.filter(
@@ -402,13 +514,15 @@ describe('filesystem-list-files', () => {
})
it('stops after primary Git files fill the result budget', async () => {
checkRgAvailableMock.mockResolvedValue(false)
const revParseProc = createMockProcess()
const gitP1 = createMockProcess()
const gitP2 = createMockProcess()
let callIndex = 0
spawnMock.mockImplementation((cmd: string, args: string[]) => {
if (cmd === 'rg') {
return createMissingRipgrepProcess()
}
if (cmd === 'git' && args.includes('rev-parse')) {
return revParseProc
}
@@ -439,10 +553,12 @@ describe('filesystem-list-files', () => {
})
it('does not let a discarded Git directory placeholder consume the result budget', async () => {
checkRgAvailableMock.mockResolvedValue(false)
const revParseProc = createMockProcess()
const primary = createMockProcess()
spawnMock.mockImplementation((cmd: string, args: string[]) => {
if (cmd === 'rg') {
return createMissingRipgrepProcess()
}
if (cmd === 'git' && args.includes('rev-parse')) {
return revParseProc
}
@@ -469,14 +585,15 @@ describe('filesystem-list-files', () => {
})
it('git fallback applies hidden dir blocklist', async () => {
checkRgAvailableMock.mockResolvedValue(false)
const revParseProc = createMockProcess()
const gitP1 = createMockProcess()
const gitP2 = createMockProcess()
let callIndex = 0
spawnMock.mockImplementation((cmd: string, args: string[]) => {
if (cmd === 'rg') {
return createMissingRipgrepProcess()
}
if (cmd === 'git' && args.includes('rev-parse')) {
return revParseProc
}
@@ -518,7 +635,6 @@ describe('filesystem-list-files', () => {
})
it('settles and detaches git fallback scans that ignore timeout kills', async () => {
checkRgAvailableMock.mockResolvedValue(false)
vi.useFakeTimers()
try {
@@ -528,6 +644,9 @@ describe('filesystem-list-files', () => {
let callIndex = 0
spawnMock.mockImplementation((cmd: string, args: string[]) => {
if (cmd === 'rg') {
return createMissingRipgrepProcess()
}
if (cmd === 'git' && args.includes('rev-parse')) {
return revParseProc
}
@@ -541,13 +660,9 @@ describe('filesystem-list-files', () => {
const storeMock = {} as unknown as Store
const promise = listQuickOpenFiles('/mock/root', storeMock)
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
await flushMicrotasks()
revParseProc.emit('close', 0, null)
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
await flushMicrotasks()
;(gitP1.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\0partial')
@@ -567,7 +682,6 @@ describe('filesystem-list-files', () => {
})
it('keeps primary results when only the ignored pass times out', async () => {
checkRgAvailableMock.mockResolvedValue(false)
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
vi.useFakeTimers()
@@ -578,6 +692,9 @@ describe('filesystem-list-files', () => {
let callIndex = 0
spawnMock.mockImplementation((cmd: string, args: string[]) => {
if (cmd === 'rg') {
return createMissingRipgrepProcess()
}
if (cmd === 'git' && args.includes('rev-parse')) {
return revParseProc
}
@@ -591,13 +708,9 @@ describe('filesystem-list-files', () => {
const storeMock = {} as unknown as Store
const promise = listQuickOpenFiles('/mock/root', storeMock)
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
await flushMicrotasks()
revParseProc.emit('close', 0, null)
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
await flushMicrotasks()
;(gitP1.stdout as unknown as EventEmitter).emit(
'data',
+47 -14
View File
@@ -17,6 +17,12 @@ import {
import { isQuickOpenReaddirBudgetError } from '../../shared/quick-open-readdir-walk'
import { buildInstallRgMessage } from '../../shared/quick-open-install-rg'
import { listFilesWithGit } from './filesystem-list-files-git-fallback'
import {
absorbPendingRipgrepSpawnError,
isRipgrepUnavailableExit,
killSpawnedRipgrepProcess,
RipgrepUnavailableError
} from '../../shared/ripgrep-process-availability'
export async function listQuickOpenFiles(
rootPath: string,
@@ -37,13 +43,9 @@ export async function listQuickOpenFiles(
// every worktree instead of just the active one. The shared helper
// normalizes, validates, and root-relativizes every input.
const excludePathPrefixes = buildExcludePathPrefixes(authorizedRootPath, excludePaths)
const wslDistroForOutput = parseWslPath(authorizedRootPath)?.distro ?? localGitOptions.wslDistro
// Why: checking rg availability upfront avoids a race condition where
// spawn('rg') emits 'close' before 'error' on some platforms, causing
// the handler to resolve with empty results before the git fallback
// can run.
const rgAvailable = await checkRgAvailable(authorizedRootPath, localGitOptions.wslDistro)
if (!rgAvailable) {
const listWithoutRipgrep = async (): Promise<string[]> => {
try {
return await listFilesWithGit(
authorizedRootPath,
@@ -59,6 +61,12 @@ export async function listQuickOpenFiles(
throw new Error(await buildInstallRgMessage(err))
}
}
if (
wslDistroForOutput &&
!(await checkRgAvailable(authorizedRootPath, localGitOptions.wslDistro))
) {
return listWithoutRipgrep()
}
const files = new Set<string>()
const children: {
@@ -68,8 +76,6 @@ export async function listQuickOpenFiles(
}[] = []
// Why: WSL-routed rg can emit Linux-native absolute paths. UNC repos carry
// their distro in the path; Windows-path repos carry it in project runtime.
const wslDistroForOutput = parseWslPath(authorizedRootPath)?.distro ?? localGitOptions.wslDistro
const { primary, ignoredPass } = buildRgArgsForQuickOpen({
// Why: rg evaluates root-relative exclude globs against cwd only when the
// search target is cwd-relative. With an absolute target, `!packages/app`
@@ -86,6 +92,8 @@ export async function listQuickOpenFiles(
let buf = ''
let done = false
let parseablePathCount = 0
let processErrorObserved = false
let unavailableExitObserved = false
const processLine = (rawLine: string): boolean => {
const translated =
@@ -138,12 +146,27 @@ export async function listQuickOpenFiles(
/* drain */
}
const handleError = (): void => {
processErrorObserved = true
// Why: treat spawn errors like an abnormal exit — discard residual
// buffer so a truncated final byte sequence cannot leak as a path.
buf = ''
if (isRipgrepUnavailableExit(child, null, null)) {
finish(new RipgrepUnavailableError())
return
}
finish(new Error('rg failed to start'))
}
const handleClose = (code: number | null, signal: NodeJS.Signals | null): void => {
if (
isRipgrepUnavailableExit(child, code, signal, {
classifyNativeLauncherExit: !wslDistroForOutput
})
) {
unavailableExitObserved = true
buf = ''
finish(new RipgrepUnavailableError())
return
}
if (signal) {
// Why: a signal exit means timeout/OOM/external kill. Returning the
// already-streamed prefix would recreate the false-empty bug this
@@ -179,6 +202,10 @@ export async function listQuickOpenFiles(
child.stderr!.off('data', handleStderrData)
child.off('error', handleError)
child.off('close', handleClose)
absorbPendingRipgrepSpawnError(child, {
errorObserved: processErrorObserved,
unavailableExitObserved
})
if (err) {
reject(err)
} else {
@@ -197,7 +224,7 @@ export async function listQuickOpenFiles(
// Why: on timeout, the buffer is likely truncated mid-path. Discard
// it so Quick Open never displays a malformed entry.
buf = ''
child.kill()
killSpawnedRipgrepProcess(child)
finish(new Error('rg list timed out'))
}, 10000)
})
@@ -213,7 +240,7 @@ export async function listQuickOpenFiles(
}
entry.finish()
if (entry.child.exitCode === null && entry.child.signalCode === null) {
entry.child.kill()
killSpawnedRipgrepProcess(entry.child)
}
}
}
@@ -225,24 +252,30 @@ export async function listQuickOpenFiles(
}
entry.finish()
if (entry.child.exitCode === null && entry.child.signalCode === null) {
entry.child.kill()
killSpawnedRipgrepProcess(entry.child)
}
}
}
try {
const primaryRun = runRg(primary)
if (maxResults === undefined) {
await Promise.all([runRg(primary), runRg(ignoredPass)])
// Why: a pid-less primary proves launch failure; avoid doubling the failed spawn.
await (children[0]?.child.pid === undefined
? primaryRun
: Promise.all([primaryRun, runRg(ignoredPass)]))
} else {
// Why: ignored-file output can be much larger and faster than the primary
// pass; let source files claim the bounded autocomplete budget first.
await runRg(primary)
await primaryRun
if (files.size < maxResults) {
await runRg(ignoredPass)
}
}
} catch (err) {
killSurvivors()
if (err instanceof RipgrepUnavailableError) {
return listWithoutRipgrep()
}
throw err
}
return Array.from(files).slice(0, maxResults)
@@ -7,6 +7,7 @@ const {
resolveAuthorizedPathMock,
checkRgAvailableMock,
getLocalGitOptionsForRegisteredWorktreeMock,
searchWithGitGrepMock,
wslAwareSpawnMock,
toWindowsWslPathMock
} = vi.hoisted(() => ({
@@ -14,6 +15,7 @@ const {
resolveAuthorizedPathMock: vi.fn(),
checkRgAvailableMock: vi.fn(),
getLocalGitOptionsForRegisteredWorktreeMock: vi.fn(),
searchWithGitGrepMock: vi.fn(),
wslAwareSpawnMock: vi.fn(),
toWindowsWslPathMock: vi.fn((value: string) => value)
}))
@@ -56,7 +58,7 @@ vi.mock('./filesystem-mutations', () => ({
}))
vi.mock('./filesystem-search-git', () => ({
searchWithGitGrep: vi.fn()
searchWithGitGrep: searchWithGitGrepMock
}))
vi.mock('./local-worktree-runtime-options', () => ({
@@ -87,6 +89,12 @@ function createMockProcess(): ChildProcess {
return p
}
async function flushMicrotasks(): Promise<void> {
for (let index = 0; index < 8; index++) {
await Promise.resolve()
}
}
describe('filesystem rg search timeout', () => {
beforeEach(() => {
handlers.clear()
@@ -120,6 +128,7 @@ describe('filesystem rg search timeout', () => {
const result = await promise
expect(result.truncated).toBe(true)
expect(checkRgAvailableMock).not.toHaveBeenCalled()
expect(child.kill).toHaveBeenCalled()
expect((child.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0)
expect((child.stderr as unknown as EventEmitter).listenerCount('data')).toBe(0)
@@ -130,8 +139,77 @@ describe('filesystem rg search timeout', () => {
}
})
it.each(['error-first', 'close-first'] as const)(
'falls back once when a native launch failure is %s',
async (order) => {
const child = createMockProcess()
Object.defineProperty(child, 'pid', { value: undefined })
wslAwareSpawnMock.mockReturnValue(child)
const fallback = { files: [], totalMatches: 0, truncated: false }
searchWithGitGrepMock.mockResolvedValue(fallback)
registerFilesystemHandlers({} as never)
const promise = handlers.get('fs:search')!(
{ sender: { id: 7 } },
{ rootPath: '/repo', query: 'ok' }
) as Promise<unknown>
await flushMicrotasks()
const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' })
if (order === 'error-first') {
expect(() => child.emit('error', error)).not.toThrow()
child.emit('close', -2, null)
} else {
child.emit('close', -2, null)
expect(() => child.emit('error', error)).not.toThrow()
}
await expect(promise).resolves.toBe(fallback)
expect(searchWithGitGrepMock).toHaveBeenCalledTimes(1)
expect(checkRgAvailableMock).not.toHaveBeenCalled()
expect(child.listenerCount('error')).toBe(0)
expect(child.listenerCount('close')).toBe(0)
}
)
it('keeps post-spawn errors on the existing empty-result path', async () => {
const child = createMockProcess()
Object.defineProperty(child, 'pid', { value: 1 })
wslAwareSpawnMock.mockReturnValue(child)
registerFilesystemHandlers({} as never)
const promise = handlers.get('fs:search')!(
{ sender: { id: 7 } },
{ rootPath: '/repo', query: 'ok' }
) as Promise<{ files: unknown[] }>
await flushMicrotasks()
child.emit('error', new Error('post-spawn failure'))
await expect(promise).resolves.toMatchObject({ files: [] })
expect(searchWithGitGrepMock).not.toHaveBeenCalled()
})
it("falls back when a native launcher exits outside ripgrep's contract", async () => {
const child = createMockProcess()
Object.defineProperty(child, 'pid', { value: 1 })
wslAwareSpawnMock.mockReturnValue(child)
const fallback = { files: [], totalMatches: 0, truncated: false }
searchWithGitGrepMock.mockResolvedValue(fallback)
registerFilesystemHandlers({} as never)
const promise = handlers.get('fs:search')!(
{ sender: { id: 7 } },
{ rootPath: '/repo', query: 'ok' }
) as Promise<unknown>
await flushMicrotasks()
child.emit('close', 127, null)
await expect(promise).resolves.toBe(fallback)
expect(searchWithGitGrepMock).toHaveBeenCalledTimes(1)
})
it('routes rg through the registered WSL project runtime for Windows-path worktrees', async () => {
const child = createMockProcess()
Object.defineProperty(child, 'pid', { value: 1 })
wslAwareSpawnMock.mockReturnValue(child)
getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' })
registerFilesystemHandlers({} as never)
@@ -142,12 +220,13 @@ describe('filesystem rg search timeout', () => {
) as Promise<unknown>
setTimeout(() => {
child.emit('close')
child.emit('close', 127, null)
}, 10)
await promise
expect(checkRgAvailableMock).toHaveBeenCalledWith('C:\\repo', 'Ubuntu')
expect(searchWithGitGrepMock).not.toHaveBeenCalled()
expect(wslAwareSpawnMock).toHaveBeenCalledWith(
'rg',
expect.any(Array),
@@ -158,6 +237,23 @@ describe('filesystem rg search timeout', () => {
)
})
it('keeps the WSL search preflight and falls back before starting real rg', async () => {
const fallback = { files: [], totalMatches: 0, truncated: false }
getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' })
checkRgAvailableMock.mockResolvedValue(false)
searchWithGitGrepMock.mockResolvedValue(fallback)
registerFilesystemHandlers({} as never)
const promise = handlers.get('fs:search')!(
{ sender: { id: 7 } },
{ rootPath: 'C:\\repo', query: 'ok' }
) as Promise<unknown>
await expect(promise).resolves.toBe(fallback)
expect(checkRgAvailableMock).toHaveBeenCalledWith('C:\\repo', 'Ubuntu')
expect(wslAwareSpawnMock).not.toHaveBeenCalled()
})
it('translates WSL rg output for Windows-path project search results', async () => {
const child = createMockProcess()
wslAwareSpawnMock.mockReturnValue(child)
+47 -13
View File
@@ -112,6 +112,11 @@ import {
} from './source-control-ai-linked-issue'
import { listMarkdownDocuments, markdownDocumentsFromRelativePaths } from './markdown-documents'
import { checkRgAvailable } from './rg-availability'
import {
absorbPendingRipgrepSpawnError,
isRipgrepUnavailableExit,
killSpawnedRipgrepProcess
} from '../../shared/ripgrep-process-availability'
import {
getSshFilesystemProvider,
requireSshFilesystemProvider
@@ -980,32 +985,35 @@ export function registerFilesystemHandlers(
Math.min(args.maxResults ?? DEFAULT_SEARCH_MAX_RESULTS, DEFAULT_SEARCH_MAX_RESULTS)
)
const searchKey = `${event.sender.id}:${rootPath}`
// Why: WSL's bash exit 127 is ambiguous with a real executable returning 127.
const wslDistroForOutput = parseWslPath(rootPath)?.distro ?? localGitOptions.wslDistro
// Why: probe rg upfront; on some platforms spawn emits 'close' before 'error', resolving empty before the git-grep fallback runs.
const rgAvailable = await checkRgAvailable(rootPath, localGitOptions.wslDistro)
if (!rgAvailable) {
if (wslDistroForOutput && !(await checkRgAvailable(rootPath, localGitOptions.wslDistro))) {
return searchWithGitGrep(rootPath, args, maxResults, localGitOptions)
}
return new Promise((resolvePromise) => {
return new Promise<SearchResult>((resolvePromise) => {
const rgArgs = buildRgArgs(args.query, rootPath, args)
// Why: kill the prior rg so it stops parsing thousands of matches on the main thread (the large-repo freeze) after the UI moved on.
activeTextSearches.get(searchKey)?.kill()
const previousChild = activeTextSearches.get(searchKey)
if (previousChild) {
killSpawnedRipgrepProcess(previousChild)
}
const acc = createAccumulator()
let stdoutBuffer = ''
let resolved = false
let processErrorObserved = false
let unavailableExitObserved = false
let child: ChildProcess | null = null
let killTimeout: ReturnType<typeof setTimeout>
// Why: WSL-routed rg emits Linux paths; UNC repos carry the distro in the path, Windows-path repos in project runtime.
const wslDistroForOutput = parseWslPath(rootPath)?.distro ?? localGitOptions.wslDistro
const transformAbsPath = wslDistroForOutput
? (p: string): string => (p.startsWith('/') ? toWindowsWslPath(p, wslDistroForOutput) : p)
: undefined
const resolveOnce = (): void => {
const finish = (result: SearchResult | PromiseLike<SearchResult>): void => {
if (resolved) {
return
}
@@ -1019,13 +1027,22 @@ export function registerFilesystemHandlers(
child?.stderr?.off('data', handleStderrData)
child?.off('error', handleError)
child?.off('close', handleClose)
resolvePromise(finalize(acc))
if (child) {
absorbPendingRipgrepSpawnError(child, {
errorObserved: processErrorObserved,
unavailableExitObserved
})
}
resolvePromise(result)
}
const resolveOnce = (): void => finish(finalize(acc))
const resolveWithoutRipgrep = (): void =>
finish(searchWithGitGrep(rootPath, args, maxResults, localGitOptions))
const processLine = (line: string): void => {
const verdict = ingestRgJsonLine(line, rootPath, acc, maxResults, transformAbsPath)
if (verdict === 'stop') {
child?.kill()
if (verdict === 'stop' && child) {
killSpawnedRipgrepProcess(child)
}
}
@@ -1049,9 +1066,24 @@ export function registerFilesystemHandlers(
// Drain stderr so rg cannot block on a full pipe.
}
const handleError = (): void => {
processErrorObserved = true
if (child && isRipgrepUnavailableExit(child, null, null)) {
resolveWithoutRipgrep()
return
}
resolveOnce()
}
const handleClose = (): void => {
const handleClose = (code: number | null, signal: NodeJS.Signals | null): void => {
if (
child &&
isRipgrepUnavailableExit(child, code, signal, {
classifyNativeLauncherExit: !wslDistroForOutput
})
) {
unavailableExitObserved = true
resolveWithoutRipgrep()
return
}
if (stdoutBuffer) {
processLine(stdoutBuffer)
}
@@ -1067,7 +1099,9 @@ export function registerFilesystemHandlers(
// Why: timeout kills the child mid-scan; mark truncated so the UI shows incomplete results.
killTimeout = setTimeout(() => {
acc.truncated = true
child?.kill()
if (child) {
killSpawnedRipgrepProcess(child)
}
resolveOnce()
}, SEARCH_TIMEOUT_MS)
})
+97 -1
View File
@@ -22,6 +22,7 @@ const {
closeWatcherInWatcherProcessMock,
checkRgAvailableMock,
getLocalGitOptionsForRegisteredWorktreeMock,
searchWithGitGrepMock,
wslAwareSpawnMock,
watchMock
} = vi.hoisted(() => ({
@@ -35,6 +36,7 @@ const {
statMock: vi.fn(),
watchInWatcherProcessMock: vi.fn(),
closeWatcherInWatcherProcessMock: vi.fn(),
searchWithGitGrepMock: vi.fn(),
wslAwareSpawnMock: vi.fn(),
watchMock: vi.fn()
}))
@@ -91,6 +93,10 @@ vi.mock('../ipc/local-worktree-runtime-options', () => ({
getLocalGitOptionsForRegisteredWorktree: getLocalGitOptionsForRegisteredWorktreeMock
}))
vi.mock('../ipc/filesystem-search-git', () => ({
searchWithGitGrep: searchWithGitGrepMock
}))
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
getSshFilesystemProvider: vi.fn(),
onSshFilesystemProviderRegistered: () => () => undefined,
@@ -202,6 +208,12 @@ function createRuntimeSearchChild(): MockRuntimeSearchChild {
return child
}
async function flushRuntimeSearchMicrotasks(): Promise<void> {
for (let index = 0; index < 8; index++) {
await Promise.resolve()
}
}
describe('RuntimeFileCommands', () => {
const originalPlatform = process.platform
@@ -217,6 +229,7 @@ describe('RuntimeFileCommands', () => {
closeWatcherInWatcherProcessMock.mockReset()
watchMock.mockReset()
checkRgAvailableMock.mockReset()
searchWithGitGrepMock.mockReset()
vi.mocked(getSshFilesystemProvider).mockReset()
resetSshConnectionGenerations()
getLocalGitOptionsForRegisteredWorktreeMock.mockReset()
@@ -754,6 +767,68 @@ describe('RuntimeFileCommands', () => {
expect(child.stderr.listenerCount('data')).toBe(0)
expect(child.listenerCount('error')).toBe(0)
expect(child.listenerCount('close')).toBe(0)
expect(checkRgAvailableMock).not.toHaveBeenCalled()
})
it.each(['error-first', 'close-first'] as const)(
'falls back once when runtime rg native launch failure is %s',
async (order) => {
const resolveRuntimeFileTarget = vi.fn(async () => ({
worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo' },
connectionId: null
}))
const { commands } = createRuntimeFileCommands({ resolveRuntimeFileTarget })
const child = createRuntimeSearchChild()
Object.defineProperty(child, 'pid', { value: undefined })
resolveAuthorizedPathMock.mockResolvedValue('/repo')
wslAwareSpawnMock.mockReturnValue(child)
const fallback = { files: [], totalMatches: 0, truncated: false }
searchWithGitGrepMock.mockResolvedValue(fallback)
const resultPromise = commands.searchRuntimeFiles('id:wt-1', {
query: 'needle',
maxResults: 10
})
await flushRuntimeSearchMicrotasks()
const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' })
if (order === 'error-first') {
expect(() => child.emit('error', error)).not.toThrow()
child.emit('close', -2, null)
} else {
child.emit('close', -2, null)
expect(() => child.emit('error', error)).not.toThrow()
}
await expect(resultPromise).resolves.toBe(fallback)
expect(searchWithGitGrepMock).toHaveBeenCalledTimes(1)
expect(checkRgAvailableMock).not.toHaveBeenCalled()
expect(child.listenerCount('error')).toBe(0)
expect(child.listenerCount('close')).toBe(0)
}
)
it("falls back when a runtime native launcher exits outside ripgrep's contract", async () => {
const resolveRuntimeFileTarget = vi.fn(async () => ({
worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo' },
connectionId: null
}))
const { commands } = createRuntimeFileCommands({ resolveRuntimeFileTarget })
const child = createRuntimeSearchChild()
Object.defineProperty(child, 'pid', { value: 1 })
resolveAuthorizedPathMock.mockResolvedValue('/repo')
wslAwareSpawnMock.mockReturnValue(child)
const fallback = { files: [], totalMatches: 0, truncated: false }
searchWithGitGrepMock.mockResolvedValue(fallback)
const resultPromise = commands.searchRuntimeFiles('id:wt-1', {
query: 'needle',
maxResults: 10
})
await flushRuntimeSearchMicrotasks()
child.emit('close', 127, null)
await expect(resultPromise).resolves.toBe(fallback)
expect(searchWithGitGrepMock).toHaveBeenCalledTimes(1)
})
it('routes runtime rg searches through the registered WSL project runtime', async () => {
@@ -767,6 +842,7 @@ describe('RuntimeFileCommands', () => {
}))
const { commands, store } = createRuntimeFileCommands({ resolveRuntimeFileTarget })
const child = createRuntimeSearchChild()
Object.defineProperty(child, 'pid', { value: 1 })
resolveAuthorizedPathMock.mockResolvedValue('C:\\repo')
checkRgAvailableMock.mockResolvedValue(true)
getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' })
@@ -779,7 +855,7 @@ describe('RuntimeFileCommands', () => {
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
child.emit('close')
child.emit('close', 127, null)
await expect(resultPromise).resolves.toMatchObject({ files: [] })
expect(getLocalGitOptionsForRegisteredWorktreeMock).toHaveBeenCalledWith(
@@ -788,6 +864,7 @@ describe('RuntimeFileCommands', () => {
'C:\\repo'
)
expect(checkRgAvailableMock).toHaveBeenCalledWith('C:\\repo', 'Ubuntu')
expect(searchWithGitGrepMock).not.toHaveBeenCalled()
expect(wslAwareSpawnMock).toHaveBeenCalledWith(
'rg',
expect.any(Array),
@@ -798,6 +875,25 @@ describe('RuntimeFileCommands', () => {
)
})
it('keeps the runtime WSL preflight and falls back before starting real rg', async () => {
const resolveRuntimeFileTarget = vi.fn(async () => ({
worktree: { id: 'wt-1', repoId: 'repo-1', path: 'C:\\repo' },
connectionId: null
}))
const { commands } = createRuntimeFileCommands({ resolveRuntimeFileTarget })
const fallback = { files: [], totalMatches: 0, truncated: false }
resolveAuthorizedPathMock.mockResolvedValue('C:\\repo')
getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' })
checkRgAvailableMock.mockResolvedValue(false)
searchWithGitGrepMock.mockResolvedValue(fallback)
await expect(
commands.searchRuntimeFiles('id:wt-1', { query: 'needle', maxResults: 10 })
).resolves.toBe(fallback)
expect(checkRgAvailableMock).toHaveBeenCalledWith('C:\\repo', 'Ubuntu')
expect(wslAwareSpawnMock).not.toHaveBeenCalled()
})
describe('resolveTerminalPath', () => {
let tempDirs: string[] = []
+52 -12
View File
@@ -58,6 +58,11 @@ import { listQuickOpenFiles } from '../ipc/filesystem-list-files'
import { searchWithGitGrep } from '../ipc/filesystem-search-git'
import { getLocalGitOptionsForRegisteredWorktree } from '../ipc/local-worktree-runtime-options'
import { checkRgAvailable } from '../ipc/rg-availability'
import {
absorbPendingRipgrepSpawnError,
isRipgrepUnavailableExit,
killSpawnedRipgrepProcess
} from '../../shared/ripgrep-process-availability'
import {
listMarkdownDocuments,
markdownDocumentsFromRelativePaths
@@ -1920,26 +1925,33 @@ export class RuntimeFileCommands {
1,
Math.min(options.maxResults ?? DEFAULT_SEARCH_MAX_RESULTS, DEFAULT_SEARCH_MAX_RESULTS)
)
const rgAvailable = await checkRgAvailable(authorizedRootPath, localGitOptions.wslDistro)
if (!rgAvailable) {
const wslInfo = parseWslPath(authorizedRootPath)
if (
(wslInfo || localGitOptions.wslDistro) &&
!(await checkRgAvailable(authorizedRootPath, localGitOptions.wslDistro))
) {
return searchWithGitGrep(authorizedRootPath, options, maxResults, localGitOptions)
}
return new Promise((resolvePromise) => {
return new Promise<SearchResult>((resolvePromise) => {
const searchKey = `${this.host.getRuntimeId()}:${authorizedRootPath}`
const rgArgs = buildRgArgs(options.query, authorizedRootPath, options)
this.activeRuntimeTextSearches.get(searchKey)?.kill()
const previousChild = this.activeRuntimeTextSearches.get(searchKey)
if (previousChild) {
killSpawnedRipgrepProcess(previousChild)
}
const acc = createAccumulator()
let stdoutBuffer = ''
let resolved = false
let processErrorObserved = false
let unavailableExitObserved = false
let child: ChildProcess | null = null
const wslInfo = parseWslPath(authorizedRootPath)
const transformAbsPath = wslInfo
? (p: string): string => toWindowsWslPath(p, wslInfo.distro)
: undefined
const resolveOnce = (): void => {
const finish = (result: SearchResult | PromiseLike<SearchResult>): void => {
if (resolved) {
return
}
@@ -1948,8 +1960,11 @@ export class RuntimeFileCommands {
this.activeRuntimeTextSearches.delete(searchKey)
}
cleanupListeners()
resolvePromise(finalize(acc))
resolvePromise(result)
}
const resolveOnce = (): void => finish(finalize(acc))
const resolveWithoutRipgrep = (): void =>
finish(searchWithGitGrep(authorizedRootPath, options, maxResults, localGitOptions))
let killTimeout: ReturnType<typeof setTimeout> | null = null
const cleanupListeners = (): void => {
@@ -1961,6 +1976,12 @@ export class RuntimeFileCommands {
child?.stderr?.off('data', onStderrData)
child?.off('error', onError)
child?.off('close', onClose)
if (child) {
absorbPendingRipgrepSpawnError(child, {
errorObserved: processErrorObserved,
unavailableExitObserved
})
}
}
const processLine = (line: string): void => {
@@ -1971,8 +1992,8 @@ export class RuntimeFileCommands {
maxResults,
transformAbsPath
)
if (verdict === 'stop') {
child?.kill()
if (verdict === 'stop' && child) {
killSpawnedRipgrepProcess(child)
}
}
@@ -1996,8 +2017,25 @@ export class RuntimeFileCommands {
const onStderrData = (): void => {
// Drain stderr so rg cannot block on a full pipe.
}
const onError = (): void => resolveOnce()
const onClose = (): void => {
const onError = (): void => {
processErrorObserved = true
if (child && isRipgrepUnavailableExit(child, null, null)) {
resolveWithoutRipgrep()
return
}
resolveOnce()
}
const onClose = (code: number | null, signal: NodeJS.Signals | null): void => {
if (
child &&
isRipgrepUnavailableExit(child, code, signal, {
classifyNativeLauncherExit: !(wslInfo || localGitOptions.wslDistro)
})
) {
unavailableExitObserved = true
resolveWithoutRipgrep()
return
}
if (stdoutBuffer) {
processLine(stdoutBuffer)
}
@@ -2011,7 +2049,9 @@ export class RuntimeFileCommands {
killTimeout = setTimeout(() => {
acc.truncated = true
child?.kill()
if (child) {
killSpawnedRipgrepProcess(child)
}
resolveOnce()
}, SEARCH_TIMEOUT_MS)
})
@@ -31,6 +31,7 @@ function createMockProcess(): ChildProcess {
;(p as unknown as Record<string, unknown>).kill = vi.fn()
;(p as unknown as Record<string, unknown>).exitCode = null
;(p as unknown as Record<string, unknown>).signalCode = null
Object.defineProperty(p, 'pid', { configurable: true, value: 1 })
return p
}
@@ -16,6 +16,11 @@ import { tmpdir } from 'node:os'
import { listFilesWithGit } from './fs-handler-git-fallback'
import { listFilesWithRg } from './fs-handler-list-files'
import { searchWithRg } from './fs-handler-utils'
import { RipgrepUnavailableError } from '../shared/ripgrep-process-availability'
import {
ListFilesScanCoordinator,
LIST_FILES_SUPERSEDED_MESSAGE
} from './fs-list-files-scan-coordinator'
const tempDirs: string[] = []
const SHA1 = '0123456789abcdef0123456789abcdef01234567'
@@ -36,6 +41,7 @@ function createMockProcess(): ChildProcess {
;(p as unknown as Record<string, unknown>).kill = vi.fn()
;(p as unknown as Record<string, unknown>).exitCode = null
;(p as unknown as Record<string, unknown>).signalCode = null
Object.defineProperty(p, 'pid', { configurable: true, value: 1 })
return p
}
@@ -72,6 +78,7 @@ describe('relay quick open ignored file listing', () => {
})
const promise = listFilesWithRg('/remote/root', ['packages/other'])
expect(spawnMock).toHaveBeenCalledTimes(2)
setTimeout(() => {
;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n')
@@ -115,6 +122,82 @@ describe('relay quick open ignored file listing', () => {
expect(callIndex).toBe(1)
})
it.each(['error-first', 'close-first'] as const)(
'tags a %s pre-spawn listing failure without starting the ignored pass',
async (order) => {
const root = await makeTempRoot()
const missing = createMockProcess()
Object.defineProperty(missing, 'pid', { value: undefined })
spawnMock.mockReturnValue(missing)
const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' })
const promise = listFilesWithRg(root)
if (order === 'error-first') {
expect(() => missing.emit('error', error)).not.toThrow()
} else {
missing.emit('close', -2, null)
}
await expect(promise).rejects.toBeInstanceOf(RipgrepUnavailableError)
if (order === 'error-first') {
missing.emit('close', -2, null)
} else {
expect(() => missing.emit('error', error)).not.toThrow()
}
expect(spawnMock).toHaveBeenCalledTimes(1)
expect(missing.listenerCount('error')).toBe(0)
expect(missing.listenerCount('close')).toBe(0)
}
)
it('kills only the admitted pass when ignored rg fails before spawn', async () => {
const root = await makeTempRoot()
const primary = createMockProcess()
const missingIgnored = createMockProcess()
Object.defineProperty(missingIgnored, 'pid', { value: undefined })
spawnMock.mockImplementation((_cmd: string, args: string[]) =>
args.includes('--no-ignore-vcs') ? missingIgnored : primary
)
const promise = listFilesWithRg(root)
expect(spawnMock).toHaveBeenCalledTimes(2)
missingIgnored.emit('close', -2, null)
await expect(promise).rejects.toBeInstanceOf(RipgrepUnavailableError)
expect(primary.kill).toHaveBeenCalled()
expect(missingIgnored.kill).not.toHaveBeenCalled()
const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' })
expect(() => missingIgnored.emit('error', error)).not.toThrow()
expect(missingIgnored.listenerCount('error')).toBe(0)
})
it('does not signal failed-spawn passes when a same-client scan is superseded', async () => {
const root = await makeTempRoot()
const firstChild = createMockProcess()
Object.defineProperty(firstChild, 'pid', { value: undefined })
spawnMock.mockReturnValue(firstChild)
const coordinator = new ListFilesScanCoordinator()
const first = coordinator.run({
clientId: 1,
key: 'first',
start: (signal) => listFilesWithRg(root, [], { signal })
})
const firstOutcome = first.catch((error: unknown) => error)
const second = coordinator.run({
clientId: 1,
key: 'second',
start: async () => ['second.ts']
})
await expect(firstOutcome).resolves.toMatchObject({ message: LIST_FILES_SUPERSEDED_MESSAGE })
await expect(second).resolves.toEqual(['second.ts'])
expect(firstChild.kill).not.toHaveBeenCalled()
expect(spawnMock).toHaveBeenCalledTimes(1)
const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' })
expect(() => firstChild.emit('error', error)).not.toThrow()
})
it('git fallback ignored pass includes ignored non-env files', async () => {
const root = await makeTempRoot()
await writeRel(root, 'dist/generated.js')
@@ -430,4 +513,101 @@ describe('relay quick open ignored file listing', () => {
vi.useRealTimers()
}
})
it.each(['error-first', 'close-first'] as const)(
'tags only a %s pre-spawn rg search failure as unavailable',
async (order) => {
const root = await makeTempRoot()
const missing = createMockProcess()
Object.defineProperty(missing, 'pid', { value: undefined })
spawnMock.mockReturnValueOnce(missing)
const unavailable = searchWithRg(root, 'ok', { maxResults: 100 })
const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' })
if (order === 'error-first') {
expect(() => missing.emit('error', error)).not.toThrow()
missing.emit('close', -2, null)
} else {
missing.emit('close', -2, null)
expect(() => missing.emit('error', error)).not.toThrow()
}
await expect(unavailable).rejects.toBeInstanceOf(RipgrepUnavailableError)
expect(missing.listenerCount('error')).toBe(0)
expect(missing.listenerCount('close')).toBe(0)
}
)
it('tags unsupported native launcher exits as unavailable', async () => {
const root = await makeTempRoot()
const child = createMockProcess()
Object.defineProperty(child, 'pid', { value: 1 })
spawnMock.mockReturnValueOnce(child)
const unavailable = searchWithRg(root, 'ok', { maxResults: 100 })
child.emit('close', 127, null)
await expect(unavailable).rejects.toBeInstanceOf(RipgrepUnavailableError)
})
it('keeps missing-root launch errors on their prior non-fallback paths', async () => {
const missingRoot = await makeTempRoot()
await rm(missingRoot, { recursive: true, force: true })
const listFirst = createMockProcess()
const listProbe = createMockProcess()
Object.defineProperty(listFirst, 'pid', { value: undefined })
Object.defineProperty(listProbe, 'pid', { value: 1 })
let callIndex = 0
spawnMock.mockImplementation(() => [listFirst, listProbe][callIndex++])
const listError = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' })
const listing = listFilesWithRg(missingRoot)
listFirst.emit('error', listError)
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2))
expect(spawnMock.mock.calls[1]).toEqual(['rg', ['--version'], { stdio: 'ignore' }])
listProbe.emit('close', 0, null)
await expect(listing).rejects.toBe(listError)
spawnMock.mockReset()
const searchChild = createMockProcess()
const searchProbe = createMockProcess()
Object.defineProperty(searchChild, 'pid', { value: undefined })
Object.defineProperty(searchProbe, 'pid', { value: 1 })
spawnMock.mockReturnValueOnce(searchChild).mockReturnValueOnce(searchProbe)
const search = searchWithRg(missingRoot, 'ok', { maxResults: 100 })
searchChild.emit('error', listError)
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2))
searchProbe.emit('close', 0, null)
await expect(search).resolves.toMatchObject({ files: [], totalMatches: 0 })
})
it('keeps missing-rg precedence when the root also disappeared', async () => {
const missingRoot = await makeTempRoot()
await rm(missingRoot, { recursive: true, force: true })
const first = createMockProcess()
const probe = createMockProcess()
for (const child of [first, probe]) {
Object.defineProperty(child, 'pid', { value: undefined })
}
let callIndex = 0
spawnMock.mockImplementation(() => [first, probe][callIndex++])
const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' })
const listing = listFilesWithRg(missingRoot)
first.emit('error', error)
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2))
probe.emit('close', -2, null)
await expect(listing).rejects.toBeInstanceOf(RipgrepUnavailableError)
expect(() => probe.emit('error', error)).not.toThrow()
})
it('keeps post-spawn rg search errors on the existing empty-result path', async () => {
const started = createMockProcess()
Object.defineProperty(started, 'pid', { value: 1 })
spawnMock.mockReturnValueOnce(started)
const ordinaryFailure = searchWithRg('/remote/root', 'ok', { maxResults: 100 })
started.emit('error', new Error('post-spawn failure'))
await expect(ordinaryFailure).resolves.toMatchObject({ files: [], totalMatches: 0 })
})
})
+47 -6
View File
@@ -1,7 +1,5 @@
/**
* Ripgrep-based file listing for Quick Open.
* Extracted from fs-handler-utils.ts to keep it under 300 lines (oxlint max-lines).
*
* 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
@@ -23,6 +21,13 @@ import {
shouldExcludeQuickOpenRelPath,
shouldIncludeQuickOpenPath
} from '../shared/quick-open-filter'
import {
absorbPendingRipgrepSpawnError,
isRipgrepUnavailableAfterLaunchFailure,
isRipgrepUnavailableExit,
killSpawnedRipgrepProcess,
RipgrepUnavailableError
} from '../shared/ripgrep-process-availability'
export const LIST_FILES_TIMEOUT_MS = 25_000
@@ -78,6 +83,9 @@ export function listFilesWithRg(
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/**`
@@ -98,6 +106,10 @@ export function listFilesWithRg(
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) {
@@ -116,6 +128,16 @@ export function listFilesWithRg(
cleanup()
passResolve()
}
const rejectLaunchFailure = (error: Error): void => {
if (launchFailureCheck) {
return
}
launchFailureCheck = isRipgrepUnavailableAfterLaunchFailure(rootPath).then(
(unavailable) => {
rejectPass(unavailable ? new RipgrepUnavailableError() : error)
}
)
}
children.push({
child,
isDone: () => passDone,
@@ -125,7 +147,7 @@ export function listFilesWithRg(
timer = setTimeout(() => {
// Discard residual buffer on abnormal exit — a truncated byte
// sequence could look like a valid path.
child.kill()
killSpawnedRipgrepProcess(child)
rejectPass(new Error('rg list timed out'))
}, LIST_FILES_TIMEOUT_MS)
@@ -149,12 +171,28 @@ export function listFilesWithRg(
/* drain to prevent backpressure stalls */
}
function handleError(err: Error): void {
processErrorObserved = true
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.
@@ -199,7 +237,7 @@ export function listFilesWithRg(
continue
}
if (entry.child.exitCode === null && entry.child.signalCode === null) {
entry.child.kill()
killSpawnedRipgrepProcess(entry.child)
}
entry.reject(new Error(reason))
}
@@ -228,12 +266,15 @@ export function listFilesWithRg(
}
signal?.addEventListener('abort', onAbort, { once: true })
const primaryPass = runPass(primary)
const passes =
maxResults === undefined
? Promise.all([runPass(primary), runPass(ignoredPass)])
? children[0]?.child.pid === undefined
? primaryPass
: Promise.all([primaryPass, runPass(ignoredPass)])
: // Why: deterministic primary-first budgeting prevents a large ignored
// tree from starving ordinary source paths on a remote host.
runPass(primary).then(() =>
primaryPass.then(() =>
files.size < maxResults ? runPass(ignoredPass) : Promise.resolve()
)
@@ -1,50 +0,0 @@
import { EventEmitter } from 'node:events'
import { describe, expect, it, vi } from 'vitest'
const { execFileMock, spawnMock } = vi.hoisted(() => ({
execFileMock: vi.fn(),
spawnMock: vi.fn()
}))
vi.mock('child_process', () => ({
execFile: execFileMock,
spawn: spawnMock
}))
import { checkRgAvailable } from './fs-handler-utils'
class FakeChildProcess extends EventEmitter {
kill = vi.fn()
}
describe('relay rg availability', () => {
it('removes listeners after a successful probe', async () => {
const child = new FakeChildProcess()
execFileMock.mockReturnValueOnce(child)
const result = checkRgAvailable()
child.emit('close', 0)
await expect(result).resolves.toBe(true)
expect(child.listenerCount('error')).toBe(0)
expect(child.listenerCount('close')).toBe(0)
})
it('settles and detaches when a wedged probe ignores timeout kill', async () => {
vi.useFakeTimers()
try {
const child = new FakeChildProcess()
execFileMock.mockReturnValueOnce(child)
const result = checkRgAvailable()
await vi.advanceTimersByTimeAsync(5000)
await expect(result).resolves.toBe(false)
expect(child.kill).toHaveBeenCalledTimes(1)
expect(child.listenerCount('error')).toBe(0)
expect(child.listenerCount('close')).toBe(0)
} finally {
vi.useRealTimers()
}
})
})
@@ -0,0 +1,121 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as ChildProcessModule from 'node:child_process'
import type * as FsHandlerGitFallback from './fs-handler-git-fallback'
import type * as FsHandlerUtils from './fs-handler-utils'
const {
execFileMock,
listFilesWithGitMock,
listFilesWithRgMock,
searchWithGitGrepMock,
searchWithRgMock
} = vi.hoisted(() => ({
execFileMock: vi.fn(),
listFilesWithGitMock: vi.fn(),
listFilesWithRgMock: vi.fn(),
searchWithGitGrepMock: vi.fn(),
searchWithRgMock: vi.fn()
}))
vi.mock('node:child_process', async (importOriginal) => ({
...(await importOriginal<typeof ChildProcessModule>()),
execFile: execFileMock
}))
vi.mock('./fs-handler-utils', async (importOriginal) => ({
...(await importOriginal<typeof FsHandlerUtils>()),
listFilesWithRg: listFilesWithRgMock,
searchWithRg: searchWithRgMock
}))
vi.mock('./fs-handler-git-fallback', async (importOriginal) => ({
...(await importOriginal<typeof FsHandlerGitFallback>()),
listFilesWithGit: listFilesWithGitMock,
searchWithGitGrep: searchWithGitGrepMock
}))
import { FileListingCancelledError } from '../shared/file-listing-cancellation'
import { RipgrepUnavailableError } from '../shared/ripgrep-process-availability'
import { RelayContext } from './context'
import { FsHandler } from './fs-handler'
type FsHandlerInternals = {
search(params: Record<string, unknown>): Promise<unknown>
runListFilesScan(
rootPath: string,
excludePathPrefixes: string[],
signal: AbortSignal,
maxResults?: number
): Promise<string[]>
}
function createHandler(): FsHandlerInternals {
const dispatcher = {
onRequest: vi.fn(),
onNotification: vi.fn(),
onClientDetached: vi.fn(() => () => undefined)
}
const watcherPool = {
dispose: vi.fn(),
forgetRoot: vi.fn(),
subscribe: vi.fn()
}
return new FsHandler(dispatcher as never, new RelayContext(), watcherPool as never) as never
}
describe('relay direct ripgrep admission', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('falls back only for a tagged search launch failure', async () => {
const handler = createHandler()
const fallback = { files: [], totalMatches: 0, truncated: false }
searchWithRgMock.mockRejectedValueOnce(new RipgrepUnavailableError())
searchWithGitGrepMock.mockResolvedValueOnce(fallback)
await expect(handler.search({ rootPath: '/repo', query: 'needle' })).resolves.toBe(fallback)
expect(searchWithRgMock).toHaveBeenCalledTimes(1)
expect(searchWithGitGrepMock).toHaveBeenCalledTimes(1)
const ordinaryFailure = new Error('rg failed after spawn')
searchWithRgMock.mockRejectedValueOnce(ordinaryFailure)
await expect(handler.search({ rootPath: '/repo', query: 'needle' })).rejects.toBe(
ordinaryFailure
)
expect(searchWithGitGrepMock).toHaveBeenCalledTimes(1)
})
it('falls back only for a tagged listing launch failure', async () => {
const handler = createHandler()
const controller = new AbortController()
listFilesWithRgMock.mockRejectedValueOnce(new RipgrepUnavailableError())
execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
callback(null)
return undefined
})
listFilesWithGitMock.mockResolvedValueOnce(['src/index.ts'])
await expect(handler.runListFilesScan('/repo', [], controller.signal)).resolves.toEqual([
'src/index.ts'
])
expect(listFilesWithRgMock).toHaveBeenCalledTimes(1)
expect(listFilesWithGitMock).toHaveBeenCalledTimes(1)
})
it('lets cancellation win an unavailable-listing race before Git starts', async () => {
const handler = createHandler()
const controller = new AbortController()
const cancellation = new FileListingCancelledError('superseded')
listFilesWithRgMock.mockImplementationOnce(async () => {
controller.abort(cancellation)
throw new RipgrepUnavailableError()
})
await expect(handler.runListFilesScan('/repo', [], controller.signal)).rejects.toBe(
cancellation
)
expect(execFileMock).not.toHaveBeenCalled()
expect(listFilesWithGitMock).not.toHaveBeenCalled()
})
})
+63 -51
View File
@@ -5,7 +5,7 @@
* These functions depend only on their arguments (plus `rg` being on PATH),
* so they are straightforward to test independently.
*/
import { spawn, execFile } from 'node:child_process'
import { spawn } from 'node:child_process'
import { open } from 'node:fs/promises'
import {
buildRgArgs,
@@ -16,6 +16,13 @@ import {
} from '../shared/text-search'
import { IMAGE_FILE_MIME_TYPES } from '../shared/image-file-extensions'
import type { SearchResult as SharedSearchResult } from '../shared/types'
import {
absorbPendingRipgrepSpawnError,
isRipgrepUnavailableAfterLaunchFailure,
isRipgrepUnavailableExit,
killSpawnedRipgrepProcess,
RipgrepUnavailableError
} from '../shared/ripgrep-process-availability'
// ─── Constants ───────────────────────────────────────────────────────
@@ -89,11 +96,14 @@ export function searchWithRg(
query: string,
opts: SearchOptions
): Promise<SearchResult> {
return new Promise((resolve) => {
return new Promise((resolve, reject) => {
const rgArgs = buildRgArgs(query, rootPath, opts)
const acc = createAccumulator()
let buffer = ''
let resolved = false
let processErrorObserved = false
let unavailableExitObserved = false
let launchFailureCheck: Promise<void> | null = null
// Why: spawn can throw synchronously on invalid options (e.g. bad cwd),
// which would leak out of the `new Promise` executor and leave the
@@ -124,13 +134,47 @@ export function searchWithRg(
child.stderr!.off('data', handleStderrData)
child.off('error', handleError)
child.off('close', handleClose)
absorbPendingRipgrepSpawnError(child, {
errorObserved: processErrorObserved,
unavailableExitObserved
})
resolve(finalize(acc))
}
function rejectUnavailable(): void {
if (resolved) {
return
}
resolved = true
clearTimeout(killTimeout)
child.stdout!.off('data', handleStdoutData)
child.stderr!.off('data', handleStderrData)
child.off('error', handleError)
child.off('close', handleClose)
absorbPendingRipgrepSpawnError(child, {
errorObserved: processErrorObserved,
unavailableExitObserved
})
reject(new RipgrepUnavailableError())
}
function settleLaunchFailure(): void {
if (launchFailureCheck) {
return
}
launchFailureCheck = isRipgrepUnavailableAfterLaunchFailure(rootPath).then((unavailable) => {
if (unavailable) {
rejectUnavailable()
} else {
resolveOnce()
}
})
}
function processLine(line: string): void {
const verdict = ingestRgJsonLine(line, rootPath, acc, opts.maxResults)
if (verdict === 'stop') {
child.kill()
killSpawnedRipgrepProcess(child)
}
}
@@ -148,10 +192,24 @@ export function searchWithRg(
}
function handleError(): void {
processErrorObserved = true
if (isRipgrepUnavailableExit(child, null, null)) {
settleLaunchFailure()
return
}
resolveOnce()
}
function handleClose(): void {
function handleClose(code: number | null, signal: NodeJS.Signals | null): void {
if (
isRipgrepUnavailableExit(child, code, signal, {
classifyNativeLauncherExit: true
})
) {
unavailableExitObserved = true
settleLaunchFailure()
return
}
if (buffer) {
processLine(buffer)
}
@@ -166,57 +224,11 @@ export function searchWithRg(
killTimeout = setTimeout(() => {
acc.truncated = true
child.kill()
killSpawnedRipgrepProcess(child)
resolveOnce()
}, SEARCH_TIMEOUT_MS)
})
}
// ─── rg availability check ──────────────────────────────────────────
// Why no cache: `rg --version` is a sub-10ms local spawn, and caching the
// result caused a footgun — a negative cache persisted across rg installs
// (forcing a relay restart), while a positive cache could mask an rg that
// was uninstalled or broken mid-session. The `settled` flag below closes
// the original race between 'error' and 'close' that the cache was added
// to paper over, so re-checking per call is both simpler and safer.
const RG_AVAILABILITY_TIMEOUT_MS = 5000
export function checkRgAvailable(): Promise<boolean> {
return new Promise((resolve) => {
let settled = false
const child = execFile('rg', ['--version'])
let timeout: ReturnType<typeof setTimeout> | null = null
const cleanup = (): void => {
if (timeout) {
clearTimeout(timeout)
timeout = null
}
child.off('error', onError)
child.off('close', onClose)
}
const settle = (available: boolean, options?: { kill?: boolean }): void => {
if (settled) {
return
}
settled = true
cleanup()
if (options?.kill) {
child.kill()
}
resolve(available)
}
const onError = (): void => settle(false)
const onClose = (code: number | null): void => settle(code === 0)
child.once('error', onError)
child.once('close', onClose)
timeout = setTimeout(() => settle(false, { kill: true }), RG_AVAILABILITY_TIMEOUT_MS)
if (typeof timeout.unref === 'function') {
timeout.unref()
}
})
}
// Moved to fs-handler-list-files.ts to keep this file under 300 lines (oxlint)
export { listFilesWithRg, LIST_FILES_TIMEOUT_MS } from './fs-handler-list-files'
+19 -23
View File
@@ -10,12 +10,7 @@ import type { RelayContext } from './context'
// Why: RelayContext is accepted in the constructor for protocol back-compat
// (see docs/relay-fs-allowlist-removal.md), but no longer consulted on FS ops.
import { expandTilde } from './context'
import {
DEFAULT_MAX_RESULTS,
searchWithRg,
listFilesWithRg,
checkRgAvailable
} from './fs-handler-utils'
import { DEFAULT_MAX_RESULTS, searchWithRg, listFilesWithRg } from './fs-handler-utils'
import { listFilesWithGit, searchWithGitGrep } from './fs-handler-git-fallback'
import { listFilesWithReaddir } from './fs-handler-readdir-fallback'
import { ListFilesScanCoordinator } from './fs-list-files-scan-coordinator'
@@ -35,6 +30,7 @@ import { RelayStreamRegistry } from './fs-stream-registry'
import { scanWorkspaceSpaceDirectory } from './workspace-space-scan'
import { buildRelayCommandEnv } from './relay-command-env'
import { assertNoClobberRenameDestinationAvailable } from '../shared/filesystem-rename-collision'
import { RipgrepUnavailableError } from '../shared/ripgrep-process-availability'
import { RelayFilesystemWatchRegistry } from './relay-filesystem-watch-registry'
import type { RelayWatcherProcessPool } from './relay-watcher-process-pool'
@@ -328,26 +324,22 @@ export class FsHandler {
DEFAULT_MAX_RESULTS
)
const rgAvailable = await checkRgAvailable()
if (!rgAvailable) {
return searchWithGitGrep(rootPath, query, {
caseSensitive,
wholeWord,
useRegex,
includePattern,
excludePattern,
maxResults
})
}
return searchWithRg(rootPath, query, {
const options = {
caseSensitive,
wholeWord,
useRegex,
includePattern,
excludePattern,
maxResults
})
}
try {
return await searchWithRg(rootPath, query, options)
} catch (error) {
if (!(error instanceof RipgrepUnavailableError)) {
throw error
}
return searchWithGitGrep(rootPath, query, options)
}
}
private listFiles(params: Record<string, unknown>, context?: RequestContext): Promise<string[]> {
@@ -380,10 +372,14 @@ export class FsHandler {
signal: AbortSignal,
maxResults?: number
): Promise<string[]> {
const rgAvailable = await checkRgAvailable()
throwIfFileListingCancelled(signal)
if (rgAvailable) {
return listFilesWithRg(rootPath, excludePathPrefixes, { signal, maxResults })
try {
return await listFilesWithRg(rootPath, excludePathPrefixes, { signal, maxResults })
} catch (error) {
throwIfFileListingCancelled(signal)
if (!(error instanceof RipgrepUnavailableError)) {
throw error
}
}
// Why: git ls-files only works inside git repos. Use rev-parse to detect
// git ancestry — unlike checking for a local .git entry, this works from
@@ -54,7 +54,6 @@ vi.mock('./fs-handler-utils', async (importOriginal) => {
const original = (await importOriginal()) as Record<string, unknown>
return {
...original,
checkRgAvailable: () => Promise.resolve(true),
listFilesWithRg: fakeListFiles
}
})
@@ -0,0 +1,90 @@
import { spawn, type ChildProcess } from 'node:child_process'
import { EventEmitter } from 'node:events'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
absorbPendingRipgrepSpawnError,
isRipgrepUnavailableExit,
isRipgrepSpawnCwdUsable,
killSpawnedRipgrepProcess
} from './ripgrep-process-availability'
function createChild(pid: number | undefined): ChildProcess {
const child = new EventEmitter() as ChildProcess
Object.defineProperty(child, 'pid', { value: pid })
return child
}
describe('ripgrep process availability', () => {
it('recognizes native pre-spawn failures before either event wins the race', () => {
expect(isRipgrepUnavailableExit(createChild(undefined), null, null)).toBe(true)
expect(isRipgrepUnavailableExit(createChild(undefined), -2, null)).toBe(true)
})
it('keeps all post-spawn and signal exits on their existing paths', () => {
for (const code of [0, 1, 2, 127]) {
expect(isRipgrepUnavailableExit(createChild(1), code, null)).toBe(false)
}
expect(isRipgrepUnavailableExit(createChild(1), null, 'SIGTERM')).toBe(false)
})
it('tags only unsupported native launcher exits after spawn', () => {
for (const code of [0, 1, 2]) {
expect(
isRipgrepUnavailableExit(createChild(1), code, null, {
classifyNativeLauncherExit: true
})
).toBe(false)
}
for (const code of [3, 126, 127, 9009]) {
expect(
isRipgrepUnavailableExit(createChild(1), code, null, {
classifyNativeLauncherExit: true
})
).toBe(true)
}
})
it('does not signal a real failed-spawn handle', async () => {
const child = spawn('orca-definitely-missing-rg-admission-test', [])
const error = new Promise<void>((resolve) => child.once('error', () => resolve()))
const close = new Promise<void>((resolve) => child.once('close', () => resolve()))
expect(killSpawnedRipgrepProcess(child)).toBe(false)
await Promise.all([error, close])
})
it('distinguishes a usable root from a missing spawn cwd', async () => {
await expect(isRipgrepSpawnCwdUsable(process.cwd())).resolves.toBe(true)
await expect(
isRipgrepSpawnCwdUsable(join(process.cwd(), 'orca-definitely-missing-rg-cwd'))
).resolves.toBe(false)
})
it('absorbs a queued spawn error when cleanup wins the race', () => {
const child = createChild(undefined)
absorbPendingRipgrepSpawnError(child, {
errorObserved: false,
unavailableExitObserved: false
})
expect(() => child.emit('error', new Error('spawn rg ENOENT'))).not.toThrow()
expect(child.listenerCount('error')).toBe(0)
})
it('does not retain a sink for started or already-observed processes', () => {
const started = createChild(1)
absorbPendingRipgrepSpawnError(started, {
errorObserved: false,
unavailableExitObserved: false
})
const observed = createChild(undefined)
absorbPendingRipgrepSpawnError(observed, {
errorObserved: true,
unavailableExitObserved: true
})
expect(started.listenerCount('error')).toBe(0)
expect(observed.listenerCount('error')).toBe(0)
})
})
+124
View File
@@ -0,0 +1,124 @@
import { constants } from 'node:fs'
import { access, stat } from 'node:fs/promises'
import { spawn, type ChildProcess } from 'node:child_process'
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'
}
}
function ignoreRipgrepSpawnError(): void {}
export function killSpawnedRipgrepProcess(child: ChildProcess): 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: ChildProcess,
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 checkRipgrepAvailableWithoutCwd(): Promise<boolean> {
return new Promise((resolve) => {
let child: ChildProcess
try {
child = spawn('rg', ['--version'], { stdio: 'ignore' })
} catch {
resolve(false)
return
}
let settled = false
let errorObserved = false
let unavailableExitObserved = false
let timeout: ReturnType<typeof setTimeout> | null = null
const settle = (available: boolean, kill = false): void => {
if (settled) {
return
}
settled = true
if (timeout) {
clearTimeout(timeout)
}
child.off('error', onError)
child.off('close', onClose)
if (kill) {
child.once('error', ignoreRipgrepSpawnError)
killSpawnedRipgrepProcess(child)
} else {
absorbPendingRipgrepSpawnError(child, { errorObserved, unavailableExitObserved })
}
resolve(available)
}
const onError = (): void => {
errorObserved = true
settle(false)
}
const onClose = (code: number | null): void => {
unavailableExitObserved = code !== null && code < 0
settle(code === 0)
}
child.once('error', onError)
child.once('close', onClose)
timeout = setTimeout(() => settle(false, true), RIPGREP_FAILURE_PROBE_TIMEOUT_MS)
timeout.unref?.()
})
}
export async function isRipgrepUnavailableAfterLaunchFailure(cwd: string): Promise<boolean> {
if (await isRipgrepSpawnCwdUsable(cwd)) {
return true
}
return !(await checkRipgrepAvailableWithoutCwd())
}
export function isRipgrepUnavailableExit(
child: ChildProcess,
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)
}