mirror of
https://github.com/mcp-use/mcp-use.git
synced 2026-09-14 18:06:01 +08:00
c990faf76b
* fix(python): add timeout mechanism to ConnectionManager.stop() Add timeout parameter (default: 30s) to stop() method to prevent infinite hangs during application shutdown. Resolves issue #562. Changes: - Add timeout parameter to stop() with 30 second default - Wrap task wait with asyncio.wait_for() to enforce timeout - Wrap cleanup event wait with asyncio.wait_for() to enforce timeout - Force cleanup on timeout by clearing connection and setting done event - Add appropriate warning/error logging on timeout Tests: - Add 10 comprehensive unit tests for timeout scenarios - All 158 unit tests pass - Manual end-to-end testing verified This ensures graceful shutdown even when cleanup hangs in production. * updated with copilot suggested edits * ruff checks passed * Improve timeout handling in ConnectionManager.stop() - Move 'import time' to module level - Use CancelledError consistently (Python 3.11+ style) - Treat timeout <= 0 or None as infinite wait (no timeout) - Add elapsed time to timeout warning logs - Add tests for negative/zero/None timeout behavior Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * docs: update ConnectionManager.stop() API reference with timeout parameter --------- Co-authored-by: pietrozullo <pietro.zullo@gmail.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
241 lines
8.4 KiB
Python
241 lines
8.4 KiB
Python
import asyncio
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from mcp_use.client.task_managers.base import ConnectionManager
|
|
|
|
|
|
class MockConnectionManager(ConnectionManager[str]):
|
|
def __init__(
|
|
self,
|
|
establish_delay: float = 0,
|
|
close_delay: float = 0,
|
|
fail_on_establish: bool = False,
|
|
fail_on_close: bool = False,
|
|
):
|
|
super().__init__()
|
|
self.establish_delay = establish_delay
|
|
self.close_delay = close_delay
|
|
self.fail_on_establish = fail_on_establish
|
|
self.fail_on_close = fail_on_close
|
|
self.close_called = False
|
|
self.close_call_count = 0
|
|
|
|
async def _establish_connection(self) -> str:
|
|
if self.establish_delay > 0:
|
|
await asyncio.sleep(self.establish_delay)
|
|
if self.fail_on_establish:
|
|
raise RuntimeError("Failed to establish connection")
|
|
return "test_connection"
|
|
|
|
async def _close_connection(self) -> None:
|
|
self.close_called = True
|
|
self.close_call_count += 1
|
|
if self.close_delay > 0:
|
|
await asyncio.sleep(self.close_delay)
|
|
if self.fail_on_close:
|
|
raise RuntimeError("Failed to close connection")
|
|
|
|
|
|
class TestConnectionManagerTimeout:
|
|
@pytest.mark.asyncio
|
|
async def test_stop_with_normal_cleanup(self):
|
|
manager = MockConnectionManager()
|
|
connection = await manager.start()
|
|
assert connection == "test_connection"
|
|
|
|
await manager.stop()
|
|
assert manager.close_called
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_with_default_timeout(self):
|
|
manager = MockConnectionManager(close_delay=0.1)
|
|
await manager.start()
|
|
|
|
await manager.stop()
|
|
assert manager.close_called
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_with_custom_timeout(self):
|
|
manager = MockConnectionManager(close_delay=0.1)
|
|
await manager.start()
|
|
|
|
await manager.stop(timeout=5.0)
|
|
assert manager.close_called
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_with_slow_cleanup_exceeding_timeout(self):
|
|
manager = MockConnectionManager(close_delay=2.0)
|
|
await manager.start()
|
|
|
|
await manager.stop(timeout=0.5)
|
|
assert manager.close_called
|
|
assert manager._connection is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_with_hanging_cleanup(self):
|
|
manager = MockConnectionManager(close_delay=10.0)
|
|
await manager.start()
|
|
|
|
await manager.stop(timeout=0.2)
|
|
assert manager._connection is None
|
|
assert manager._done_event.is_set()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_idempotent(self):
|
|
manager = MockConnectionManager()
|
|
await manager.start()
|
|
|
|
# First stop
|
|
await manager.stop()
|
|
assert manager.close_called
|
|
assert manager.close_call_count == 1
|
|
assert manager._connection is None
|
|
assert manager._done_event.is_set()
|
|
first_task = manager._task
|
|
|
|
# Second stop should be idempotent
|
|
await manager.stop()
|
|
# Verify no additional cleanup operations occurred
|
|
assert manager.close_call_count == 1 # Should still be 1, not 2
|
|
assert manager._connection is None
|
|
assert manager._done_event.is_set()
|
|
assert manager._task is first_task # Task object should not change
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_when_task_not_started(self):
|
|
manager = MockConnectionManager()
|
|
|
|
start = time.monotonic()
|
|
await manager.stop()
|
|
elapsed = time.monotonic() - start
|
|
|
|
# Should return quickly when not started (no 30s default wait)
|
|
assert elapsed < 0.5, f"stop() took too long when task not started: {elapsed}s"
|
|
assert manager._connection is None
|
|
assert manager._done_event.is_set()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_timeout_with_zero_timeout(self):
|
|
manager = MockConnectionManager(close_delay=0.1)
|
|
await manager.start()
|
|
|
|
await manager.stop(timeout=0.01)
|
|
assert manager._connection is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_timeout_during_task_execution(self):
|
|
manager = MockConnectionManager(close_delay=5.0)
|
|
await manager.start()
|
|
|
|
await manager.stop(timeout=0.1)
|
|
assert manager._done_event.is_set()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_without_hanging_task(self):
|
|
manager = MockConnectionManager()
|
|
await manager.start()
|
|
await asyncio.sleep(0.1)
|
|
|
|
await manager.stop(timeout=0.5)
|
|
assert manager.close_called
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_timeout_with_close_exception(self):
|
|
"""Test that timeout and exception handling work together correctly."""
|
|
manager = MockConnectionManager(close_delay=5.0, fail_on_close=True)
|
|
await manager.start()
|
|
|
|
# Stop with short timeout - should timeout AND handle the exception
|
|
await manager.stop(timeout=0.2)
|
|
|
|
# Verify forced cleanup occurred
|
|
assert manager._connection is None
|
|
assert manager._done_event.is_set()
|
|
# close was called but raised an exception
|
|
assert manager.close_called
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_exception_without_timeout(self):
|
|
"""Test that exceptions during close are handled gracefully without timeout."""
|
|
manager = MockConnectionManager(close_delay=0.1, fail_on_close=True)
|
|
await manager.start()
|
|
|
|
# Stop with sufficient timeout - exception should be caught and logged
|
|
await manager.stop(timeout=5.0)
|
|
|
|
# Verify cleanup completed despite exception
|
|
assert manager.close_called
|
|
assert manager._connection is None
|
|
assert manager._done_event.is_set()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_respects_total_timeout_timing(self):
|
|
"""Ensure stop() completes within the provided total timeout (plus small epsilon)."""
|
|
manager = MockConnectionManager(close_delay=2.0)
|
|
await manager.start()
|
|
|
|
timeout = 0.5
|
|
start = time.monotonic()
|
|
await manager.stop(timeout=timeout)
|
|
elapsed = time.monotonic() - start
|
|
|
|
# Allow a small scheduling epsilon
|
|
assert elapsed <= timeout + 0.25, f"stop() took too long: {elapsed}s (timeout={timeout}s)"
|
|
|
|
# Verify forced cleanup occurred
|
|
assert manager._connection is None
|
|
assert manager._done_event.is_set()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_with_negative_timeout_waits_indefinitely(self):
|
|
"""Test that negative timeout means infinite wait (no timeout)."""
|
|
manager = MockConnectionManager(close_delay=0.2)
|
|
await manager.start()
|
|
|
|
start = time.monotonic()
|
|
# Negative timeout should wait indefinitely (no forced cleanup)
|
|
await manager.stop(timeout=-1.0)
|
|
elapsed = time.monotonic() - start
|
|
|
|
# Should wait for actual cleanup (~0.2s), not timeout
|
|
assert elapsed >= 0.15, f"stop() returned too quickly: {elapsed}s"
|
|
assert manager.close_called
|
|
assert manager._connection is None
|
|
assert manager._done_event.is_set()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_with_none_timeout_waits_indefinitely(self):
|
|
"""Test that None timeout means infinite wait (no timeout)."""
|
|
manager = MockConnectionManager(close_delay=0.2)
|
|
await manager.start()
|
|
|
|
start = time.monotonic()
|
|
# None timeout should wait indefinitely (no forced cleanup)
|
|
await manager.stop(timeout=None)
|
|
elapsed = time.monotonic() - start
|
|
|
|
# Should wait for actual cleanup (~0.2s), not timeout
|
|
assert elapsed >= 0.15, f"stop() returned too quickly: {elapsed}s"
|
|
assert manager.close_called
|
|
assert manager._connection is None
|
|
assert manager._done_event.is_set()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_with_zero_timeout_is_infinite(self):
|
|
"""Test that zero timeout means infinite wait (no timeout), not immediate timeout."""
|
|
manager = MockConnectionManager(close_delay=0.2)
|
|
await manager.start()
|
|
|
|
start = time.monotonic()
|
|
# Zero timeout should be treated as infinite (no forced cleanup)
|
|
await manager.stop(timeout=0)
|
|
elapsed = time.monotonic() - start
|
|
|
|
# Should wait for actual cleanup (~0.2s), not force immediate timeout
|
|
assert elapsed >= 0.15, f"stop() returned too quickly: {elapsed}s"
|
|
assert manager.close_called
|
|
assert manager._connection is None
|
|
assert manager._done_event.is_set()
|