fix(server): guard error-path send into closed session stream#2983
Open
Bartok9 wants to merge 1 commit into
Open
fix(server): guard error-path send into closed session stream#2983Bartok9 wants to merge 1 commit into
Bartok9 wants to merge 1 commit into
Conversation
Closes modelcontextprotocol#2741. When a stateful Streamable HTTP session task crashes, it tears down its read stream before the concurrent POST handler reaches its except block. The trailing `await writer.send(Exception(err))` then targets an already closed/broken stream and raises a secondary ClosedResourceError that escapes the ASGI app ("Exception in ASGI application"), masking the original failure. Guard the trailing send with except (ClosedResourceError, BrokenResourceError): pass, mirroring the existing closed-stream guards in this module. The 500 response is already delivered before this send, so swallowing the secondary error preserves client behavior while removing the noisy, misleading traceback. Regression test models the crashed-session teardown by pre-closing the read stream; it fails without the guard (ClosedResourceError out of handle_request) and passes with it.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
ClosedResourceErrorout of the ASGI app when the session's read stream is already gone.Exception in ASGI applicationtraceback.Motivation
Closes #2741.
In stateful Streamable HTTP (
stateless_http=False), when a session'sapp.run()raises, the session task logsSession <id> crashedand tears down its memory streams. The concurrent_handle_post_requestthen hits itsexceptblock, sends the 500 response, and finally runsawait writer.send(Exception(err))— butwriter(the session read stream) is already closed, so this send raises a secondaryanyio.ClosedResourceErrorthat escapes the ASGI app asException in ASGI application, burying the real cause.Note: this PR deliberately does not change the client-facing 500 message. Since #2741 was filed,
mainreworked this path to intentionally log the exception server-side and return a generic"Error handling POST request"to the client (the original PR #2742 was closed by its author for exactly this reason). This PR addresses only the remaining robustness gap from the issue — point (3) in the issue's root-cause analysis: the unguarded trailing send.Root Cause
Symptom — Every POST after a session crash produces a noisy
Exception in ASGI application/anyio.ClosedResourceErrortraceback that is unrelated to and hides the real error.Root cause — In
StreamableHTTPServerTransport._handle_post_request(streamable_http.py), theexcept Exception as errblock ends withawait writer.send(Exception(err)).writerisself._read_stream_writer. When the session task has already crashed andconnect()has torn down the streams, that stream is closed, so the send raisesClosedResourceError. Nothing catches it, so it propagates out ofhandle_request→ out of the ASGI app.Evidence —
streamable_http.pyline ~649 (await writer.send(Exception(err))) has no guard, unlike the module's other stream sends (lines 301, 723, 1014, 1023 already catchClosedResourceError/BrokenResourceError). The added regression test pre-closes the read stream to model the crashed-session teardown and reproduces the escapingClosedResourceError.Fix + why this level — Wrap the trailing send in
try/except (anyio.ClosedResourceError, anyio.BrokenResourceError): pass. This is the correct layer: the 500 response is already delivered to the client before this send, so the wrapped-error forwarding is best-effort internal signaling; swallowing the secondary error when the stream is gone is exactly the right semantics and matches the existing closed-stream guards in this file.Scope / risk — Touches only the
exceptblock of_handle_post_request. Does not change the client response, status code, or the server-sidelogger.exception("Error handling POST request"). When the stream is still open (normal in-flight error), behavior is unchanged.Verification
uv run pytest tests/server/test_streamable_http_router.py tests/server/test_streamable_http_manager.py -q— 23 passeduv run ruff check/ruff format --check— cleanuv run pyright src/mcp/server/streamable_http.py— 0 errorsReal behavior proof
tests/server/test_streamable_http_router.py::test_post_error_path_tolerates_closed_session_stream. It models the crashed-session teardown by pre-closing the read stream beforehandle_requestruns._PrimingFailingStoretest pattern in this file).