Skip to content

fix(server): guard error-path send into closed session stream#2983

Open
Bartok9 wants to merge 1 commit into
modelcontextprotocol:mainfrom
Bartok9:fix/2741-guard-closed-writer
Open

fix(server): guard error-path send into closed session stream#2983
Bartok9 wants to merge 1 commit into
modelcontextprotocol:mainfrom
Bartok9:fix/2741-guard-closed-writer

Conversation

@Bartok9

@Bartok9 Bartok9 commented Jun 26, 2026

Copy link
Copy Markdown

Summary

  • Guards the stateful Streamable HTTP POST error path so it never raises a secondary ClosedResourceError out of the ASGI app when the session's read stream is already gone.
  • User-facing impact: the original failure is no longer masked by a misleading Exception in ASGI application traceback.

Motivation

Closes #2741.

In stateful Streamable HTTP (stateless_http=False), when a session's app.run() raises, the session task logs Session <id> crashed and tears down its memory streams. The concurrent _handle_post_request then hits its except block, sends the 500 response, and finally runs await writer.send(Exception(err)) — but writer (the session read stream) is already closed, so this send raises a secondary anyio.ClosedResourceError that escapes the ASGI app as Exception in ASGI application, burying the real cause.

Note: this PR deliberately does not change the client-facing 500 message. Since #2741 was filed, main reworked 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.ClosedResourceError traceback that is unrelated to and hides the real error.

Root cause — In StreamableHTTPServerTransport._handle_post_request (streamable_http.py), the except Exception as err block ends with await writer.send(Exception(err)). writer is self._read_stream_writer. When the session task has already crashed and connect() has torn down the streams, that stream is closed, so the send raises ClosedResourceError. Nothing catches it, so it propagates out of handle_request → out of the ASGI app.

Evidencestreamable_http.py line ~649 (await writer.send(Exception(err))) has no guard, unlike the module's other stream sends (lines 301, 723, 1014, 1023 already catch ClosedResourceError/BrokenResourceError). The added regression test pre-closes the read stream to model the crashed-session teardown and reproduces the escaping ClosedResourceError.

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 except block of _handle_post_request. Does not change the client response, status code, or the server-side logger.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 passed
  • uv run ruff check / ruff format --check — clean
  • uv run pyright src/mcp/server/streamable_http.py — 0 errors

Real behavior proof

  • Regression test: 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 before handle_request runs.
  • Without the fix (source reverted, test kept):
    tests/server/test_streamable_http_router.py::test_post_error_path_tolerates_closed_session_stream
    ...
    anyio.ClosedResourceError
    1 failed
    
  • With the fix:
    Results (0.02s):
             3 passed
    
  • What was NOT tested: the full uvicorn end-to-end repro from the issue (the router-level test pins the exact failing call site directly, matching the existing _PrimingFailingStore test pattern in this file).

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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 2 files

Re-trigger cubic

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Stateful Streamable HTTP: in-session exceptions are masked as an empty -32603; real cause only in a separate "Session crashed" log

1 participant