Repository navigation
ClosedResourceError + 'Unexpected ASGI message ... after response already completed' race in _handle_post_request notification path (streamable-http) #3641
Description
Bug: RuntimeError: Unexpected ASGI message 'http.response.start' sent, after response already completed following ClosedResourceError in _handle_post_request notification/response path
Environment
mcp: 2.1.1starlette: 1.6.0uvicorn: 0.52.4anyio: 4.14.2- Python: 3.13.14
- OS: macOS 27.0.0 (Darwin, arm64)
- Server:
basic-memory0.23.2 (FastMCP-based),streamable-httptransport, stateful (notstateless_http), single uvicorn worker, run as a long-lived local service (launchd) on127.0.0.1.
Description
Our server logs this pair of exceptions every 1-2 hours of normal multi-client usage (several unrelated local MCP clients making tool calls concurrently against the same long-running session). Each occurrence is almost always transient — the server keeps serving the next request fine within milliseconds — but on one occasion it coincided with the server task group getting wedged: the process stayed alive and listening, but stopped responding to any further request, accumulating CPU in a runnable state until we force-restarted it. I can't yet prove the wedge and this exception pair are causally linked (vs. coincidental timing), but I haven't found another candidate in the logs for that incident, so I'm reporting the reproducible half of it (the exception pair) and flagging the wedge as a possible consequence.
The two paired exceptions (always appear together, in this order)
1. In _handle_post_request's notification/response branch (around streamable_http.py:588, await writer.send(session_message)):
Error handling POST request
Traceback (most recent call last):
File ".../mcp/server/streamable_http.py", line 588, in _handle_post_request
await writer.send(session_message)
...
raise ClosedResourceError
anyio.ClosedResourceError
2. Immediately after (same request, different connection, usually 1-4 seconds later):
ERROR: Exception in ASGI application
Traceback (most recent call last):
File ".../mcp/server/streamable_http.py", line 588, in _handle_post_request
await writer.send(session_message)
...
raise RuntimeError(f"Unexpected ASGI message '{message['type']}' sent, after response already completed.")
RuntimeError: Unexpected ASGI message 'http.response.start' sent, after response already completed.
Where this sits relative to known issues
This looks related to, but distinct from, the previously-reported/fixed races in this area:
- Race Condition in StreamableHTTP Transport Causes ClosedResourceError #1363 / fix: handle ClosedResourceError in StreamableHTTP message router #1384 — message-router
ClosedResourceErrorfrom early request validation failure. Already fixed and present in our installedmcp2.1.1 (we checked:except anyio.ClosedResourceErrorguards are present in the message router). - ClientDisconnect during _handle_post_request crashes stateless session with ClosedResourceError #2064 / fix: handle ClosedResourceError in _handle_message error recovery path #2072 —
ClosedResourceErrorfromsend_log_message()in the error recovery path after a client disconnect. Different code path (ours is the success/notification path, not error logging). - [Bug] Server crashes with anyio.ClosedResourceError when receiving raw invalid UTF-8 bytes #2328 / [v1.x] fix: handle ClosedResourceError when transport closes mid-request #2334 —
respond()-after-cancel assertion race, backported for v1.x. Different symptom (AssertionError, not a double ASGI response).
Ours happens on the notification/response branch at line 588 — the code path that:
- Immediately sends a
202 AcceptedHTTP response for a non-JSONRPCRequestmessage (notification or response), then - Forwards the message to
writerfor the session's message router to process.
The second exception (Unexpected ASGI message 'http.response.start' sent, after response already completed) implies something tried to start an ASGI response a second time on a connection that had already fully completed its response — i.e., there appear to be two handlers (or one handler re-entered) racing on the same underlying ASGI send callable, not just a stream that was already closed.
Hypothesis
With several concurrent clients attached to the same session, two POST requests can race such that:
- Request A completes its
202 Acceptedresponse and returns. - Something tied to request A's scope/send (session teardown, or a delayed completion callback from the message router on the same
writer) fires again afterward and tries to writehttp.response.startthrough the same (now-closed, already-completed) ASGIsend, producing theRuntimeError. - The reactor appears to mix up these fast-fail paths tightly enough that it can spin instead of cleanly terminating, which is consistent with what we saw during the wedge (sustained CPU, no response to new requests, had to be killed externally).
I don't have a minimal repro yet — this only shows up under organic concurrent load against a long-running stateful session, not in a quick scripted test. Happy to help instrument/reproduce if a maintainer can point at the likely callback path (e.g. is there a completion/cancel callback registered per-request that isn't being cancelled/unregistered when the 202 response path returns early?).
Impact
- Usually harmless (logged, swallowed, next request succeeds).
- At least once, coincided with the whole server task group wedging (high CPU, unresponsive, required external restart). Severity assessment is tentative since causation isn't proven, but flagging given the potential for a hang in a long-running server.
What we've done in the meantime
Running a watchdog that health-checks the MCP endpoint and does a bounded, backoff-limited restart (not an unconditional restart loop) with alerting if it doesn't recover after a few attempts.
Activity
0xamlab commentedon Oct 6, 2026
I reproduced this on current main: sending a notification, then closing the read stream right after the 202 body is emitted, causes
writer.send(session_message)atsrc/mcp/server/streamable_http.py:629to raiseClosedResourceError; the outerexceptthen callsresponse(...)again at line 724, producing the secondhttp.response.startafter the response completed.The race is that the notification branch returns 202 before forwarding to
writer, so any concurrent close is indistinguishable from a late failure and the shared error handler tries to send a fresh response on an already-completed ASGI scope. The fix is to guard that post-response write: once the final body frame has gone out, the handler should swallowClosedResourceErrorand not emit another response.Disclosure: I contribute to thyn-ai/Algenta (open-source agent tooling).
maxisbey commentedon Oct 6, 2026
Thanks for the detailed write-up, and for checking it against the earlier issues. This is the same bug as #3631, which has a minimal repro, so I'm going to close this one as a duplicate to keep the fix in one place.
Both exceptions come from a single request rather than two handlers racing. For a notification, the transport sends the 202 first and then forwards the message to the session. If the session's stream has already closed, that raises
ClosedResourceErrorinto the catch-all handler, which then tries to send a 500 on a response that has already completed. That second attempt is yourUnexpected ASGI message 'http.response.start'.That doesn't explain the hang you saw, and I don't want to lose it. If it happens again, especially once #3631 is fixed, please open a new issue with whatever you can capture (a
py-spy dumpof the stuck process would be ideal).AI Disclaimer
Generated by Claude Code
Varshith-Kali commentedon Oct 6, 2026
I've reproduced this locally and I'd like to take it on — could you assign #3641 to me?
What's happening: in
_handle_post_request's notification branch, the 202 response goes out and thenwriter.send(session_message)runs. If the session tears down in that window, the send raisesClosedResourceError, and the outerexcept Exceptionhandler tries to emit a 500 on the already-completed ASGI scope — which Starlette rejects with "Unexpected ASGI message 'http.response.start' sent, after response already completed". That's the exact pair in the report. Worse, the handler'swriter.send(Exception(err))then re-raises on the closed stream, so the pair escapeshandle_requestas an unhandled ASGI exception on every occurrence.My approach: guard the post-202 send against
ClosedResourceError/BrokenResourceError. Since the 202 already went out, there is nothing left to report, so the handler drops the notification (debug log) instead of emitting a second response. I have it implemented with regression tests that fail before and pass after the fix — 7/7 intests/server/test_streamable_http_router.py, withruffandpyrightclean.