Spinning up a Claude Agent SDK subprocess per request is expensive (startup cost, MCP attach, system prompt warm-up). For chat-style workloads, a pool of long-lived clients keyed by conversation_id is dramatically faster. cwkPippa's ClaudeSessionManager began as exactly this pool. Since 2026-05-14 it starts a fresh client for every turn and replays the whole conversation into it, so the pool now exists for per-turn locking, releasing the client after the turn, and a reaper for entries that error paths leave behind. Either shape needs the hygiene below.
What the pool owns
The pool owns: lifecycle (connect on first use, disconnect on TTL or shutdown), error handling (replace dead clients), capacity caps (max concurrent subprocesses), and metrics (active count, lifetime, last-use timestamp). Treat it like a database connection pool — the same disciplines apply.
Stale subprocess hygiene
Long-lived subprocesses leak resources if you forget about them. Sweep the pool periodically: drop clients that have sat unused for N minutes, free their MCP connections, log the reason. Bound the pool size; refuse new sessions when full and surface the failure rather than queuing forever.
Idle means nobody holds it
Unused is the word that bites. A turn that streams for ten minutes has not touched its last-use timestamp since it began, so a sweep that judges idleness from that timestamp alone calls a working client idle and disconnects it mid-stream. Tie liveness to ownership instead. The caller holds the client's lock for the whole turn, the sweep skips any client whose lock is held, and the idle clock starts when the turn ends. The pool below does exactly that. The test beside it calls the real sweep on a turn that the sweep's clock puts twenty minutes in. A test that copies the sweep's condition into its own body checks only the copy, and it keeps passing while the real sweep kills turns.
Principle: A pool is its own component. Build it like infrastructure, not like glue code.
Code
A session pool whose sweep never touches a running turn·python
import asyncio, time
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from claude_agent_sdk import ClaudeSDKClient, ClaudeAgentOptions
@dataclass
class Entry:
client: ClaudeSDKClient
last_used: float = field(default_factory=time.monotonic)
turn_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
class SessionManager:
def __init__(self, max_size: int = 50, ttl_seconds: int = 1800):
self._entries: dict[str, Entry] = {}
self._pool_lock = asyncio.Lock()
self.max_size = max_size
self.ttl = ttl_seconds
@asynccontextmanager
async def turn(self, conv_id: str, options: ClaudeAgentOptions):
"""Lend one client for one whole turn."""
async with self._pool_lock:
await self.sweep()
entry = self._entries.get(conv_id)
if entry is None:
if len(self._entries) >= self.max_size:
raise RuntimeError("session pool full")
client = ClaudeSDKClient(options=options)
await client.connect()
entry = self._entries[conv_id] = Entry(client)
entry.last_used = time.monotonic() # checked out, so not idle
async with entry.turn_lock: # held until the turn is over
try:
yield entry.client
finally:
entry.last_used = time.monotonic() # the idle clock starts here
async def sweep(self, now: float | None = None):
"""One pass. A client whose turn holds its lock is never idle."""
now = time.monotonic() if now is None else now
for conv_id, entry in list(self._entries.items()):
if entry.turn_lock.locked():
continue
if now - entry.last_used > self.ttl:
del self._entries[conv_id]
await entry.client.disconnect()
async def reaper(self, every: float = 60):
"""Background loop; it runs the same pass the tests call."""
while True:
await asyncio.sleep(every)
async with self._pool_lock:
await self.sweep()
# One turn, start to finish, inside the lock:
#
# async with pool.turn(conv_id, options) as client:
# await client.query(prompt)
# async for message in client.receive_response():
# ...
The test calls the real sweep, not a copy of its condition·python
import asyncio, time
import pytest
import session_pool
class FakeClient:
def __init__(self, options=None):
self.connected = False
async def connect(self):
self.connected = True
async def disconnect(self):
self.connected = False
@pytest.fixture
def pool(monkeypatch):
monkeypatch.setattr(session_pool, "ClaudeSDKClient", FakeClient)
return session_pool.SessionManager(ttl_seconds=300)
def test_a_turn_in_progress_is_never_swept(pool):
async def scenario():
async with pool.turn("conv-a", options=None) as client:
# Twenty minutes into one long turn, by the sweep's clock.
await pool.sweep(now=time.monotonic() + 1200)
return client.connected
assert asyncio.run(scenario()) # delete the lock check: this goes red
def test_a_client_idle_past_the_ttl_is_swept(pool):
async def scenario():
async with pool.turn("conv-a", options=None) as client:
pass
await pool.sweep(now=time.monotonic() + 301)
return client.connected
assert not asyncio.run(scenario())
Add a TTL sweeper to your Claude session pool (or build one from the code above). Test it by calling the real sweep, never a copy of its condition: a client idle for TTL + 1 seconds is disconnected, and a client whose turn is still running survives a sweep whose clock says twenty minutes have passed. Then delete the lock check and confirm the running-turn test fails.
Hint
If your pool grew unbounded in production, a missing sweep is the bug, not a 'memory leak in the SDK'. If long turns die on the same clock every time, suspect your own sweep before the vendor.
Progress
Progress is local-only — sign in to sync across devices.