mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-29 14:34:55 -07:00
fix(cli): prevent OOM crash by limiting file search traversal and adding timeout (#16696)
This commit is contained in:
@@ -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();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user