Skip to content

Preserve request-stream ownership during server cleanup - #3540

Open
Kludex wants to merge 1 commit into
mainfrom
preserve-request-stream-ownership
Open

Kludex wants to merge 1 commit into
mainfrom
preserve-request-stream-ownership

Conversation

@Kludex

@Kludex Kludex commented Sep 18, 2026

Copy link
Copy Markdown
Member

Extract server stream ownership and cancellation cleanup from #3511. Each HTTP request closes its own streams, so an older connection cannot remove a successor's streams after request-ID reuse; legacy SSE also closes all owned memory streams.

Replay locking and buffering are left to the next PR. The router regressions run on both asyncio and trio without enabling Trio across the suite or requiring an HTTPX2 update.

Validation

macOS, Python 3.14, locked dependencies: ./scripts/test passes with 5,981 passed, 9 skipped, 1 xfailed, 100% line/branch coverage, and strict-no-cover passing. Ruff lint/format and Pyright pass.

Stack

Part 1 of 5, based on main. Merge the focused fixes before the final backend-enablement change in #3511.

AI Disclaimer

This PR was developed with the assistance of either Claude or Codex. I've reviewed and verified the changes.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 18, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-18T10:52:47.421390Z 1bdf987 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions

Copy link
Copy Markdown
Contributor

📚 Documentation preview

Preview https://pr-3540.mcp-python-docs.pages.dev
Deployment https://87d466c7.mcp-python-docs.pages.dev
Commit 1bdf987
Triggered by @Kludex
Updated 2026-09-18 10:51:06 UTC

@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.

1 issue found across 5 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/mcp/server/streamable_http.py">

<violation number="1" location="src/mcp/server/streamable_http.py:662">
P2: When an older SSE handler calls `ctx.close_sse_stream()` after a later POST reuses its request ID, it closes the successor's streams. Bind the callback to the request's stream ownership and close it only if that owner is still registered.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

logger.exception("SSE response error")
await sse_stream_writer.aclose()
await self._clean_up_memory_streams(request_id)
self._sse_stream_writers[request_id] = sse_stream_writer

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: When an older SSE handler calls ctx.close_sse_stream() after a later POST reuses its request ID, it closes the successor's streams. Bind the callback to the request's stream ownership and close it only if that owner is still registered.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/mcp/server/streamable_http.py, line 662:

<comment>When an older SSE handler calls `ctx.close_sse_stream()` after a later POST reuses its request ID, it closes the successor's streams. Bind the callback to the request's stream ownership and close it only if that owner is still registered.</comment>

