-
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 3 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,147 @@ 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() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
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 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
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" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
if current_thread == self._thread_id: | |
# Same thread: perform shutdown inline | |
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" | |
) | |
if current_thread == self._thread_id: | |
# Same thread: perform shutdown inline | |
# Capture state atomically before any awaits | |
is_active = self._tg_active | |
logger.debug("MCPConnectionManager: shutting down all server tasks...") | |
await self.disconnect_all() | |
await anyio.sleep(0.5) | |
# Use the captured state for the conditional | |
if is_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" | |
) |
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.
Potential deadlock risk in cross-thread coordination
The current implementation creates a complex threading scenario that could lead to deadlocks. When asyncio.run_coroutine_threadsafe()
schedules work on the original loop, and then anyio.to_thread.run_sync(cfut.result)
blocks waiting for that work, there's a dependency chain across threads that can become problematic.
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:
- Use a simpler signaling mechanism between threads
- Implement a more robust cancellation pattern that ensures the coroutine is properly cancelled if the timeout occurs
- Add explicit cleanup code that runs after the timeout to ensure the future doesn't remain pending
This would improve shutdown reliability, especially in complex threading scenarios.
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" | |
) | |
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" | |
) | |
# Explicitly cancel the future to prevent the coroutine from | |
# continuing to run in the background after timeout | |
cfut.cancel() | |
Spotted by Diamond
Is this helpful? React 👍 or 👎 to let us know.
Outdated
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 task lifecycle check has a potential issue: checking if self._tg_owner_task and not self._tg_owner_task.done()
only verifies that an existing task hasn't completed, but doesn't distinguish between successful completion and failure. A task that failed with an exception would have done()
return True
, causing this check to miss it, but the TaskGroup might not be properly initialized.
Consider enhancing this check to also verify the TaskGroup's state or to explicitly handle tasks that completed with exceptions. Perhaps something like:
if (self._tg_owner_task and not self._tg_owner_task.done()) or (self._tg_active and self._tg is not None):
return
Or alternatively, add explicit exception handling to restart the TaskGroup if the previous one failed.
Spotted by Diamond
Is this helpful? React 👍 or 👎 to let us know.
Outdated
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.
Potential Race Condition in Event Object Replacement
There's a synchronization concern in how the coordination events are being replaced. When creating new Event()
instances:
self._tg_ready_event = Event()
self._tg_close_event = Event()
self._tg_closed_event = Event()
If other threads are currently waiting on any of these events (via await event.wait()
), replacing the event objects will leave those threads waiting on stale event instances that will never be signaled. This could lead to deadlocks or indefinite hangs.
Consider implementing a synchronization mechanism to ensure safe event replacement, or restructure the code to avoid replacing events that might have waiters. One approach would be to reset existing events (if they have a .clear()
method) rather than creating new instances.
# 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.
coderabbitai[bot] marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
Outdated
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 silent exception handling here could create a deadlock risk. If _tg_closed_event.set()
fails, any code waiting on _tg_closed_event.wait()
will hang indefinitely, potentially causing the entire shutdown process to stall.
Consider either:
- Logging the exception to help with debugging:
logger.warning(f"Failed to set _tg_closed_event: {e}")
- Or implementing a fallback mechanism to ensure waiting tasks can proceed even if the event can't be set
This is particularly important since preventing shutdown hangs is a core goal of this PR.
try: | |
self._tg_closed_event.set() | |
except Exception: | |
pass | |
try: | |
self._tg_closed_event.set() | |
except Exception as e: | |
logger.warning(f"Failed to set _tg_closed_event: {e}") |
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