Skip to content

Close client SSE iterators explicitly when supported - #3543

Open
Kludex wants to merge 1 commit into
bound-subscription-cleanupfrom
close-client-sse-iterators
Open

Kludex wants to merge 1 commit into
bound-subscription-cleanupfrom
close-client-sse-iterators

Conversation

@Kludex

@Kludex Kludex commented Sep 18, 2026

Copy link
Copy Markdown
Member

Extract client SSE iterator cleanup from #3511. Both HTTP transports explicitly close their outer event iterators when aclose() is supported, accepting generators, closable class-based iterators, and plain async iterators.

The compatibility regression runs on both AnyIO backends. This does not claim complete nested HTTPX2 cleanup: HTTPX2 #1212 remains the blocker for full-suite Trio enablement in #3511, not for this extraction's locked-dependency checks.

Validation

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

Also validated independently on main: 5,975 passed with 100% coverage and strict-no-cover. The three changed test modules pass all 78 cases on Python 3.10.

Stack

Part 4 of 5, stacked on #3542. The final backend-enablement PR is #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:07.411249Z f7ef5f8 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-3543.mcp-python-docs.pages.dev
Deployment https://287a3ae5.mcp-python-docs.pages.dev
Commit f7ef5f8
Triggered by @Kludex
Updated 2026-09-18 10:51:56 UTC

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f7ef5f8ec6

ℹ️ 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".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

yield events
finally:
if isinstance(events, _AsyncClosable):
await events.aclose()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Shield iterator cleanup from task cancellation

When a reader is cancelled—such as during transport teardown or modern request cancellation—AnyIO's level cancellation remains active while this finally block runs, so an aclose() implementation that reaches an async checkpoint is cancelled before cleanup completes. The new tests miss this because their closers perform no checkpoint, while real iterator cleanup commonly awaits nested resources; repeated cancellations can therefore retain stream resources despite the newly documented guarantee. Run the close operation inside a shielded cancellation scope, ideally with an appropriate bound.

Useful? React with 👍 / 👎.

@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 7 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/shared/_httpx_utils.py">

<violation number="1" location="src/mcp/shared/_httpx_utils.py:182">
P2: When an SSE reader is cancelled, `sse_events` awaits `aclose()` inside the already-cancelled AnyIO scope, so asynchronous iterator cleanup can be cancelled and the response resources can leak. Run `events.aclose()` inside a shielded `anyio.CancelScope`.</violation>
</file>

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

Re-trigger cubic

yield events
finally:
if isinstance(events, _AsyncClosable):
await events.aclose()

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 SSE reader is cancelled, sse_events awaits aclose() inside the already-cancelled AnyIO scope, so asynchronous iterator cleanup can be cancelled and the response resources can leak. Run events.aclose() inside a shielded anyio.CancelScope.

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

<comment>When an SSE reader is cancelled, `sse_events` awaits `aclose()` inside the already-cancelled AnyIO scope, so asynchronous iterator cleanup can be cancelled and the response resources can leak. Run `events.aclose()` inside a shielded `anyio.CancelScope`.</comment>

<file context>
@@ -165,6 +166,22 @@ async def sse_within_origin(
+        yield events
+    finally:
+        if isinstance(events, _AsyncClosable):
+            await events.aclose()
+
+
</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.

I reviewed this PR and didn't find any bugs. Since it changes cleanup/cancellation semantics on every client SSE read loop and is one piece of a five-PR Trio-enablement stack, a human look at how it composes with the rest of the stack would still be worthwhile.

What was reviewed:

  • The sse_events helper in src/mcp/shared/_httpx_utils.py and all five call sites; calling __aiter__() before the redirect/status checks does no eager work since async-generator creation is lazy.
  • Ordering of response.aclose() then generator aclose() on normal completion in _handle_sse_response and the reconnect loop; closing a generator suspended at yield only unwinds GeneratorExit, so no spurious reconnect.
  • The new backend-parametrized test and the two partial-consumption test fixes; _module_runner_lease opt-out and parenthesized async with match existing repo patterns, and no new pragmas or type: ignore were introduced.
Extended reasoning...

Overview

The PR adds a small sse_events async context manager plus a @ runtime_checkable _AsyncClosable protocol to /home/claude/python-sdk/src/mcp/shared/_httpx_utils.py, then wraps all five client-side SSE consumption loops with it: the legacy reader in /home/claude/python-sdk/src/mcp/client/sse.py and the GET stream, resumption GET, POST-SSE response, and reconnect loops in /home/claude/python-sdk/src/mcp/client/streamable_http.py. The transport bodies are otherwise unchanged (re-indentation only), preserving existing # pragma: no branch markers rather than adding new ones. Tests add one backend-parametrized regression in /home/claude/python-sdk/tests/client/test_streamable_http.py that stubs httpx2.EventSource.__aiter__ with a generator, a closable class iterator, and a plain iterator, plus explicit aclose() calls in two existing tests that partially consume async generators. A docs admonition describes the behaviour.

Security risks

None identified. The change does not touch auth, origin checks, redirect handling, or header construction; sse_within_origin and _unfollowed_redirect are still evaluated before any event is consumed, and __aiter__() on an async generator function does not execute the generator body, so no bytes are read from an out-of-origin or error response earlier than before.

Level of scrutiny

Moderate. The helper itself is ten lines and mechanical, but it alters when cleanup runs on every client SSE path, including inside cancelled scopes during transport shutdown. The three candidate issues from the hunt (eager work in __aiter__, generator aclose() after response.aclose() triggering a spurious reconnect, and an extra await inside an already-cancelled scope) were all examined against the code and ruled out for this httpx2 version, but the third depends on what httpx2's generator does in its own finalization, which the PR itself notes is pending an upstream change. Because the PR is explicitly part 4 of a 5-PR stack aimed at Trio enablement, a maintainer familiar with the stack is better placed than an automated pass to judge whether this extraction lands cleanly ahead of the upstream httpx2 fix.

Other factors

No CODEOWNERS file exists in the repo. The _module_runner_lease autouse override follows the same pattern used in eight other test modules, parenthesized multi-item async with is already used in src/mcp/server/sse.py, and typing_extensions is already a runtime dependency imported across the codebase. The isinstance branch in sse_events is exercised on both sides by the parametrized test, consistent with the 100% branch coverage requirement. I could not execute the test suite or inspect the installed httpx2 source in this environment, so the author's coverage and cross-backend claims are unverified here.

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