Skip to content

Commit bde30c1

Browse files
committed
refactor: consolidate coverage-tracer resync workarounds into one helper
Eight call sites carried near-identical comment blocks explaining the CPython 3.11 trace-loss workaround (python/cpython#106749). Move the explanation into a single resync_tracer() helper in mcp.shared._compat and call it by name, so the workaround is greppable and deletable as a unit when 3.11 support ends.
1 parent 278b0a3 commit bde30c1

9 files changed

Lines changed: 37 additions & 60 deletions

File tree

src/mcp/client/sse.py

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

77
import anyio
8-
import anyio.lowlevel
98
import httpx
109
from anyio.abc import TaskStatus
1110
from httpx_sse import SSEError, aconnect_sse
1211

1312
from mcp import types
13+
from mcp.shared._compat import resync_tracer
1414
from mcp.shared._context_streams import create_context_streams
1515
from mcp.shared._httpx_utils import McpHttpClientFactory, create_mcp_http_client
1616
from mcp.shared.message import SessionMessage
@@ -158,10 +158,4 @@ async def _send_message(session_message: SessionMessage) -> None:
158158

159159
yield read_stream, write_stream
160160
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()
161+
await resync_tracer()

src/mcp/client/streamable_http.py

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

1111
import anyio
12-
import anyio.lowlevel
1312
import httpx
1413
from anyio.abc import TaskGroup
1514
from httpx_sse import EventSource, ServerSentEvent, aconnect_sse
1615
from pydantic import ValidationError
1716

1817
from mcp.client._transport import TransportStreams
18+
from mcp.shared._compat import resync_tracer
1919
from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams
2020
from mcp.shared._httpx_utils import create_mcp_http_client
2121
from mcp.shared.message import ClientMessageMetadata, SessionMessage
@@ -587,10 +587,4 @@ def start_get_stream() -> None:
587587
if transport.session_id and terminate_on_close:
588588
await transport.terminate_session(client)
589589
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()
590+
await resync_tracer()

src/mcp/client/websocket.py

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

55
import anyio
6-
import anyio.lowlevel
76
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
87
from pydantic import ValidationError
98
from websockets.asyncio.client import connect as ws_connect
109
from websockets.typing import Subprotocol
1110

1211
from mcp import types
12+
from mcp.shared._compat import resync_tracer
1313
from mcp.shared.message import SessionMessage
1414

1515

@@ -84,10 +84,4 @@ async def ws_writer():
8484

8585
# Once the caller's 'async with' block exits, we shut down
8686
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()
87+
await resync_tracer()

src/mcp/server/streamable_http_manager.py

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

1111
import anyio
12-
import anyio.lowlevel
1312
from anyio.abc import TaskStatus
1413
from starlette.requests import Request
1514
from starlette.responses import Response
@@ -22,6 +21,7 @@
2221
StreamableHTTPServerTransport,
2322
)
2423
from mcp.server.transport_security import TransportSecuritySettings
24+
from mcp.shared._compat import resync_tracer
2525
from mcp.types import INVALID_REQUEST, ErrorData, JSONRPCError
2626

2727
if TYPE_CHECKING:
@@ -140,13 +140,7 @@ async def lifespan(app: Starlette) -> AsyncIterator[None]:
140140
# Clear any remaining server instances
141141
self._server_instances.clear()
142142
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()
143+
await resync_tracer()
150144

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

src/mcp/shared/_compat.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
"""Workarounds for CPython interpreter bugs the SDK papers over."""
2+
3+
import anyio.lowlevel
4+
5+
__all__ = ["resync_tracer"]
6+
7+
8+
async def resync_tracer() -> None:
9+
"""Resync coverage tracing after a cancelled task-group join.
10+
11+
A cancel delivered at a join resumes the awaiting coroutine chain via
12+
`coro.throw()`; on CPython 3.11 (python/cpython#106749) that drops the
13+
`'call'` trace events for the outer frames and desyncs coverage's CTracer
14+
until the chain next suspends and resumes normally. Yielding once here
15+
resumes via `.send()`, which re-stamps the missing events. Shielded so a
16+
pending outer cancel is not re-delivered at this point; behaviorally a
17+
no-op. Delete this module when Python 3.11 support ends (EOL 2027-10).
18+
"""
19+
await anyio.lowlevel.cancel_shielded_checkpoint()

src/mcp/shared/memory.py

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

