Skip to content

Commit e1538c7

Browse files
committed
Bound direct subscription cleanup without delaying remote exits
1 parent 04f67da commit e1538c7

3 files changed

Lines changed: 209 additions & 28 deletions

File tree

docs/client/subscriptions.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ Requests run freely beside an open stream, from the watcher task or any other, o
5959

6060
To stop watching, leave the block: there is no `unsubscribe` call. Cancelling the task that owns the block does that for you, and the SDK cancels the listen request the way the transport expects: over streamable HTTP, by closing that request's stream. A watcher that runs for the life of your app never returns on its own, so cancel it, or its task group's scope, at shutdown.
6161

62+
With a direct in-memory connection (`Client(server)` or a `ClientSession` using `DirectDispatcher`), exit waits up to five seconds for the listen task to finish, even if the caller is cancelled. This lets cooperative handler cleanup release its subscription slot before you open another subscription. The limit prevents an uncooperative handler from blocking exit indefinitely.
63+
64+
With a stream-backed connection, exit cancels the listen task without waiting for its courtesy cancellation write. The session still owns that task and its cleanup. A slow transport does not delay each subscription's exit, and exit does not acknowledge that the remote server has finished its cleanup.
65+
6266
## Streams end
6367

6468
A stream ends in one of two ways, both ordinary control flow. A graceful server close ends the `async for`; an abrupt drop raises `SubscriptionLost`.

src/mcp/client/subscriptions.py

Lines changed: 38 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import mcp_types as types
1919
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
2020

