Skip to content

Commit 3c0d47b

Browse files
committed
fix: use set of CancelScopes to prevent concurrent GET-start race
The single _active_get_response_scope variable can be overwritten by concurrent GET SSE connections, leaving earlier responses without a cancellation handle. Switch to a set of scopes so terminate() cancels all in-flight SSE responses, fully addressing the Windows deadlock scenario in #2653. Github-Issue:#2653
1 parent 328538c commit 3c0d47b

1 file changed

Lines changed: 15 additions & 9 deletions

File tree

src/mcp/server/streamable_http.py

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -188,9 +188,11 @@ def __init__(
188188
] = {}
189189
self._sse_stream_writers: dict[RequestId, MemoryObjectSendStream[SSEEvent]] = {}
190190
self._terminated = False
191-
# CancelScope for the active GET SSE response, so terminate() can
192-
# force-cancel a hung EventSourceResponse on Windows (python-sdk#2653).
193-
self._active_get_response_scope: anyio.CancelScope | None = None
191+
# CancelScopes for active GET SSE responses, so terminate() can
192+
# force-cancel all hung EventSourceResponses on Windows (python-sdk#2653).
193+
# A set is used instead of a single scope because concurrent GET-start
194+
# races can create multiple in-flight SSE responses.
195+
self._active_get_response_scopes: set[anyio.CancelScope] = set()
194196
# Idle timeout cancel scope; managed by the session manager.
195197
self.idle_scope: anyio.CancelScope | None = None
196198

@@ -745,19 +747,22 @@ async def standalone_sse_writer():
745747
headers=headers,
746748
)
747749

750+
scope: anyio.CancelScope | None = None
748751
try:
749752
# Guard the SSE response with a cancel scope so terminate()
750753
# can force-cancel a hung EventSourceResponse on Windows
751754
# where sse-starlette's TaskGroup children may not respond
752755
# to cancellation promptly (python-sdk#2653).
753-
self._active_get_response_scope = anyio.CancelScope()
754-
with self._active_get_response_scope:
756+
scope = anyio.CancelScope()
757+
self._active_get_response_scopes.add(scope)
758+
with scope:
755759
await response(request.scope, request.receive, send)
756760
except Exception: # pragma: lax no cover
757761
logger.exception("Error in standalone SSE response")
758762
await self._clean_up_memory_streams(GET_STREAM_KEY)
759763
finally:
760-
self._active_get_response_scope = None
764+
if scope is not None:
765+
self._active_get_response_scopes.discard(scope)
761766
await sse_stream_writer.aclose()
762767
await sse_stream_reader.aclose()
763768

@@ -797,10 +802,11 @@ async def terminate(self) -> None:
797802
self._terminated = True
798803
logger.info(f"Terminating session: {self.mcp_session_id}")
799804

800-
# Cancel any in-flight GET SSE response to prevent
805+
# Cancel all in-flight GET SSE responses to prevent
801806
# sse-starlette TaskGroup deadlock on Windows (python-sdk#2653).
802-
if self._active_get_response_scope is not None: # pragma: no cover
803-
self._active_get_response_scope.cancel()
807+
for scope in self._active_get_response_scopes:
808+
scope.cancel()
809+
self._active_get_response_scopes.clear()
804810

805811
# We need a copy of the keys to avoid modification during iteration
806812
request_stream_keys = list(self._request_streams.keys())

0 commit comments

Comments
 (0)