diff --git a/tests/tools/test_mcp_tool_session_expired.py b/tests/tools/test_mcp_tool_session_expired.py index c794236e04f2e..f6aa9ee590bc6 100644 --- a/tests/tools/test_mcp_tool_session_expired.py +++ b/tests/tools/test_mcp_tool_session_expired.py @@ -121,6 +121,39 @@ def test_is_session_expired_rejects_mixed_group_with_user_interruption(): assert _is_session_expired_error(exc) is False +def test_exception_tree_finds_interruption_beyond_recursion_limit(): + """Arbitrarily deep wrapper trees must not overflow Python's call stack.""" + import sys + + from tools.mcp_tool import _exception_tree_contains_interruption + + class NestedException(Exception): + exceptions: tuple[BaseException, ...] + + exc = InterruptedError("cancel") + for _ in range(sys.getrecursionlimit() + 100): + wrapper = NestedException("wrapped") + wrapper.exceptions = (exc,) + exc = wrapper + + assert _exception_tree_contains_interruption(exc) is True + + +def test_exception_tree_handles_cyclic_exceptions_graph(): + """Malformed exception graphs may contain cycles and must terminate safely.""" + from tools.mcp_tool import _exception_tree_contains_interruption + + class CyclicException(Exception): + exceptions: tuple[BaseException, ...] + + first = CyclicException("first") + second = CyclicException("second") + first.exceptions = (second,) + second.exceptions = (first,) + + assert _exception_tree_contains_interruption(first) is False + + def test_is_session_expired_rejects_empty_message(): """Bare exceptions with no message shouldn't match.""" from tools.mcp_tool import _is_session_expired_error diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index aca2651597d48..c2b9503a84e2c 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -3902,12 +3902,18 @@ _SESSION_EXPIRED_MARKERS: tuple = ( def _exception_tree_contains_interruption(exc: BaseException) -> bool: """Return whether ``exc`` or any nested exception is user cancellation.""" - if isinstance(exc, InterruptedError): - return True - return any( - _exception_tree_contains_interruption(child) - for child in getattr(exc, "exceptions", ()) - ) + stack = [exc] + seen: set[int] = set() + while stack: + current = stack.pop() + identity = id(current) + if identity in seen: + continue + seen.add(identity) + if isinstance(current, InterruptedError): + return True + stack.extend(getattr(current, "exceptions", ())) + return False def _is_session_expired_error(exc: BaseException) -> bool: