Skip to content

Commit 2afd2bd

Browse files
committed
fix(server): guard GET SSE response with CancelScope to prevent Windows deadlock
On Windows + ProactorEventLoop, sse-starlette's EventSourceResponse can leave orphan child tasks parked in anyio.sleep() or Event.wait() after client disconnect. These orphans prevent TaskGroup.__aexit__ from completing, causing _handle_get_request to hang forever. Each hung GET response accumulates resources until the uvicorn worker deadlocks. The companion issue PrefectHQ/fastmcp#4192 has a full investigation, cross-control matrix, and deterministic reproduction confirming the bug is in the FastMCP wrapper layer's interaction with sse-starlette, not the SDK's core transport. Fix: wrap the EventSourceResponse await in _handle_get_request with an anyio.CancelScope stored on the transport. terminate() cancels this scope before closing streams, breaking the TaskGroup deadlock and allowing the session to clean up properly. Fixes #2653
1 parent 0e3a604 commit 2afd2bd

1 file changed

Lines changed: 19 additions & 5 deletions

File tree

src/mcp/server/streamable_http.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,9 @@ 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
191194
# Idle timeout cancel scope; managed by the session manager.
192195
self.idle_scope: anyio.CancelScope | None = None
193196

@@ -743,14 +746,20 @@ async def standalone_sse_writer():
743746
)
744747

745748
try:
746-
# This will send headers immediately and establish the SSE connection
747-
await response(request.scope, request.receive, send)
748-
except Exception:
749+
# Guard the SSE response with a cancel scope so terminate()
750+
# can force-cancel a hung EventSourceResponse on Windows
751+
# where sse-starlette's TaskGroup children may not respond
752+
# to cancellation promptly (python-sdk#2653).
753+
self._active_get_response_scope = anyio.CancelScope()
754+
with self._active_get_response_scope:
755+
await response(request.scope, request.receive, send)
756+
except Exception: # pragma: lax no cover
749757
logger.exception("Error in standalone SSE response")
758+
await self._clean_up_memory_streams(GET_STREAM_KEY)
759+
finally:
760+
self._active_get_response_scope = None
750761
await sse_stream_writer.aclose()
751762
await sse_stream_reader.aclose()
752-
await self._clean_up_memory_streams(GET_STREAM_KEY)
753-
754763
async def _handle_delete_request(self, request: Request, send: Send) -> None: # pragma: no cover
755764
"""Handle DELETE requests for explicit session termination."""
756765
# Validate session ID
@@ -787,6 +796,11 @@ async def terminate(self) -> None:
787796
self._terminated = True
788797
logger.info(f"Terminating session: {self.mcp_session_id}")
789798

799+
# Cancel any in-flight GET SSE response to prevent
800+
# sse-starlette TaskGroup deadlock on Windows (python-sdk#2653).
801+
if self._active_get_response_scope is not None:
802+
self._active_get_response_scope.cancel()
803+
790804
# We need a copy of the keys to avoid modification during iteration
791805
request_stream_keys = list(self._request_streams.keys())
792806

0 commit comments

Comments
 (0)