Skip to content

Bug: Tool.define() accumulates wrapper closures — unbounded memory leak + RangeError crash in server mode #17047

@jpcarranza94

Description

@jpcarranza94

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.tsresolveTools()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 serve since 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

  1. Run opencode serve
  2. Send ~1,000+ agentic requests with tool calls (across any number of sessions)
  3. 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) // RangeError

Operating System

Linux x86_64 (Amazon Linux 2023) — also reproduced on macOS

Metadata

Metadata

Assignees

Labels

coreAnything pertaining to core functionality of the application (opencode server stuff)

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions