Skip to content

Clean up session locking in agent pool manager#19

Open
MarkCiliaVincenti wants to merge 3 commits intoxyOz-dev:masterfrom
MarkCiliaVincenti:AsyncKeyedLock
Open

Clean up session locking in agent pool manager#19
MarkCiliaVincenti wants to merge 3 commits intoxyOz-dev:masterfrom
MarkCiliaVincenti:AsyncKeyedLock

Conversation

@MarkCiliaVincenti
Copy link

@MarkCiliaVincenti MarkCiliaVincenti commented Dec 5, 2025

Re-adding the semaphore to the dictionary was risky since it introduced a race condition.

Summary by CodeRabbit

  • Chores
    • Reworked agent pool concurrency to use a keyed async locking mechanism, reducing contention and simplifying lock management.
    • Improved agent reuse, lifecycle updates, and recycling so sessions see lower latency and more stable behavior under concurrent load.
    • Updated project dependency to support the new concurrency mechanism and disposal semantics.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link

coderabbitai bot commented Dec 5, 2025

📝 Walkthrough

Walkthrough

Replaced per-session SemaphoreSlims with AsyncKeyedLocker in AgentPoolManager, scoping locks with LockAsync, re-checking and updating existing agents inside the lock, reorganizing recycle/create paths, and updating Dispose. Added AsyncKeyedLock package (v8.0.0) to project dependencies.

Changes

Cohort / File(s) Summary
Agent pool manager synchronization refactoring
Core/Agents/AgentPoolManager.cs
Replaced manual per-session SemaphoreSlim locking with AsyncKeyedLocker<string> and LockAsync usage. Scoped critical section with using var _ = await _sessionLocks.LockAsync(sessionId), re-checks for existing agents inside lock, updates LastAccessTime/AccessCount when returning existing agent, moves recycling path before eviction/creation, removes manual finally/cleanup, and updates Dispose to dispose the AsyncKeyedLocker. Event emission (AgentCreated, AgentRecycled) and state updates preserved.
Project dependency update
Saturn.csproj
Added AsyncKeyedLock NuGet package (Version="8.0.0") to project dependencies.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Poem

🐰 I hopped through locks both old and new,
Keys in paw, I scoped the view,
Agents recycled, others born,
No more finally at the dawn,
I nibble bits and code anew. 🥕✨

Pre-merge checks

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Clean up session locking in agent pool manager' accurately and specifically describes the main change: refactoring the session locking mechanism from manual SemaphoreSlim to AsyncKeyedLocker.

📜 Recent review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8fb33a8 and 208bffe.

📒 Files selected for processing (1)
  • Saturn.csproj
🔇 Additional comments (1)
Saturn.csproj (1)

32-32: AsyncKeyedLock 8.0.0 is appropriate for this use case.

Version 8.0.0 exists on NuGet, is the latest stable release, and has no known security vulnerabilities. The release includes improvements in code quality, null checks, and resource disposal that make it suitable for replacing the manual SemaphoreSlim-based locking pattern.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Core/Agents/AgentPoolManager.cs (1)

271-284: Remove the per-semaphore disposal loop; AsyncKeyedLocker<string> does not expose .Values and manages its own cleanup

The foreach loop:

foreach (var semaphore in _sessionLocks.Values)
{
    semaphore.Dispose();
}

will not compile. AsyncKeyedLocker<T> does not expose a .Values property; the internal semaphores are managed privately by the locker. This is a type mismatch error that prevents compilation.

Remove the loop entirely and let _sessionLocks.Dispose() handle its own internal cleanup:

-            foreach (var semaphore in _sessionLocks.Values)
-            {
-                semaphore.Dispose();
-            }
-            
             _agentPool.Clear();
             _sessionAgentMapping.Clear();
             _sessionLocks.Dispose();
🧹 Nitpick comments (1)
Core/Agents/AgentPoolManager.cs (1)

7-7: AsyncKeyedLocker integration correctly scopes per‑session critical section

Using AsyncKeyedLocker<string> with:

using var _ = await _sessionLocks.LockAsync(sessionId);

around the entire GetOrCreateAgentAsync critical path is a good fit here: it removes the manual semaphore dictionary bookkeeping and eliminates the race around re‑adding per‑session semaphores, while ensuring only one GetOrCreateAgentAsync runs per sessionId. The re‑check for an existing agent inside the lock plus the recycle/evict/create flow and event emission all look consistent.

Optional: to make intent clearer and match common AsyncKeyedLock examples, you could use an explicit releaser and await using:

await using var sessionLock = await _sessionLocks.LockAsync(sessionId);
// ...

Functionally this is equivalent here, but slightly more self‑documenting.

Also applies to: 19-19, 46-86

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5b52123 and f1a8fe1.

📒 Files selected for processing (2)
  • Core/Agents/AgentPoolManager.cs (4 hunks)
  • Saturn.csproj (1 hunks)
🔇 Additional comments (1)
Saturn.csproj (1)

31-39: AsyncKeyedLock dependency addition looks appropriate; just confirm version/compatibility

Adding AsyncKeyedLock 7.1.8 is consistent with the new AsyncKeyedLocker<string> usage in AgentPoolManager and targets net8.0 fine. Please just double‑check that this exact version is what you validated in CI (and is compatible with your hosting environment and licensing guidelines).

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (1)
Core/Agents/AgentPoolManager.cs (1)

59-69: Recycling path still allows the same idle agent to be assigned to multiple sessions concurrently

