Skip to content

fix: release stream state on end to prevent memory leaks#943

Open
Gummygamer wants to merge 1 commit intoanthropics:mainfrom
Gummygamer:fix/stream-memory-leak
Open

fix: release stream state on end to prevent memory leaks#943
Gummygamer wants to merge 1 commit intoanthropics:mainfrom
Gummygamer:fix/stream-memory-leak

Conversation

@Gummygamer
Copy link

Summary

  • MessageStream and BetaMessageStream accumulate all data in messages and receivedMessages arrays for the entire lifetime of the stream object
  • When stream instances are retained after completion (e.g. in a long-running tool loop where the caller holds references to yielded streams), this causes unbounded memory growth
  • The BetaToolRunner yields a new BetaMessageStream per iteration; if the caller retains all yielded streams, each holds a copy of the full message history at that point — O(n²) total

Fix

Cache the final message in _emitFinal() (before the 'end' event fires), then after all 'end' listeners are called, clear:

  • messages (copy of all API params — grows with conversation)
  • receivedMessages (accumulates all API responses)
  • #currentMessageSnapshot, #params, #listeners

finalMessage() and finalText() continue to work via #cachedFinalMessage.

Observed impact

Claude Code sessions growing from 2GB → 20GB+ in a single session when using tool-heavy workflows. The process had to be restarted repeatedly due to OOM. The root cause was identified by extracting embedded JS from the Claude Code binary and tracing the retention chain:

  1. BetaToolRunner yields a new BetaMessageStream per iteration
  2. Claude Code pushes each yielded stream into a React ref that is never trimmed
  3. Each retained stream holds messages (copy of all params at that iteration) + receivedMessages
  4. Compaction reduces params.messages in the runner but old stream copies are unaffected

Test plan

  • Existing tests pass
  • finalMessage() resolves correctly after end
  • finalText() resolves correctly after end
  • Stream event listeners (including 'end' listeners) still fire before cleanup

When stream instances are retained after completion (e.g. Claude Code
retains all yielded BetaMessageStream objects from BetaToolRunner in a
React ref), the `messages` and `receivedMessages` arrays accumulate
the full conversation history for the entire session lifetime, causing
unbounded O(n²) memory growth.

This fix caches the final message before emitting 'end', then clears
`messages`, `receivedMessages`, `#currentMessageSnapshot`, `#params`,
and `#listeners` once the 'end' event listeners have been called.
The public `finalMessage()` and `finalText()` methods continue to work
via `#cachedFinalMessage`.

Observed impact: Claude Code sessions growing from 2GB → 20GB+ within
a single session when using tool-heavy workflows.
@Gummygamer Gummygamer requested a review from a team as a code owner March 13, 2026 19:08
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.

1 participant