Deflake the session-level timeout test with trio's virtual clock (#2788)

This commit is contained in:
Max 2026-06-05 21:33:48 +01:00 • committed by GitHub
commit ac96f88abd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -1,14 +1,16 @@
"""Request timeouts against the low-level Server, driven through the public Client API. """Request timeouts against the low-level Server, driven through the public Client API.
The handler blocks on an event that is never set, so the awaited response can never arrive and The handler blocks on an event that is never set, so the awaited response can never arrive and
any positive timeout fires deterministically on the next event-loop pass. The timeout is therefore any positive timeout fires deterministically on the next event-loop pass. Per-request timeouts are
set to an effectively-zero duration: the tests add no wall-clock time to the suite. (Zero itself set to an effectively-zero duration; the session-level test runs on trio's virtual clock instead
(see the comment there). Either way the tests add no wall-clock time to the suite. (Zero itself
cannot be used: a falsy read_timeout_seconds is silently treated as "no timeout".) cannot be used: a falsy read_timeout_seconds is silently treated as "no timeout".)
""" """
import anyio import anyio
import pytest import pytest
from inline_snapshot import snapshot from inline_snapshot import snapshot
from trio.testing import MockClock
from mcp import MCPError, types from mcp import MCPError, types
from mcp.client.client import Client from mcp.client.client import Client
@ -85,7 +87,19 @@ async def test_session_serves_requests_after_timeout() -> None:
assert result == snapshot(CallToolResult(content=[TextContent(text="still alive")])) assert result == snapshot(CallToolResult(content=[TextContent(text="still alive")]))
# A session-level timeout cannot use the effectively-zero pattern above: it also governs the
# initialize handshake, which must complete before the blocked tool call can wait the timeout
# out in full. Any real-clock margin is a bet against CI scheduler stalls (a 50ms value lost
# that bet in CI; the in-process handshake tail reaches ~190ms on a loaded windows runner), so
# this test runs on trio's virtual clock instead. With autojump, time advances only when every
# task is blocked: the handshake always has a runnable task and therefore cannot time out no
# matter how slow the runner, and once the tool call blocks on the never-answered request the
# run goes idle and the clock jumps straight to the deadline — deterministic, with no real wait.
@requirement("protocol:timeout:session-default") @requirement("protocol:timeout:session-default")
@pytest.mark.parametrize(
"anyio_backend",
[pytest.param(("trio", {"clock": MockClock(autojump_threshold=0)}), id="trio-mockclock")],
)
async def test_session_level_timeout_applies_to_every_request() -> None: async def test_session_level_timeout_applies_to_every_request() -> None:
"""A read timeout configured on the client applies to requests that do not set their own.""" """A read timeout configured on the client applies to requests that do not set their own."""
@ -96,12 +110,6 @@ async def test_session_level_timeout_applies_to_every_request() -> None:
server = Server("blocker", on_call_tool=call_tool) server = Server("blocker", on_call_tool=call_tool)
# The one real wall-clock wait in the suite, and it cannot be made effectively zero like the
# per-request timeouts: a session-level timeout also governs the initialize handshake, so the
# value must be long enough for the in-process handshake to complete before the blocked tool
# call waits it out in full. 50ms buys a ~50x safety margin over the handshake's actual
# latency; lowering it only erodes the margin against CI scheduler jitter without saving
# anything perceptible.
async with Client(server, read_timeout_seconds=0.05) as client: async with Client(server, read_timeout_seconds=0.05) as client:
with pytest.raises(MCPError) as exc_info: with pytest.raises(MCPError) as exc_info:
await client.call_tool("block", {}) await client.call_tool("block", {})