diff --git a/src/main/ipc/filesystem/filesystem-list-files.ts b/src/main/ipc/filesystem/filesystem-list-files.ts index 197a7ba5698..e4165d2e91b 100644 --- a/src/main/ipc/filesystem/filesystem-list-files.ts +++ b/src/main/ipc/filesystem/filesystem-list-files.ts @@ -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 { + 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 => 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 }) diff --git a/src/main/ipc/filesystem/filesystem-listing-cancel-admission.test.ts b/src/main/ipc/filesystem/filesystem-listing-cancel-admission.test.ts new file mode 100644 index 00000000000..191e90b809d --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-listing-cancel-admission.test.ts @@ -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() + 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() + }) +}) diff --git a/src/shared/file-listing-cancellation.ts b/src/shared/file-listing-cancellation.ts index b318a059726..9ddf82875ab 100644 --- a/src/shared/file-listing-cancellation.ts +++ b/src/shared/file-listing-cancellation.ts @@ -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 }