Skip to content

Commit b880950

Browse files
committed
Deflake the session-level timeout test with trio's virtual clock (#2788)
1 parent 03fdaed commit b880950

1 file changed

Lines changed: 16 additions & 8 deletions

File tree

tests/interaction/lowlevel/test_timeouts.py

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
"""Request timeouts against the low-level Server, driven through the public client API.
22
33
The handler blocks on an event that is never set, so the awaited response can never arrive and
4-
any positive timeout fires deterministically on the next event-loop pass. The timeout is therefore
5-
set to an effectively-zero duration: the tests add no wall-clock time to the suite. (Zero itself
4+
any positive timeout fires deterministically on the next event-loop pass. Per-request timeouts are
5+
set to an effectively-zero duration; the session-level test runs on trio's virtual clock instead
6+
(see the comment there). Either way the tests add no wall-clock time to the suite. (Zero itself
67
cannot be used: a falsy read_timeout_seconds is silently treated as "no timeout".)
78
"""
89

@@ -12,6 +13,7 @@
1213
import anyio
1314
import pytest
1415
from inline_snapshot import snapshot
16+
from trio.testing import MockClock
1517

1618
from mcp import McpError, types
1719
from mcp.server.lowlevel import Server
@@ -82,7 +84,19 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.ContentB
8284
assert result == snapshot(CallToolResult(content=[TextContent(type="text", text="still alive")]))
8385

8486

87+
# A session-level timeout cannot use the effectively-zero pattern above: it also governs the
88+
# initialize handshake, which must complete before the blocked tool call can wait the timeout
89+
# out in full. Any real-clock margin is a bet against CI scheduler stalls (a 50ms value lost
90+
# that bet in CI; the in-process handshake tail reaches ~190ms on a loaded windows runner), so
91+
# this test runs on trio's virtual clock instead. With autojump, time advances only when every
92+
# task is blocked: the handshake always has a runnable task and therefore cannot time out no
93+
# matter how slow the runner, and once the tool call blocks on the never-answered request the
94+
# run goes idle and the clock jumps straight to the deadline — deterministic, with no real wait.
8595
@requirement("protocol:timeout:session-default")
96+
@pytest.mark.parametrize(
97+
"anyio_backend",
98+
[pytest.param(("trio", {"clock": MockClock(autojump_threshold=0)}), id="trio-mockclock")],
99+
)
86100
async def test_session_level_timeout_applies_to_every_request(connect: Connect) -> None:
87101
"""A read timeout configured on the client applies to requests that do not set their own."""
88102
server: Server[Any] = Server("blocker")
@@ -93,12 +107,6 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.ContentB
93107
await anyio.Event().wait() # blocks until the session is torn down
94108
raise NotImplementedError # unreachable
95109

96-
# The one real wall-clock wait in the suite, and it cannot be made effectively zero like the
97-
# per-request timeouts: a session-level timeout also governs the initialize handshake, so the
98-
# value must be long enough for the in-process handshake to complete before the blocked tool
99-
# call waits it out in full. 50ms buys a ~50x safety margin over the handshake's actual
100-
# latency; lowering it only erodes the margin against CI scheduler jitter without saving
101-
# anything perceptible.
102110
async with connect(server, read_timeout_seconds=timedelta(seconds=0.05)) as client:
103111
with pytest.raises(McpError) as exc_info:
104112
await client.call_tool("block", {})

0 commit comments

Comments
 (0)