49b9bb6724
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
579 lines
25 KiB
Python
579 lines
25 KiB
Python
"""Behavioral tests for the Dispatcher Protocol.
|
|
|
|
The contract tests are parametrized over every `Dispatcher` implementation via
|
|
the `pair_factory` fixture (see `conftest.py`); they must pass for both
|
|
`DirectDispatcher` and `JSONRPCDispatcher`. Implementation-specific tests pass
|
|
a concrete factory directly.
|
|
"""
|
|
|
|
from collections.abc import AsyncIterator, Mapping
|
|
from contextlib import asynccontextmanager
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
import anyio
|
|
import pytest
|
|
from mcp_types import (
|
|
CONNECTION_CLOSED,
|
|
INTERNAL_ERROR,
|
|
INVALID_PARAMS,
|
|
INVALID_REQUEST,
|
|
REQUEST_TIMEOUT,
|
|
ErrorData,
|
|
RequestId,
|
|
Tool,
|
|
)
|
|
|
|
from mcp.shared._compat import resync_tracer
|
|
from mcp.shared.direct_dispatcher import DirectDispatcher, create_direct_dispatcher_pair
|
|
from mcp.shared.dispatcher import DispatchContext, Dispatcher, OnNotify, OnNotifyIntercept, OnRequest, Outbound
|
|
from mcp.shared.exceptions import MCPError
|
|
from mcp.shared.transport_context import TransportContext
|
|
|
|
from .conftest import PairFactory, direct_pair
|
|
|
|
|
|
class Recorder:
|
|
def __init__(self) -> None:
|
|
self.requests: list[tuple[str, Mapping[str, Any] | None]] = []
|
|
self.notifications: list[tuple[str, Mapping[str, Any] | None]] = []
|
|
self.contexts: list[DispatchContext[TransportContext]] = []
|
|
self.notified = anyio.Event()
|
|
|
|
|
|
def echo_handlers(recorder: Recorder) -> tuple[OnRequest, OnNotify]:
|
|
async def on_request(
|
|
ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
|
|
) -> dict[str, Any]:
|
|
# Strip `_meta` so JSON-RPC and direct dispatch record identically:
|
|
# the JSON-RPC outbound path always attaches `_meta` (otel injection).
|
|
recorded = {k: v for k, v in (params or {}).items() if k != "_meta"} if params is not None else None
|
|
recorder.requests.append((method, recorded))
|
|
recorder.contexts.append(ctx)
|
|
return {"echoed": method, "params": recorded or {}}
|
|
|
|
async def on_notify(ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None) -> None:
|
|
recorder.notifications.append((method, params))
|
|
recorder.notified.set()
|
|
|
|
return on_request, on_notify
|
|
|
|
|
|
@asynccontextmanager
|
|
async def running_pair(
|
|
factory: PairFactory,
|
|
*,
|
|
server_on_request: OnRequest | None = None,
|
|
server_on_notify: OnNotify | None = None,
|
|
client_on_request: OnRequest | None = None,
|
|
client_on_notify: OnNotify | None = None,
|
|
client_on_notify_intercept: OnNotifyIntercept | None = None,
|
|
can_send_request: bool = True,
|
|
) -> AsyncIterator[tuple[Dispatcher[TransportContext], Dispatcher[TransportContext], Recorder, Recorder]]:
|
|
"""Yield `(client, server, client_recorder, server_recorder)` with both `run()` loops live."""
|
|
client, server, close = factory(can_send_request=can_send_request)
|
|
client_rec, server_rec = Recorder(), Recorder()
|
|
c_req, c_notify = echo_handlers(client_rec)
|
|
s_req, s_notify = echo_handlers(server_rec)
|
|
try:
|
|
async with anyio.create_task_group() as tg:
|
|
await tg.start(
|
|
client.run, client_on_request or c_req, client_on_notify or c_notify, client_on_notify_intercept
|
|
)
|
|
await tg.start(server.run, server_on_request or s_req, server_on_notify or s_notify)
|
|
try:
|
|
yield client, server, client_rec, server_rec
|
|
finally:
|
|
tg.cancel_scope.cancel()
|
|
finally:
|
|
await resync_tracer()
|
|
close()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_send_raw_request_returns_result_from_peer_on_request(pair_factory: PairFactory):
|
|
async with running_pair(pair_factory) as (client, _server, _crec, srec):
|
|
with anyio.fail_after(5):
|
|
result = await client.send_raw_request("tools/list", {"cursor": "abc"})
|
|
assert result == {"echoed": "tools/list", "params": {"cursor": "abc"}}
|
|
assert srec.requests == [("tools/list", {"cursor": "abc"})]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_send_raw_request_reraises_mcperror_from_handler_unchanged(pair_factory: PairFactory):
|
|
async def on_request(
|
|
ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
|
|
) -> dict[str, Any]:
|
|
raise MCPError(code=INVALID_PARAMS, message="bad cursor")
|
|
|
|
async with running_pair(pair_factory, server_on_request=on_request) as (client, *_):
|
|
with anyio.fail_after(5), pytest.raises(MCPError) as exc:
|
|
await client.send_raw_request("tools/list", {})
|
|
assert exc.value.error.code == INVALID_PARAMS
|
|
assert exc.value.error.message == "bad cursor"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_send_raw_request_maps_validation_error_to_invalid_params(pair_factory: PairFactory):
|
|
"""A pydantic `ValidationError` from the handler surfaces as the
|
|
normalized INVALID_PARAMS shape on every dispatcher."""
|
|
|
|
async def on_request(
|
|
ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
|
|
) -> dict[str, Any]:
|
|
Tool.model_validate({"name": 123}) # raises ValidationError
|
|
raise NotImplementedError
|
|
|
|
async with running_pair(pair_factory, server_on_request=on_request) as (client, *_):
|
|
with anyio.fail_after(5), pytest.raises(MCPError) as exc:
|
|
await client.send_raw_request("tools/list", None)
|
|
assert exc.value.error == ErrorData(code=INVALID_PARAMS, message="Invalid request parameters", data="")
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_send_raw_request_with_timeout_raises_mcperror_request_timeout(pair_factory: PairFactory):
|
|
async def on_request(
|
|
ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
|
|
) -> dict[str, Any]:
|
|
await anyio.sleep_forever()
|
|
raise NotImplementedError
|
|
|
|
async with running_pair(pair_factory, server_on_request=on_request) as (client, *_):
|
|
with anyio.fail_after(5), pytest.raises(MCPError) as exc:
|
|
await client.send_raw_request("slow", None, {"timeout": 0})
|
|
assert exc.value.error.code == REQUEST_TIMEOUT
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_notify_invokes_peer_on_notify(pair_factory: PairFactory):
|
|
async with running_pair(pair_factory) as (client, _server, _crec, srec):
|
|
with anyio.fail_after(5):
|
|
await client.notify("notifications/initialized", {"v": 1})
|
|
await srec.notified.wait()
|
|
assert srec.notifications == [("notifications/initialized", {"v": 1})]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_ctx_send_raw_request_round_trips_to_calling_side(pair_factory: PairFactory):
|
|
"""A handler's ctx.send_raw_request reaches the side that made the inbound request."""
|
|
|
|
async def server_on_request(
|
|
ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
|
|
) -> dict[str, Any]:
|
|
sample = await ctx.send_raw_request("sampling/createMessage", {"prompt": "hi"})
|
|
return {"sampled": sample}
|
|
|
|
async with running_pair(pair_factory, server_on_request=server_on_request) as (client, _server, crec, _srec):
|
|
with anyio.fail_after(5):
|
|
result = await client.send_raw_request("tools/call", None)
|
|
assert crec.requests == [("sampling/createMessage", {"prompt": "hi"})]
|
|
assert result == {"sampled": {"echoed": "sampling/createMessage", "params": {"prompt": "hi"}}}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_ctx_send_raw_request_raises_nobackchannelerror_when_transport_disallows(pair_factory: PairFactory):
|
|
async def server_on_request(
|
|
ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
|
|
) -> dict[str, Any]:
|
|
return await ctx.send_raw_request("sampling/createMessage", None)
|
|
|
|
async with running_pair(pair_factory, server_on_request=server_on_request, can_send_request=False) as (client, *_):
|
|
with anyio.fail_after(5), pytest.raises(MCPError) as exc:
|
|
await client.send_raw_request("tools/call", None)
|
|
assert exc.value.error.code == INVALID_REQUEST
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_ctx_notify_invokes_calling_side_on_notify(pair_factory: PairFactory):
|
|
async def server_on_request(
|
|
ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
|
|
) -> dict[str, Any]:
|
|
await ctx.notify("notifications/message", {"level": "info"})
|
|
return {}
|
|
|
|
async with running_pair(pair_factory, server_on_request=server_on_request) as (client, _server, crec, _srec):
|
|
with anyio.fail_after(5):
|
|
await client.send_raw_request("tools/call", None)
|
|
await crec.notified.wait()
|
|
assert crec.notifications == [("notifications/message", {"level": "info"})]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_ctx_progress_invokes_caller_on_progress_callback(pair_factory: PairFactory):
|
|
async def server_on_request(
|
|
ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
|
|
) -> dict[str, Any]:
|
|
await ctx.progress(0.5, total=1.0, message="halfway")
|
|
return {}
|
|
|
|
received: list[tuple[float, float | None, str | None]] = []
|
|
|
|
async def on_progress(progress: float, total: float | None, message: str | None) -> None:
|
|
received.append((progress, total, message))
|
|
|
|
async with running_pair(pair_factory, server_on_request=server_on_request) as (client, *_):
|
|
with anyio.fail_after(5):
|
|
await client.send_raw_request("tools/call", None, {"on_progress": on_progress})
|
|
assert received == [(0.5, 1.0, "halfway")]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_ctx_progress_is_noop_when_caller_supplied_no_callback(pair_factory: PairFactory):
|
|
async def server_on_request(
|
|
ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
|
|
) -> dict[str, Any]:
|
|
await ctx.progress(0.5)
|
|
return {"ok": True}
|
|
|
|
async with running_pair(pair_factory, server_on_request=server_on_request) as (client, *_):
|
|
with anyio.fail_after(5):
|
|
result = await client.send_raw_request("tools/call", None)
|
|
assert result == {"ok": True}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_ctx_message_metadata_is_none_when_transport_attaches_nothing(pair_factory: PairFactory):
|
|
"""Plain requests carry no transport metadata, so handlers see `None`."""
|
|
async with running_pair(pair_factory) as (client, _server, _crec, srec):
|
|
with anyio.fail_after(5):
|
|
await client.send_raw_request("tools/call", None)
|
|
assert len(srec.contexts) == 1
|
|
assert srec.contexts[0].message_metadata is None
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_ctx_request_id_exposes_inbound_id(pair_factory: PairFactory):
|
|
"""Every dispatcher assigns each inbound request a distinct int id; JSON-RPC carries
|
|
the wire id through, DirectDispatcher synthesizes one (SDK-defined)."""
|
|
async with running_pair(pair_factory) as (client, _server, _crec, srec):
|
|
with anyio.fail_after(5):
|
|
await client.send_raw_request("tools/call", None)
|
|
await client.send_raw_request("tools/call", None)
|
|
a, b = (ctx.request_id for ctx in srec.contexts)
|
|
assert isinstance(a, int) and isinstance(b, int)
|
|
assert a != b
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_direct_send_raw_request_wraps_non_mcperror_exception_as_internal_error_with_cause():
|
|
"""DirectDispatcher-specific: the original exception is chained via __cause__."""
|
|
|
|
async def on_request(
|
|
ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
|
|
) -> dict[str, Any]:
|
|
raise ValueError("oops")
|
|
|
|
async with running_pair(direct_pair, server_on_request=on_request) as (client, *_):
|
|
with anyio.fail_after(5), pytest.raises(MCPError) as exc:
|
|
await client.send_raw_request("tools/list", {})
|
|
assert exc.value.error.code == INTERNAL_ERROR
|
|
assert isinstance(exc.value.__cause__, ValueError)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_direct_send_raw_request_issued_before_peer_run_blocks_until_peer_ready():
|
|
client, server = create_direct_dispatcher_pair()
|
|
s_req, s_notify = echo_handlers(Recorder())
|
|
c_req, c_notify = echo_handlers(Recorder())
|
|
|
|
async with anyio.create_task_group() as tg:
|
|
await tg.start(client.run, c_req, c_notify)
|
|
# start_soon: the server side only becomes ready once the request below has parked.
|
|
tg.start_soon(server.run, s_req, s_notify)
|
|
with anyio.fail_after(5):
|
|
result = await client.send_raw_request("ping", None)
|
|
assert result == {"echoed": "ping", "params": {}}
|
|
client.close()
|
|
server.close()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_direct_send_raw_request_before_run_raises_runtimeerror():
|
|
"""The not-running guard fires immediately - before any waiting on the peer - matching JSONRPCDispatcher."""
|
|
client, _server = create_direct_dispatcher_pair()
|
|
with anyio.fail_after(5), pytest.raises(RuntimeError) as exc:
|
|
await client.send_raw_request("ping", None)
|
|
assert str(exc.value) == "DirectDispatcher.send_raw_request called before run()"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_direct_send_raw_request_to_never_run_peer_honors_timeout():
|
|
"""A configured timeout bounds the wait for a peer whose run() has not started."""
|
|
client, _server = create_direct_dispatcher_pair()
|
|
c_req, c_notify = echo_handlers(Recorder())
|
|
async with anyio.create_task_group() as tg:
|
|
await tg.start(client.run, c_req, c_notify)
|
|
with anyio.fail_after(5), pytest.raises(MCPError) as exc:
|
|
await client.send_raw_request("ping", None, {"timeout": 0})
|
|
assert exc.value.error.code == REQUEST_TIMEOUT
|
|
client.close()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_direct_request_parked_waiting_for_peer_run_is_woken_by_peer_close():
|
|
"""A request waiting on a never-run peer fails with CONNECTION_CLOSED when that peer closes."""
|
|
client, server = create_direct_dispatcher_pair()
|
|
c_req, c_notify = echo_handlers(Recorder())
|
|
with anyio.fail_after(5):
|
|
async with anyio.create_task_group() as tg:
|
|
await tg.start(client.run, c_req, c_notify)
|
|
|
|
async def send() -> None:
|
|
with pytest.raises(MCPError) as exc:
|
|
await client.send_raw_request("ping", None)
|
|
assert exc.value.error.code == CONNECTION_CLOSED
|
|
client.close()
|
|
|
|
tg.start_soon(send)
|
|
await anyio.wait_all_tasks_blocked()
|
|
server.close()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_direct_send_raw_request_after_local_close_raises_and_notify_is_dropped():
|
|
"""After this side has closed, send_raw_request raises CONNECTION_CLOSED and notify
|
|
drops fire-and-forget, matching JSONRPCDispatcher (SDK-defined)."""
|
|
async with running_pair(direct_pair) as (client, _server, _crec, srec):
|
|
pass # exiting cancels both run() loops, closing both sides
|
|
with pytest.raises(MCPError) as exc:
|
|
await client.send_raw_request("ping", None)
|
|
assert exc.value.error.code == CONNECTION_CLOSED
|
|
await client.notify("notifications/roots/list_changed", None)
|
|
assert srec.requests == []
|
|
assert srec.notifications == []
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_direct_inbound_after_peer_close_refuses_requests_and_drops_notifications():
|
|
"""Dispatch to a closed side fails the peer's request with CONNECTION_CLOSED and silently
|
|
drops the peer's notify; the closed side's handlers are never invoked (SDK-defined)."""
|
|
client, server = create_direct_dispatcher_pair()
|
|
crec, srec = Recorder(), Recorder()
|
|
c_req, c_notify = echo_handlers(crec)
|
|
s_req, s_notify = echo_handlers(srec)
|
|
with anyio.fail_after(5):
|
|
async with anyio.create_task_group() as tg:
|
|
await tg.start(client.run, c_req, c_notify)
|
|
await tg.start(server.run, s_req, s_notify)
|
|
client.close()
|
|
with pytest.raises(MCPError) as exc:
|
|
await server.send_raw_request("roots/list", None)
|
|
assert exc.value.error.code == CONNECTION_CLOSED
|
|
await server.notify("notifications/message", None)
|
|
server.close()
|
|
assert crec.requests == []
|
|
assert crec.notifications == []
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_direct_inbound_to_closed_never_run_peer_fails_with_connection_closed():
|
|
"""A peer that closed without ever running refuses dispatch instead of parking the caller."""
|
|
client, server = create_direct_dispatcher_pair()
|
|
c_req, c_notify = echo_handlers(Recorder())
|
|
server.close()
|
|
with anyio.fail_after(5):
|
|
async with anyio.create_task_group() as tg:
|
|
await tg.start(client.run, c_req, c_notify)
|
|
with pytest.raises(MCPError) as exc:
|
|
await client.send_raw_request("ping", None)
|
|
assert exc.value.error.code == CONNECTION_CLOSED
|
|
client.close()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_direct_send_raw_request_and_notify_raise_runtimeerror_when_no_peer_connected():
|
|
d = DirectDispatcher(TransportContext(kind="direct", can_send_request=True))
|
|
with pytest.raises(RuntimeError, match="no peer"):
|
|
await d.send_raw_request("ping", None)
|
|
with pytest.raises(RuntimeError, match="no peer"):
|
|
await d.notify("ping", None)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_direct_close_makes_run_return():
|
|
client, server = create_direct_dispatcher_pair()
|
|
on_request, on_notify = echo_handlers(Recorder())
|
|
with anyio.fail_after(5):
|
|
async with anyio.create_task_group() as tg:
|
|
tg.start_soon(server.run, on_request, on_notify)
|
|
tg.start_soon(client.run, on_request, on_notify)
|
|
client.close()
|
|
server.close()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_send_raw_request_honors_caller_supplied_request_id_verbatim_typed(pair_factory: PairFactory):
|
|
"""A caller-supplied `CallOptions["request_id"]` reaches the peer's context verbatim —
|
|
"7" stays a string, never the integer 7 — and the next call without one still mints
|
|
a dispatcher id as before."""
|
|
async with running_pair(pair_factory) as (client, _server, _crec, srec):
|
|
with anyio.fail_after(5):
|
|
await client.send_raw_request("first", None, {"request_id": "7"})
|
|
await client.send_raw_request("second", None)
|
|
supplied, minted = (ctx.request_id for ctx in srec.contexts)
|
|
assert supplied == "7"
|
|
assert type(supplied) is str
|
|
assert type(minted) is int
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_send_raw_request_with_in_flight_request_id_raises_and_frees_id_on_completion(
|
|
pair_factory: PairFactory,
|
|
):
|
|
"""Reusing an id while it is in flight is a loud `ValueError` — silent reuse would
|
|
corrupt response correlation. Once the first request completes, the id is free
|
|
again: the reservation is in-flight-scoped, not permanent."""
|
|
entered = anyio.Event()
|
|
release = anyio.Event()
|
|
|
|
async def parked(
|
|
ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
|
|
) -> dict[str, Any]:
|
|
entered.set()
|
|
await release.wait()
|
|
return {"served": method}
|
|
|
|
async with running_pair(pair_factory, server_on_request=parked) as (client, *_):
|
|
with anyio.fail_after(5):
|
|
async with anyio.create_task_group() as tg:
|
|
|
|
async def first() -> None:
|
|
await client.send_raw_request("slow", None, {"request_id": "listen-1"})
|
|
|
|
tg.start_soon(first)
|
|
await entered.wait()
|
|
with pytest.raises(ValueError, match="already in flight"):
|
|
await client.send_raw_request("duplicate", None, {"request_id": "listen-1"})
|
|
release.set()
|
|
result = await client.send_raw_request("again", None, {"request_id": "listen-1"})
|
|
assert result == {"served": "again"}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_minted_ids_skip_a_caller_supplied_id_still_in_flight(pair_factory: PairFactory):
|
|
"""The dispatcher mints PAST a key a supplied id occupies — the collision error
|
|
is reserved for the caller who chose the id, never an innocent minted request."""
|
|
entered = anyio.Event()
|
|
release = anyio.Event()
|
|
seen_ids: list[RequestId | None] = []
|
|
|
|
async def maybe_park(
|
|
ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
|
|
) -> dict[str, Any]:
|
|
seen_ids.append(ctx.request_id)
|
|
if method == "park":
|
|
entered.set()
|
|
await release.wait()
|
|
return {}
|
|
|
|
async with running_pair(pair_factory, server_on_request=maybe_park) as (client, *_):
|
|
with anyio.fail_after(5):
|
|
async with anyio.create_task_group() as tg:
|
|
|
|
async def parked() -> None:
|
|
await client.send_raw_request("park", None, {"request_id": "3"})
|
|
|
|
tg.start_soon(parked)
|
|
await entered.wait()
|
|
# The counter mints 1 and 2, then skips the occupied 3 to 4.
|
|
for _ in range(3):
|
|
await client.send_raw_request("plain", None)
|
|
release.set()
|
|
assert [request_id for request_id in seen_ids if request_id != "3"] == [1, 2, 4]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_supplied_numeric_string_id_collides_with_its_int_twin(pair_factory: PairFactory):
|
|
""" "7" and 7 are one id in the collision domain on BOTH dispatchers, so the
|
|
in-memory pair raises exactly where the wire dispatcher (whose pending keys
|
|
are coerced for response correlation) would."""
|
|
entered = anyio.Event()
|
|
release = anyio.Event()
|
|
|
|
async def parked(
|
|
ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
|
|
) -> dict[str, Any]:
|
|
entered.set()
|
|
await release.wait()
|
|
return {}
|
|
|
|
async with running_pair(pair_factory, server_on_request=parked) as (client, *_):
|
|
with anyio.fail_after(5):
|
|
async with anyio.create_task_group() as tg:
|
|
|
|
async def first() -> None:
|
|
await client.send_raw_request("slow", None, {"request_id": 7})
|
|
|
|
tg.start_soon(first)
|
|
await entered.wait()
|
|
with pytest.raises(ValueError, match="already in flight"):
|
|
await client.send_raw_request("duplicate", None, {"request_id": "7"})
|
|
release.set()
|
|
# Completion frees the id for either spelling.
|
|
assert await client.send_raw_request("again", None, {"request_id": "7"}) == {}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_notify_intercept_sees_every_notification_and_consumes_on_true(pair_factory: PairFactory):
|
|
"""The intercept sees every inbound notification; a frame it consumes never reaches `on_notify`, the rest do."""
|
|
intercepted: list[str] = []
|
|
|
|
def intercept(method: str, params: Mapping[str, Any] | None) -> bool:
|
|
intercepted.append(method)
|
|
return method == "notifications/consumed"
|
|
|
|
async with running_pair(pair_factory, client_on_notify_intercept=intercept) as (_client, server, crec, _srec):
|
|
with anyio.fail_after(5):
|
|
await server.notify("notifications/consumed", None)
|
|
await server.notify("notifications/passed", None)
|
|
await crec.notified.wait()
|
|
assert intercepted == ["notifications/consumed", "notifications/passed"]
|
|
assert [method for method, _ in crec.notifications] == ["notifications/passed"]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_notify_intercept_completes_before_a_later_response_resolves(pair_factory: PairFactory):
|
|
"""Notifications written before a response are intercepted before it resolves, whatever spawned handlers do."""
|
|
seen: list[str] = []
|
|
|
|
def intercept(method: str, params: Mapping[str, Any] | None) -> bool:
|
|
seen.append(method)
|
|
return False
|
|
|
|
async def notify_then_answer(
|
|
ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
|
|
) -> dict[str, Any]:
|
|
await ctx.notify("notifications/first", None)
|
|
await ctx.notify("notifications/second", None)
|
|
return {}
|
|
|
|
async with running_pair(
|
|
pair_factory, server_on_request=notify_then_answer, client_on_notify_intercept=intercept
|
|
) as (client, *_):
|
|
with anyio.fail_after(5):
|
|
await client.send_raw_request("burst", None)
|
|
assert seen == ["notifications/first", "notifications/second"]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_a_raising_notify_intercept_is_contained_and_passes_the_frame_through(pair_factory: PairFactory):
|
|
"""An intercept exception costs only that interception: the frame still reaches `on_notify`, the loop survives."""
|
|
|
|
def broken_intercept(method: str, params: Mapping[str, Any] | None) -> bool:
|
|
raise RuntimeError("intercept exploded")
|
|
|
|
async with running_pair(pair_factory, client_on_notify_intercept=broken_intercept) as (
|
|
_client,
|
|
server,
|
|
crec,
|
|
_srec,
|
|
):
|
|
with anyio.fail_after(5):
|
|
await server.notify("notifications/survives", None)
|
|
await crec.notified.wait()
|
|
assert [method for method, _ in crec.notifications] == ["notifications/survives"]
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
_d: Dispatcher[TransportContext] = DirectDispatcher(TransportContext(kind="direct", can_send_request=True))
|
|
_o: Outbound = _d
|