Jeremiah Lowin commited on
Commit
ab749aa
·
unverified ·
2 Parent(s): 0a8046ff4d38b0

Merge pull request #1057 from jlowin/concurrency-fix-1054

Browse files
Files changed (1) hide show
  1. src/fastmcp/client/client.py +73 -10
src/fastmcp/client/client.py CHANGED
@@ -74,6 +74,24 @@ class Client(Generic[ClientTransportT]):
74
  handles connection establishment and management. Client provides methods for
75
  working with resources, prompts, tools and other MCP capabilities.
76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  Args:
78
  transport: Connection source specification, which can be:
79
  - ClientTransport: Direct transport instance
@@ -214,14 +232,15 @@ class Client(Generic[ClientTransportT]):
214
  elicitation_handler
215
  )
216
 
217
- # session context management
218
- self._session: ClientSession | None = None
219
- self._exit_stack: AsyncExitStack | None = None
220
- self._nesting_counter: int = 0
221
- self._context_lock = anyio.Lock()
222
- self._session_task: asyncio.Task | None = None
223
- self._ready_event = anyio.Event()
224
- self._stop_event = anyio.Event()
 
225
 
226
  @property
227
  def session(self) -> ClientSession:
@@ -291,11 +310,26 @@ class Client(Generic[ClientTransportT]):
291
  await self._disconnect()
292
 
293
  async def _connect(self):
 
 
 
 
 
 
 
 
 
 
 
 
294
  # ensure only one session is running at a time to avoid race conditions
295
  async with self._context_lock:
296
  need_to_start = self._session_task is None or self._session_task.done()
297
  if need_to_start:
298
- assert self._nesting_counter == 0
 
 
 
299
  self._stop_event = anyio.Event()
300
  self._ready_event = anyio.Event()
301
  self._session_task = asyncio.create_task(self._session_runner())
@@ -303,7 +337,10 @@ class Client(Generic[ClientTransportT]):
303
 
304
  if self._session_task.done():
305
  exception = self._session_task.exception()
306
- assert exception is not None
 
 
 
307
  if isinstance(exception, httpx.HTTPStatusError):
308
  raise exception
309
  raise RuntimeError(
@@ -314,6 +351,19 @@ class Client(Generic[ClientTransportT]):
314
  return self
315
 
316
  async def _disconnect(self, force: bool = False):
 
 
 
 
 
 
 
 
 
 
 
 
 
317
  # ensure only one session is running at a time to avoid race conditions
318
  async with self._context_lock:
319
  # if we are forcing a disconnect, reset the nesting counter
@@ -337,6 +387,19 @@ class Client(Generic[ClientTransportT]):
337
  self._session_task = None
338
 
339
  async def _session_runner(self):
 
 
 
 
 
 
 
 
 
 
 
 
 
340
  try:
341
  async with AsyncExitStack() as stack:
342
  await stack.enter_async_context(self._context_manager())
 
74
  handles connection establishment and management. Client provides methods for
75
  working with resources, prompts, tools and other MCP capabilities.
76
 
77
+ This client supports reentrant context managers (multiple concurrent
78
+ `async with client:` blocks) using reference counting and background session
79
+ management. This allows efficient session reuse in any scenario with
80
+ nested or concurrent client usage.
81
+
82
+ MCP SDK 1.10 introduced automatic list_tools() calls during call_tool()
83
+ execution. This created a race condition where events could be reset while
84
+ other tasks were waiting on them, causing deadlocks. The issue was exposed
85
+ in proxy scenarios but affects any reentrant usage.
86
+
87
+ The solution uses reference counting to track active context managers,
88
+ a background task to manage the session lifecycle, events to coordinate
89
+ between tasks, and ensures all session state changes happen within a lock.
90
+ Events are only created when needed, never reset outside locks.
91
+
92
+ See: https://github.com/jlowin/fastmcp/issues/1051
93
+ https://github.com/jlowin/fastmcp/pull/1054
94
+
95
  Args:
96
  transport: Connection source specification, which can be:
97
  - ClientTransport: Direct transport instance
 
232
  elicitation_handler
233
  )
