Skip to content

Commit f5c7f2c

Browse files
committed
fix(bash): avoid O(n²) memory growth for large command output
Previously, output was accumulated via `output += chunk.toString()` which creates a new string for every chunk, causing O(n²) memory usage. For commands producing megabytes of output, this caused catastrophic memory consumption. Add StreamingOutput class that: - Accumulates output in memory up to 50KB threshold - Spills to a temp file when threshold exceeded - Tracks bytes incrementally to avoid scanning Fixes memory exhaustion on commands like `find /` or large builds. Assisted-by: OpenCode (Claude Sonnet 4)
1 parent f96d269 commit f5c7f2c

4 files changed

Lines changed: 319 additions & 48 deletions

File tree

packages/opencode/src/session/prompt.ts

Lines changed: 67 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import { ListTool } from "../tool/ls"
2929
import { FileTime } from "../file/time"
3030
import { Flag } from "../flag/flag"
3131
import { ulid } from "ulid"
32-
import { spawn } from "child_process"
32+
3333
import { Command } from "../command"
3434
import { $, fileURLToPath } from "bun"
3535
import { ConfigMarkdown } from "../config/markdown"
@@ -44,7 +44,8 @@ import { SessionStatus } from "./status"
4444
import { LLM } from "./llm"
4545
import { iife } from "@/util/iife"
4646
import { Shell } from "@/shell/shell"
47-
import { Truncate } from "@/tool/truncation"
47+
import { Truncate, StreamingOutput } from "@/tool/truncation"
48+
import { spawn } from "child_process"
4849

4950
// @ts-ignore
5051
globalThis.AI_SDK_LOG_WARNINGS = false
@@ -1475,39 +1476,31 @@ NOTE: At any point in time through this workflow you should feel free to ask the
14751476
const matchingInvocation = invocations[shellName] ?? invocations[""]
14761477
const args = matchingInvocation?.args
14771478

1479+
const streaming = new StreamingOutput()
1480+
14781481
const proc = spawn(shell, args, {
14791482
cwd: Instance.directory,
14801483
detached: process.platform !== "win32",
1481-
stdio: ["ignore", "pipe", "pipe"],
14821484
env: {
14831485
...process.env,
14841486
TERM: "dumb",
14851487
},
1488+
stdio: ["ignore", "pipe", "pipe"],
14861489
})
14871490

1488-
let output = ""
1489-
1490-
proc.stdout?.on("data", (chunk) => {
1491-
output += chunk.toString()
1491+
const append = (chunk: Buffer) => {
1492+
const preview = streaming.append(chunk)
14921493
if (part.state.status === "running") {
14931494
part.state.metadata = {
1494-
output: output,
1495+
output: preview,
14951496
description: "",
14961497
}
14971498
Session.updatePart(part)
14981499
}
1499-
})
1500+
}
15001501

1501-
proc.stderr?.on("data", (chunk) => {
1502-
output += chunk.toString()
1503-
if (part.state.status === "running") {
1504-
part.state.metadata = {
1505-
output: output,
1506-
description: "",
1507-
}
1508-
Session.updatePart(part)
1509-
}
1510-
})
1502+
proc.stdout?.on("data", append)
1503+
proc.stderr?.on("data", append)
15111504

15121505
let aborted = false
15131506
let exited = false
@@ -1526,33 +1519,72 @@ NOTE: At any point in time through this workflow you should feel free to ask the
15261519

15271520
abort.addEventListener("abort", abortHandler, { once: true })
15281521

