fix(mcp): keep connection cleanup in owner tasks - #358
Conversation
Jason Robert (jrob5756)
left a comment
There was a problem hiding this comment.
Good fix overall. The owner-task-per-connection model is the right way to solve the anyio cancel-scope task affinity problem, and the new task-affinity tests are a solid way to prove it.
Left a few small comments on double-cancellation edge cases and a couple of small cleanups, but nothing blocking here.
| self._initialized = False | ||
| for result in results: | ||
| if isinstance(result, Exception): | ||
| logger.warning(f"Error closing MCP connections: {result}") |
There was a problem hiding this comment.
If close() gets cancelled a second time while it's sitting at the unshielded await cleanup on line 573, that cancellation propagates into the still-running owner tasks and can interrupt teardown mid-flight (e.g. while a subprocess is being terminated). But cleanup.done() is true even for a cancelled future, so this finally block clears all bookkeeping as if everything shut down cleanly, and there's no log line for this path at all. Worth tracking whether results was actually obtained before treating state as cleared, and logging when a repeated cancellation prevents confirming cleanup completed.
There was a problem hiding this comment.
Fixed in ca8551d. The unshielded await cleanup fallback is replaced with a re-shielding loop: repeated cancellations now land on a throwaway asyncio.shield(cleanup) while the gather keeps running, and bookkeeping is cleared only after results is actually obtained (a cancelled gather result is never read). The behavior goes slightly further than suggested: close() now deliberately absorbs cancellation until owner-task teardown completes (documented in the docstring), so teardown is never interrupted mid-flight and state is always consistent. Covered by test_double_cancelled_close_still_finishes_cleanup.
| cleanup = asyncio.gather(task, return_exceptions=True) | ||
| try: | ||
| results = await asyncio.shield(cleanup) | ||
| except asyncio.CancelledError: |
There was a problem hiding this comment.
Same gap here as in close(): if a second cancellation lands while awaiting the unshielded await cleanup on this line, it propagates straight out and skips the pop()/_discard_server_state() calls below. That leaves _connection_tasks[name] populated with a task that may not have finished cleanup, so subsequent connect_server(name=...) calls for that name will incorrectly raise "already connected or connecting" until the whole manager is torn down, with no log explaining why.
There was a problem hiding this comment.
Fixed in ca8551d with the same re-shielding loop as in close(): a repeated cancellation lands on the shield instead of the gather, so cleanup completes and the pop()/_discard_server_state() calls always run before the cancellation is re-raised. _connection_tasks[name] can no longer be left populated, so a later connect_server(name=...) will not be spuriously rejected. Covered by test_double_cancelled_connection_clears_bookkeeping.
| self._connection_tasks.pop(name, None) | ||
| self._connection_stops.pop(name, None) | ||
| self._discard_server_state(name) | ||
| logger.error(f"Failed to connect to MCP server '{name}': {exc}") |
There was a problem hiding this comment.
{exc} only calls str(). If the AsyncExitStack teardown raises a different exception while unwinding after the real connect failure (e.g. a broken pipe during stdio_client/ClientSession exit), that secondary exception replaces the original as what's caught and logged here, masking the actual root cause.
| logger.error(f"Failed to connect to MCP server '{name}': {exc}") | |
| logger.error(f"Failed to connect to MCP server '{name}': {exc}", exc_info=exc) |
There was a problem hiding this comment.
Applied in ca8551d - the log call now passes exc_info=exc, so the original connect failure traceback is preserved even when AsyncExitStack teardown raises a secondary exception while unwinding.
| except asyncio.CancelledError: | ||
| results = await cleanup | ||
| for result in results: | ||
| if isinstance(result, Exception) and not isinstance(result, asyncio.CancelledError): |
There was a problem hiding this comment.
asyncio.CancelledError derives from BaseException, not Exception, since Python 3.8, so isinstance(result, Exception) already excludes it. The trailing check is dead code.
| if isinstance(result, Exception) and not isinstance(result, asyncio.CancelledError): | |
| if isinstance(result, Exception): |
There was a problem hiding this comment.
Applied in ca8551d - the dead CancelledError check is removed; the condition is now plain isinstance(result, Exception).
| self._exit_stack = AsyncExitStack() | ||
| self._connection_tasks: dict[str, asyncio.Task[None]] = {} | ||
| self._connection_stops: dict[str, asyncio.Event] = {} | ||
| self._initialized = False |
There was a problem hiding this comment.
_initialized is set here and at two other spots but nothing reads it anymore — close()'s guard now checks self._connection_tasks instead. Worth removing it so a future reader doesn't assume it reflects live connection state.
There was a problem hiding this comment.
Removed in ca8551d - _initialized is gone from all three spots; close() keeps guarding on _connection_tasks.
| if not self._initialized: | ||
| if not self._connection_tasks: | ||
| return | ||
|
|
There was a problem hiding this comment.
This counts len(self.sessions), which is only populated once a connection's ready future resolves. If close() runs while a connect_server() call is still mid-handshake, this understates how many owner tasks are actually being torn down. len(self._connection_tasks) would be accurate.
There was a problem hiding this comment.
Fixed in ca8551d - the debug log now counts len(self._connection_tasks), which includes connections still mid-handshake.
Address review feedback on double-cancellation edge cases: - Replace the unshielded `await cleanup` fallback in both connect_server() and close() with a re-shielding loop. A repeated cancellation previously landed on the gather itself, discarding its result and skipping (close()) or racing past (connect_server()) the bookkeeping cleanup, which could leave a stale _connection_tasks entry blocking reconnects. Repeated cancellations now land on a throwaway shield while the gather completes. - Log the connect failure with exc_info so a secondary AsyncExitStack teardown exception cannot mask the original root cause. - Drop the dead `not isinstance(result, asyncio.CancelledError)` check (CancelledError derives from BaseException since Python 3.8). - Remove the unused _initialized flag; close() already guards on _connection_tasks. - Count _connection_tasks (not sessions) when logging shutdown, so mid-handshake connections are included. close() now deliberately absorbs cancellation until owner-task teardown finishes, documented in its docstring. Regression tests cover double cancellation of both connect_server() and close().
hertznsk
left a comment
There was a problem hiding this comment.
All six review comments are addressed in ca8551d. Per-thread replies via the review-comment API return 404 for my token (fork-based PR), so summarizing here:
- close() double-cancellation — the unshielded
await cleanupfallback is replaced with a re-shielding loop: repeated cancellations land on a throwawayasyncio.shield(cleanup)while the gather keeps running, and bookkeeping is cleared only afterresultsis actually obtained. close() now deliberately absorbs cancellation until owner-task teardown completes (documented in the docstring); covered bytest_double_cancelled_close_still_finishes_cleanup. - connect_server() double-cancellation — same re-shielding loop: cleanup completes and the
pop()/_discard_server_state()calls always run before the cancellation is re-raised, so_connection_tasks[name]can no longer be left populated and block reconnects; covered bytest_double_cancelled_connection_clears_bookkeeping. - Connect-failure logging — now passes
exc_info=exc, so a secondary AsyncExitStack teardown exception cannot mask the original root cause. - Dead
not isinstance(result, asyncio.CancelledError)check removed. - Unused
_initializedflag removed from all three spots. - Shutdown log now counts
len(self._connection_tasks), including mid-handshake connections.
Full test suite passes (4580 tests), ruff and ty clean.
Jason Robert (jrob5756)
left a comment
There was a problem hiding this comment.
LGTM. Approved!
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #358 +/- ##
=======================================
Coverage ? 90.64%
=======================================
Files ? 85
Lines ? 14464
Branches ? 0
=======================================
Hits ? 13111
Misses ? 1353
Partials ? 0 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Summary
Testing
PYTHONASYNCIODEBUG=1 uv run pytest tests/test_mcp -q(51 passed)make checkFixes #353