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()