Skip to content

SSE: a POST blocks forever (and SIGTERM hangs) when the server lifespan raises after an await #3616

Description

@khuisman

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)  # 202

Steps: 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

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    v1Affects the v1.x maintenance linev2Affects the v2 line (2.x on main)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions