Skip to content

Commit 3626051

Browse files
committed
fix: address Copilot AI review suggestions for disconnect cleanup
Changes based on Copilot AI review: 1. Fixed fallback path using temp event loop pattern: - asyncio.run() may fail if there's already an event loop in current thread - Now uses new_event_loop() + set_event_loop() + run_until_complete() pattern - Preserves and restores original loop if one existed 2. Set references to None immediately after __aexit__: - self._session = None after closing session - self._stdio_context = None after closing context - Prevents window where closed objects are still referenced - Also clears on error to prevent reuse of broken objects 3. Added documentation for critical cleanup order: - Session must close BEFORE stdio_context - Session depends on streams provided by stdio_context - Mirrors initialization order in _connect_stdio() - Added warning comment to prevent future breakage
1 parent fc539a5 commit 3626051

File tree

1 file changed

+38
-4
lines changed

1 file changed

+38
-4
lines changed

core/framework/runner/mcp_client.py

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -390,18 +390,33 @@ def _call_tool_http(self, tool_name: str, arguments: dict[str, Any]) -> Any:
390390
raise RuntimeError(f"Failed to call tool via HTTP: {e}")
391391

392392
async def _cleanup_stdio_async(self) -> None:
393-
"""Async cleanup for STDIO session and context managers."""
393+
"""Async cleanup for STDIO session and context managers.
394+
395+
Cleanup order is critical:
396+
- The session must be closed BEFORE the stdio_context because the session
397+
depends on the streams provided by stdio_context.
398+
- This mirrors the initialization order in _connect_stdio(), where
399+
stdio_context is entered first (providing streams), then the session is
400+
created with those streams and entered.
401+
- Do not change this ordering without carefully considering these dependencies.
402+
"""
403+
# First: close session (depends on stdio_context streams)
394404
try:
395405
if self._session:
396406
await self._session.__aexit__(None, None, None)
407+
self._session = None # Clear reference immediately after cleanup
397408
except Exception as e:
398409
logger.warning(f"Error closing MCP session: {e}")
410+
self._session = None # Clear even on error to prevent reuse
399411

412+
# Second: close stdio_context (provides the underlying streams)
400413
try:
401414
if self._stdio_context:
402415
await self._stdio_context.__aexit__(None, None, None)
416+
self._stdio_context = None # Clear reference immediately after cleanup
403417
except Exception as e:
404418
logger.warning(f"Error closing STDIO context: {e}")
419+
self._stdio_context = None # Clear even on error to prevent reuse
405420

406421
def disconnect(self) -> None:
407422
"""Disconnect from the MCP server."""
@@ -422,17 +437,36 @@ def disconnect(self) -> None:
422437
self._loop.call_soon_threadsafe(self._loop.stop)
423438
else:
424439
# Fallback: loop exists but not running (e.g., due to error or external stop)
425-
# Use asyncio.run() to execute cleanup in a new event loop
440+
# Cannot use asyncio.run() directly as it may fail if there's already
441+
# an event loop associated with current thread (even if stopped).
442+
# Use a temporary event loop pattern that preserves any existing loop.
426443
try:
427-
asyncio.run(self._cleanup_stdio_async())
444+
# Check if there's an existing event loop in this thread
445+
original_loop = None
446+
try:
447+
original_loop = asyncio.get_event_loop()
448+
except RuntimeError:
449+
# No existing event loop in this thread
450+
original_loop = None
451+
452+
# Create and use a temporary event loop for cleanup
453+
temp_loop = asyncio.new_event_loop()
454+
try:
455+
asyncio.set_event_loop(temp_loop)
456+
temp_loop.run_until_complete(self._cleanup_stdio_async())
457+
finally:
458+
temp_loop.close()
459+
# Restore original loop if there was one
460+
if original_loop is not None:
461+
asyncio.set_event_loop(original_loop)
428462
except Exception as e:
429463
logger.warning(f"Error during fallback async cleanup: {e}")
430464

431465
# Wait for thread to finish
432466
if self._loop_thread and self._loop_thread.is_alive():
433467
self._loop_thread.join(timeout=2)
434468

435-
# Clear references
469+
# Clear remaining references (some may already be None from _cleanup_stdio_async)
436470
self._session = None
437471
self._stdio_context = None
438472
self._read_stream = None

0 commit comments

Comments
 (0)