Skip to content

[Bug]: LocalSessionTurnGate.gates grows without bound — Semaphore entries for completed sessions are never evicted #3532

Open

Description

@Koiyd

Describe the bug

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

  1. Create a LocalSessionTurnGate instance
  2. Call acquire(key) and lease.close() for many distinct keys
  3. Use reflection to inspect the internal gates map size
@Test
void closedLeaseEvictsIdleEntryFromInternalMap() throws Exception {
    LocalSessionTurnGate gate = new LocalSessionTurnGate();
    for (int i = 0; i < 50; i++) {
        TurnLease lease = gate.acquire("session-" + i);
        lease.close();
    }
    Field gatesField = LocalSessionTurnGate.class.getDeclaredField("gates");
    gatesField.setAccessible(true);
    ConcurrentHashMap<?, ?> gatesMap = (ConcurrentHashMap<?, ?>) gatesField.get(gate);

    // Actual: map.size() == 50 (all 50 stale entries remain)
    assertEquals(0, gatesMap.size());
}

Expected behavior

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".

Affected file: agentscope-harness/src/main/java/io/agentscope/harness/agent/gateway/LocalSessionTurnGate.java

Proposed fix: in TurnLease.close(), after releasing the permit, remove the entry when no thread is waiting:

return () -> {
    semaphore.release();
    if (semaphore.getQueueLength() == 0) {
        gates.remove(key, semaphore);
    }
};

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.

Activity

  1. wahllllll commented on Oct 14, 2026

    @wahllllll
    Contributor

    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:

    1. holder A closes → release(), queue is empty;
    2. thread C barges in and takes that permit (still no queue);
    3. A reads getQueueLength() == 0 and removes the entry while C is inside;
    4. 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.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions