Skip to content

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

Open

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

  1. added
    v2Affects the v2 line (2.x on main)
    v1Affects the v1.x maintenance line
    on Oct 2, 2026
  2. i20000109 commented on Oct 2, 2026

    @i20000109

    Reproduced the trace on main and confirmed every step. src/mcp/server/sse.py:

    read_stream_writer, read_stream = create_context_streams[SessionMessage | Exception](0)
    write_stream, write_stream_reader = create_context_streams[SessionMessage](0)

    Both zero-buffer, as described. And the finally at the end of connect_sse:

    finally:
        self._read_stream_writers.pop(session_id, None)
        self._session_owners.pop(session_id, None)

    — dict cleanup only. The aclose() calls live in response_wrapper, after await EventSourceResponse(...)(scope, receive, send), so task-group cancellation on lifespan failure skips them. The parked await 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_message does the same unbounded send:

    except ValidationError as err:
        ...
        await response(scope, receive, send)
        await writer.send(err)
        return

    So a client that opens a session and then POSTs invalid JSON parks that request forever on the same zero-buffer stream — no lifespan exception, 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:

    1. Should the streams get a small non-zero buffer (create_context_streams(1)), or should the POST side use send_nowait and 503 when the session isn't draining? The first keeps 202-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.
    2. Is closing the streams in connect_sse's finally (rather than only in response_wrapper) safe with respect to the normal disconnect path, or does the task group rely on response_wrapper owning those closes?

    Happy to take this if assigned — AGENTS.md closes 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 a anyio.fail_after.

  3. 0xamlab commented on Oct 3, 2026

    @0xamlab

    Reproduced on main: with a lifespan that awaits before failing, the client POSTs initialize, gets 202, and the request task stays parked in writer.send at src/mcp/server/sse.py:287. My 2 s SIGTERM wait catches uvicorn's Waiting for background tasks to complete; the aclose() calls that would wake the sender live in response_wrapper after EventSourceResponse, so task-group cancellation skips them.

    The zero-buffer streams are created at src/mcp/server/sse.py:152. Closing read_stream_writer in connect_sse's finally — 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).

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

    bugSomething isn't workingv1Affects 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