Skip to content

Commit 7375b26

Browse files
committed
fix: address all Copilot AI review comments
Changes based on Copilot AI review (5 issues): 1. Simplified _cleanup_stdio_async(): - Used try/finally pattern for cleaner reference clearing - References cleared in finally block (always executed) 2. Removed deprecated asyncio.get_event_loop(): - Removed complex temp loop pattern entirely - Simplified fallback to just log warning and clear refs 3. Simplified fallback path (Issue #4): - When loop exists but not running, resources are in undefined state - Complex event loop manipulation removed - Just log warning and proceed with reference clearing - OS will reclaim resources on process exit 4. Handled race condition (Issue #5): - Added comment documenting the inherent race condition - Added try/except around loop.call_soon_threadsafe() - Track cleanup_attempted flag for proper fallback handling 5. Added explanatory comments: - Documented why redundant None assignments exist (safety) - Explained race condition handling approach Note: Test coverage suggestion (#3) acknowledged but deferred to separate PR to keep this fix focused.
1 parent 3626051 commit 7375b26

File tree

1 file changed

+30
-31
lines changed

1 file changed

+30
-31
lines changed

core/framework/runner/mcp_client.py

Lines changed: 30 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -404,69 +404,68 @@ async def _cleanup_stdio_async(self) -> None:
404404
try:
405405
if self._session:
406406
await self._session.__aexit__(None, None, None)
407-
self._session = None # Clear reference immediately after cleanup
408407
except Exception as e:
409408
logger.warning(f"Error closing MCP session: {e}")
410-
self._session = None # Clear even on error to prevent reuse
409+
finally:
410+
self._session = None # Always clear reference after cleanup attempt
411411

412412
# Second: close stdio_context (provides the underlying streams)
413413
try:
414414
if self._stdio_context:
415415
await self._stdio_context.__aexit__(None, None, None)
416-
self._stdio_context = None # Clear reference immediately after cleanup
417416
except Exception as e:
418417
logger.warning(f"Error closing STDIO context: {e}")
419-
self._stdio_context = None # Clear even on error to prevent reuse
418+
finally:
419+
self._stdio_context = None # Always clear reference after cleanup attempt
420420

421421
def disconnect(self) -> None:
422422
"""Disconnect from the MCP server."""
423423
# Clean up persistent STDIO connection
424424
if self._loop is not None:
425+
cleanup_attempted = False
426+
425427
# Properly close session and context managers before stopping loop
428+
# Note: There's an inherent race condition between checking is_running()
429+
# and calling run_coroutine_threadsafe(). We handle this by catching
430+
# any exceptions that may occur if the loop stops between these calls.
426431
if self._loop.is_running():
427432
try:
428433
cleanup_future = asyncio.run_coroutine_threadsafe(
429434
self._cleanup_stdio_async(),
430435
self._loop
431436
)
432437
cleanup_future.result(timeout=5) # Wait for cleanup with timeout
438+
cleanup_attempted = True
433439
except Exception as e:
440+
# This can happen if loop stopped between is_running() check and
441+
# run_coroutine_threadsafe(), or if cleanup itself failed
434442
logger.warning(f"Error during async cleanup: {e}")
435443

436444
# Now stop the event loop
437-
self._loop.call_soon_threadsafe(self._loop.stop)
438-
else:
439-
# Fallback: loop exists but not running (e.g., due to error or external stop)
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.
443445
try:
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)
462-
except Exception as e:
463-
logger.warning(f"Error during fallback async cleanup: {e}")
446+
self._loop.call_soon_threadsafe(self._loop.stop)
447+
except RuntimeError:
448+
# Loop may have already stopped
449+
pass
450+
451+
if not cleanup_attempted:
452+
# Fallback: loop exists but is not running (e.g., crashed or stopped externally).
453+
# At this point the loop and associated resources are in an undefined state.
454+
# The context managers (_session, _stdio_context) were created in the loop's
455+
# thread and may not be safely cleanable from here. Just log and proceed
456+
# with reference clearing - the OS will reclaim resources on process exit.
457+
logger.warning(
458+
"Event loop for STDIO MCP connection exists but is not running; "
459+
"skipping async cleanup. Resources may not be fully released."
460+
)
464461

465462
# Wait for thread to finish
466463
if self._loop_thread and self._loop_thread.is_alive():
467464
self._loop_thread.join(timeout=2)
468465

469-
# Clear remaining references (some may already be None from _cleanup_stdio_async)
466+
# Clear remaining references
467+
# Note: _session and _stdio_context are cleared in _cleanup_stdio_async()
468+
# if cleanup was successful, but we clear all references here for safety
470469
self._session = None
471470
self._stdio_context = None
472471
self._read_stream = None

0 commit comments

Comments
 (0)