From fbf494395f8577ae05dadab7ba79b6ba25a2a790 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:18:18 +0000 Subject: [PATCH] Stop counting an interactive OAuth login against request timeouts A 401 makes OAuthClientProvider await the developer's redirect_handler and callback_handler inside the challenged HTTP request, so a request's timeout also counted the person in the browser. For the connect-time server/discover probe that meant a login longer than its 10 seconds read as a legacy server, and Client(mode="auto") settled on 2025-11-25 against a server that speaks 2026-07-28. Any request with a read_timeout_seconds shorter than the login failed outright. send_raw_request now runs its timeout on a per-request clock held in a ContextVar, and the provider stops that clock while it awaits the two handlers. When they return, the timeout resumes with the budget that was left. The provider's own network calls stay on the clock, the probe keeps its 10 seconds, and a server that never answers is still given up on as before. A request timeout no longer ends a login nobody finishes: that takes a limit inside callback_handler or cancelling the caller. Fixes #3601 --- src/mcp/client/auth/oauth2.py | 8 +- src/mcp/shared/_request_clock.py | 81 +++++ src/mcp/shared/jsonrpc_dispatcher.py | 16 +- tests/interaction/_requirements.py | 28 ++ tests/interaction/auth/_harness.py | 12 +- tests/interaction/auth/test_login_time.py | 341 ++++++++++++++++++++++ tests/shared/test_request_clock.py | 65 +++++ 7 files changed, 538 insertions(+), 13 deletions(-) create mode 100644 src/mcp/shared/_request_clock.py create mode 100644 tests/interaction/auth/test_login_time.py create mode 100644 tests/shared/test_request_clock.py diff --git a/src/mcp/client/auth/oauth2.py b/src/mcp/client/auth/oauth2.py index 8588208924..c513a99b46 100644 --- a/src/mcp/client/auth/oauth2.py +++ b/src/mcp/client/auth/oauth2.py @@ -43,6 +43,7 @@ validate_metadata_issuer, ) from mcp.shared._httpx_utils import RedirectAwareAuth, redirect_note +from mcp.shared._request_clock import waiting_on_a_person from mcp.shared.auth import ( AuthorizationCodeResult, OAuthClientInformationFull, @@ -425,10 +426,9 @@ async def _perform_authorization_code_grant(self) -> tuple[str, str]: auth_params["prompt"] = "consent" authorization_url = f"{auth_endpoint}?{urlencode(auth_params)}" - await self.context.redirect_handler(authorization_url) - - # Wait for callback - result = await self.context.callback_handler() + with waiting_on_a_person(): + await self.context.redirect_handler(authorization_url) + result = await self.context.callback_handler() if result.state is None or not secrets.compare_digest(result.state, state): raise OAuthFlowError(f"State parameter mismatch: {result.state} != {state}") diff --git a/src/mcp/shared/_request_clock.py b/src/mcp/shared/_request_clock.py new file mode 100644 index 0000000000..5b9ad91e55 --- /dev/null +++ b/src/mcp/shared/_request_clock.py @@ -0,0 +1,81 @@ +"""A request's timeout measures the peer, so its clock stops while the request waits on a person. + +The clock travels in a `ContextVar`: the HTTP transports run each outgoing message in its sender's +context, which is how code far below `send_raw_request` finds the clock of the request it serves. + +Nothing in this module is public API: it may change or be removed without notice. It is likely to +change with the client dispatcher work in https://github.com/modelcontextprotocol/python-sdk/pull/3517. +""" + +import math +from collections.abc import Iterator +from contextlib import contextmanager +from contextvars import ContextVar + +import anyio + + +class RequestClock: + """One request's timeout, as a budget of seconds that is spent only while the clock runs. + + Not public API: may change or be removed without notice. + """ + + def __init__(self, timeout: float | None, scope: anyio.CancelScope) -> None: + self._budget = math.inf if timeout is None else timeout + self._scope = scope + self._pauses = 1 # the write, which is off the clock too; `start()` ends it + + def start(self) -> None: + self.resume() + + def pause(self) -> None: + if not self._pauses: + self._budget = self._scope.deadline - anyio.current_time() + self._scope.deadline = math.inf + self._pauses += 1 + + def resume(self) -> None: + self._pauses -= 1 + if not self._pauses: + self._scope.deadline = anyio.current_time() + self._budget + + +_clock: ContextVar[RequestClock | None] = ContextVar("request_clock", default=None) + + +@contextmanager +def request_clock(timeout: float | None) -> Iterator[RequestClock]: + """Put a clock, not yet started, in the context of the request sent in this block. + + Raises `TimeoutError` once the clock has run for `timeout` seconds. + + Not public API: may change or be removed without notice. + """ + with anyio.CancelScope() as scope: + clock = RequestClock(timeout, scope) + token = _clock.set(clock) + try: + yield clock + finally: + _clock.reset(token) + # Not `fail_after`: it re-reads the deadline here, which a pause may have moved since it expired. + if scope.cancelled_caught: + raise TimeoutError + + +@contextmanager +def waiting_on_a_person() -> Iterator[None]: + """Stop the clock of the request this code is serving while the block is open; a no-op if there is none. + + Not public API: may change or be removed without notice. + """ + clock = _clock.get() + if clock is None: + yield + return + clock.pause() + try: + yield + finally: + clock.resume() diff --git a/src/mcp/shared/jsonrpc_dispatcher.py b/src/mcp/shared/jsonrpc_dispatcher.py index 1ba061b4e2..223373da0f 100644 --- a/src/mcp/shared/jsonrpc_dispatcher.py +++ b/src/mcp/shared/jsonrpc_dispatcher.py @@ -38,6 +38,7 @@ from mcp.shared._compat import resync_tracer from mcp.shared._otel import inject_trace_context, otel_span +from mcp.shared._request_clock import request_clock from mcp.shared._stream_protocols import ReadStream, WriteStream from mcp.shared.dispatcher import ( CallOptions, @@ -404,12 +405,13 @@ async def send_raw_request( # never started; past this point a cancelled write counts as issued. await anyio.lowlevel.checkpoint_if_cancelled() request_write_started = True - try: - await self._write(msg, plan.metadata) - except (anyio.BrokenResourceError, anyio.ClosedResourceError): - # Transport tore down before run() noticed EOF; surface the documented contract. - raise MCPError(code=CONNECTION_CLOSED, message="Connection closed") from None - with anyio.fail_after(opts.get("timeout")): + with request_clock(opts.get("timeout")) as clock: + try: + await self._write(msg, plan.metadata) + except (anyio.BrokenResourceError, anyio.ClosedResourceError): + # Transport tore down before run() noticed EOF; surface the documented contract. + raise MCPError(code=CONNECTION_CLOSED, message="Connection closed") from None + clock.start() timeout_armed = True outcome = await receive.receive() if isinstance(outcome, ErrorData) and outcome is not _CLOSED_OUTCOME: @@ -418,7 +420,7 @@ async def send_raw_request( span.set_status(StatusCode.ERROR, outcome.message) except TimeoutError: if not timeout_armed: - # `fail_after` arms only after the write, so this TimeoutError is the + # The clock starts only after the write, so this TimeoutError is the # transport's own bounded send() failing - a transport error, not # `opts["timeout"]` elapsing. Propagate it raw (v1 kept the write # outside the timeout-catching try and did the same). diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index 89101ab1f4..48e956e662 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -430,6 +430,15 @@ def __post_init__(self) -> None: ), added_in="2026-07-28", ), + "lifecycle:discover:fallback-silence": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/transports/stdio#backward-compatibility", + behavior=( + "When server/discover goes unanswered for the probe deadline, an auto-negotiating client falls " + "back to the legacy initialize handshake." + ), + added_in="2026-07-28", + note="The spec states the timeout rule for stdio only; the SDK applies it on every transport.", + ), "lifecycle:discover:network-error-raises": Requirement( source="sdk", behavior=( @@ -3826,6 +3835,25 @@ def __post_init__(self) -> None: transports=("streamable-http",), note="OAuth is HTTP-only.", ), + "client-auth:login-time:not-counted": Requirement( + source="issue:#3601", + behavior=( + "The time OAuthClientProvider spends awaiting redirect_handler and callback_handler does not count " + "against the timeout of the request that was challenged; afterwards the timeout resumes with the " + "budget that was left. The provider's own HTTP calls do count." + ), + transports=("streamable-http",), + note="OAuth is HTTP-only.", + ), + "client-auth:login-time:other-requests-keep-counting": Requirement( + source="sdk", + behavior=( + "A login suspends the timeout of the challenged request only: a request queued behind it in the " + "provider still times out on its own clock." + ), + transports=("streamable-http",), + note="OAuth is HTTP-only.", + ), "client-auth:pkce:refuse-if-unsupported": Requirement( source=f"{SPEC_BASE_URL}/basic/authorization#authorization-code-protection", behavior=( diff --git a/tests/interaction/auth/_harness.py b/tests/interaction/auth/_harness.py index 16b84b1de5..1dd0cc5da6 100644 --- a/tests/interaction/auth/_harness.py +++ b/tests/interaction/auth/_harness.py @@ -418,6 +418,9 @@ async def connect_with_oauth( verify_tokens: bool = True, app_shim: Callable[[ASGIApp], ASGIApp] | None = None, on_request: Callable[[httpx2.Request], None] | None = None, + mode: str = "legacy", + read_timeout_seconds: float | None = None, + json_response: bool = False, ) -> AsyncIterator[tuple[Client, HeadlessOAuth]]: """Connect a `Client` to a server's bearer-gated streamable-HTTP app, completing OAuth in process. @@ -455,6 +458,7 @@ async def connect_with_oauth( ) app: ASGIApp = server.streamable_http_app( + json_response=json_response, auth=settings, token_verifier=ProviderTokenVerifier(provider) if verify_tokens else None, auth_server_provider=provider, @@ -481,7 +485,11 @@ async def hook(request: httpx2.Request) -> None: ) headless.bind(http_client) client = await stack.enter_async_context( - # The auth flow tests snapshot the legacy initialize-handshake HTTP shape. - Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client), mode="legacy") + # The auth flow tests snapshot the legacy initialize-handshake HTTP shape, hence the default mode. + Client( + streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client), + mode=mode, + read_timeout_seconds=read_timeout_seconds, + ) ) yield client, headless diff --git a/tests/interaction/auth/test_login_time.py b/tests/interaction/auth/test_login_time.py new file mode 100644 index 0000000000..5fc35ea187 --- /dev/null +++ b/tests/interaction/auth/test_login_time.py @@ -0,0 +1,341 @@ +"""Request timeouts around an interactive OAuth login: they measure the server, not the person. + +A 401 makes `OAuthClientProvider` await the developer's `redirect_handler` and `callback_handler` +inside the challenged HTTP request, so the person's time in the browser passes while that +request's timeout is pending. + +Every test runs on trio's autojumping virtual clock: time advances only when every task is +blocked, and then straight to the next deadline. In-process work therefore costs zero seconds, an +hour-long login costs no real wait, and the recorded times are exact. The `fail_after` guards +count virtual seconds too, so the ones around a login have to outlast it. + +The tests that reach the handshake era ask the server for JSON responses: there the client stops +reading an SSE response at the answer, which leaves httpx2's generators to the garbage collector, +and trio (unlike asyncio) reports that as a `ResourceWarning`. +""" + +import json +import math +from collections.abc import Callable + +import anyio +import httpx2 +import mcp_types as types +import pytest +from inline_snapshot import snapshot +from mcp_types import REQUEST_TIMEOUT, ErrorData, ListToolsResult, Tool +from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION +from starlette.types import ASGIApp, Receive, Scope, Send +from trio.testing import MockClock + +from mcp import MCPError +from mcp.client.session import DISCOVER_TIMEOUT_SECONDS +from mcp.server import Server, ServerRequestContext +from mcp.shared.auth import AuthorizationCodeResult +from tests.interaction._requirements import requirement +from tests.interaction.auth._harness import AppShim, HeadlessOAuth, connect_with_oauth +from tests.interaction.auth._provider import InMemoryAuthorizationServerProvider + +pytestmark = [ + pytest.mark.anyio, + pytest.mark.parametrize( + "anyio_backend", + [pytest.param(("trio", {"clock": MockClock(autojump_threshold=0)}), id="trio-mockclock")], + ), +] + +LOGIN_SECONDS = DISCOVER_TIMEOUT_SECONDS * 360 + + +@pytest.fixture(autouse=True) +def _module_runner_lease() -> None: + """Opt out of the shared per-module event loop: this module parametrizes `anyio_backend`.""" + + +class Person(HeadlessOAuth): + """Completes the authorize step like `HeadlessOAuth`, but takes `seconds` over it, half in each handler.""" + + def __init__(self, seconds: float) -> None: + super().__init__() + self._seconds = seconds + self.in_the_browser = anyio.Event() + self.abandoned = anyio.Event() + + async def _take_half_the_time(self) -> None: + try: + await anyio.sleep(self._seconds / 2) # virtual time: the clock jumps, nothing really waits + except anyio.get_cancelled_exc_class(): + self.abandoned.set() + raise + + async def redirect_handler(self, authorization_url: str) -> None: + self.in_the_browser.set() + await self._take_half_the_time() + await super().redirect_handler(authorization_url) + + async def callback_handler(self) -> AuthorizationCodeResult: + await self._take_half_the_time() + return await super().callback_handler() + + +async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[Tool(name="echo", input_schema={"type": "object"})]) + + +Timeline = list[tuple[float, str, bool]] + + +def record_timeline() -> tuple[Timeline, Callable[[httpx2.Request], None]]: + """Build an `on_request` that logs each MCP POST as (virtual seconds since now, method, carries a token).""" + timeline: Timeline = [] + start = anyio.current_time() + + def on_request(request: httpx2.Request) -> None: + if request.method == "POST" and request.url.path == "/mcp": + seconds = round(anyio.current_time() - start, 9) + timeline.append((seconds, json.loads(request.content)["method"], "authorization" in request.headers)) + + return timeline, on_request + + +def slow_then_silent_probe(*, challenge_after: float = 0.0) -> AppShim: + """Build an `app_shim` whose server takes its time over `server/discover` and answers everything else. + + A probe without a token reaches the real app (and its 401) only after `challenge_after` seconds. + A probe that would be served, because it carries a token or the endpoint is not gated, gets an + SSE response that is opened and then never carries an event (the provider holds its lock until + a request's response headers arrive, so with none the fallback `initialize` could not be sent). + No real `Server` stalls on the probe, so the shim plays that part. + """ + + def factory(app: ASGIApp) -> ASGIApp: + async def wrapped(scope: Scope, receive: Receive, send: Send) -> None: + headers = dict(scope["headers"]) + if headers.get(b"mcp-method") == b"server/discover": + if challenge_after and b"authorization" not in headers: + await anyio.sleep(challenge_after) + else: + content_type = [(b"content-type", b"text/event-stream")] + await send({"type": "http.response.start", "status": 200, "headers": content_type}) + await anyio.sleep_forever() + await app(scope, receive, send) + + return wrapped + + return factory + + +def slow(path: str, seconds: float) -> AppShim: + """Build an `app_shim` whose server takes `seconds` (`math.inf`: forever) to start on any request for `path`.""" + + def factory(app: ASGIApp) -> ASGIApp: + async def wrapped(scope: Scope, receive: Receive, send: Send) -> None: + if scope["path"] == path: + await anyio.sleep(seconds) + await app(scope, receive, send) + + return wrapped + + return factory + + +@requirement("client-auth:login-time:not-counted") +async def test_auto_mode_negotiates_modern_when_the_login_far_outlasts_the_probe_deadline() -> None: + """A login hundreds of times longer than the probe deadline still lands on the modern protocol. + + Regression lock for #3601. The server speaks both eras, so a client that gave up on the probe + while the person was in the browser would complete `initialize` and show it on the wire. + """ + timeline, on_request = record_timeline() + + with anyio.fail_after(LOGIN_SECONDS * 2): + async with connect_with_oauth( + Server("guarded"), + provider=InMemoryAuthorizationServerProvider(), + headless=Person(LOGIN_SECONDS), + on_request=on_request, + mode="auto", + ) as (client, _): + assert client.protocol_version == LATEST_MODERN_VERSION + + assert timeline == [(0.0, "server/discover", False), (LOGIN_SECONDS, "server/discover", True)] + + +@requirement("lifecycle:discover:fallback-silence") +async def test_auto_mode_falls_back_at_the_probe_deadline_when_the_server_never_answers_the_probe() -> None: + """With nobody logging in, an unanswered probe is given up on after exactly the probe deadline. + + SDK-defined over HTTP (the spec states the timeout rule for stdio). `verify_tokens=False` leaves + the endpoint ungated, so the provider is attached but never challenged. + """ + timeline, on_request = record_timeline() + + with anyio.fail_after(DISCOVER_TIMEOUT_SECONDS * 2): + async with connect_with_oauth( + Server("guarded"), + provider=InMemoryAuthorizationServerProvider(), + verify_tokens=False, + app_shim=slow_then_silent_probe(), + on_request=on_request, + mode="auto", + json_response=True, + ) as (client, headless): + assert client.protocol_version == LATEST_HANDSHAKE_VERSION + + assert headless.authorize_urls == [] + assert timeline == [ + (0.0, "server/discover", False), + (DISCOVER_TIMEOUT_SECONDS, "initialize", False), + (DISCOVER_TIMEOUT_SECONDS, "notifications/initialized", False), + ] + + +@requirement("client-auth:login-time:not-counted") +@requirement("lifecycle:discover:fallback-silence") +async def test_the_probe_deadline_resumes_after_the_login_with_the_budget_that_was_left() -> None: + """A server that goes silent once the person has logged in is given up on when the rest of the deadline is spent. + + SDK-defined. Steps: + 1. The server takes 4 s to challenge the probe: 4 s of the deadline are spent. + 2. The person takes an hour: nothing is spent. + 3. The authorized probe is never answered: the client falls back when the remaining seconds are + gone, neither at once (the login counted) nor a whole deadline later (a fresh budget). + """ + challenge_after = 4.0 + timeline, on_request = record_timeline() + + with anyio.fail_after(LOGIN_SECONDS * 2): + async with connect_with_oauth( + Server("guarded"), + provider=InMemoryAuthorizationServerProvider(), + headless=Person(LOGIN_SECONDS), + app_shim=slow_then_silent_probe(challenge_after=challenge_after), + on_request=on_request, + mode="auto", + json_response=True, + ) as (client, _): + assert client.protocol_version == LATEST_HANDSHAKE_VERSION + + assert timeline == [ + (0.0, "server/discover", False), + (challenge_after + LOGIN_SECONDS, "server/discover", True), + (LOGIN_SECONDS + DISCOVER_TIMEOUT_SECONDS, "initialize", True), + (LOGIN_SECONDS + DISCOVER_TIMEOUT_SECONDS, "notifications/initialized", True), + ] + + +@requirement("client-auth:login-time:not-counted") +@pytest.mark.parametrize("mode", ["auto", "legacy", LATEST_MODERN_VERSION]) +async def test_a_read_timeout_shorter_than_the_login_does_not_fail_the_challenged_request(mode: str) -> None: + """Whichever request meets the 401 (the probe, `initialize`, or the first call), connecting and calling succeed. + + SDK-defined. One prompt proves the request that started the login is the one that finished it. + """ + with anyio.fail_after(LOGIN_SECONDS * 2): + async with connect_with_oauth( + Server("guarded", on_list_tools=list_tools), + provider=InMemoryAuthorizationServerProvider(), + headless=Person(LOGIN_SECONDS), + mode=mode, + read_timeout_seconds=5, + json_response=True, + ) as (client, headless): + result = await client.list_tools() + + assert result.tools[0].name == "echo" + assert len(headless.authorize_urls) == 1 + + +@requirement("client-auth:login-time:not-counted") +@pytest.mark.parametrize( + ("hung_endpoint", "prompts", "seconds"), + [("/register", 0, 5.0), ("/token", 1, LOGIN_SECONDS + 5.0)], +) +async def test_an_authorization_server_that_hangs_still_times_the_request_out( + hung_endpoint: str, prompts: int, seconds: float +) -> None: + """Only the person is off the clock: the provider's own HTTP calls, before and after the login, spend the budget. + + SDK-defined. + """ + started = anyio.current_time() + + with anyio.fail_after(LOGIN_SECONDS * 2): + async with connect_with_oauth( + Server("guarded"), + provider=InMemoryAuthorizationServerProvider(), + headless=Person(LOGIN_SECONDS), + app_shim=slow(hung_endpoint, math.inf), + mode=LATEST_MODERN_VERSION, + read_timeout_seconds=5, + ) as (client, headless): + with pytest.raises(MCPError) as exc_info: + await client.list_tools() + gave_up_after = anyio.current_time() - started + + assert exc_info.value.code == REQUEST_TIMEOUT + assert (len(headless.authorize_urls), gave_up_after) == (prompts, seconds) + + +@requirement("protocol:cancel:abort-signal") +async def test_cancelling_the_caller_mid_login_abandons_the_login() -> None: + """A suspended timeout is not a shield: cancelling the awaiting task returns at once and ends `callback_handler`. + + Pins unchanged behaviour. At 2026-07-28 abandoning a request closes its HTTP exchange, and the + login runs inside that exchange. + """ + person = Person(LOGIN_SECONDS) + + with anyio.fail_after(5): + async with connect_with_oauth( + Server("guarded"), + provider=InMemoryAuthorizationServerProvider(), + headless=person, + mode=LATEST_MODERN_VERSION, + read_timeout_seconds=5, + ) as (client, _): + async with anyio.create_task_group() as tg: + tg.start_soon(client.list_tools) + await person.in_the_browser.wait() + tg.cancel_scope.cancel() + await person.abandoned.wait() + + +@requirement("client-auth:login-time:not-counted") +@requirement("client-auth:login-time:other-requests-keep-counting") +async def test_a_login_suspends_only_the_timeout_of_the_request_that_was_challenged() -> None: + """Of two calls with the same read timeout, the challenged one survives the login and the queued one times out. + + SDK-defined, and a known limit rather than a goal: the second call waits for the provider's + lock, which is not counted as waiting on a person. It is sent while the server is still taking + its 2 s over the first, so the clock the login stops is not simply the newest one. + """ + first_call_on_the_wire = anyio.Event() + queued_error: list[tuple[float, ErrorData]] = [] + + with anyio.fail_after(LOGIN_SECONDS * 2): + async with ( + connect_with_oauth( + Server("guarded", on_list_tools=list_tools), + provider=InMemoryAuthorizationServerProvider(), + headless=Person(LOGIN_SECONDS), + app_shim=slow("/mcp", 2), + on_request=lambda _: first_call_on_the_wire.set(), + mode=LATEST_MODERN_VERSION, + read_timeout_seconds=5, + ) as (client, _), + anyio.create_task_group() as tg, + ): + + async def queued_call() -> None: + await first_call_on_the_wire.wait() + sent_at = anyio.current_time() + with pytest.raises(MCPError) as exc_info: + await client.list_tools(cache_mode="bypass") + queued_error.append((round(anyio.current_time() - sent_at, 9), exc_info.value.error)) + + tg.start_soon(queued_call) + challenged = await client.list_tools(cache_mode="bypass") + assert challenged.tools[0].name == "echo" + + assert queued_error == snapshot([(5.0, ErrorData(code=REQUEST_TIMEOUT, message="Request 'tools/list' timed out"))]) diff --git a/tests/shared/test_request_clock.py b/tests/shared/test_request_clock.py new file mode 100644 index 0000000000..dccb987919 --- /dev/null +++ b/tests/shared/test_request_clock.py @@ -0,0 +1,65 @@ +"""`request_clock` cases the end-to-end tests in `tests/interaction/auth/test_login_time.py` cannot produce.""" + +from contextlib import ExitStack + +import anyio +import pytest +from trio.testing import MockClock + +from mcp.shared._request_clock import request_clock, waiting_on_a_person + +pytestmark = pytest.mark.anyio + + +@pytest.fixture(autouse=True) +def _module_runner_lease() -> None: + """Opt out of the shared per-module event loop: this module parametrizes `anyio_backend`.""" + + +# trio's autojumping virtual clock: the sleeps cost no real time and the measured seconds are exact. +@pytest.mark.parametrize( + "anyio_backend", + [pytest.param(("trio", {"clock": MockClock(autojump_threshold=0)}), id="trio-mockclock")], +) +async def test_a_wait_that_began_before_the_clock_started_leaves_the_whole_budget() -> None: + """A transport may reach the person before `send_raw_request` gets to start the clock (its write + can hand the message over and only then yield), so starting must not resume a clock that is paused.""" + began = anyio.current_time() + + with anyio.fail_after(1000), pytest.raises(TimeoutError): # virtual seconds, so the guard has to outlast the waits + with request_clock(10) as clock: + with waiting_on_a_person(): + await anyio.sleep(60) + clock.start() + await anyio.sleep(60) + await anyio.sleep_forever() + + assert anyio.current_time() - began == 60 + 60 + 10 + + +async def test_the_clock_stops_and_resumes_on_asyncio() -> None: + """The end-to-end tests need trio's virtual clock; this is the same stop and resume on the backend users run.""" + outlasted_the_budget = False + + with anyio.fail_after(5), pytest.raises(TimeoutError): + with request_clock(0.01) as clock: + clock.start() + with waiting_on_a_person(): + await anyio.sleep(0.05) # real time, under test: five budgets go by while the clock is stopped + outlasted_the_budget = True + await anyio.sleep_forever() + + assert outlasted_the_budget + + +async def test_a_wait_that_begins_after_the_budget_ran_out_does_not_swallow_the_timeout() -> None: + """The deadline fires, and before the waiting task wakes up the transport's task reaches the person. + + asyncio only: there, starting a clock with nothing left cancels the scope on the spot, which puts + the two events in that order without a race. The wait outlives the request, as a login does. + """ + with anyio.fail_after(5), ExitStack() as login, pytest.raises(TimeoutError): + with request_clock(0) as clock: + clock.start() + login.enter_context(waiting_on_a_person()) + await anyio.sleep_forever()