mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-22 21:44:18 +02:00
fix(http): terminate active streamable-HTTP transports before lifespan shutdown (#4118)
This commit is contained in:
parent
cf59a4511f
commit
8209093871
2 changed files with 56 additions and 1 deletions
|
|
@ -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(
|
||||
|
|
|
|||
36
tests/server/http/test_streamable_http_shutdown.py
Normal file
36
tests/server/http/test_streamable_http_shutdown.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue