fix(http): terminate active streamable-HTTP transports before lifespan shutdown (#4118)

This commit is contained in:
Sarthak Bhardwaj 2026-05-10 20:28:13 +05:30 committed by GitHub
commit 8209093871
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 56 additions and 1 deletions

View file

@ -370,7 +370,26 @@ def create_streamable_http_app(
server._lifespan_manager(),
streamable_http_app.session_manager.run(),
):
yield
try:
yield
finally:
# Gracefully terminate active streamable-HTTP transports before
# the session manager's task group is cancelled. Without this,
# active SSE/streaming responses are aborted mid-flight and
# Uvicorn logs "ASGI callable returned without completing
# response." See PrefectHQ/fastmcp#3025.
sm = streamable_http_app.session_manager
# `_server_instances` is a private attribute of the upstream
# `StreamableHTTPSessionManager` (mcp SDK); termination is
# idempotent and tolerates new instances being added concurrently.
for transport in list(sm._server_instances.values()):
try:
await transport.terminate()
except Exception:
logger.debug(
"Error terminating streamable-HTTP transport on shutdown",
exc_info=True,
)
# Create and return the app with lifespan
app = create_base_app(

View file

@ -0,0 +1,36 @@
"""Regression tests for graceful streamable-HTTP shutdown (issue #3025)."""
from unittest.mock import AsyncMock
from fastmcp.server import FastMCP
async def test_lifespan_terminates_active_transports_before_task_group_cancel():
"""Active streamable-HTTP transports must be terminated during lifespan
shutdown so streaming responses end cleanly instead of being aborted by
the session manager's task-group cancel.
See PrefectHQ/fastmcp#3025: without graceful termination, Uvicorn logs
"ASGI callable returned without completing response." on CTRL+C while a
client holds an SSE GET stream open.
"""
server = FastMCP(name="ShutdownTest")
app = server.http_app(path="/mcp")
fake_transport = AsyncMock()
fake_transport.terminate = AsyncMock()
async with app.router.lifespan_context(app):
# The session manager is constructed inside the lifespan; once it has
# started, register a fake active transport as if a client were
# holding an SSE stream open.
sm = None
for route in app.router.routes:
endpoint = getattr(route, "endpoint", None)
sm = getattr(endpoint, "session_manager", None)
if sm is not None:
break
assert sm is not None, "streamable-http session manager not initialised"
sm._server_instances["fake-session"] = fake_transport
fake_transport.terminate.assert_awaited_once()