The TryRecycleAgentAssignAgentToSession path is still vulnerable to the race called out in the earlier review:

  • Two concurrent GetOrCreateAgentAsync calls for different sessionIds both call TryRecycleAgent(configuration) (no cross‑session coordination).
  • Both can select the same AgentPoolEntry from _agentPool.Values because it’s Idle/SessionId == null at the time of enumeration.
  • Both then call AssignAgentToSession(recycledAgent.Id, sessionId), leading to the same Agent instance being reused for multiple sessions and its state/history being shared.

The new per‑session AsyncKeyedLocker doesn’t prevent this because it only serializes operations per sessionId, not across sessions. The risk is the same as before but is worth fixing while you’re tightening locking semantics.

One straightforward fix is to serialize recycling itself so that a given idle agent can only be selected once, e.g.:

+        private readonly object _recycleLock = new();
...
         private Agent? TryRecycleAgent(AgentConfiguration targetConfig)
         {
             if (!_configuration.EnableRecycling)
                 return null;
-            
-            var recyclableAgent = _agentPool.Values
-                .Where(e => e.State == AgentState.Idle &&
-                           e.SessionId == null &&
-                           IsConfigurationCompatible(e.Configuration, targetConfig))
-                .OrderBy(e => e.LastAccessTime)
-                .FirstOrDefault();
-            
-            if (recyclableAgent != null)
-            {
-                recyclableAgent.Agent.ClearHistory();
-                recyclableAgent.LastAccessTime = DateTime.UtcNow;
-                recyclableAgent.State = AgentState.Active;
-                recyclableAgent.RecycleCount++;
-                return recyclableAgent.Agent;
-            }
-            
-            return null;
+            lock (_recycleLock)
+            {
+                var recyclableAgent = _agentPool.Values
+                    .Where(e => e.State == AgentState.Idle &&
+                               e.SessionId == null &&
+                               IsConfigurationCompatible(e.Configuration, targetConfig))
+                    .OrderBy(e => e.LastAccessTime)
+                    .FirstOrDefault();
+
+                if (recyclableAgent == null)
+                    return null;
+
+                // Mark as active while still under the lock so it won't be picked again.
+                recyclableAgent.Agent.ClearHistory();
+                recyclableAgent.LastAccessTime = DateTime.UtcNow;
+                recyclableAgent.State = AgentState.Active;
+                recyclableAgent.RecycleCount++;
+
+                return recyclableAgent.Agent;
+            }
         }

Because TryRecycleAgent is synchronous and does no await, using a simple lock here is safe and avoids widening the existing per‑session async lock. This should eliminate the multi‑session double‑assignment of a recycled agent.

🧹 Nitpick comments (1)
Core/Agents/AgentPoolManager.cs (1)

46-57: Per-session lock cleanup is solid; consider also locking ReleaseAgent to avoid subtle races

The new per‑session lock:

using var _ = await _sessionLocks.LockAsync(sessionId);

plus the in‑lock re‑check of _sessionAgentMapping nicely fixes the prior session‑semaphore race and ensures only one GetOrCreateAgentAsync runs per sessionId. The reordering (existing-agent check → recycle → optional eviction → create) is clean and keeps behavior intuitive.

One remaining concurrency edge to be aware of: ReleaseAgent and other methods that touch _sessionAgentMapping for a given sessionId do so without taking the same keyed lock. If, in practice, ReleaseAgent(sessionId) can run concurrently with GetOrCreateAgentAsync(sessionId, …), you can get inconsistent state (e.g., ReleaseAgent marking an agent idle while a fresh GetOrCreate has just decided to reuse it).

If that concurrency is possible, consider also guarding ReleaseAgent (and any other per-session mutators) with the same keyed lock, using the synchronous API to avoid making it async, e.g.:

public void ReleaseAgent(string sessionId)
{
    using (_sessionLocks.Lock(sessionId))
    {
        if (_sessionAgentMapping.TryRemove(sessionId, out var agentId) &&
            _agentPool.TryGetValue(agentId, out var entry))
        {
            entry.SessionId = null;
            entry.State = AgentState.Idle;
            entry.LastAccessTime = DateTime.UtcNow;
        }
    }
}

If your higher‑level protocol already guarantees that ReleaseAgent and GetOrCreateAgentAsync won’t overlap for the same sessionId, you can treat this as a future hardening rather than a blocker.

Also applies to: 71-76, 79-85

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f1a8fe1 and 8fb33a8.

📒 Files selected for processing (1)
  • Core/Agents/AgentPoolManager.cs (4 hunks)
🔇 Additional comments (1)
Core/Agents/AgentPoolManager.cs (1)

7-7: AsyncKeyedLocker lifecycle and usage look good; just confirm language/version assumptions

Using AsyncKeyedLocker<string> as a long-lived field, acquiring locks via await _sessionLocks.LockAsync(sessionId) and disposing it in Dispose() matches the library’s documented patterns. The zero-arg constructor and direct Dispose() call are appropriate for a singleton-style manager.

Only follow‑ups I’d suggest:

  • Ensure the project’s C# language version is high enough that using var _ = await _sessionLocks.LockAsync(sessionId); is allowed on all target TFMs.
  • If you later see contention or allocation pressure, you may want to consider wiring AsyncKeyedLocker<string> via DI with tuned AsyncKeyedLockOptions (pool size, etc.), but that’s not required for correctness here.

Also applies to: 19-19, 278-278

@MarkCiliaVincenti
Copy link
Author

Are you still interested in this PR @xyOz-dev?

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

Labels

None yet

1 participant