Skip to content

Commit 48e4c23

Browse files
committed
fix: gh-106749 healing checkpoints at remaining throw sites; anyio>=4.10 on 3.14
The BaseSession.__aexit__ checkpoint only heals throws at or before session exit. Under xdist per-worker test ordering, the unmasked victims sit after later cancel-scope sites: client/streamable_http.py, client/sse.py, client/websocket.py, server/streamable_http_manager.py (finally-cancel after task-group join), and shared/memory.py:create_client_server_memory_streams (heals caller-driven cancels). Same shielded-checkpoint pattern at each. Also updated the _memory.py comment to reference the new memory.py heal. 3.14 lowest-direct: anyio 4.9.0 from_thread.py has return-in-finally which Python 3.14 (PEP 765) warns about at compile time; the warning lands in the stdio test child stderr. Fixed in anyio 4.10 (agronholm/anyio#816); marker-split the floor (locked already has 4.10).
1 parent 6c51892 commit 48e4c23

8 files changed

Lines changed: 52 additions & 3 deletions

File tree

pyproject.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,11 @@ classifiers = [
2525
"Programming Language :: Python :: 3.14",
2626
]
2727
dependencies = [
28-
"anyio>=4.9",
28+
# anyio < 4.10 triggers a compile-time SyntaxWarning on Python 3.14 (PEP 765,
29+
# "'return' in a 'finally' block"); for stdio servers it lands on the child's
30+
# stderr (agronholm/anyio#816, fixed in 4.10).
31+
"anyio>=4.10; python_version >= '3.14'",
32+
"anyio>=4.9; python_version < '3.14'",
2933
"httpx>=0.27.1,<1.0.0",
3034
"httpx-sse>=0.4",
3135
"pydantic>=2.12.0",

src/mcp/client/_memory.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,9 @@ async def _run_server() -> None:
8787
# completes the join would hang forever, so bound the wait
8888
# and fall back to cancelling. The healthy path returns
8989
# from wait() without the timeout firing, so the cancel is
90-
# never reached and gh-106749 stays avoided.
90+
# never reached and gh-106749 stays avoided. If the cancel
91+
# does fire, the checkpoint at the end of
92+
# `create_client_server_memory_streams` resyncs the tracer.
9193
with anyio.move_on_after(SERVER_SHUTDOWN_GRACE):
9294
await server_done.wait()
9395
if not server_done.is_set():

src/mcp/client/sse.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from urllib.parse import parse_qs, urljoin, urlparse
66

77
import anyio
8+
import anyio.lowlevel
89
import httpx
910
from anyio.abc import TaskStatus
1011
from httpx_sse import SSEError, aconnect_sse
@@ -157,3 +158,10 @@ async def _send_message(session_message: SessionMessage) -> None:
157158

158159
yield read_stream, write_stream
159160
tg.cancel_scope.cancel()
161+
# The cancel above is delivered via `coro.throw()` into this task at
162+
# the task-group join; on CPython 3.11 (gh-106749) that drops `'call'`
163+
# trace events for the outer await chain and desyncs coverage's CTracer
164+
# past the caller's frame. Yielding once here resumes via `.send()`,
165+
# which re-stamps the missing `'call'` events and resyncs the tracer.
166+
# Shielded so a pending outer cancel is not re-delivered at this point.
167+
await anyio.lowlevel.cancel_shielded_checkpoint()

src/mcp/client/streamable_http.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from dataclasses import dataclass
1010

1111
import anyio
12+
import anyio.lowlevel
1213
import httpx
1314
from anyio.abc import TaskGroup
1415
from httpx_sse import EventSource, ServerSentEvent, aconnect_sse
@@ -586,3 +587,10 @@ def start_get_stream() -> None:
586587
if transport.session_id and terminate_on_close:
587588
await transport.terminate_session(client)
588589
tg.cancel_scope.cancel()
590+
# The cancel above is delivered via `coro.throw()` into this task at
591+
# the task-group join; on CPython 3.11 (gh-106749) that drops `'call'`
592+
# trace events for the outer await chain and desyncs coverage's CTracer
593+
# past the caller's frame. Yielding once here resumes via `.send()`,
594+
# which re-stamps the missing `'call'` events and resyncs the tracer.
595+
# Shielded so a pending outer cancel is not re-delivered at this point.
596+
await anyio.lowlevel.cancel_shielded_checkpoint()

src/mcp/client/websocket.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from contextlib import asynccontextmanager
44

55
import anyio
6+
import anyio.lowlevel
67
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
78
from pydantic import ValidationError
89
from websockets.asyncio.client import connect as ws_connect
@@ -83,3 +84,10 @@ async def ws_writer():
8384

8485
# Once the caller's 'async with' block exits, we shut down
8586
tg.cancel_scope.cancel()
87+
# The cancel above is delivered via `coro.throw()` into this task at
88+
# the task-group join; on CPython 3.11 (gh-106749) that drops `'call'`
89+
# trace events for the outer await chain and desyncs coverage's CTracer
90+
# past the caller's frame. Yielding once here resumes via `.send()`,
91+
# which re-stamps the missing `'call'` events and resyncs the tracer.
92+
# Shielded so a pending outer cancel is not re-delivered at this point.
93+
await anyio.lowlevel.cancel_shielded_checkpoint()

src/mcp/server/streamable_http_manager.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from uuid import uuid4
1010

1111
import anyio
12+
import anyio.lowlevel
1213
from anyio.abc import TaskStatus
1314
from starlette.requests import Request
1415
from starlette.responses import Response
@@ -139,6 +140,13 @@ async def lifespan(app: Starlette) -> AsyncIterator[None]:
139140
# Clear any remaining server instances
140141
self._server_instances.clear()
141142
self._session_owners.clear()
143+
# The cancel above is delivered via `coro.throw()` into this task at
144+
# the task-group join; on CPython 3.11 (gh-106749) that drops `'call'`
145+
# trace events for the outer await chain and desyncs coverage's CTracer
146+
# past the caller's frame. Yielding once here resumes via `.send()`,
147+
# which re-stamps the missing `'call'` events and resyncs the tracer.
148+
# Shielded so a pending outer cancel is not re-delivered at this point.
149+
await anyio.lowlevel.cancel_shielded_checkpoint()
142150

143151
async def handle_request(self, scope: Scope, receive: Receive, send: Send) -> None:
144152
"""Process ASGI request with proper session handling and transport setup.

src/mcp/shared/memory.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
from collections.abc import AsyncGenerator
66
from contextlib import asynccontextmanager
77

8+
import anyio.lowlevel
9+
810
from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams
911
from mcp.shared.message import SessionMessage
1012

@@ -28,3 +30,11 @@ async def create_client_server_memory_streams() -> AsyncGenerator[tuple[MessageS
2830

2931
async with server_to_client_receive, client_to_server_send, client_to_server_receive, server_to_client_send:
3032
yield client_streams, server_streams
33+
# Callers routinely cancel a task group wrapped around these streams just
34+
# before this context exits; that cancel is delivered via `coro.throw()`,
35+
# which on CPython 3.11 (gh-106749) drops `'call'` trace events for the
36+
# outer await chain and desyncs coverage's CTracer past the caller's frame.
37+
# Closing memory streams never suspends, so this is the last chance to
38+
# resync: yielding once resumes via `.send()`, which re-stamps the missing
39+
# `'call'` events. Shielded so a pending outer cancel is not re-delivered.
40+
await anyio.lowlevel.cancel_shielded_checkpoint()

uv.lock

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)