Avoid starting file scans for canceled listings (#35287)

* fix(search): reject canceled file listings before process admission

* test(search): trim listing admission cancellation cases

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-10-28 14:43:34 -07:00
committed by GitHub
co-authored by OrcaWin
parent 85dfae514d
commit 7375d3abd5
3 changed files with 81 additions and 2 deletions
@@ -36,7 +36,7 @@ import {
ripgrepMissingCwdError,
RipgrepUnavailableError
} from '../../../shared/ripgrep-process-availability'
import { fileListingCancellationError } from '../../../shared/file-listing-cancellation'
import * as listingCancellation from '../../../shared/file-listing-cancellation'
import {
finishFilesystemListingProcesses,
type FilesystemListingProcess
@@ -53,6 +53,7 @@ export async function listQuickOpenFiles(
pathFilter?: (relativePath: string) => boolean,
options: { includeIgnored?: boolean; followSymlinks?: boolean; candidatePaths?: string[] } = {}
): Promise<string[]> {
listingCancellation.throwIfFileListingCancelled(signal)
const authorizedRootPath = await resolveAuthorizedPath(rootPath, store)
const { wslDistro, wslDistroForOutput } = resolveBundledRipgrepWslRouting(
authorizedRootPath,
@@ -86,6 +87,7 @@ export async function listQuickOpenFiles(
const runRg = (args: string[]): Promise<void> =>
new Promise((resolve, reject) => {
listingCancellation.throwIfFileListingCancelled(signal)
const filenameDecoder = new RipgrepFilenameDecoder((error) => {
killSpawnedRipgrepProcess(child)
finish(error)
@@ -264,7 +266,7 @@ export async function listQuickOpenFiles(
const handleAbort = (): void => {
buf = ''
killSpawnedRipgrepProcess(child)
finish(fileListingCancellationError(signal))
finish(listingCancellation.fileListingCancellationError(signal))
}
children.push({ child, isDone: () => done, finish })
@@ -0,0 +1,71 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createFakePipedChild } from '../../../shared/__fixtures__/fake-spawned-child'
import { FileListingCancelledError } from '../../../shared/file-listing-cancellation'
import type { RuntimeStore } from '../../runtime/runtime-store-contract'
const { authorize, spawn } = vi.hoisted(() => ({ authorize: vi.fn(), spawn: vi.fn() }))
vi.mock('./filesystem-auth', () => ({ resolveAuthorizedPath: authorize }))
vi.mock('../../ripgrep/bundled-ripgrep-spawn', () => ({ spawnBundledRipgrep: spawn }))
vi.mock('../local-worktree-runtime-options', () => ({
getLocalGitOptionsForRegisteredWorktree: () => ({})
}))
import { listQuickOpenFiles } from './filesystem-list-files'
const store: RuntimeStore = Object.create(null)
function childProcess() {
return Object.assign(createFakePipedChild(), { exitCode: null, signalCode: null })
}
describe('file listing cancellation before native admission', () => {
beforeEach(() => {
vi.resetAllMocks()
authorize.mockImplementation(async (path: string) => path)
spawn.mockImplementation(childProcess)
})
it('does not authorize or launch an already canceled listing', async () => {
const controller = new AbortController()
const reason = new FileListingCancelledError('superseded')
controller.abort(reason)
await expect(listQuickOpenFiles('/repo', store, undefined, controller.signal)).rejects.toBe(
reason
)
expect(authorize).not.toHaveBeenCalled()
expect(spawn).not.toHaveBeenCalled()
})
it('admits no obsolete scan after a pending authorization', async () => {
const held = Promise.withResolvers<string>()
authorize.mockReturnValue(held.promise)
const reason = new FileListingCancelledError('workspace switched')
const controller = new AbortController()
const pending = expect(
listQuickOpenFiles('/repo', store, undefined, controller.signal)
).rejects.toBe(reason)
controller.abort(reason)
held.resolve('/repo')
await pending
expect(spawn).not.toHaveBeenCalled()
})
it('does not start the ignored pass when canceled after the primary pass closes', async () => {
const primary = childProcess()
spawn.mockReturnValue(primary)
const controller = new AbortController()
const reason = new FileListingCancelledError('closed')
const listing = listQuickOpenFiles('/repo', store, undefined, controller.signal, 10)
const rejected = expect(listing).rejects.toBe(reason)
await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(1))
primary.once('close', () => controller.abort(reason))
primary.stdout.emit('data', 'src/primary.ts\0')
primary.emit('close', 0, null)
await rejected
expect(spawn).toHaveBeenCalledTimes(1)
expect(primary.kill).not.toHaveBeenCalled()
})
})
+6
View File
@@ -15,6 +15,12 @@ export function fileListingCancellationError(signal?: AbortSignal): Error {
return new FileListingCancelledError()
}
export function throwIfFileListingCancelled(signal?: AbortSignal): void {
if (signal?.aborted) {
throw fileListingCancellationError(signal)
}
}
export function isFileListingCancellation(error: unknown): boolean {
return error instanceof FileListingCancelledError
}