diff --git a/src/main/ripgrep/bundled-ripgrep-text-search.ts b/src/main/ripgrep/bundled-ripgrep-text-search.ts index 8ea66e25d09..ccea89f4c19 100644 --- a/src/main/ripgrep/bundled-ripgrep-text-search.ts +++ b/src/main/ripgrep/bundled-ripgrep-text-search.ts @@ -28,6 +28,8 @@ import { stopBundledRipgrep } from './bundled-ripgrep-stop' // NTFS permits POSIX-created names/types Windows rg cannot open (1/2/1920); skip them without truncating the search. const WINDOWS_UNOPENABLE_PATH_OS_ERRORS: ReadonlySet = new Set([1, 2, 1920]) +function ignoreCompletedSearchTerminationError(): void {} + export function runBundledRipgrepTextSearch({ options, rootPath, @@ -110,10 +112,7 @@ export function runBundledRipgrepTextSearch({ } const rejectUnavailable = (): void => finish(Promise.reject(bundledRipgrepUnavailableError())) const processLine = (line: string): void => { - const verdict = ingestRgJsonLine(line, resultRootPath, acc, maxResults, transformAbsPath) - if (verdict === 'stop' && child) { - stopBundledRipgrep(child, Boolean(wslDistroForOutput)) - } + ingestRgJsonLine(line, resultRootPath, acc, maxResults, transformAbsPath) } let nextChild: ReturnType @@ -135,12 +134,14 @@ export function runBundledRipgrepTextSearch({ releaseChild = onSpawn(nextChild) const handleStdoutData = (chunk: string): void => { - if (!lines.push(chunk, processLine)) { + const withinLineLimit = lines.push(chunk, processLine) + if (!withinLineLimit || acc.totalMatches >= maxResults) { acc.truncated = true + child?.once('error', ignoreCompletedSearchTerminationError) + resolveOnce() if (child) { stopBundledRipgrep(child, Boolean(wslDistroForOutput)) } - resolveOnce() } } const handleStderrData = (chunk: Buffer): void => { diff --git a/src/main/runtime/orca-runtime-files-search.test.ts b/src/main/runtime/orca-runtime-files-search.test.ts index 04542005ff7..fb384e3d649 100644 --- a/src/main/runtime/orca-runtime-files-search.test.ts +++ b/src/main/runtime/orca-runtime-files-search.test.ts @@ -168,6 +168,57 @@ describe('RuntimeFileCommands', () => { ) }) + it.each(['ignored', 'erroring'] as const)( + 'publishes capped results and detaches when termination is %s', + async (termination) => { + const { commands } = createRuntimeFileCommands() + const child = createRuntimeSearchChild() + if (termination === 'erroring') { + child.kill.mockImplementation(() => { + child.emit('error', Object.assign(new Error('kill EPERM'), { code: 'EPERM' })) + return false + }) + } + resolveAuthorizedPathMock.mockResolvedValue('/repo') + wslAwareSpawnMock.mockReturnValue(child) + const resultPromise = commands.searchRuntimeFiles('id:wt-1', { + query: 'needle', + maxResults: 2 + }) + let settled = false + void resultPromise.then(() => { + settled = true + }) + await flushRuntimeSearchMicrotasks() + const record = JSON.stringify({ + type: 'match', + data: { + path: { text: './example.txt' }, + lines: { text: 'needle needle\n' }, + line_number: 1, + submatches: [ + { start: 0, end: 6 }, + { start: 7, end: 13 } + ] + } + }) + expect(() => child.stdout.emit('data', `${record}\n{"type":"match"`)).not.toThrow() + await flushRuntimeSearchMicrotasks() + + expect(settled).toBe(true) + await expect(resultPromise).resolves.toMatchObject({ + totalMatches: 2, + truncated: true, + files: [{ matches: [{ column: 1 }, { column: 8 }] }] + }) + expect(child.kill).toHaveBeenCalledOnce() + expect(child.stdout.listenerCount('data')).toBe(0) + expect(child.stderr.listenerCount('data')).toBe(0) + expect(child.listenerCount('close')).toBe(0) + expect(vi.getTimerCount()).toBe(0) + } + ) + it.each(['error-first', 'close-first'] as const)( 'rejects with the bundled-ripgrep error when runtime rg native launch failure is %s', async (order) => {