234
 
235
+ # Session context management - see class docstring for detailed explanation
236
+ self._session: ClientSession | None = None # Active MCP session
237
+ self._nesting_counter: int = 0 # Reference count for active context managers
238
+ self._context_lock = anyio.Lock() # Protects all session state changes
239
+ self._session_task: asyncio.Task | None = (
240
+ None # Background session manager task
241
+ )
242
+ self._ready_event = anyio.Event() # Signals when session is ready for use
243
+ self._stop_event = anyio.Event() # Signals when session should stop
244
 
245
  @property
246
  def session(self) -> ClientSession:
 
310
  await self._disconnect()
311
 
312
  async def _connect(self):
313
+ """
314
+ Establish or reuse a session connection.
315
+
316
+ This method implements the reentrant context manager pattern:
317
+ - First call: Creates background session task and waits for it to be ready
318
+ - Subsequent calls: Increments reference counter and reuses existing session
319
+ - All operations protected by _context_lock to prevent race conditions
320
+
321
+ The critical fix: Events are only created when starting a new session,
322
+ never reset outside the lock, preventing the deadlock scenario where
323
+ tasks wait on events that get replaced by other tasks.
324
+ """
325
  # ensure only one session is running at a time to avoid race conditions
326
  async with self._context_lock:
327
  need_to_start = self._session_task is None or self._session_task.done()
328
  if need_to_start:
329
+ if self._nesting_counter != 0:
330
+ raise RuntimeError(
331
+ f"Internal error: nesting counter should be 0 when starting new session, got {self._nesting_counter}"
332
+ )
333
  self._stop_event = anyio.Event()
334
  self._ready_event = anyio.Event()
335
  self._session_task = asyncio.create_task(self._session_runner())
 
337
 
338
  if self._session_task.done():
339
  exception = self._session_task.exception()
340
+ if exception is None:
341
+ raise RuntimeError(
342
+ "Session task completed without exception but connection failed"
343
+ )
344
  if isinstance(exception, httpx.HTTPStatusError):
345
  raise exception
346
  raise RuntimeError(
 
351
  return self
352
 
353
  async def _disconnect(self, force: bool = False):
354
+ """
355
+ Disconnect from session using reference counting.
356
+
357
+ This method implements proper cleanup for reentrant context managers:
358
+ - Decrements reference counter for normal exits
359
+ - Only stops session when counter reaches 0 (no more active contexts)
360
+ - Force flag bypasses reference counting for immediate shutdown
361
+ - Session cleanup happens inside the lock to ensure atomicity
362
+
363
+ Key fix: Removed the problematic "Reset for future reconnects" logic
364
+ that was resetting events outside the lock, causing race conditions.
365
+ Event recreation now happens only in _connect() when actually needed.
366
+ """
367
  # ensure only one session is running at a time to avoid race conditions
368
  async with self._context_lock:
369
  # if we are forcing a disconnect, reset the nesting counter
 
387
  self._session_task = None
388
 
389
  async def _session_runner(self):
390
+ """
391
+ Background task that manages the actual session lifecycle.
392
+
393
+ This task runs in the background and:
394
+ 1. Establishes the transport connection via _context_manager()
395
+ 2. Signals that the session is ready via _ready_event.set()
396
+ 3. Waits for disconnect signal via _stop_event.wait()
397
+ 4. Ensures _ready_event is always set, even on failures
398
+
399
+ The simplified error handling (compared to the original) removes
400
+ redundant exception re-raising while ensuring waiting tasks are
401
+ always unblocked via the finally block.
402
+ """
403
  try:
404
  async with AsyncExitStack() as stack:
405
  await stack.enter_async_context(self._context_manager())