<file context>
@@ -679,40 +657,38 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re
-                    logger.exception("SSE response error")
-                    await sse_stream_writer.aclose()
-                    await self._clean_up_memory_streams(request_id)
+                    self._sse_stream_writers[request_id] = sse_stream_writer
+                    self._request_streams[request_id] = request_streams
+                    headers = {
</file context>

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline findings, two other things were checked and ruled out: the new module-scoped anyio_backend fixture in tests/server/test_streamable_http_router.py does not trip the ScopeMismatch that tests/conftest.py warns about for _module_runner_lease (the lease's dependency stays module-scoped, unlike the function-scoped parametrize pattern); and the SSE-mode POST now leaving its registry entry in place while the response drains is harmless, since the router's send hits the closed reader and the identity-checked cleanup removes only that entry, so a same-ID successor is never evicted.

Extended reasoning...

Two confirmed findings are posted inline, so this note only records what else was examined. The trio concern from tests/conftest.py:65-70 was investigated: the conftest docstring's ScopeMismatch warning applies to function-scoped anyio_backend parametrization, but this module defines a module-scoped anyio_backend fixture, so the module-scoped lease can depend on it; the direct-trio.run Windows caveat does not apply because the anyio plugin drives both backends. The extended dead-registry window during SSE response drain in src/mcp/server/streamable_http.py (POST handler finally at the end of the SSE branch) was traced: _run_sse_writer closes the request reader when it breaks on the response, a later router send raises BrokenResourceError, and _clean_up_memory_streams deletes the entry only if it is the same object, so a newer request reusing the ID is unaffected and any message for the finished request is dropped exactly as it was on the base branch (where it hit the not-found path). This is a concurrency-sensitive transport change with a first inline finding about close_sse_stream() that a human should weigh, so the decision is defer rather than approve.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟣 src/mcp/server/streamable_http.py — Servers that call ctx.close_sse_stream() from a long-running handler can still kill a newer request's SSE stream after request-ID reuse, so that newer client sees its stream end with no response. close_sse_stream at src/mcp/server/streamable_http.py:270-278 pops _sse_stream_writers and _request_streams by ID with no identity check, unlike every implicit cleanup this PR reworked. The new docs note promises "cleanup from an older connection does not close the newer request's streams", which this path breaks. Fix: make close_sse_stream (and the callback minted at line 316-317) only close the streams the calling request registered, e.g. capture the request's own stream tuple and writer in the closure and apply the same is identity check before popping. [also at: src/mcp/server/streamable_http.py:934 - Sessions can leave a resumed GET connection open forever, even after terminate() or DELETE, because its streams are unreachable from any registry.; src/mcp/server/streamable_http.py:663 - A client that reuses a request id while the earlier request is still open now keeps that earlier SSE connection open until the client disconnects, with no server-side release. streamable_http.py:663 overwrites _request_streams[request_id] unconditionally; the earlier handler's streams leave the…]

    Extended reasoning...

    Client sends POST id=7 in SSE mode; handler is slow and later calls ctx.close_sse_stream() to poll. Before it does, the client (or a reconnected client in the same session) sends a new POST id=7. Line 664 registers the new request's sse writer and streams under "7", overwriting the old ones. Old handler now calls close_sse_stream("7"). Line 270 pops the NEW request's sse writer and closes it. Line 276-278 pops and closes the NEW request's memory streams. New POST's _run_sse_writer gets ClosedResourceError at 366, its response ends with no JSON-RPC response. When the old handler finally…

    Verification: pre-existing. Triggering condition: a client reuses JSON-RPC request ID "7" while the earlier handler for "7" is still running (the dispatcher blind-overwrites duplicate inbound ids, src/mcp/shared/jsonrpc_dispatcher.py:588-591 "TODO(maxisbey): duplicate ids blind-overwrite"), and that earlier handler later calls ctx.close_sse_stream(). Mechanism verified:… | pre-existing. Trigger: a second…

message, request, request_id, protocol_version
)
await writer.send(session_message)
except Exception: # pragma: lax no cover

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟣 pre-existing, not blocking: nit: AGENTS.md forbids except Exception: outside top-level handlers. The restructured POST SSE branch (line 684) and standalone GET handler (line 785) re-emit except Exception: # pragma: lax no cover blocks nested inside the request handlers' own outer except Exception as err catch-all, so they are not the top-level handler. These are pre-existing blocks moved/re-indented with their cleanup bodies trimmed (the diff also removed one except Exception from the old _clean_up_memory_streams). Fix: since cleanup now lives in finally, drop the inner catch-all and let the outer handler log, or narrow it to the specific errors EventSourceResponse/writer.send raise (e.g. anyio.BrokenResourceError, anyio.ClosedResourceError, OSError). …
A small fix can ride a push you are already making; otherwise a short reply is enough.

Extended reasoning...

…Same instruction at 2 sites (src/mcp/server/streamable_http.py:684, src/mcp/server/streamable_http.py:785).

Nothing fails at runtime. The instruction guards against catch-alls that swallow programmer errors and hide which exceptions a block really expects. Here the inner blocks only log and fall through to finally cleanup, so behaviour is the same as letting the outer except Exception as err handler log it; the only cost is a duplicated catch-all with a lax no cover pragma that coverage does not verify. Pre-existing in substance: both lines existed at the base and were re-indented when the diff wrapped them in a new try/finally.

Verification: AGENTS.md at base (Exception Handling section) reads verbatim «- FORBIDDEN except Exception: - unless in top-level handlers». The diff re-emits, as added lines, except Exception: # pragma: lax no cover at src/mcp/server/streamable_http.py:684 (inside _handle_post_request's SSE branch, nested within the method's outer except Exception as err: at line 693, so not the top-level…

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.

1 participant