Skip to content

fix(server): guard GET SSE response with CancelScope to prevent Windows deadlock (#2653)#3069

Open
isheng-eqi wants to merge 4 commits into
modelcontextprotocol:v1.xfrom
isheng-eqi:fix/2653-windows-sse-deadlock
Open

fix(server): guard GET SSE response with CancelScope to prevent Windows deadlock (#2653)#3069
isheng-eqi wants to merge 4 commits into
modelcontextprotocol:v1.xfrom
isheng-eqi:fix/2653-windows-sse-deadlock

Conversation

@isheng-eqi

Copy link
Copy Markdown

Summary

Fixes #2653mcp.server.fastmcp.FastMCP HTTP transport deadlocks after ~5 sequential client sessions on Windows.

Root Cause

sse-starlette's EventSourceResponse.__call__ creates an internal anyio.TaskGroup whose 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 in anyio.sleep() or Event.wait(), preventing TaskGroup.__aexit__ from completing.

The _handle_get_request handler directly awaits the EventSourceResponse callable, so the hung TaskGroup blocks 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 next initialize POST never dispatches and the server deadlocks.

The raw mcp.server.lowlevel.Server + StreamableHTTPSessionManager path is unaffected because it writes text/event-stream directly without sse-starlette. The Node.js reference server is also unaffected. The defect is confined to the FastMCP wrapper layer's use of sse-starlette.EventSourceResponse.

Fix

Three changes in src/mcp/server/streamable_http.py:

  1. __init__: Add _active_get_response_scope: anyio.CancelScope | None attribute to track the in-flight GET SSE response.

  2. _handle_get_request: Wrap the await response(...) call with the cancel scope instead of awaiting it directly. This gives terminate() a handle to force-cancel a hung response.

  3. terminate(): Cancel _active_get_response_scope (if set) before closing streams. This breaks the sse-starlette TaskGroup 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 EventSourceResponse is awaited directly. The POST handler already runs EventSourceResponse inside an anyio.create_task_group() which provides equivalent cancellation safety.

Related

…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

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 1 file

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/mcp/server/streamable_http.py Outdated
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 isheng-eqi left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant