fix(cli): prevent OOM crash by limiting file search traversal and adding timeout (#16696)

This commit is contained in:
Gal Zahavi
2026-01-15 12:04:22 -08:00
committed by GitHub
parent 48fdb9872f
commit e77d7b2e1e
7 changed files with 126 additions and 3 deletions
@@ -5,6 +5,7 @@
*/ */
import { useEffect, useReducer, useRef } from 'react'; import { useEffect, useReducer, useRef } from 'react';
import { setTimeout as setTimeoutPromise } from 'node:timers/promises';
import type { Config, FileSearch } from '@google/gemini-cli-core'; import type { Config, FileSearch } from '@google/gemini-cli-core';
import { FileSearchFactory, escapePath } from '@google/gemini-cli-core'; import { FileSearchFactory, escapePath } from '@google/gemini-cli-core';
import type { Suggestion } from '../components/SuggestionsDisplay.js'; import type { Suggestion } from '../components/SuggestionsDisplay.js';
@@ -12,6 +13,8 @@ import { MAX_SUGGESTIONS_TO_SHOW } from '../components/SuggestionsDisplay.js';
import { CommandKind } from '../commands/types.js'; import { CommandKind } from '../commands/types.js';
import { AsyncFzf } from 'fzf'; import { AsyncFzf } from 'fzf';
const DEFAULT_SEARCH_TIMEOUT_MS = 5000;
export enum AtCompletionStatus { export enum AtCompletionStatus {
IDLE = 'idle', IDLE = 'idle',
INITIALIZING = 'initializing', INITIALIZING = 'initializing',
@@ -257,6 +260,7 @@ export function useAtCompletion(props: UseAtCompletionProps): void {
config?.getEnableRecursiveFileSearch() ?? true, config?.getEnableRecursiveFileSearch() ?? true,
disableFuzzySearch: disableFuzzySearch:
config?.getFileFilteringDisableFuzzySearch() ?? false, config?.getFileFilteringDisableFuzzySearch() ?? false,
maxFiles: config?.getFileFilteringOptions()?.maxFileCount,
}); });
await searcher.initialize(); await searcher.initialize();
fileSearch.current = searcher; fileSearch.current = searcher;
@@ -285,6 +289,22 @@ export function useAtCompletion(props: UseAtCompletionProps): void {
dispatch({ type: 'SET_LOADING', payload: true }); dispatch({ type: 'SET_LOADING', payload: true });
}, 200); }, 200);
const timeoutMs =
config?.getFileFilteringOptions()?.searchTimeout ??
DEFAULT_SEARCH_TIMEOUT_MS;
// eslint-disable-next-line @typescript-eslint/no-floating-promises
(async () => {
try {
await setTimeoutPromise(timeoutMs, undefined, {
signal: controller.signal,
});
controller.abort();
} catch {
// ignore
}
})();
try { try {
const results = await fileSearch.current.search(state.pattern, { const results = await fileSearch.current.search(state.pattern, {
signal: controller.signal, signal: controller.signal,
@@ -332,6 +352,8 @@ export function useAtCompletion(props: UseAtCompletionProps): void {
if (!(error instanceof Error && error.name === 'AbortError')) { if (!(error instanceof Error && error.name === 'AbortError')) {
dispatch({ type: 'ERROR' }); dispatch({ type: 'ERROR' });
} }
} finally {
controller.abort();
} }
}; };
+14
View File
@@ -310,6 +310,8 @@ export interface ConfigParameters {
respectGeminiIgnore?: boolean; respectGeminiIgnore?: boolean;
enableRecursiveFileSearch?: boolean; enableRecursiveFileSearch?: boolean;
disableFuzzySearch?: boolean; disableFuzzySearch?: boolean;
maxFileCount?: number;
searchTimeout?: number;
}; };
checkpointing?: boolean; checkpointing?: boolean;
proxy?: string; proxy?: string;
@@ -441,6 +443,8 @@ export class Config {
respectGeminiIgnore: boolean; respectGeminiIgnore: boolean;
enableRecursiveFileSearch: boolean; enableRecursiveFileSearch: boolean;
disableFuzzySearch: boolean; disableFuzzySearch: boolean;
maxFileCount: number;
searchTimeout: number;
}; };
private fileDiscoveryService: FileDiscoveryService | null = null; private fileDiscoveryService: FileDiscoveryService | null = null;
private gitService: GitService | undefined = undefined; private gitService: GitService | undefined = undefined;
@@ -593,6 +597,14 @@ export class Config {
enableRecursiveFileSearch: enableRecursiveFileSearch:
params.fileFiltering?.enableRecursiveFileSearch ?? true, params.fileFiltering?.enableRecursiveFileSearch ?? true,
disableFuzzySearch: params.fileFiltering?.disableFuzzySearch ?? false, disableFuzzySearch: params.fileFiltering?.disableFuzzySearch ?? false,
maxFileCount:
params.fileFiltering?.maxFileCount ??
DEFAULT_FILE_FILTERING_OPTIONS.maxFileCount ??
20000,
searchTimeout:
params.fileFiltering?.searchTimeout ??
DEFAULT_FILE_FILTERING_OPTIONS.searchTimeout ??
5000,
}; };
this.checkpointing = params.checkpointing ?? false; this.checkpointing = params.checkpointing ?? false;
this.proxy = params.proxy; this.proxy = params.proxy;
@@ -1385,6 +1397,8 @@ export class Config {
return { return {
respectGitIgnore: this.fileFiltering.respectGitIgnore, respectGitIgnore: this.fileFiltering.respectGitIgnore,
respectGeminiIgnore: this.fileFiltering.respectGeminiIgnore, respectGeminiIgnore: this.fileFiltering.respectGeminiIgnore,
maxFileCount: this.fileFiltering.maxFileCount,
searchTimeout: this.fileFiltering.searchTimeout,
}; };
} }
+6
View File
@@ -7,16 +7,22 @@
export interface FileFilteringOptions { export interface FileFilteringOptions {
respectGitIgnore: boolean; respectGitIgnore: boolean;
respectGeminiIgnore: boolean; respectGeminiIgnore: boolean;
maxFileCount?: number;
searchTimeout?: number;
} }
// For memory files // For memory files
export const DEFAULT_MEMORY_FILE_FILTERING_OPTIONS: FileFilteringOptions = { export const DEFAULT_MEMORY_FILE_FILTERING_OPTIONS: FileFilteringOptions = {
respectGitIgnore: false, respectGitIgnore: false,
respectGeminiIgnore: true, respectGeminiIgnore: true,
maxFileCount: 20000,
searchTimeout: 5000,
}; };
// For all other files // For all other files
export const DEFAULT_FILE_FILTERING_OPTIONS: FileFilteringOptions = { export const DEFAULT_FILE_FILTERING_OPTIONS: FileFilteringOptions = {
respectGitIgnore: true, respectGitIgnore: true,
respectGeminiIgnore: true, respectGeminiIgnore: true,
maxFileCount: 20000,
searchTimeout: 5000,
}; };
@@ -503,14 +503,14 @@ describe('crawler', () => {
}); });
}); });
const getCrawlResults = (maxDepth?: number) => { const getCrawlResults = async (maxDepth?: number) => {
const ignore = loadIgnoreRules({ const ignore = loadIgnoreRules({
projectRoot: tmpDir, projectRoot: tmpDir,
useGitignore: false, useGitignore: false,
useGeminiignore: false, useGeminiignore: false,
ignoreDirs: [], ignoreDirs: [],
}); });
return crawl({ const paths = await crawl({
crawlDirectory: tmpDir, crawlDirectory: tmpDir,
cwd: tmpDir, cwd: tmpDir,
ignore, ignore,
@@ -518,6 +518,7 @@ describe('crawler', () => {
cacheTtl: 0, cacheTtl: 0,
maxDepth, maxDepth,
}); });
return paths;
}; };
it('should only crawl top-level files when maxDepth is 0', async () => { it('should only crawl top-level files when maxDepth is 0', async () => {
@@ -571,4 +572,34 @@ describe('crawler', () => {
); );
}); });
}); });
it('should detect truncation when maxFiles is hit', async () => {
tmpDir = await createTmpDir({
'file1.js': '',
'file2.js': '',
'file3.js': '',
});
const ignore = loadIgnoreRules({
projectRoot: tmpDir,
useGitignore: false,
useGeminiignore: false,
ignoreDirs: [],
});
const paths = await crawl({
crawlDirectory: tmpDir,
cwd: tmpDir,
ignore,
cache: false,
cacheTtl: 0,
maxFiles: 2,
});
// fdir returns files and directories.
// In our filter, we only increment fileCount for files.
// So we should have 2 files + some directories.
const files = paths.filter((p) => p !== '.' && !p.endsWith('/'));
expect(files.length).toBe(2);
});
}); });
+20 -1
View File
@@ -16,6 +16,8 @@ export interface CrawlOptions {
cwd: string; cwd: string;
// The fdir maxDepth option. // The fdir maxDepth option.
maxDepth?: number; maxDepth?: number;
// Maximum number of files to return.
maxFiles?: number;
// A pre-configured Ignore instance. // A pre-configured Ignore instance.
ignore: Ignore; ignore: Ignore;
// Caching options. // Caching options.
@@ -43,6 +45,9 @@ export async function crawl(options: CrawlOptions): Promise<string[]> {
const posixCwd = toPosixPath(options.cwd); const posixCwd = toPosixPath(options.cwd);
const posixCrawlDirectory = toPosixPath(options.crawlDirectory); const posixCrawlDirectory = toPosixPath(options.crawlDirectory);
const maxFiles = options.maxFiles ?? Infinity;
let fileCount = 0;
let truncated = false;
let results: string[]; let results: string[];
try { try {
@@ -51,7 +56,21 @@ export async function crawl(options: CrawlOptions): Promise<string[]> {
.withRelativePaths() .withRelativePaths()
.withDirs() .withDirs()
.withPathSeparator('/') // Always use unix style paths .withPathSeparator('/') // Always use unix style paths
.filter((path, isDirectory) => {
if (!isDirectory) {
fileCount++;
if (fileCount > maxFiles) {
truncated = true;
return false;
}
}
return true;
})
.exclude((_, dirPath) => { .exclude((_, dirPath) => {
if (fileCount > maxFiles) {
truncated = true;
return true;
}
const relativePath = path.posix.relative(posixCrawlDirectory, dirPath); const relativePath = path.posix.relative(posixCrawlDirectory, dirPath);
return dirFilter(`${relativePath}/`); return dirFilter(`${relativePath}/`);
}); });
@@ -72,7 +91,7 @@ export async function crawl(options: CrawlOptions): Promise<string[]> {
path.posix.join(relativeToCrawlDir, p), path.posix.join(relativeToCrawlDir, p),
); );
if (options.cache) { if (options.cache && !truncated) {
const cacheKey = cache.getCacheKey( const cacheKey = cache.getCacheKey(
options.crawlDirectory, options.crawlDirectory,
options.ignore.getFingerprint(), options.ignore.getFingerprint(),
@@ -7,6 +7,7 @@
import { describe, it, expect, afterEach, vi } from 'vitest'; import { describe, it, expect, afterEach, vi } from 'vitest';
import { FileSearchFactory, AbortError, filter } from './fileSearch.js'; import { FileSearchFactory, AbortError, filter } from './fileSearch.js';
import { createTmpDir, cleanupTmpDir } from '@google/gemini-cli-test-utils'; import { createTmpDir, cleanupTmpDir } from '@google/gemini-cli-test-utils';
import * as crawler from './crawler.js';
describe('FileSearch', () => { describe('FileSearch', () => {
let tmpDir: string; let tmpDir: string;
@@ -481,6 +482,33 @@ describe('FileSearch', () => {
expect(results).toEqual(['src/', 'src/main.js']); expect(results).toEqual(['src/', 'src/main.js']);
}); });
it('should respect default maxFiles budget of 20000 in RecursiveFileSearch', async () => {
const crawlSpy = vi.spyOn(crawler, 'crawl');
tmpDir = await createTmpDir({
'file1.js': '',
});
const fileSearch = FileSearchFactory.create({
projectRoot: tmpDir,
useGitignore: false,
useGeminiignore: false,
ignoreDirs: [],
cache: false,
cacheTtl: 0,
enableRecursiveFileSearch: true,
disableFuzzySearch: false,
});
await fileSearch.initialize();
expect(crawlSpy).toHaveBeenCalledWith(
expect.objectContaining({
maxFiles: 20000,
}),
);
});
it('should be cancellable via AbortSignal', async () => { it('should be cancellable via AbortSignal', async () => {
const largeDir: Record<string, string> = {}; const largeDir: Record<string, string> = {};
for (let i = 0; i < 100; i++) { for (let i = 0; i < 100; i++) {
@@ -24,6 +24,7 @@ export interface FileSearchOptions {
enableRecursiveFileSearch: boolean; enableRecursiveFileSearch: boolean;
disableFuzzySearch: boolean; disableFuzzySearch: boolean;
maxDepth?: number; maxDepth?: number;
maxFiles?: number;
} }
export class AbortError extends Error { export class AbortError extends Error {
@@ -109,7 +110,9 @@ class RecursiveFileSearch implements FileSearch {
cache: this.options.cache, cache: this.options.cache,
cacheTtl: this.options.cacheTtl, cacheTtl: this.options.cacheTtl,
maxDepth: this.options.maxDepth, maxDepth: this.options.maxDepth,
maxFiles: this.options.maxFiles ?? 20000,
}); });
this.buildResultCache(); this.buildResultCache();
} }