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
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions