Clean up relay git file listing listeners (#3834)

This commit is contained in:
Neil
2026-05-30 11:49:20 -07:00
committed by GitHub
parent 8275cbe508
commit 3193ea5f16
2 changed files with 86 additions and 31 deletions
+78 -31
View File
@@ -38,6 +38,11 @@ export function listFilesWithGit(
): Promise<string[]> {
const files = new Set<string>()
const { primary, ignoredPass } = buildGitLsFilesArgsForQuickOpen(excludePathPrefixes)
const children: {
child: ReturnType<typeof spawn>
isDone: () => boolean
reject: (error: Error) => void
}[] = []
const runGitLsFiles = (args: string[]): Promise<void> => {
return new Promise((resolve, reject) => {
@@ -64,8 +69,41 @@ export function listFilesWithGit(
env: buildRelayCommandEnv(),
stdio: ['ignore', 'pipe', 'pipe']
})
child.stdout!.setEncoding('utf-8')
child.stdout!.on('data', (chunk: string) => {
let timer: ReturnType<typeof setTimeout> | null = null
const cleanup = (): void => {
if (timer) {
clearTimeout(timer)
timer = null
}
child.stdout!.off('data', handleStdoutData)
child.stderr!.off('data', handleStderrData)
child.off('error', handleError)
child.off('close', handleClose)
}
const rejectPass = (error: Error): void => {
if (done) {
return
}
done = true
buf = ''
cleanup()
reject(error)
}
const resolvePass = (): void => {
if (done) {
return
}
done = true
cleanup()
resolve()
}
children.push({
child,
isDone: () => done,
reject: rejectPass
})
function handleStdoutData(chunk: string): void {
buf += chunk
let start = 0
let idx = buf.indexOf('\n', start)
@@ -75,53 +113,62 @@ export function listFilesWithGit(
idx = buf.indexOf('\n', start)
}
buf = start < buf.length ? buf.substring(start) : ''
})
child.stderr!.on('data', () => {
}
function handleStderrData(): void {
/* drain */
})
child.once('error', (err) => {
}
function handleError(err: Error): void {
rejectPass(err)
}
function handleClose(_code: number | null, signal: NodeJS.Signals | null): void {
if (done) {
return
}
done = true
clearTimeout(timer)
buf = ''
reject(err)
})
child.once('close', (_code, signal) => {
if (done) {
return
}
done = true
clearTimeout(timer)
if (signal) {
// Why: a signal exit means the child was killed (timeout or
// external). Treat that as a load failure rather than silently
// resolving with whatever git had managed to print.
buf = ''
reject(new Error(`git ls-files killed by ${signal}`))
rejectPass(new Error(`git ls-files killed by ${signal}`))
return
}
if (buf) {
processLine(buf)
}
resolve()
})
const timer = setTimeout(() => {
if (done) {
return
}
done = true
buf = ''
resolvePass()
}
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(() => {
child.kill()
reject(new Error('git ls-files timed out'))
rejectPass(new Error('git ls-files timed out'))
}, 10_000)
})
}
return Promise.all([runGitLsFiles(primary), runGitLsFiles(ignoredPass)]).then(() =>
Array.from(files)
)
const killSurvivors = (): void => {
// Why: Promise.all returns after the first failed pass, but the sibling
// git process can keep streaming on SSH unless we cancel it explicitly.
for (const entry of children) {
if (entry.isDone()) {
continue
}
if (entry.child.exitCode === null && entry.child.signalCode === null) {
entry.child.kill()
}
entry.reject(new Error('git ls-files canceled after sibling failure'))
}
}
return Promise.all([runGitLsFiles(primary), runGitLsFiles(ignoredPass)])
.then(() => Array.from(files))
.catch((err) => {
killSurvivors()
throw err
})
}
/**
@@ -154,6 +154,14 @@ describe('relay quick open ignored file listing', () => {
expect(outcome).toContain('git ls-files timed out')
expect(primaryProc.kill).toHaveBeenCalled()
expect(ignoredProc.kill).toHaveBeenCalled()
expect((primaryProc.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0)
expect((primaryProc.stderr as unknown as EventEmitter).listenerCount('data')).toBe(0)
expect(primaryProc.listenerCount('error')).toBe(0)
expect(primaryProc.listenerCount('close')).toBe(0)
expect((ignoredProc.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0)
expect((ignoredProc.stderr as unknown as EventEmitter).listenerCount('data')).toBe(0)
expect(ignoredProc.listenerCount('error')).toBe(0)
expect(ignoredProc.listenerCount('close')).toBe(0)
} finally {
vi.useRealTimers()
}