mirror of
https://github.com/cline/cline.git
synced 2026-08-28 19:48:08 +08:00
fix(core): prevent search_codebase from crashing the process on giant single-line files (#13525)
* fix(core): prevent search_codebase from crashing the process on giant single-line files searchWithRipgrep buffered all of rg's --json stdout into one string. Each JSON event embeds the full text of the matched line (--max-columns is ignored in JSON mode), so searching a directory of serialized trace dumps (single-line multi-hundred-MB JSON files) accumulated gigabytes of stdout until string concatenation threw RangeError: Out of memory inside the stream data handler. That throw is outside the tool's try/catch, so it escalated to an uncaughtException and killed the CLI/hub daemon. Parse rg's JSON events incrementally line by line, drop events larger than 256KB, truncate matched/context lines to MAX_LINE_CHARS, and stop reading once maxResults is reached. The fallback regex scan now skips files larger than 10MB (reporting the skip count) and truncates its context lines the same way. Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> * simplify search_codebase crash fix to a minimal diff Replace the incremental JSON-event parser with three small guards: stop buffering rg stdout past 10MB, drop the trailing partial event before parsing, and slice fallback context lines to MAX_LINE_CHARS. Drops the fallback file-size skip and skip-count reporting. Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com> --------- Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
This commit is contained in:
@@ -16,22 +16,51 @@ describe("createSearchExecutor", () => {
|
||||
it("middle-truncates oversized search output with recovery guidance", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "agents-search-"));
|
||||
const filePath = path.join(dir, "large.ts");
|
||||
await fs.writeFile(
|
||||
filePath,
|
||||
`needle ${"x".repeat(MAX_SEARCH_OUTPUT_CHARS * 2)} TAIL`,
|
||||
"utf-8",
|
||||
// Many matching lines so the joined output exceeds the cap even though
|
||||
// each line stays under the per-line truncation limit.
|
||||
const rows = Array.from(
|
||||
{ length: 200 },
|
||||
(_, i) => `needle ${"x".repeat(900)} row-${i}`,
|
||||
);
|
||||
await fs.writeFile(filePath, rows.join("\n"), "utf-8");
|
||||
|
||||
try {
|
||||
const search = createSearchExecutor({ contextLines: 0 });
|
||||
// Lookahead is unsupported by ripgrep, forcing the fallback scan.
|
||||
const result = await search("(?=needle)", dir, ctx);
|
||||
|
||||
expect(result.length).toBeGreaterThan(MAX_SEARCH_OUTPUT_CHARS);
|
||||
expect(result.length).toBeLessThanOrEqual(50_000);
|
||||
expect(result).toContain("Found 1 result for pattern");
|
||||
expect(result).toContain("Found 100 results for pattern");
|
||||
expect(result).toContain("search output truncated");
|
||||
expect(result).toContain("Narrow the pattern or scope");
|
||||
expect(result).toContain("TAIL");
|
||||
} finally {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("returns bounded output when a match lands in a giant single-line file", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "agents-search-"));
|
||||
// Simulates a serialized trace dump. Buffering ripgrep's --json events
|
||||
// for such files unbounded previously crashed the host process once
|
||||
// accumulated stdout passed the engine's max string length.
|
||||
await fs.writeFile(
|
||||
path.join(dir, "trace.json"),
|
||||
`{"trace": "${"x".repeat(20 * 1024 * 1024)}"}`,
|
||||
"utf-8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(dir, "small.ts"),
|
||||
"const trace = 1;\n",
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
try {
|
||||
const search = createSearchExecutor();
|
||||
const result = await search("trace", dir, ctx);
|
||||
|
||||
expect(result.length).toBeLessThanOrEqual(50_000);
|
||||
expect(result).toContain("small.ts");
|
||||
} finally {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -10,7 +10,17 @@ import * as path from "node:path";
|
||||
import type { AgentToolContext } from "@cline/shared";
|
||||
import { getFileIndex } from "../../../services/workspace";
|
||||
import type { SearchExecutor } from "../types";
|
||||
import { MAX_SEARCH_OUTPUT_CHARS } from "./output-limits";
|
||||
import { MAX_LINE_CHARS, MAX_SEARCH_OUTPUT_CHARS } from "./output-limits";
|
||||
|
||||
/**
|
||||
* Cap on buffered `rg --json` stdout. Each event embeds the full text of its
|
||||
* matched line, so one match in a giant single-line file (e.g. a serialized
|
||||
* trace dump) can produce a multi-hundred-MB event; buffering unbounded can
|
||||
* exceed the engine's max string length and crash the whole process with an
|
||||
* uncaught RangeError from the stream data handler. Results are capped to
|
||||
* MAX_SEARCH_OUTPUT_CHARS anyway, so output past this is never shown.
|
||||
*/
|
||||
const MAX_RG_STDOUT_CHARS = 10 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Options for the search executor
|
||||
@@ -213,6 +223,9 @@ function searchWithRipgrep(
|
||||
});
|
||||
|
||||
child.stdout.on("data", (chunk: Buffer | string) => {
|
||||
if (stdout.length > MAX_RG_STDOUT_CHARS) {
|
||||
return;
|
||||
}
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
|
||||
@@ -224,7 +237,11 @@ function searchWithRipgrep(
|
||||
if (code === 0 || code === 1) {
|
||||
try {
|
||||
const matches: SearchMatch[] = [];
|
||||
const lines = stdout.split("\n").filter((line) => line.trim());
|
||||
// Drop the trailing partial event left behind by the stdout cap.
|
||||
const lines = stdout
|
||||
.slice(0, stdout.lastIndexOf("\n") + 1)
|
||||
.split("\n")
|
||||
.filter((line) => line.trim());
|
||||
|
||||
for (const line of lines) {
|
||||
if (matches.length >= maxResults) break;
|
||||
@@ -427,7 +444,9 @@ export function createSearchExecutor(
|
||||
|
||||
for (let i = contextStart; i <= contextEnd; i++) {
|
||||
const prefix = i === lineIdx ? ">" : " ";
|
||||
contextLinesArr.push(`${prefix} ${i + 1}: ${lines[i]}`);
|
||||
contextLinesArr.push(
|
||||
`${prefix} ${i + 1}: ${lines[i].slice(0, MAX_LINE_CHARS)}`,
|
||||
);
|
||||
}
|
||||
|
||||
matches.push({
|
||||
|
||||
Reference in New Issue
Block a user