Skip to content

Commit 6c51892

Browse files
committed
fix: BaseSession.__aexit__ healing checkpoint for gh-106749 (CI 3.11/3.14)
The second gh-106749 site (the first being _memory.py): BaseSession's task-group cancel is also delivered via coro.throw() into the host task, desyncing CTracer past the caller's frame on 3.11. A cancel_shielded_checkpoint() after the join resumes via .send() and re-stamps the missing 'call' events. Shielded so a pending outer cancel isn't re-delivered here. The added tick shifts whether tests reach streamable_http.py's outer POST-error handler (it was timing-dependent before too); marked lax no cover. Also: collapse a nested with in test_jsonrpc_dispatcher.py and pragma: no branch it (3.14 misreports the 254->255 arc on the nested-with bytecode shape).
1 parent 67d61ab commit 6c51892

3 files changed

Lines changed: 17 additions & 6 deletions

File tree

‎src/mcp/server/streamable_http.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -635,15 +635,18 @@ async def sse_writer():
635635
finally:
636636
await sse_stream_reader.aclose()
637637

638-
except Exception as err:
638+
except Exception as err: # pragma: lax no cover
639+
# Reached only when something raises during POST handling outside
640+
# the per-SSE-stream guard above; whether tests reach this depends
641+
# on client teardown timing.
639642
logger.exception("Error handling POST request")
640643
response = self._create_error_response(
641644
f"Error handling POST request: {err}",
642645
HTTPStatus.INTERNAL_SERVER_ERROR,
643646
INTERNAL_ERROR,
644647
)
645648
await response(scope, receive, send)
646-
if writer: # pragma: no cover
649+
if writer:
647650
await writer.send(Exception(err))
648651
return # pragma: no cover
649652

‎src/mcp/shared/session.py‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from typing import Any, Generic, Protocol, TypeVar
88

99
import anyio
10+
import anyio.lowlevel
1011
from anyio.streams.memory import MemoryObjectSendStream
1112
from opentelemetry.trace import SpanKind
1213
from pydantic import BaseModel, TypeAdapter
@@ -173,7 +174,15 @@ async def __aexit__(
173174
# would be very surprising behavior), so make sure to cancel the tasks
174175
# in the task group.
175176
self._task_group.cancel_scope.cancel()
176-
return await self._task_group.__aexit__(exc_type, exc_val, exc_tb)
177+
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()
185+
return result
177186

178187
async def send_request(
179188
self,

‎tests/shared/test_jsonrpc_dispatcher.py‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -249,10 +249,9 @@ async def test_run_closes_write_stream_on_exit():
249249
async with anyio.create_task_group() as tg:
250250
await tg.start(server.run, on_request, on_notify)
251251
c2s_send.close() # EOF the read side; run() exits
252-
with anyio.fail_after(5):
252+
with anyio.fail_after(5), pytest.raises(anyio.EndOfStream): # pragma: no branch
253253
# Write end was entered and released by run(); peer's receive sees EOF.
254-
with pytest.raises(anyio.EndOfStream):
255-
await s2c_recv.receive()
254+
await s2c_recv.receive()
256255
s2c_recv.close()
257256

258257

0 commit comments

Comments
 (0)