1529-
await new Promise<void>((resolve) => {
1530-
proc.on("close", () => {
1531-
exited = true
1522+
await new Promise<void>((resolve, reject) => {
1523+
const cleanup = () => {
15321524
abort.removeEventListener("abort", abortHandler)
1525+
}
1526+
1527+
proc.once("exit", () => {
1528+
exited = true
1529+
cleanup()
1530+
resolve()
1531+
})
1532+
1533+
proc.once("error", (error) => {
1534+
exited = true
1535+
cleanup()
1536+
reject(error)
1537+
})
1538+
1539+
proc.once("close", () => {
1540+
exited = true
1541+
cleanup()
15331542
resolve()
15341543
})
15351544
})
15361545

1546+
streaming.close()
1547+
15371548
if (aborted) {
1538-
output += "\n\n" + ["<metadata>", "User aborted the command", "</metadata>"].join("\n")
1549+
streaming.appendMetadata("\n\n" + ["<metadata>", "User aborted the command", "</metadata>"].join("\n"))
15391550
}
1551+
15401552
msg.time.completed = Date.now()
15411553
await Session.updateMessage(msg)
1554+
15421555
if (part.state.status === "running") {
1543-
part.state = {
1544-
status: "completed",
1545-
time: {
1546-
...part.state.time,
1547-
end: Date.now(),
1548-
},
1549-
input: part.state.input,
1550-
title: "",
1551-
metadata: {
1556+
if (streaming.truncated) {
1557+
part.state = {
1558+
status: "completed",
1559+
time: {
1560+
...part.state.time,
1561+
end: Date.now(),
1562+
},
1563+
input: part.state.input,
1564+
title: "",
1565+
metadata: {
1566+
output: `[output streamed to file: ${streaming.totalBytes} bytes]`,
1567+
description: "",
1568+
outputPath: streaming.outputPath,
1569+
},
1570+
output: streaming.finalize(),
1571+
}
1572+
} else {
1573+
const output = streaming.inMemoryOutput
1574+
part.state = {
1575+
status: "completed",
1576+
time: {
1577+
...part.state.time,
1578+
end: Date.now(),
1579+
},
1580+
input: part.state.input,
1581+
title: "",
1582+
metadata: {
1583+
output,
1584+
description: "",
1585+
},
15521586
output,
1553-
description: "",
1554-
},
1555-
output,
1587+
}
15561588
}
15571589
await Session.updatePart(part)
15581590
}

packages/opencode/src/tool/bash.ts

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,26 @@ import { lazy } from "@/util/lazy"
99
import { Language } from "web-tree-sitter"
1010

1111
import { $ } from "bun"
12-
import { Filesystem } from "@/util/filesystem"
1312
import { fileURLToPath } from "url"
1413
import { Flag } from "@/flag/flag.ts"
1514
import { Shell } from "@/shell/shell"
1615

1716
import { BashArity } from "@/permission/arity"
18-
import { Truncate } from "./truncation"
17+
import { Truncate, StreamingOutput } from "./truncation"
1918

2019
const MAX_METADATA_LENGTH = 30_000
2120
const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000
2221

2322
export const log = Log.create({ service: "bash-tool" })
2423

24+
export interface BashMetadata {
25+
output: string
26+
exit: number | null
27+
description: string
28+
truncated?: boolean
29+
outputPath?: string
30+
}
31+
2532
const resolveWasm = (asset: string) => {
2633
if (asset.startsWith("file://")) return fileURLToPath(asset)
2734
if (asset.startsWith("/") || /^[a-z]:/i.test(asset)) return asset
@@ -80,6 +87,7 @@ export const BashTool = Tool.define("bash", async () => {
8087
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
8188
}
8289
const timeout = params.timeout ?? DEFAULT_TIMEOUT
90+
8391
const tree = await parser().then((p) => p.parse(params.command))
8492
if (!tree) {
8593
throw new Error("Failed to parse command")
@@ -154,6 +162,8 @@ export const BashTool = Tool.define("bash", async () => {
154162
})
155163
}
156164

165+
const streaming = new StreamingOutput()
166+
157167
const proc = spawn(params.command, {
158168
shell,
159169
cwd,
@@ -164,8 +174,6 @@ export const BashTool = Tool.define("bash", async () => {
164174
detached: process.platform !== "win32",
165175
})
166176

167-
let output = ""
168-
169177
// Initialize metadata with empty output
170178
ctx.metadata({
171179
metadata: {
@@ -175,11 +183,12 @@ export const BashTool = Tool.define("bash", async () => {
175183
})
176184

177185
const append = (chunk: Buffer) => {
178-
output += chunk.toString()
186+
const preview = streaming.append(chunk)
187+
const display =
188+
preview.length > MAX_METADATA_LENGTH ? preview.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : preview
179189
ctx.metadata({
180190
metadata: {
181-
// truncate the metadata to avoid GIANT blobs of data (has nothing to do w/ what agent can access)
182-
output: output.length > MAX_METADATA_LENGTH ? output.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : output,
191+
output: display,
183192
description: params.description,
184193
},
185194
})
@@ -228,29 +237,50 @@ export const BashTool = Tool.define("bash", async () => {
228237
cleanup()
229238
reject(error)
230239
})
240+
241+
proc.once("close", () => {
242+
exited = true
243+
cleanup()
244+
resolve()
245+
})
231246
})
232247

233-
const resultMetadata: string[] = []
248+
streaming.close()
234249

250+
const resultMetadata: string[] = []
235251
if (timedOut) {
236252
resultMetadata.push(`bash tool terminated command after exceeding timeout ${timeout} ms`)
237253
}
238-
239254
if (aborted) {
240255
resultMetadata.push("User aborted the command")
241256
}
242-
243257
if (resultMetadata.length > 0) {
244-
output += "\n\n<bash_metadata>\n" + resultMetadata.join("\n") + "\n</bash_metadata>"
258+
streaming.appendMetadata("\n\n<bash_metadata>\n" + resultMetadata.join("\n") + "\n</bash_metadata>")
259+
}
260+
261+
// If we streamed to a file (threshold exceeded), return truncated result
262+
if (streaming.truncated) {
263+
return {
264+
title: params.description,
265+
metadata: {
266+
output: `[output streamed to file: ${streaming.totalBytes} bytes]`,
267+
exit: proc.exitCode,
268+
description: params.description,
269+
truncated: true,
270+
outputPath: streaming.outputPath,
271+
} as BashMetadata,
272+
output: streaming.finalize(),
273+
}
245274
}
246275

276+
const output = streaming.inMemoryOutput
247277
return {
248278
title: params.description,
249279
metadata: {
250280
output: output.length > MAX_METADATA_LENGTH ? output.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : output,
251281
exit: proc.exitCode,
252282
description: params.description,
253-
},
283+
} as BashMetadata,
254284
output,
255285
}
256286
},

0 commit comments

Comments
 (0)