mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 08:01:56 +00:00
fix: detach quick open rg timeout listeners (#3757)
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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 = ''
|
||||
|
||||
Reference in New Issue
Block a user