-
Notifications
You must be signed in to change notification settings - Fork 768
Fix a hang that can happen at shutdown #440
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
@@ -3,6 +3,7 @@ | |||||||||||||||||||||||||||||||||||||||||||||||||||||||
""" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
from datetime import timedelta | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
import asyncio | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
import threading | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
from typing import ( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
AsyncGenerator, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
@@ -237,50 +238,172 @@ def __init__( | |||||||||||||||||||||||||||||||||||||||||||||||||||||||
self._tg_active = False | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
# Track the thread this manager was created in to ensure TaskGroup cleanup | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
self._thread_id = threading.get_ident() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
# Event loop where the TaskGroup lives | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
self._loop: asyncio.AbstractEventLoop | None = None | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
# Owner task + coordination events for safe TaskGroup lifecycle | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
self._tg_owner_task: asyncio.Task | None = None | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
self._owner_tg: TaskGroup | None = None | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
self._tg_ready_event: Event = Event() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
self._tg_close_event: Event = Event() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
self._tg_closed_event: Event = Event() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
# Ensure a single close sequence at a time on the origin loop | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
self._close_lock = Lock() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
async def __aenter__(self): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
# We create a task group to manage all server lifecycle tasks | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
tg = create_task_group() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
# Enter the task group context | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
await tg.__aenter__() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
self._tg_active = True | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
self._tg = tg | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
# Start the TaskGroup owner task and wait until ready | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
await self._start_owner() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
# Record the loop and thread where the TaskGroup is running | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
try: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
self._loop = asyncio.get_running_loop() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
except RuntimeError: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
self._loop = None | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
return self | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
async def __aexit__(self, exc_type, exc_val, exc_tb): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
"""Ensure clean shutdown of all connections before exiting.""" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
await self.close(exc_type, exc_val, exc_tb) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
# Close the owner TaskGroup in the same task that entered it | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
if self._owner_tg is not None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
try: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
await self._owner_tg.__aexit__(exc_type, exc_val, exc_tb) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
except Exception as e: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
logger.warning( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
f"MCPConnectionManager: Error during owner TaskGroup cleanup: {e}" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
finally: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
self._owner_tg = None | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
async def close(self, exc_type=None, exc_val=None, exc_tb=None): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
"""Close all connections and tear down the internal TaskGroup safely. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
This is thread-aware: if called from a different thread than the one where the | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
TaskGroup was created, it will signal the owner task on the original loop to | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
perform cleanup and await completion without violating task affinity. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
""" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
try: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
# First request all servers to shutdown | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
logger.debug("MCPConnectionManager: shutting down all server tasks...") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
await self.disconnect_all() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
current_thread = threading.get_ident() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
if current_thread == self._thread_id: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
# Same thread: perform shutdown inline with exclusive access | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
async with self._close_lock: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
logger.debug( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
"MCPConnectionManager: shutting down all server tasks..." | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
await self.disconnect_all() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
await anyio.sleep(0.5) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
if self._tg_active: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
self._tg_close_event.set() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
# Wait for owner to report TaskGroup closed with an anyio timeout | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
try: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
with anyio.fail_after(5.0): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
await self._tg_closed_event.wait() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
except TimeoutError: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
logger.warning( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
"MCPConnectionManager: Timeout waiting for TaskGroup owner to close" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
# Do not attempt to close the owner TaskGroup here; __aexit__ will handle it | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
else: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
# Different thread – run entire shutdown on the original loop to avoid cross-thread Event.set | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
if self._loop is not None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
# Add a small delay to allow for clean shutdown of subprocess transports, etc. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
await anyio.sleep(0.5) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
async def _shutdown_and_close(): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
logger.debug( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
"MCPConnectionManager: shutting down all server tasks (origin loop)..." | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
async with self._close_lock: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
await self.disconnect_all() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
await anyio.sleep(0.5) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
if self._tg_active: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
self._tg_close_event.set() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
await self._tg_closed_event.wait() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
# Then close the task group if it's active and we're in the same thread | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
if self._tg_active and self._tg: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
current_thread = threading.get_ident() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
if current_thread == self._thread_id: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
try: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
await self._tg.__aexit__(exc_type, exc_val, exc_tb) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
cfut = asyncio.run_coroutine_threadsafe( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
_shutdown_and_close(), self._loop | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
# Wait in a worker thread to avoid blocking non-asyncio contexts | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
try: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
with anyio.fail_after(5.0): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
await anyio.to_thread.run_sync(cfut.result) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
except TimeoutError: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
logger.warning( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
"MCPConnectionManager: Timeout during cross-thread shutdown/close" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Comment on lines
+322
to
+332
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Potential deadlock risk in cross-thread coordination The current implementation creates a complex threading scenario that could lead to deadlocks. When If the original event loop is already in the process of shutting down or becomes blocked, the future may never complete, leaving resources hanging despite the timeout. While the timeout provides some protection, it doesn't guarantee cleanup of the scheduled coroutine. Consider alternatives:
This would improve shutdown reliability, especially in complex threading scenarios.
Suggested change
Spotted by Diamond |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
try: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
cfut.cancel() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
except Exception: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
pass | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
except Exception as e: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
logger.warning( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
f"MCPConnectionManager: Error during task group cleanup: {e}" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
f"MCPConnectionManager: Error scheduling cross-thread shutdown: {e}" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
else: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
# Different thread – cannot safely cleanup anyio TaskGroup | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
logger.warning( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
f"MCPConnectionManager: Task group cleanup called from different thread " | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
f"(created in {self._thread_id}, called from {current_thread}). Skipping cleanup." | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
"MCPConnectionManager: No event loop recorded for cleanup; skipping TaskGroup close" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
# Always mark as inactive and drop reference | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
self._tg_active = False | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
self._tg = None | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
except AttributeError: # Handle missing `_exceptions` | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
pass | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
except Exception as e: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
logger.warning(f"MCPConnectionManager: Error during shutdown: {e}") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
async def _start_owner(self): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
"""Start the TaskGroup owner task if not already running.""" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
# If an owner is active or TaskGroup is already active, nothing to do | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
if (self._tg_owner_task and not self._tg_owner_task.done()) or ( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
self._tg_active and self._tg is not None | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
return | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
# If previous owner exists but is done (possibly with error), log and restart | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
if self._tg_owner_task and self._tg_owner_task.done(): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
try: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
exc = self._tg_owner_task.exception() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
if exc: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
logger.warning( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
f"MCPConnectionManager: restarting owner after error: {exc}" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
except Exception: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
logger.warning( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
"MCPConnectionManager: restarting owner after unknown state" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
# Reset coordination events | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
self._tg_ready_event = Event() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
self._tg_close_event = Event() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
self._tg_closed_event = Event() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
# Reset coordination events | |
self._tg_ready_event = Event() | |
self._tg_close_event = Event() | |
self._tg_closed_event = Event() | |
# Reset coordination events | |
if self._tg_ready_event: | |
self._tg_ready_event.clear() | |
else: | |
self._tg_ready_event = Event() | |
if self._tg_close_event: | |
self._tg_close_event.clear() | |
else: | |
self._tg_close_event = Event() | |
if self._tg_closed_event: | |
self._tg_closed_event.clear() | |
else: | |
self._tg_closed_event = Event() |
Spotted by Diamond
Is this helpful? React 👍 or 👎 to let us know.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The exception handling in the _tg_owner
finally block could lead to deadlocks. If _tg_closed_event.set()
fails, any threads waiting on this event will hang indefinitely while only a warning is logged. Consider making this signaling mechanism more robust by either:
- Using a more reliable synchronization primitive
- Implementing a retry mechanism with backoff
- Propagating the error to waiting threads so they can time out appropriately
This is particularly important since this event is used for cross-thread coordination during shutdown, where hanging threads can prevent clean application termination.
try: | |
self._tg_closed_event.set() | |
except Exception as e: | |
logger.warning(f"Failed to set _tg_closed_event: {e}") | |
max_retries = 3 | |
retry_delay = 0.1 # seconds | |
for attempt in range(max_retries): | |
try: | |
self._tg_closed_event.set() | |
break | |
except Exception as e: | |
if attempt < max_retries - 1: | |
logger.warning(f"Failed to set _tg_closed_event (attempt {attempt+1}/{max_retries}): {e}. Retrying in {retry_delay}s") | |
time.sleep(retry_delay) | |
retry_delay *= 2 # Exponential backoff | |
else: | |
logger.error(f"Failed to set _tg_closed_event after {max_retries} attempts: {e}. Threads waiting on this event may hang.") |
Spotted by Diamond
Is this helpful? React 👍 or 👎 to let us know.
Original file line number | Diff line number | Diff line change | ||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
@@ -22,7 +22,7 @@ | |||||||||||||||||
logger = get_logger(__name__) | ||||||||||||||||||
|
||||||||||||||||||
|
||||||||||||||||||
class QualityRating(str, Enum): | ||||||||||||||||||
class QualityRating(int, Enum): | ||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Type Safety Consideration: Changing Before merging, it would be valuable to:
If this is an intentional change to enforce numeric semantics, adding a brief comment explaining the rationale would help future maintainers understand the design decision.
Suggested change
Spotted by Diamond |
||||||||||||||||||
"""Enum for evaluation quality ratings""" | ||||||||||||||||||
|
||||||||||||||||||
POOR = 0 # Major improvements needed | ||||||||||||||||||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Catch AnyIO TimeoutError, not built-in TimeoutError
anyio.fail_after raises anyio.exceptions.TimeoutError. The current except TimeoutError won’t catch it.
Also applies to: 17-18, 292-297, 319-324
🤖 Prompt for AI Agents