fix: detach quick open rg timeout listeners (#3757)

This commit is contained in:
Neil
2026-05-30 09:05:41 -07:00
committed by GitHub
parent 64e0857358
commit d296ea78a3
2 changed files with 73 additions and 22 deletions
@@ -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()
+34 -22
View File
@@ -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<typeof setTimeout>
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 = ''