Skip to content

Commit 77c35e7

Browse files
committed
Convert the remaining listen-over-loop review probes into regression tests
Falsy request id 0 through the full listen lifecycle (ack, era lock, and graceful-close result all stamped with the literal 0), double-cancel and stale-cancel idempotence with the max_subscriptions slot recycled, event fan-out surviving the cancellation of a sibling stream, and a plain async-function listen handler whose schema-invalid result fails loudly without locking the era while a valid one serves and locks.
1 parent 927b48e commit 77c35e7

1 file changed

Lines changed: 135 additions & 0 deletions

File tree

tests/server/test_runner.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@
4242
RequestId,
4343
RequestParams,
4444
SetLevelRequestParams,
45+
SubscriptionsListenRequestParams,
46+
SubscriptionsListenResult,
4547
Tool,
4648
UnsupportedProtocolVersionErrorData,
4749
)
@@ -2141,6 +2143,139 @@ async def list_tools(ctx: Ctx, params: PaginatedRequestParams | None) -> ListToo
21412143
assert result.id == 10
21422144

21432145

2146+
@pytest.mark.anyio
2147+
async def test_dual_era_loop_listen_with_request_id_zero_stamps_and_resolves_the_falsy_id():
2148+
"""Request id 0 is falsy but valid: the ack and the graceful-close result
2149+
are stamped with the literal 0, the pending request keeps its id through
2150+
the whole lifecycle, and the ack still locks the era."""
2151+
bus = InMemorySubscriptionBus()
2152+
handler = ListenHandler(bus)
2153+
srv: SrvT = Server(name="listen-server", version="0.0.1", on_subscriptions_listen=handler)
2154+
on_notify, notes_send, notes = _note_collector()
2155+
result_box: list[dict[str, Any]] = []
2156+
try:
2157+
async with dual_era_client(srv, client_on_notify=on_notify) as (client, _):
2158+
async with anyio.create_task_group() as tg:
2159+
2160+
async def open_listen() -> None:
2161+
result_box.append(
2162+
await client.send_raw_request(
2163+
"subscriptions/listen", _modern_listen_params(), {"request_id": 0}
2164+
)
2165+
)
2166+
2167+
tg.start_soon(open_listen)
2168+
method, params = await notes.receive()
2169+
assert method == ACK_METHOD
2170+
assert params["_meta"][SUBSCRIPTION_ID_META_KEY] == 0
2171+
with pytest.raises(MCPError) as init_exc:
2172+
await client.send_raw_request("initialize", _initialize_params())
2173+
assert init_exc.value.error.code == UNSUPPORTED_PROTOCOL_VERSION
2174+
handler.close()
2175+
finally:
2176+
notes_send.close()
2177+
notes.close()
2178+
assert result_box[0]["_meta"][SUBSCRIPTION_ID_META_KEY] == 0
2179+
2180+
2181+
@pytest.mark.anyio
2182+
async def test_dual_era_loop_double_cancel_and_stale_cancel_are_silent_and_recycle_the_slot():
2183+
"""Cancel idempotence over the loop: a double cancel of a live listen is
2184+
total silence and frees its max_subscriptions=1 slot; a cancel for an
2185+
already-resolved listen id is ignored and the connection keeps serving."""
2186+
bus = InMemorySubscriptionBus()
2187+
handler = ListenHandler(bus, max_subscriptions=1)
2188+
srv: SrvT = Server(name="listen-server", version="0.0.1", on_subscriptions_listen=handler)
2189+
async with raw_dual_era_loop(srv) as (c2s, s2c):
2190+
c2s.send_nowait(_raw_req("d1", "subscriptions/listen", _modern_listen_params()))
2191+
await _expect_ack(s2c, "d1")
2192+
c2s.send_nowait(_raw_note("notifications/cancelled", {"requestId": "d1"}))
2193+
c2s.send_nowait(_raw_note("notifications/cancelled", {"requestId": "d1"}))
2194+
await anyio.wait_all_tasks_blocked()
2195+
with pytest.raises(anyio.WouldBlock):
2196+
s2c.receive_nowait()
2197+
# The slot is free again: with max_subscriptions=1 a second listen
2198+
# could only ack if the cancelled stream fully cleaned up.
2199+
c2s.send_nowait(_raw_req("d2", "subscriptions/listen", _modern_listen_params()))
2200+
await _expect_ack(s2c, "d2")
2201+
handler.close()
2202+
result = (await s2c.receive()).message
2203+
assert isinstance(result, JSONRPCResponse)
2204+
assert result.id == "d2"
2205+
# A stale cancel for the resolved id is ignored: no frame for d2, and
2206+
# a third listen still serves.
2207+
c2s.send_nowait(_raw_note("notifications/cancelled", {"requestId": "d2"}))
2208+
c2s.send_nowait(_raw_req("d3", "subscriptions/listen", _modern_listen_params()))
2209+
await _expect_ack(s2c, "d3")
2210+
await anyio.wait_all_tasks_blocked()
2211+
with pytest.raises(anyio.WouldBlock):
2212+
s2c.receive_nowait()
2213+
2214+
2215+
@pytest.mark.anyio
2216+
async def test_dual_era_loop_cancelling_one_of_two_listens_keeps_the_other_flowing():
2217+
"""Two live listen streams on one connection: cancelling one is silent for
2218+
its id and events keep fanning out to the survivor, stamped with the
2219+
survivor's subscription id only."""
2220+
bus = InMemorySubscriptionBus()
2221+
handler = ListenHandler(bus)
2222+
srv: SrvT = Server(name="listen-server", version="0.0.1", on_subscriptions_listen=handler)
2223+
async with raw_dual_era_loop(srv) as (c2s, s2c):
2224+
c2s.send_nowait(_raw_req("a", "subscriptions/listen", _modern_listen_params()))
2225+
c2s.send_nowait(_raw_req("b", "subscriptions/listen", _modern_listen_params()))
2226+
for expected in ("a", "b"):
2227+
await _expect_ack(s2c, expected)
2228+
c2s.send_nowait(_raw_note("notifications/cancelled", {"requestId": "a"}))
2229+
await anyio.wait_all_tasks_blocked()
2230+
await bus.publish(ToolsListChanged())
2231+
event = (await s2c.receive()).message
2232+
assert isinstance(event, JSONRPCNotification)
2233+
assert event.method == "notifications/tools/list_changed"
2234+
assert event.params is not None
2235+
assert event.params["_meta"][SUBSCRIPTION_ID_META_KEY] == "b"
2236+
# One event, one survivor: nothing was written for the cancelled "a".
2237+
await anyio.wait_all_tasks_blocked()
2238+
with pytest.raises(anyio.WouldBlock):
2239+
s2c.receive_nowait()
2240+
handler.close()
2241+
result = (await s2c.receive()).message
2242+
assert isinstance(result, JSONRPCResponse)
2243+
assert result.id == "b"
2244+
2245+
2246+
@pytest.mark.anyio
2247+
async def test_dual_era_loop_custom_listen_handler_serves_and_only_a_valid_result_locks():
2248+
"""A plain async function as `on_subscriptions_listen` (not
2249+
`ListenHandler`) serves over the loop like any request. A result missing
2250+
the required stamped `_meta` fails outbound validation with
2251+
INTERNAL_ERROR and - like every failed request - never locks the era; a
2252+
schema-valid result serves and locks."""
2253+
2254+
async def bad(ctx: Ctx, params: SubscriptionsListenRequestParams) -> SubscriptionsListenResult:
2255+
return SubscriptionsListenResult() # no _meta: schema-invalid
2256+
2257+
srv_bad: SrvT = Server(name="listen-server", version="0.0.1", on_subscriptions_listen=bad)
2258+
async with dual_era_client(srv_bad) as (client, _):
2259+
with pytest.raises(MCPError) as exc_info:
2260+
await client.send_raw_request("subscriptions/listen", _modern_listen_params())
2261+
assert exc_info.value.error.code == INTERNAL_ERROR
2262+
assert exc_info.value.error.message == "Handler returned an invalid result"
2263+
# The failed listen never locked: the legacy handshake still works.
2264+
init = await client.send_raw_request("initialize", _initialize_params())
2265+
assert init["protocolVersion"] == LATEST_HANDSHAKE_VERSION
2266+
2267+
async def good(ctx: Ctx, params: SubscriptionsListenRequestParams) -> SubscriptionsListenResult:
2268+
return SubscriptionsListenResult(_meta={SUBSCRIPTION_ID_META_KEY: ctx.request_id})
2269+
2270+
srv_good: SrvT = Server(name="listen-server", version="0.0.1", on_subscriptions_listen=good)
2271+
async with dual_era_client(srv_good) as (client, _):
2272+
result = await client.send_raw_request("subscriptions/listen", _modern_listen_params())
2273+
assert result["resultType"] == "complete"
2274+
with pytest.raises(MCPError) as init_exc:
2275+
await client.send_raw_request("initialize", _initialize_params())
2276+
assert init_exc.value.error.code == UNSUPPORTED_PROTOCOL_VERSION
2277+
2278+
21442279
@pytest.mark.anyio
21452280
async def test_dual_era_loop_maps_unmapped_handler_exceptions_like_the_modern_http_entry():
21462281
"""An unmapped handler exception on a modern request surfaces as the

0 commit comments

Comments
 (0)