You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[Bug]: LocalSessionTurnGate.gates grows without bound — Semaphore entries for completed sessions are never evicted #3532
LocalSessionTurnGate.gates is a ConcurrentHashMap<String, Semaphore> that grows without bound. Every acquire(key) call inserts an entry via computeIfAbsent, but TurnLease.close() only calls semaphore::release and never removes the entry from the map. Every historical session key accumulates indefinitely; the Semaphore objects cannot be garbage-collected.
This is a pure memory leak — mutual-exclusion correctness is not affected.
To Reproduce
Create a LocalSessionTurnGate instance
Call acquire(key) and lease.close() for many distinct keys
Use reflection to inspect the internal gates map size
After all leases are closed, the internal gates map should be empty (or contain only keys for sessions still in flight). Idle entries must be evicted to prevent unbounded growth.
Error messages
org.opentest4j.AssertionFailedError: gates map must be empty after all leases are released; found 50 stale entries — unbounded memory leak
Expected :0
Actual :50
Environment (please complete the following information):
AgentScope-Java Version: 2.1.0-SNAPSHOT
Java Version: 17
OS: macOS
Additional context
The same pattern was already fixed in MemoryFlushMiddleware.drainFlushQueue(), which explicitly returns null to evict idle keys with the comment "idle: evict so the map holds only active keys".
gates.remove(key, semaphore) is a conditional remove — it only deletes the entry when the map still holds this exact instance, so a concurrent computeIfAbsent that already inserted a fresh instance is unaffected.
Thanks for the report — this is a real leak and a good catch. We opened #3533 to fix it.
One thing worth flagging about the fix shape: the eviction described here (release(), then remove when semaphore.getQueueLength() == 0) has a race. Semaphore.acquire() takes an available permit without entering the queue, so a thread can barge in between the release and the queue-length read:
holder A closes → release(), queue is empty;
thread C barges in and takes that permit (still no queue);
A reads getQueueLength() == 0 and removes the entry while C is inside;
thread D calls acquire, gets a fresh semaphore, and now holds the key together with C.
So #3533 counts leases (holders + waiters) per key and drops the entry when the count reaches zero, with the decrement and the removal done inside ConcurrentHashMap.compute — atomic against a concurrent acquire on the same key. That fixes the leak without ever handing out a second semaphore for a key that still has a holder.
#3533 also fixes a second defect in the same three lines: TurnLease.close() was a bare semaphore::release even though the interface documents close() as idempotent, so a repeated close leaked a permit and let two acquirers hold the key at once.
Activity
wahllllll commentedon Oct 14, 2026
Thanks for the report — this is a real leak and a good catch. We opened #3533 to fix it.
One thing worth flagging about the fix shape: the eviction described here (
release(), then remove whensemaphore.getQueueLength() == 0) has a race.Semaphore.acquire()takes an available permit without entering the queue, so a thread can barge in between the release and the queue-length read:release(), queue is empty;getQueueLength() == 0and removes the entry while C is inside;acquire, gets a fresh semaphore, and now holds the key together with C.So #3533 counts leases (holders + waiters) per key and drops the entry when the count reaches zero, with the decrement and the removal done inside
ConcurrentHashMap.compute— atomic against a concurrentacquireon the same key. That fixes the leak without ever handing out a second semaphore for a key that still has a holder.#3533 also fixes a second defect in the same three lines:
TurnLease.close()was a baresemaphore::releaseeven though the interface documentsclose()as idempotent, so a repeated close leaked a permit and let two acquirers hold the key at once.