Skip to content

fix: unbind ShutdownStateSaver on ReActAgent.close() to prevent memory leak#2384

Open
sikisama wants to merge 1 commit into
agentscope-ai:mainfrom
sikisama:fix/graceful-shutdown-state-saver-leak
Open

fix: unbind ShutdownStateSaver on ReActAgent.close() to prevent memory leak#2384
sikisama wants to merge 1 commit into
agentscope-ai:mainfrom
sikisama:fix/graceful-shutdown-state-saver-leak

Conversation

@sikisama

@sikisama sikisama commented Jul 24, 2026

Copy link
Copy Markdown

Summary

Fixes a memory leak in GracefulShutdownManager.stateSavers where per-call ReActAgent instances (created with AgentStateStore) register a ShutdownStateSaver in the constructor but are never unregistered.

Changes

  1. GracefulShutdownManager.java: Add unbindStateSaver(Agent) method (counterpart to bindStateSaver)
  2. ReActAgent.java: Implement close() to call shutdownManager.unbindStateSaver(this) (was no-op)

Problem

bindStateSaver() does stateSavers.put(agentId, saver) but there was no remove/unbind path. ReActAgent.close() was a no-op. In high-throughput services creating one agent per request (as documented in the class Javadoc), stateSavers accumulates every agent instance forever — retaining their Model, HttpClient, Memory, Toolkit, etc.

Production heap dump showed 8073 ReActAgent instances retained by stateSavers (ConcurrentHashMap capacity 16384), occupying 140MB (43% of heap), plus 1666+ HttpClient thread groups and CLOSE-WAIT connection buildup.

Testing

  • Verified via MAT (Eclipse Memory Analyzer) heap dump analysis on a production system running 12 days.
  • After fix: per-call agents are GC'd normally once close() is called; stateSavers stays bounded.

Checklist

  • Code changes follow existing style and conventions
  • close() is safe to call multiple times (ConcurrentHashMap.remove is idempotent)
  • Does not break shutdown logic (stateSavers is only read during performGracefulShutdown; removing an already-closed agent's entry is correct — that agent is no longer active)
  • Unit test added (existing test suite may need a test verifying stateSavers size stays bounded after close())

@CLAassistant

CLAassistant commented Jul 24, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

…y leak

GracefulShutdownManager.bindStateSaver() registers a ShutdownStateSaver in a
static ConcurrentHashMap (stateSavers) keyed by agentId, but there is no
corresponding unbind method. ReActAgent.close() was a no-op.

When ephemeral/per-call ReActAgent instances are created with an AgentStateStore
(e.g. via factory patterns), each constructor call registers a saver that is
never removed. The stateSavers map grows unboundedly, retaining all agent
instances (and their Model, HttpClient, Memory, Toolkit references) until JVM
exit.

In production this caused 8073+ ReActAgent instances to accumulate in
stateSavers (ConcurrentHashMap capacity 16384), occupying 140MB (43% of heap).
The leak also indirectly caused HttpClient thread proliferation (1600+ groups)
and CLOSE-WAIT connection buildup.

Fix:
- Add GracefulShutdownManager.unbindStateSaver(Agent) to remove the entry
- Implement ReActAgent.close() to call unbindStateSaver(this)
- Users should call agent.close() (or use try-with-resources) after each
  per-call agent is done
@sikisama
sikisama force-pushed the fix/graceful-shutdown-state-saver-leak branch from ad88979 to c1290ec Compare July 24, 2026 08:43
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.

2 participants