8-
import anyio.lowlevel
9-
8+
from mcp.shared._compat import resync_tracer
109
from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams
1110
from mcp.shared.message import SessionMessage
1211

@@ -30,11 +29,5 @@ async def create_client_server_memory_streams() -> AsyncGenerator[tuple[MessageS
3029

3130
async with server_to_client_receive, client_to_server_send, client_to_server_receive, server_to_client_send:
3231
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()
32+
# Heals caller-driven cancels; closing memory streams never suspends.
33+
await resync_tracer()

src/mcp/shared/session.py

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,12 @@
77
from typing import Any, Generic, Protocol, TypeVar
88

99
import anyio
10-
import anyio.lowlevel
1110
from anyio.streams.memory import MemoryObjectSendStream
1211
from opentelemetry.trace import SpanKind
1312
from pydantic import BaseModel, TypeAdapter
1413
from typing_extensions import Self
1514

15+
from mcp.shared._compat import resync_tracer
1616
from mcp.shared._otel import inject_trace_context, otel_span
1717
from mcp.shared._stream_protocols import ReadStream, WriteStream
1818
from mcp.shared.exceptions import MCPError
@@ -175,13 +175,7 @@ async def __aexit__(
175175
# in the task group.
176176
self._task_group.cancel_scope.cancel()
177177
result = await self._task_group.__aexit__(exc_type, exc_val, exc_tb)
178-
# The cancel above is delivered via `coro.throw()` into this task; on
179-
# CPython 3.11 (gh-106749) that drops `'call'` trace events for the
180-
# outer await chain and desyncs coverage's CTracer past the caller's
181-
# frame. Yielding once here resumes via `.send()`, which re-stamps the
182-
# missing `'call'` events and resyncs the tracer. Shielded so a pending
183-
# outer cancel is not re-delivered at this point.
184-
await anyio.lowlevel.cancel_shielded_checkpoint()
178+
await resync_tracer()
185179
return result
186180

187181
async def send_request(

tests/interaction/transports/_bridge.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,12 @@
3131

3232
import anyio
3333
import anyio.abc
34-
import anyio.lowlevel
3534
import httpx
3635
from anyio.streams.memory import MemoryObjectReceiveStream
3736
from starlette.types import ASGIApp, Message, Scope
3837

38+
from mcp.shared._compat import resync_tracer
39+
3940

4041
class _StreamingResponseBody(httpx.AsyncByteStream):
4142
"""A response body that yields chunks as the application produces them.
@@ -89,10 +90,7 @@ async def __aexit__(
8990
if self._cancel_on_close:
9091
self._task_group.cancel_scope.cancel()
9192
await self._task_group.__aexit__(exc_type, exc_value, traceback)
92-
# gh-106749: the cancel above is delivered via coro.throw() on
93-
# CPython 3.11; resume via .send() so coverage's CTracer re-syncs
94-
# for whatever the caller does after this context manager exits.
95-
await anyio.lowlevel.cancel_shielded_checkpoint()
93+
await resync_tracer()
9694

9795
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
9896
assert isinstance(request.stream, httpx.AsyncByteStream)

tests/shared/test_streamable_http.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
from urllib.parse import urlparse
1717

1818
import anyio
19-
import anyio.lowlevel
2019
import httpx
2120
import pytest
2221
from httpx_sse import ServerSentEvent
@@ -41,6 +40,7 @@
4140
)
4241
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
4342
from mcp.server.transport_security import TransportSecuritySettings
43+
from mcp.shared._compat import resync_tracer
4444
from mcp.shared._context import RequestContext
4545
from mcp.shared._context_streams import create_context_streams
4646
from mcp.shared.message import ClientMessageMetadata, ServerMessageMetadata, SessionMessage
@@ -1188,10 +1188,7 @@ async def run_tool():
11881188
# Kill the client session while tool is waiting on lock
11891189
tg.cancel_scope.cancel()
11901190

1191-
# gh-106749 heal: the cancel above is delivered via coro.throw() on
1192-
# CPython 3.11; resume via .send() so coverage's CTracer re-syncs before
1193-
# the second session's lines.
1194-
await anyio.lowlevel.cancel_shielded_checkpoint()
1191+
await resync_tracer()
11951192

11961193
async with make_client(app, headers=headers) as httpx_client2:
11971194
async with streamable_http_client(f"{BASE_URL}/mcp", http_client=httpx_client2) as (

0 commit comments

Comments
 (0)