Skip to content

fix: prevent Tool.define() wrapper accumulation on object-defined tools#16952

Open
jpcarranza94 wants to merge 2 commits intoanomalyco:devfrom
jpcarranza94:fix/tool-define-wrapper-accumulation
Open

fix: prevent Tool.define() wrapper accumulation on object-defined tools#16952
jpcarranza94 wants to merge 2 commits intoanomalyco:devfrom
jpcarranza94:fix/tool-define-wrapper-accumulation

Conversation

@jpcarranza94
Copy link

@jpcarranza94 jpcarranza94 commented Mar 11, 2026

Issue for this PR

Closes #17047

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

Tool.define() wraps toolInfo.execute with a validation + truncation layer each time init() is called. When init is an object literal (not a function), line 55 returns the same object reference every call. This means each init() call:

  1. Captures the current toolInfo.execute (already wrapped N times)
  2. Overwrites it with wrapper N+1 that calls the previous one
  3. Builds a closure chain: wrap_N → wrap_{N-1} → ... → wrap_1 → original

This is an unbounded memory leak — each wrapper closure (~1KB) is retained permanently and can never be GC'd. At production traffic (~800 sessions/hr, ~10 steps/session), this leaks ~8MB/hr.

After ~1,000+ accumulated steps, invoking any affected tool recurses through the entire chain and crashes with RangeError: Maximum call stack size exceeded.

Affected tools: all object-defined tools (Read, Glob, Grep, Edit, Write, TodoRead, TodoWrite). Function-defined tools (Bash, Task, WebSearch, etc.) are safe because await init(initCtx) returns a fresh object each time.

The fix: shallow-copy the object with { ...init } so each init() call gets a fresh copy. The original object is never mutated, each copy gets exactly one wrapper layer, and the copy is GC'd after the step completes. This matches how function-defined tools already work.

How did you verify your code works?

1. Regression tests (test/tool/tool-define.test.ts) — 5 tests that directly verify the fix:

Test What it checks
does not mutate the original init object original.execute reference is unchanged after multiple init() calls
does not accumulate wrapper layers Stack depth inside execute stays constant (<5 tool.ts frames) after 100 init() calls
function-defined tool returns fresh objects Baseline — confirms function-defined tools already return distinct objects per init()
object-defined tool returns distinct objects per init() Each init() returns a different object reference (spread creates a copy)
validation still works after many init() calls Validation wrapper correctly rejects bad args and passes good args after 100 init() cycles

Tests 1 and 4 fail without the fix, confirming they catch the exact bug.

2. Stress testing — ran opencode serve under sustained load (~5,000 sessions/hr, 128 concurrent workers). Without the fix, RangeErrors started appearing at ~1,275 sessions. Stack trace confirmed tool.ts:69 repeated hundreds of times. With the fix applied, no RangeErrors after thousands of sessions.

3. Typecheckbun turbo typecheck passes (all 13 packages).

Screenshots / recordings

Stack trace captured from a failing instance (pre-fix), showing tool.ts:69 recursion:

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)
    [... repeated hundreds of times ...]

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant