-
Notifications
You must be signed in to change notification settings - Fork 13.2k
Description
Description
Tool.define() in src/tool/tool.ts:55 has an unbounded memory leak that causes RangeError: Maximum call stack size exceeded in long-running processes.
When init is an object literal (not a function), Tool.define() returns the same object reference on every init() call. Each call wraps toolInfo.execute with a new closure layer that calls the previous one, building an ever-growing chain:
Call 1: toolInfo.execute = wrap₁(original)
Call 2: toolInfo.execute = wrap₂(wrap₁(original))
Call N: toolInfo.execute = wrapₙ(wrapₙ₋₁(...wrap₁(original)))
Since init() is called on every agentic step (via prompt.ts → resolveTools() → ToolRegistry.tools() → t.init()), the chain grows with every tool-call round-trip across all sessions. Each wrapper closure is retained in memory permanently. After ~1,000+ steps, invoking any affected tool recurses through the entire chain and crashes.
Impact
- Memory leak: Each wrapper closure is ~1KB. At production traffic (~800 sessions/hr, ~10 steps/session), this leaks ~8MB/hr of closures that can never be GC'd
- Crash: After ~1,000+ accumulated steps, any object-defined tool throws
RangeError: Maximum call stack size exceeded - Silent failure: The error is caught by the AI SDK tool wrapper and passed as a tool-error event. No stack trace appears in OpenCode logs — the failure is invisible
- Server mode: Particularly severe for
opencode servesince the process is long-lived. In TUI mode, single sessions rarely hit the threshold
Affected tools
Affected (object-defined init) |
Safe (function-defined init) |
|---|---|
| Read, Glob, Grep, Edit, Write, TodoRead, TodoWrite | Bash, Task, WebSearch, Skill, Batch |
Function-defined tools return a fresh object each init() call, so nothing accumulates.
The fix
One-line change in tool.ts:55 — spread to create a fresh copy:
// Before (bug): reuses same object, wrappers accumulate
const toolInfo = init instanceof Function ? await init(initCtx) : init
// After (fix): fresh copy each time, original never mutated
const toolInfo = init instanceof Function ? await init(initCtx) : { ...init }PR with fix + regression tests: #16952
Stack trace
Captured via diagnostic instrumentation on a production-like workload:
RangeError: Maximum call stack size exceeded.
at containsPath (src/project/instance.ts:97:29)
at assertExternalDirectory (src/tool/external-directory.ts:17:16)
at execute (src/tool/read.ts:45:11)
at <anonymous> (src/tool/tool.ts:69:32)
at <anonymous> (src/tool/tool.ts:69:32)
at <anonymous> (src/tool/tool.ts:69:32)
[... tool.ts:69 repeated hundreds of times ...]
OpenCode version
v1.2.24 (bug exists on current dev as well)
Steps to reproduce
- Run
opencode serve - Send ~1,000+ agentic requests with tool calls (across any number of sessions)
- Any object-defined tool (Read, Glob, Grep, Edit, Write) crashes with
RangeError: Maximum call stack size exceeded
Alternatively, call tool.init() in a loop — after ~1,000 iterations the wrapper chain is deep enough to overflow:
const tool = Tool.define("test", { description: "test", parameters: z.object({ input: z.string() }), async execute() { return { title: "", output: "ok", metadata: {} } } })
for (let i = 0; i < 2000; i++) await tool.init()
const resolved = await tool.init()
await resolved.execute({ input: "hello" }, ctx) // RangeErrorOperating System
Linux x86_64 (Amazon Linux 2023) — also reproduced on macOS