diff --git a/src/main/ipc/filesystem-list-files.test.ts b/src/main/ipc/filesystem-list-files.test.ts index 119763acee4..b57a07faf08 100644 --- a/src/main/ipc/filesystem-list-files.test.ts +++ b/src/main/ipc/filesystem-list-files.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- Why: one Quick Open file-list suite covers both rg and git fallback process lifecycles. */ import { describe, expect, it, vi, beforeEach } from 'vitest' const { spawnMock, resolveAuthorizedPathMock, checkRgAvailableMock } = vi.hoisted(() => ({ @@ -165,6 +166,44 @@ describe('filesystem-list-files', () => { await expect(promise).resolves.toEqual(['src/index.ts']) }) + it('settles and detaches rg scans that ignore timeout kills', async () => { + vi.useFakeTimers() + + try { + const p1 = createMockProcess() + const p2 = createMockProcess() + + spawnMock.mockImplementation((_cmd, args: string[]) => { + if (isIgnoredRgPass(args)) { + return p2 + } + return p1 + }) + + const storeMock = {} as unknown as Store + const promise = listQuickOpenFiles('/mock/root', storeMock) + + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + + ;(p1.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\npartial') + const rejection = expect(promise).rejects.toThrow('rg list timed out') + + await vi.advanceTimersByTimeAsync(10000) + + await rejection + expect(p1.kill).toHaveBeenCalled() + expect(p2.kill).toHaveBeenCalled() + expect((p1.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0) + expect((p1.stderr as unknown as EventEmitter).listenerCount('data')).toBe(0) + expect(p1.listenerCount('error')).toBe(0) + expect(p1.listenerCount('close')).toBe(0) + } finally { + vi.useRealTimers() + } + }) + it('filters out .next, .cache, .stably, .vscode, .idea', async () => { const p1 = createMockProcess() const p2 = createMockProcess() diff --git a/src/main/ipc/filesystem-list-files.ts b/src/main/ipc/filesystem-list-files.ts index 5394a0c4604..c08aa4817f6 100644 --- a/src/main/ipc/filesystem-list-files.ts +++ b/src/main/ipc/filesystem-list-files.ts @@ -59,18 +59,6 @@ export async function listQuickOpenFiles( let buf = '' let done = false let parseablePathCount = 0 - const finish = (err?: Error): void => { - if (done) { - return - } - done = true - clearTimeout(timer) - if (err) { - reject(err) - } else { - resolve() - } - } const processLine = (rawLine: string): void => { const translated = @@ -94,8 +82,8 @@ export async function listQuickOpenFiles( stdio: ['ignore', 'pipe', 'pipe'] }) children.push(child) - child.stdout!.setEncoding('utf-8') - child.stdout!.on('data', (chunk: string) => { + let timer: ReturnType + const handleStdoutData = (chunk: string): void => { buf += chunk let start = 0 let newlineIdx = buf.indexOf('\n', start) @@ -105,17 +93,17 @@ export async function listQuickOpenFiles( newlineIdx = buf.indexOf('\n', start) } buf = start < buf.length ? buf.substring(start) : '' - }) - child.stderr!.on('data', () => { + } + const handleStderrData = (): void => { /* drain */ - }) - child.once('error', () => { + } + const handleError = (): void => { // Why: treat spawn errors like an abnormal exit — discard residual // buffer so a truncated final byte sequence cannot leak as a path. buf = '' finish(new Error('rg failed to start')) - }) - child.once('close', (code, signal) => { + } + const handleClose = (code: number | null, signal: NodeJS.Signals | null): void => { if (signal) { // Why: a signal exit means timeout/OOM/external kill. Returning the // already-streamed prefix would recreate the false-empty bug this @@ -136,8 +124,32 @@ export async function listQuickOpenFiles( } else { finish(new Error(`rg exited with code ${code}`)) } - }) - const timer = setTimeout(() => { + } + const finish = (err?: Error): void => { + if (done) { + return + } + done = true + clearTimeout(timer) + // Why: child.kill() is advisory. If rg ignores it, detach our + // closures so repeated Quick Open attempts do not retain old scans. + child.stdout!.off('data', handleStdoutData) + child.stderr!.off('data', handleStderrData) + child.off('error', handleError) + child.off('close', handleClose) + if (err) { + reject(err) + } else { + resolve() + } + } + + child.stdout!.setEncoding('utf-8') + child.stdout!.on('data', handleStdoutData) + child.stderr!.on('data', handleStderrData) + child.once('error', handleError) + child.once('close', handleClose) + timer = setTimeout(() => { // Why: on timeout, the buffer is likely truncated mid-path. Discard // it so Quick Open never displays a malformed entry. buf = ''