fix(server): guard GET SSE response with CancelScope to prevent Windows deadlock (#2653)#3069
Open
isheng-eqi wants to merge 4 commits into
Open
Conversation
…ws 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 modelcontextprotocol#2653
There was a problem hiding this comment.
1 issue found across 1 file
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
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 modelcontextprotocol#2653. Github-Issue:modelcontextprotocol#2653
isheng-eqi
commented
Jul 7, 2026
isheng-eqi
left a comment
Author
There was a problem hiding this comment.
Good catch. The single _active_get_response_scope does have a concurrent GET-start race — two in-flight SSE connections would leave the first without a cancellation handle.
Fixed in 3c0d47b: switched to set[CancelScope] so terminate() iterates and cancels all active scopes. Added scope: CancelScope | None = None guard + if scope is not None in the finally block for type safety.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes #2653 —
mcp.server.fastmcp.FastMCPHTTP transport deadlocks after ~5 sequential client sessions on Windows.Root Cause
sse-starlette'sEventSourceResponse.__call__creates an internalanyio.TaskGroupwhose child tasks (_ping,_listen_for_disconnect,_listen_for_exit_signal,standalone_sse_writer) may not respond to cancellation promptly on Windows ProactorEventLoop. When the client disconnects, orphan children remain parked inanyio.sleep()orEvent.wait(), preventingTaskGroup.__aexit__from completing.The
_handle_get_requesthandler directlyawaits theEventSourceResponsecallable, so the hungTaskGroupblocks the handler forever. Each session that opens a GET SSE stream and disconnects leaks one such hung task group. After enough sessions (~5–18 depending on Python version), the nextinitializePOST never dispatches and the server deadlocks.The raw
mcp.server.lowlevel.Server+StreamableHTTPSessionManagerpath is unaffected because it writestext/event-streamdirectly withoutsse-starlette. The Node.js reference server is also unaffected. The defect is confined to the FastMCP wrapper layer's use ofsse-starlette.EventSourceResponse.Fix
Three changes in
src/mcp/server/streamable_http.py:__init__: Add_active_get_response_scope: anyio.CancelScope | Noneattribute to track the in-flight GET SSE response._handle_get_request: Wrap theawait response(...)call with the cancel scope instead of awaiting it directly. This givesterminate()a handle to force-cancel a hung response.terminate(): Cancel_active_get_response_scope(if set) before closing streams. This breaks thesse-starletteTaskGroup deadlock and allows the session to clean up, even on Windows ProactorEventLoop.The fix is minimal (19 insertions, 5 deletions), backward-compatible, and targets only the GET SSE path where
EventSourceResponseis awaited directly. The POST handler already runsEventSourceResponseinside ananyio.create_task_group()which provides equivalent cancellation safety.Related
sse-starlette— issue is in the library's TaskGroup cleanup on Windows, but the SDK can defend against it here