Skip to content

Commit 7b77f4a

Browse files
committed
Run the session-level timeout test on trio's virtual clock
The session-level read timeout also governs the initialize handshake sent by Client.__aenter__, so the test's 50ms value doubled as a real-time deadline for the in-process handshake. Under CI load the handshake tail exceeds 50ms (observed max ~190ms on a saturated windows runner), which failed the test before its body ran -- three times in the week since it landed, twice on windows and once on ubuntu, always on the 3.12/locked matrix cell. Instead of widening the margin and paying for it in real wait time, run this one test on trio's MockClock with autojump: virtual time advances only when every task is blocked, so the handshake can never time out no matter how slow the runner, and the blocked tool call hits its deadline the moment the run goes idle. The test keeps its original timeout value and snapshot, is immune to scheduler stalls by construction, and the file now runs in milliseconds.
1 parent bdc48e9 commit 7b77f4a

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,14 +1,16 @@
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

910
import anyio
1011
import pytest
1112
from inline_snapshot import snapshot
13+
from trio.testing import MockClock
1214

1315
from mcp import MCPError, types
1416
from mcp.client.client import Client
@@ -85,7 +87,19 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara
8587
assert result == snapshot(CallToolResult(content=[TextContent(text="still alive")]))
8688

8789

90+
# A session-level timeout cannot use the effectively-zero pattern above: it also governs the
91+
# initialize handshake, which must complete before the blocked tool call can wait the timeout
92+
# out in full. Any real-clock margin is a bet against CI scheduler stalls (a 50ms value lost
93+
# that bet in CI; the in-process handshake tail reaches ~190ms on a loaded windows runner), so
94+
# this test runs on trio's virtual clock instead. With autojump, time advances only when every
95+
# task is blocked: the handshake always has a runnable task and therefore cannot time out no
96+
# matter how slow the runner, and once the tool call blocks on the never-answered request the
97+
# run goes idle and the clock jumps straight to the deadline — deterministic, with no real wait.
8898
@requirement("protocol:timeout:session-default")
99+
@pytest.mark.parametrize(
100+
"anyio_backend",
101+
[pytest.param(("trio", {"clock": MockClock(autojump_threshold=0)}), id="trio-mockclock")],
102+
)
89103
async def test_session_level_timeout_applies_to_every_request() -> None:
90104
"""A read timeout configured on the client applies to requests that do not set their own."""
91105

@@ -96,12 +110,6 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara
96110

97111
server = Server("blocker", on_call_tool=call_tool)
98112

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

0 commit comments

Comments
 (0)