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:
Saoud Rizwan
2026-08-24 17:45:59 -07:00
committed by GitHub
parent 8e7a55498b
commit a2fb1d15ca
2 changed files with 57 additions and 9 deletions
@@ -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({