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
Ben-Home commentedon Oct 5, 2026
The two exceptions are almost certainly one event, not two, and the ordering tells you where to look.
ClosedResourceErroron the read side followed byUnexpected ASGI message 'http.response.start' sent, after response already completedmeans the response object was already finalized when a secondhttp.response.startarrived. In a statefulstreamable-httpserver with multiple concurrent clients on one session, the notification/response path in_handle_post_requestis racing: two branches both believe they own the response for a given request/notification exchange, or a notification is being answered on a channel that was already closed.Two concrete things worth checking in your repro:
Every notification should produce at most one HTTP outcome - either the 202 Accepted (fire-and-forget) or a JSON response, never both, and never on a closed channel. If the handler awaits the notification and also lets the transport emit a response, you get exactly the
response.start after completedpair. Audit whether the notification branch returns early after the 202, and whether the response is written through the samesendcallable that the closed-resource path already released.The wedge you cannot yet prove is likely the same root cause at a different severity. If a task in the session task group raises
ClosedResourceErrorinside the notification path, whether the group unwinds and replaces the failed task or leaves a slot occupied depends on how the exception propagates through the anyio task group. The "alive and listening but not responding, CPU runnable" state is consistent with a task group that lost a member without failing the group and is now awaiting a task that already died. That matches the transient-vs-wedge split you observed: usually the raise lands inside a live request context and is swallowed; occasionally it orphans a group member.A useful discriminator for the wedge: capture a
py-spy dumpof the stuck process and look for a task parked on aReceive/sendon a channel whose counterpart task is gone. If you see that, it confirms the task-group-leak theory and narrows the fix to the notification branch's exception handling.The general hardening for stateful streamable-http servers: treat a
ClosedResourceErrorin a notification path as terminal for that exchange (log, return, do not attempt a second write), and make the response completion idempotent - a secondhttp.response.startshould be a no-op-and-log rather than an unhandled ASGI protocol violation. We hit the same class in our MCP connector work when a session was torn down mid-notification; the fix was an explicit per-exchange "response claimed" guard so exactly one branch can write.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.