Skip to content

Commit 5db90d2

Browse files
committed
Fix completed request cancellation cleanup
1 parent 161834d commit 5db90d2

2 files changed

Lines changed: 58 additions & 1 deletion

File tree

src/mcp/shared/session.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,12 @@ def __exit__(
117117
self._entered = False
118118
if not self._cancel_scope: # pragma: no cover
119119
raise RuntimeError("No active cancel scope")
120-
self._cancel_scope.__exit__(exc_type, exc_val, exc_tb)
120+
try:
121+
self._cancel_scope.__exit__(exc_type, exc_val, exc_tb)
122+
except BaseException as exc:
123+
if self._completed and isinstance(exc, anyio.get_cancelled_exc_class()):
124+
return
125+
raise
121126

122127
async def respond(self, response: SendResultT | ErrorData) -> None:
123128
"""Send a response for this request.

tests/shared/test_session.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from typing import Any, cast
2+
13
import anyio
24
import pytest
35

@@ -23,6 +25,18 @@
2325
)
2426

2527

28+
class _CancelScopeThatRaisesOnExit:
29+
cancel_called = True
30+
31+
def __exit__(
32+
self,
33+
exc_type: type[BaseException] | None,
34+
exc_val: BaseException | None,
35+
exc_tb: object | None,
36+
) -> None:
37+
raise anyio.get_cancelled_exc_class()()
38+
39+
2640
@pytest.mark.anyio
2741
async def test_in_flight_requests_cleared_after_completion():
2842
"""Verify that _in_flight is empty after all requests complete."""
@@ -98,6 +112,44 @@ async def make_request(client: Client):
98112
await ev_cancelled.wait()
99113

100114

115+
@pytest.mark.anyio
116+
async def test_completed_request_responder_suppresses_cancel_scope_exit() -> None:
117+
completed: list[Any] = []
118+
responder = RequestResponder(
119+
request_id=1,
120+
request_meta=None,
121+
request=types.PingRequest(),
122+
session=cast(Any, object()),
123+
on_complete=completed.append,
124+
)
125+
responder._completed = True # type: ignore[reportPrivateUsage]
126+
responder._cancel_scope = cast( # type: ignore[reportPrivateUsage]
127+
anyio.CancelScope, _CancelScopeThatRaisesOnExit()
128+
)
129+
130+
responder.__exit__(None, None, None)
131+
132+
assert completed == [responder]
133+
assert not responder._entered # type: ignore[reportPrivateUsage]
134+
135+
136+
@pytest.mark.anyio
137+
async def test_incomplete_request_responder_propagates_cancel_scope_exit() -> None:
138+
responder = RequestResponder(
139+
request_id=1,
140+
request_meta=None,
141+
request=types.PingRequest(),
142+
session=cast(Any, object()),
143+
on_complete=lambda _: None,
144+
)
145+
responder._cancel_scope = cast( # type: ignore[reportPrivateUsage]
146+
anyio.CancelScope, _CancelScopeThatRaisesOnExit()
147+
)
148+
149+
with pytest.raises(anyio.get_cancelled_exc_class()):
150+
responder.__exit__(None, None, None)
151+
152+
101153
@pytest.mark.anyio
102154
async def test_response_id_type_mismatch_string_to_int():
103155
"""Test that responses with string IDs are correctly matched to requests sent with

0 commit comments

Comments
 (0)