Skip to content

Commit dae59f5

Browse files
committed
fix(grep): stream ripgrep output to prevent memory exhaustion
Cherry-pick from upstream PR anomalyco#5432 (anomalyco/opencode). - Stream ripgrep output line-by-line instead of buffering into memory - Kill ripgrep early after 100 matches to save resources - Remove per-file stat() calls for significant performance improvement - Handle Windows line endings in streaming context Trade-off: Results no longer sorted by modification time.
1 parent 75c77f3 commit dae59f5

2 files changed

Lines changed: 82 additions & 38 deletions

File tree

packages/opencode/src/tool/grep.ts

Lines changed: 67 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import DESCRIPTION from "./grep.txt"
66
import { Instance } from "../project/instance"
77

88
const MAX_LINE_LENGTH = 2000
9+
const MATCH_LIMIT = 100
910

1011
export const GrepTool = Tool.define("grep", {
1112
description: DESCRIPTION,
@@ -44,65 +45,93 @@ export const GrepTool = Tool.define("grep", {
4445
stderr: "pipe",
4546
})
4647

47-
const output = await new Response(proc.stdout).text()
48+
// Stream ripgrep output to prevent memory exhaustion on large result sets
49+
const reader = proc.stdout.getReader()
50+
const decoder = new TextDecoder()
51+
let buffer = ""
52+
const matches: Array<{
53+
path: string
54+
lineNum: number
55+
lineText: string
56+
}> = []
57+
let truncated = false
58+
59+
try {
60+
while (true) {
61+
const { done, value } = await reader.read()
62+
if (done) break
63+
64+
buffer += decoder.decode(value, { stream: true })
65+
// Handle both Unix (\n) and Windows (\r\n) line endings
66+
const lines = buffer.split(/\r?\n/)
67+
buffer = lines.pop() || ""
68+
69+
for (const line of lines) {
70+
if (!line) continue
71+
if (matches.length >= MATCH_LIMIT) {
72+
truncated = true
73+
break
74+
}
75+
76+
const [filePath, lineNumStr, ...lineTextParts] = line.split("|")
77+
if (!filePath || !lineNumStr || lineTextParts.length === 0) continue
78+
79+
matches.push({
80+
path: filePath,
81+
lineNum: parseInt(lineNumStr, 10),
82+
lineText: lineTextParts.join("|"),
83+
})
84+
}
85+
86+
if (truncated) break
87+
}
88+
89+
// Process any remaining buffer content
90+
if (!truncated && buffer) {
91+
const [filePath, lineNumStr, ...lineTextParts] = buffer.split("|")
92+
if (filePath && lineNumStr && lineTextParts.length > 0 && matches.length < MATCH_LIMIT) {
93+
matches.push({
94+
path: filePath,
95+
lineNum: parseInt(lineNumStr, 10),
96+
lineText: lineTextParts.join("|"),
97+
})
98+
}
99+
}
100+
} finally {
101+
// Kill ripgrep early if we've hit the limit to save resources
102+
if (truncated) proc.kill()
103+
reader.releaseLock()
104+
}
105+
48106
const errorOutput = await new Response(proc.stderr).text()
49107
const exitCode = await proc.exited
50108

51-
if (exitCode === 1) {
109+
// Exit code 1 means no matches found
110+
if (exitCode === 1 && matches.length === 0) {
52111
return {
53112
title: params.pattern,
54113
metadata: { matches: 0, truncated: false },
55114
output: "No files found",
56115
}
57116
}
58117

59-
if (exitCode !== 0) {
118+
// Only throw on non-zero exit if we didn't truncate (kill) the process
119+
if (exitCode !== 0 && exitCode !== 1 && !truncated) {
60120
throw new Error(`ripgrep failed: ${errorOutput}`)
61121
}
62122

63-
// Handle both Unix (\n) and Windows (\r\n) line endings
64-
const lines = output.trim().split(/\r?\n/)
65-
const matches = []
66-
67-
for (const line of lines) {
68-
if (!line) continue
69-
70-
const [filePath, lineNumStr, ...lineTextParts] = line.split("|")
71-
if (!filePath || !lineNumStr || lineTextParts.length === 0) continue
72-
73-
const lineNum = parseInt(lineNumStr, 10)
74-
const lineText = lineTextParts.join("|")
75-
76-
const file = Bun.file(filePath)
77-
const stats = await file.stat().catch(() => null)
78-
if (!stats) continue
79-
80-
matches.push({
81-
path: filePath,
82-
modTime: stats.mtime.getTime(),
83-
lineNum,
84-
lineText,
85-
})
86-
}
87-
88-
matches.sort((a, b) => b.modTime - a.modTime)
89-
90-
const limit = 100
91-
const truncated = matches.length > limit
92-
const finalMatches = truncated ? matches.slice(0, limit) : matches
93-
94-
if (finalMatches.length === 0) {
123+
if (matches.length === 0) {
95124
return {
96125
title: params.pattern,
97126
metadata: { matches: 0, truncated: false },
98127
output: "No files found",
99128
}
100129
}
101130

102-
const outputLines = [`Found ${finalMatches.length} matches`]
131+
const outputLines = [`Found ${matches.length} matches`]
103132

104133
let currentFile = ""
105-
for (const match of finalMatches) {
134+
for (const match of matches) {
106135
if (currentFile !== match.path) {
107136
if (currentFile !== "") {
108137
outputLines.push("")
@@ -123,7 +152,7 @@ export const GrepTool = Tool.define("grep", {
123152
return {
124153
title: params.pattern,
125154
metadata: {
126-
matches: finalMatches.length,
155+
matches: matches.length,
127156
truncated,
128157
},
129158
output: outputLines.join("\n"),

script/sync/fork-features.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1786,6 +1786,21 @@
17861786
"markers": ["isRunning", "getSpinnerFrame", "Show when={isRunning()}", "theme.primary"]
17871787
}
17881788
]
1789+
},
1790+
{
1791+
"pr": 5432,
1792+
"title": "Stream ripgrep output to prevent memory exhaustion",
1793+
"author": "Hona",
1794+
"status": "cherry-picked",
1795+
"description": "Stream ripgrep output line-by-line instead of buffering all into memory. Kills ripgrep early after 100 matches to prevent memory exhaustion on massive result sets. Removes per-file stat() calls for performance.",
1796+
"files": ["packages/opencode/src/tool/grep.ts"],
1797+
"criticalCode": [
1798+
{
1799+
"file": "packages/opencode/src/tool/grep.ts",
1800+
"description": "Stream ripgrep stdout with ReadableStream reader and early termination",
1801+
"markers": ["MATCH_LIMIT", "reader.read()", "proc.kill()", "truncated"]
1802+
}
1803+
]
17891804
}
17901805
]
17911806
}

0 commit comments

Comments
 (0)