Repository navigation
SSE: a POST blocks forever (and SIGTERM hangs) when the server lifespan raises after an await #3616
Description
Summary
With the SSE transport, if the server's lifespan raises after its first await, a client POST /messages/ that arrives in the meantime is answered 202 and then blocks forever in handle_post_message. On SIGTERM, uvicorn then waits on that request task indefinitely (Waiting for background tasks to complete.).
Cause
SseServerTransport.connect_sse creates the per-session streams with buffer size 0, and handle_post_message does await writer.send(session_message) after responding 202. That send completes only when Server.run receives the message or the stream is closed.
The stream ends are closed only in response_wrapper, after EventSourceResponse returns normally:
await EventSourceResponse(...)(scope, receive, send)
await read_stream_writer.aclose()
await write_stream_reader.aclose()
await sse_stream_reader.aclose()If Server.run raises (here, from the lifespan, before the read loop starts), the task group cancels response_wrapper before those aclose() calls run. connect_sse's finally only removes the session from _read_stream_writers/_session_owners. Nothing ever receives from or closes the stream, so the parked send() never returns.
The await matters for timing. It lets the response task send the endpoint event before the lifespan fails, so a client that POSTs immediately (as MCP clients do) lands in the window. A lifespan that raises without awaiting fails before the endpoint event is sent, so the client never POSTs.
Repro (mcp 2.0.0, uvicorn 0.47.0, Python 3.13, macOS)
server.py:
import asyncio
from contextlib import asynccontextmanager
from mcp.server.mcpserver import MCPServer
@asynccontextmanager
async def lifespan(server):
await asyncio.sleep(0.1) # any await before failing, e.g. loading credentials
raise RuntimeError("startup failed")
yield
mcp = MCPServer("repro", lifespan=lifespan)
if __name__ == "__main__":
mcp.run(transport="sse", host="127.0.0.1", port=8765)client.py: POSTs initialize as soon as the endpoint event arrives:
import json
import re
import socket
import urllib.request
s = socket.create_connection(("127.0.0.1", 8765))
s.sendall(b"GET /sse HTTP/1.1\r\nHost: 127.0.0.1:8765\r\nAccept: text/event-stream\r\n\r\n")
buf = b""
while not (m := re.search(rb"session_id=([0-9a-f]{32})", buf)):
buf += s.recv(4096)
body = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
"protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": {"name": "repro", "version": "0"}}}).encode()
req = urllib.request.Request(
f"http://127.0.0.1:8765/messages/?session_id={m.group(1).decode()}",
data=body, headers={"Content-Type": "application/json"},
)
print(urllib.request.urlopen(req).status) # 202Steps: run python server.py, run python client.py (prints 202), then send the server SIGTERM.
Expected: the server exits.
Actual: Waiting for background tasks to complete. (CTRL+C to force quit), indefinitely. 5 of 5 runs.
A task dump of the hung server shows the POST request task parked here:
mcp/server/sse.py:262 handle_post_message
mcp/shared/_context_streams.py:37 send
anyio/streams/memory.py:256 send
Possible fix
Close the session's stream ends (or at least read_stream_writer) in connect_sse's finally, so that a pending send() raises instead of waiting forever when the body exits for any reason.
Related
#514 shows the same symptom (SSE server stuck on "Waiting for background tasks to complete" at shutdown) with a different trigger: an open session after normal requests. This case needs no open session or handled requests, only a lifespan failure after an await.
Activity
i20000109 commentedon Oct 2, 2026
Reproduced the trace on
mainand confirmed every step.src/mcp/server/sse.py:Both zero-buffer, as described. And the
finallyat the end ofconnect_sse:— dict cleanup only. The
aclose()calls live inresponse_wrapper, afterawait EventSourceResponse(...)(scope, receive, send), so task-group cancellation on lifespan failure skips them. The parkedawait writer.send(...)has no counterparty to wake it. Agreed on all of it.One extra path that doesn't need a failing lifespan at all. The malformed-body branch in
handle_post_messagedoes the same unbounded send:So a client that opens a session and then POSTs invalid JSON parks that request forever on the same zero-buffer stream — no
lifespanexception, no SIGTERM, no race. A client that simply retries a truncated or mis-encoded body hits it. On a default-1-buffer stream this would park at the first message instead of only after the endpoint event, which is why I think the zero buffer is what makes it look lifespan-specific.Two questions that decide where the fix belongs:
create_context_streams(1)), or should the POST side usesend_nowaitand 503 when the session isn't draining? The first keeps202-then-deliver semantics; the second makes backpressure visible instead of hanging. I lean toward the second for the error path regardless, since an error response doesn't need to queue behind a stuck session.connect_sse'sfinally(rather than only inresponse_wrapper) safe with respect to the normal disconnect path, or does the task group rely onresponse_wrapperowning those closes?Happy to take this if assigned —
AGENTS.mdcloses unassigned PRs, and I'd rather not open one blind. I can also add a regression test that asserts a POST against a session whose lifespan has died returns (rather than hanging) under aanyio.fail_after.0xamlab commentedon Oct 3, 2026
Reproduced on main: with a lifespan that awaits before failing, the client POSTs
initialize, gets202, and the request task stays parked inwriter.sendatsrc/mcp/server/sse.py:287. My 2 s SIGTERM wait catches uvicorn'sWaiting for background tasks to complete; theaclose()calls that would wake the sender live inresponse_wrapperafterEventSourceResponse, so task-group cancellation skips them.The zero-buffer streams are created at
src/mcp/server/sse.py:152. Closingread_stream_writerinconnect_sse'sfinally— or propagating lifespan failure to cancel in-flight request tasks — unblocks shutdown. Happy to open a PR if you want me to take it.Disclosure: I contribute to thyn-ai/Algenta (open-source agent tooling).