21+
from mcp.shared.direct_dispatcher import DirectDispatcher
2122
from mcp.shared.dispatcher import CallOptions
2223
from mcp.shared.exceptions import MCPError
2324
from mcp.shared.subscriptions import (
@@ -241,42 +242,51 @@ async def listen(
241242
data = request.model_dump(by_alias=True, mode="json", exclude_none=True)
242243
opts: CallOptions = {"request_id": request_id}
243244
session._stamp(data, opts) # pyright: ignore[reportPrivateUsage]
245+
dispatcher = session._dispatcher # pyright: ignore[reportPrivateUsage]
244246
driver_scope = anyio.CancelScope()
247+
driver_done = anyio.Event()
245248

246249
async def drive() -> None:
247250
# Deliberately no result timeout: the response arrives when the stream ends.
248-
with driver_scope:
249-
try:
250-
await session._dispatcher.send_raw_request( # pyright: ignore[reportPrivateUsage]
251-
data["method"], data.get("params"), opts
252-
)
253-
except MCPError as error:
254-
route.settle("lost", error=error)
255-
return
256-
except ValueError as error:
257-
# A raw request id collided with our minted listen id: fail this subscription
258-
# and release the route in this same slice, so it cannot consume the raw caller's ack.
259-
session._unregister_listen_route(request_id) # pyright: ignore[reportPrivateUsage]
260-
route.settle("lost", error=MCPError(types.INTERNAL_ERROR, str(error)))
261-
return
262-
# A result, whatever its body, is the spec's graceful close; with no prior ack
263-
# it opens the subscription already closed.
264-
route.set_acked(types.SubscriptionFilter())
265-
route.settle("graceful")
251+
try:
252+
with driver_scope:
253+
try:
254+
await dispatcher.send_raw_request(data["method"], data.get("params"), opts)
255+
except MCPError as error:
256+
route.settle("lost", error=error)
257+
return
258+
except ValueError as error:
259+
# A raw request id collided with our minted listen id: fail this subscription
260+
# and release the route in this same slice, so it cannot consume the raw caller's ack.
261+
session._unregister_listen_route(request_id) # pyright: ignore[reportPrivateUsage]
262+
route.settle("lost", error=MCPError(types.INTERNAL_ERROR, str(error)))
263+
return
264+
# A result, whatever its body, is the spec's graceful close; with no prior ack
265+
# it opens the subscription already closed.
266+
route.set_acked(types.SubscriptionFilter())
267+
route.settle("graceful")
268+
finally:
269+
driver_done.set()
266270

267271
# Register the demux route before the request is written so the ack cannot race it.
268272
route = session._register_listen_route(request_id) # pyright: ignore[reportPrivateUsage]
269273
try:
270274
task_group.start_soon(drive)
271-
with anyio.fail_after(session._session_read_timeout_seconds): # pyright: ignore[reportPrivateUsage]
272-
await route.acked.wait()
273-
if route.honored is None:
274-
# Only reachable on failure paths: a graceful no-ack result acked an empty filter in drive().
275-
if route.error is not None:
276-
raise route.error
277-
raise SubscriptionLost(f"subscription {request_id!r} ended before it was acknowledged")
278-
yield Subscription(route, request_id, route.honored, on_event)
275+
try:
276+
with anyio.fail_after(session._session_read_timeout_seconds): # pyright: ignore[reportPrivateUsage]
277+
await route.acked.wait()
278+
if route.honored is None:
279+
# Only reachable on failure paths: a graceful no-ack result acked an empty filter in drive().
280+
if route.error is not None:
281+
raise route.error
282+
raise SubscriptionLost(f"subscription {request_id!r} ended before it was acknowledged")
283+
yield Subscription(route, request_id, route.honored, on_event)
284+
finally:
285+
route.settle("local")
286+
driver_scope.cancel()
287+
# Only direct drivers own handler cleanup; remote courtesy writes remain session-owned.
288+
if isinstance(dispatcher, DirectDispatcher):
289+
with anyio.move_on_after(5, shield=True):
290+
await driver_done.wait()
279291
finally:
280-
route.settle("local")
281-
driver_scope.cancel()
282292
session._unregister_listen_route(request_id) # pyright: ignore[reportPrivateUsage]

tests/client/test_subscriptions.py

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import mcp_types as types
1111
import pytest
1212
from mcp_types import SubscriptionFilter
13+
from trio.testing import MockClock
1314

1415
import mcp.client.subscriptions as subscriptions_module
1516
from mcp import Client, MCPError
@@ -34,10 +35,17 @@
3435
)
3536
from mcp.shared.direct_dispatcher import create_direct_dispatcher_pair
3637
from mcp.shared.dispatcher import CallOptions
38+
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
39+
from mcp.shared.message import SessionMessage
3740

3841
pytestmark = pytest.mark.anyio
3942

4043

44+
@pytest.fixture(autouse=True)
45+
def _module_runner_lease() -> None:
46+
"""Opt out of the shared runner because the cleanup timeout test parametrizes `anyio_backend`."""
47+
48+
4149
def _bus_server(bus: InMemorySubscriptionBus, *, max_subscriptions: int | None = None) -> Server[Any]:
4250
"""A lowlevel server whose only feature is serving listen streams from `bus`."""
4351
handler = (
@@ -214,6 +222,7 @@ async def cancelling_listen(
214222
await anext(sub)
215223

216224

225+
@pytest.mark.parametrize("anyio_backend", ["asyncio", "trio"])
217226
async def test_exiting_the_context_frees_the_server_slot():
218227
"""Leaving the block ends the subscription server-side: a one-slot handler admits a second listen."""
219228
bus = InMemorySubscriptionBus()
@@ -226,6 +235,162 @@ async def test_exiting_the_context_frees_the_server_slot():
226235
assert second.subscription_id != first.subscription_id
227236

228237

238+
@pytest.mark.parametrize("anyio_backend", ["asyncio", "trio"])
239+
async def test_a_cancelled_task_can_close_a_subscription_opened_by_another_task() -> None:
240+
"""SDK-defined: cross-task exit joins direct handler cleanup even when the closing task is cancelled."""
241+
handler = ListenHandler(InMemorySubscriptionBus())
242+
cleanup_started = anyio.Event()
243+
release_cleanup = anyio.Event()
244+
cleanup_finished = anyio.Event()
245+
closed = anyio.Event()
246+
247+
async def slow_cleanup(
248+
ctx: ServerRequestContext, params: types.SubscriptionsListenRequestParams
249+
) -> types.SubscriptionsListenResult:
250+
assert params.notifications.tools_list_changed is True
251+
try:
252+
return await handler(ctx, params)
253+
finally:
254+
with anyio.fail_after(5, shield=True):
255+
cleanup_started.set()
256+
await release_cleanup.wait()
257+
cleanup_finished.set()
258+
259+
server = Server("subs", on_subscriptions_listen=slow_cleanup)
260+
with anyio.fail_after(5):
261+
async with Client(server) as client, anyio.create_task_group() as tg:
262+
subscription = client.listen(tools_list_changed=True)
263+
await subscription.__aenter__()
264+
265+
async def close() -> None:
266+
with anyio.CancelScope() as scope:
267+
scope.cancel()
268+
try:
269+
await anyio.Event().wait()
270+
except anyio.get_cancelled_exc_class() as exc:
271+
await subscription.__aexit__(type(exc), exc, exc.__traceback__)
272+
assert cleanup_finished.is_set()
273+
closed.set()
274+
raise
275+
276+
tg.start_soon(close)
277+
try:
278+
await cleanup_started.wait()
279+
await anyio.wait_all_tasks_blocked()
280+
assert not closed.is_set()
281+
finally:
282+
release_cleanup.set()
283+
await closed.wait()
284+
285+
286+
@pytest.mark.parametrize(
287+
"anyio_backend",
288+
[pytest.param(("trio", {"clock": MockClock(autojump_threshold=0)}), id="trio-mockclock")],
289+
)
290+
async def test_exiting_a_subscription_bounds_uncooperative_direct_handler_cleanup() -> None:
291+
"""SDK-defined: exit stops waiting after five seconds if a direct handler shields its cleanup."""
292+
handler = ListenHandler(InMemorySubscriptionBus())
293+
cleanup_started = anyio.Event()
294+
release_cleanup = anyio.Event()
295+
cleanup_finished = anyio.Event()
296+
297+
async def shielded_cleanup(
298+
ctx: ServerRequestContext, params: types.SubscriptionsListenRequestParams
299+
) -> types.SubscriptionsListenResult:
300+
assert params.notifications.tools_list_changed is True
301+
try:
302+
return await handler(ctx, params)
303+
finally:
304+
# The watchdog must outlast the SDK's five-second cleanup cap.
305+
with anyio.fail_after(10, shield=True):
306+
cleanup_started.set()
307+
await release_cleanup.wait()
308+
cleanup_finished.set()
309+
310+
server = Server("subs", on_subscriptions_listen=shielded_cleanup)
311+
async with Client(server) as client:
312+
subscription = client.listen(tools_list_changed=True)
313+
with anyio.fail_after(5):
314+
sub = await subscription.__aenter__()
315+
try:
316+
started = anyio.current_time()
317+
await subscription.__aexit__(None, None, None)
318+
assert anyio.current_time() - started == 5 # MockClock time, never wall-clock time.
319+
assert cleanup_started.is_set()
320+
assert not cleanup_finished.is_set()
321+
with pytest.raises(StopAsyncIteration):
322+
await anext(sub)
323+
finally:
324+
release_cleanup.set()
325+
with anyio.fail_after(5):
326+
await cleanup_finished.wait()
327+
328+
329+
@pytest.mark.parametrize(
330+
"anyio_backend",
331+
[pytest.param(("trio", {"clock": MockClock(autojump_threshold=0)}), id="trio-mockclock")],
332+
)
333+
@pytest.mark.parametrize("cancelled", [False, True], ids=["normal-exit", "cancelled-exit"])
334+
async def test_sequential_remote_subscription_exits_do_not_wait_for_courtesy_writes(
335+
monkeypatch: pytest.MonkeyPatch, cancelled: bool
336+
) -> None:
337+
"""SDK-defined: remote exits leave courtesy writes to session-owned drivers, even when cancelled.
338+
339+
Block the public stream `send` boundary; typed server handlers cannot wedge a client's transport write.
340+
"""
341+
server = Server("subs", on_subscriptions_listen=ListenHandler(InMemorySubscriptionBus()))
342+
client_write, server_read = anyio.create_memory_object_stream[SessionMessage | Exception]()
343+
server_write, client_read = anyio.create_memory_object_stream[SessionMessage | Exception]()
344+
release_writes = anyio.Event()
345+
attempted: list[types.RequestId] = []
346+
delivered: list[types.RequestId] = []
347+
send = client_write.send
348+
349+
async def block_courtesy_write(item: SessionMessage | Exception) -> None:
350+
assert isinstance(item, SessionMessage)
351+
message = item.message
352+
if isinstance(message, types.JSONRPCNotification) and message.method == "notifications/cancelled":
353+
assert message.params is not None
354+
request_id = message.params["requestId"]
355+
attempted.append(request_id)
356+
with anyio.fail_after(5):
357+
await release_writes.wait()
358+
await send(item)
359+
delivered.append(request_id)
360+
else:
361+
await send(item)
362+
363+
monkeypatch.setattr(client_write, "send", block_courtesy_write)
364+
dispatcher = JSONRPCDispatcher(client_read, client_write)
365+
with anyio.fail_after(5):
366+
async with client_write, server_read, server_write, client_read, anyio.create_task_group() as tg:
367+
tg.start_soon(server.run, server_read, server_write, server.create_initialization_options())
368+
async with ClientSession(dispatcher=dispatcher) as session:
369+
await session.discover()
370+
subscription_ids: list[types.RequestId] = []
371+
started = anyio.current_time()
372+
try:
373+
for _ in range(2):
374+
subscription = listen(session, tools_list_changed=True)
375+
sub = await subscription.__aenter__()
376+
subscription_ids.append(sub.subscription_id)
377+
with anyio.CancelScope() as scope:
378+
if cancelled:
379+
scope.cancel()
380+
await subscription.__aexit__(None, None, None)
381+
assert anyio.current_time() == started # MockClock time, never wall-clock time.
382+
with pytest.raises(StopAsyncIteration):
383+
await anext(sub)
384+
await anyio.wait_all_tasks_blocked()
385+
assert attempted == subscription_ids
386+
assert delivered == []
387+
finally:
388+
release_writes.set()
389+
await anyio.wait_all_tasks_blocked()
390+
assert set(delivered) == set(subscription_ids)
391+
tg.cancel_scope.cancel()
392+
393+
229394
async def test_concurrent_subscriptions_demux_independently():
230395
"""Two open subscriptions each receive only their own filter's events."""
231396
bus = InMemorySubscriptionBus()
@@ -599,6 +764,8 @@ async def test_client_listen_installs_the_cache_eviction_barrier_exactly_when_a_
599764
with anyio.fail_after(5):
600765
async with uncached_client.listen(tools_list_changed=True) as sub: # pragma: no branch
601766
assert sub._on_event is None # pyright: ignore[reportPrivateUsage]
767+
await bus.publish(ToolsListChanged())
768+
assert await anext(sub) == ToolsListChanged()
602769

603770

604771
async def test_the_cache_eviction_barrier_maps_events_and_contains_store_faults(

0 commit comments

Comments
 (0)