From a6610fc2a2233bfabd69adf0a3605756e452c9f2 Mon Sep 17 00:00:00 2001 From: Bogdan Opanchuk Date: Wed, 10 Dec 2025 12:51:04 -0800 Subject: [PATCH 1/3] Make `autojump`/`autojump_threshold` methods of `abc.Clock` --- src/trio/_abc.py | 10 ++++++++++ src/trio/_core/_mock_clock.py | 13 ++----------- src/trio/_core/_run.py | 10 +++------- src/trio/_core/_tests/test_guest_mode.py | 7 +++++++ src/trio/_core/_tests/test_mock_clock.py | 4 ++-- 5 files changed, 24 insertions(+), 20 deletions(-) diff --git a/src/trio/_abc.py b/src/trio/_abc.py index abb6824381..933030bfd9 100644 --- a/src/trio/_abc.py +++ b/src/trio/_abc.py @@ -1,5 +1,6 @@ from __future__ import annotations +from math import inf import socket from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Generic, TypeVar @@ -65,6 +66,15 @@ def deadline_to_sleep_time(self, deadline: float) -> float: """ + @property + def autojump_threshold(self) -> float: + return inf + + def autojump(self) -> None: + # If `autojump_threshold()` has the default implementation (returning `inf`), + # this will never be called. + raise NotImplementedError + class Instrument(ABC): # noqa: B024 # conceptually is ABC """The interface for run loop instrumentation. diff --git a/src/trio/_core/_mock_clock.py b/src/trio/_core/_mock_clock.py index e437464b65..c545f98aab 100644 --- a/src/trio/_core/_mock_clock.py +++ b/src/trio/_core/_mock_clock.py @@ -105,12 +105,6 @@ def autojump_threshold(self, new_autojump_threshold: float) -> None: self._autojump_threshold = float(new_autojump_threshold) self._try_resync_autojump_threshold() - # runner.clock_autojump_threshold is an internal API that isn't easily - # usable by custom third-party Clock objects. If you need access to this - # functionality, let us know, and we'll figure out how to make a public - # API. Discussion: - # - # https://github.com/python-trio/trio/issues/1587 def _try_resync_autojump_threshold(self) -> None: try: runner = GLOBAL_RUN_CONTEXT.runner @@ -118,13 +112,10 @@ def _try_resync_autojump_threshold(self) -> None: runner.force_guest_tick_asap() except AttributeError: pass - else: - if runner.clock is self: - runner.clock_autojump_threshold = self._autojump_threshold # Invoked by the run loop when runner.clock_autojump_threshold is # exceeded. - def _autojump(self) -> None: + def autojump(self) -> None: statistics = _core.current_statistics() jump = statistics.seconds_to_next_deadline if 0 < jump < inf: @@ -136,7 +127,7 @@ def _real_to_virtual(self, real: float) -> float: return self._virtual_base + virtual_offset def start_clock(self) -> None: - self._try_resync_autojump_threshold() + pass def current_time(self) -> float: return self._real_to_virtual(self._real_clock()) diff --git a/src/trio/_core/_run.py b/src/trio/_core/_run.py index 8a7ddc1dc3..b5d108e9b6 100644 --- a/src/trio/_core/_run.py +++ b/src/trio/_core/_run.py @@ -1816,9 +1816,6 @@ class Runner: # type: ignore[explicit-any] trio_token: TrioToken | None = None asyncgens: AsyncGenerators = attrs.Factory(AsyncGenerators) - # If everything goes idle for this long, we call clock._autojump() - clock_autojump_threshold: float = inf - # Guest mode stuff is_guest: bool = False guest_tick_scheduled: bool = False @@ -2758,8 +2755,8 @@ def unrolled_run( # We use 'elif' here because if there are tasks in # wait_all_tasks_blocked, then those tasks will wake up without # jumping the clock, so we don't need to autojump. - elif runner.clock_autojump_threshold < timeout: - timeout = runner.clock_autojump_threshold + elif runner.clock.autojump_threshold < timeout: + timeout = runner.clock.autojump_threshold idle_primed = IdlePrimedTypes.AUTOJUMP_CLOCK if "before_io_wait" in runner.instruments: @@ -2810,8 +2807,7 @@ def unrolled_run( break else: assert idle_primed is IdlePrimedTypes.AUTOJUMP_CLOCK - assert isinstance(runner.clock, _core.MockClock) - runner.clock._autojump() + runner.clock.autojump() # Process all runnable tasks, but only the ones that are already # runnable now. Anything that becomes runnable during this cycle diff --git a/src/trio/_core/_tests/test_guest_mode.py b/src/trio/_core/_tests/test_guest_mode.py index 743eddc846..c289eabde2 100644 --- a/src/trio/_core/_tests/test_guest_mode.py +++ b/src/trio/_core/_tests/test_guest_mode.py @@ -187,6 +187,13 @@ def current_time(self) -> float: def deadline_to_sleep_time(self, deadline: float) -> float: raise NotImplementedError() + @property + def autojump_threshold(self) -> float: + raise NotImplementedError() + + def autojump(self) -> None: + raise NotImplementedError() + def after_start_never_runs() -> None: # pragma: no cover pytest.fail("shouldn't get here") diff --git a/src/trio/_core/_tests/test_mock_clock.py b/src/trio/_core/_tests/test_mock_clock.py index 6131e7ba3f..a529446cd5 100644 --- a/src/trio/_core/_tests/test_mock_clock.py +++ b/src/trio/_core/_tests/test_mock_clock.py @@ -181,13 +181,13 @@ async def waiter() -> None: async def test_initialization_doesnt_mutate_runner() -> None: before = ( GLOBAL_RUN_CONTEXT.runner.clock, - GLOBAL_RUN_CONTEXT.runner.clock_autojump_threshold, + GLOBAL_RUN_CONTEXT.runner.clock.autojump_threshold, ) MockClock(autojump_threshold=2, rate=3) after = ( GLOBAL_RUN_CONTEXT.runner.clock, - GLOBAL_RUN_CONTEXT.runner.clock_autojump_threshold, + GLOBAL_RUN_CONTEXT.runner.clock.autojump_threshold, ) assert before == after From 9087286725acc5e394c528691f0ae92f94a75237 Mon Sep 17 00:00:00 2001 From: Bogdan Opanchuk Date: Wed, 10 Dec 2025 12:48:34 -0800 Subject: [PATCH 2/3] Rework clock usage in the event loop --- src/trio/_abc.py | 12 ++------ src/trio/_core/_mock_clock.py | 34 ++++++++++++---------- src/trio/_core/_run.py | 37 ++++++++++++------------ src/trio/_core/_tests/test_guest_mode.py | 7 ----- src/trio/_core/_tests/test_mock_clock.py | 26 +++++++++-------- 5 files changed, 54 insertions(+), 62 deletions(-) diff --git a/src/trio/_abc.py b/src/trio/_abc.py index 933030bfd9..f36672fc4a 100644 --- a/src/trio/_abc.py +++ b/src/trio/_abc.py @@ -43,7 +43,7 @@ def current_time(self) -> float: """ @abstractmethod - def deadline_to_sleep_time(self, deadline: float) -> float: + def deadline_to_sleep_time(self, timeout: float) -> float: """Compute the real time until the given deadline. This is called before we enter a system-specific wait function like @@ -66,14 +66,8 @@ def deadline_to_sleep_time(self, deadline: float) -> float: """ - @property - def autojump_threshold(self) -> float: - return inf - - def autojump(self) -> None: - # If `autojump_threshold()` has the default implementation (returning `inf`), - # this will never be called. - raise NotImplementedError + def propagate(self, real_time_passed: float, virtual_timeout: float) -> None: + pass class Instrument(ABC): # noqa: B024 # conceptually is ABC diff --git a/src/trio/_core/_mock_clock.py b/src/trio/_core/_mock_clock.py index c545f98aab..1e52335557 100644 --- a/src/trio/_core/_mock_clock.py +++ b/src/trio/_core/_mock_clock.py @@ -113,13 +113,11 @@ def _try_resync_autojump_threshold(self) -> None: except AttributeError: pass - # Invoked by the run loop when runner.clock_autojump_threshold is - # exceeded. - def autojump(self) -> None: - statistics = _core.current_statistics() - jump = statistics.seconds_to_next_deadline - if 0 < jump < inf: - self.jump(jump) + def propagate(self, real_time_passed: float, virtual_timeout: float) -> None: + if self._rate > 0: + self.jump(real_time_passed * self._rate) + else: + self.jump(virtual_timeout) def _real_to_virtual(self, real: float) -> float: real_offset = real - self._real_base @@ -132,14 +130,20 @@ def start_clock(self) -> None: def current_time(self) -> float: return self._real_to_virtual(self._real_clock()) - def deadline_to_sleep_time(self, deadline: float) -> float: - virtual_timeout = deadline - self.current_time() - if virtual_timeout <= 0: - return 0 - elif self._rate > 0: - return virtual_timeout / self._rate - else: - return 999999999 + def deadline_to_sleep_time(self, timeout: float) -> float: + virtual_timeout = max(0.0, timeout) + + real_timeout = virtual_timeout + + if self._rate > 0: + real_timeout /= self._rate + elif real_timeout > 0 and self._rate == 0: + real_timeout = 999999999.0 + + if real_timeout > self.autojump_threshold: + real_timeout = self.autojump_threshold + + return real_timeout def jump(self, seconds: float) -> None: """Manually advance the clock by the given number of seconds. diff --git a/src/trio/_core/_run.py b/src/trio/_core/_run.py index b5d108e9b6..16892608f0 100644 --- a/src/trio/_core/_run.py +++ b/src/trio/_core/_run.py @@ -204,8 +204,8 @@ def start_clock(self) -> None: def current_time(self) -> float: return self.offset + perf_counter() - def deadline_to_sleep_time(self, deadline: float) -> float: - return deadline - self.current_time() + def deadline_to_sleep_time(self, timeout: float) -> float: + return timeout class IdlePrimedTypes(enum.Enum): @@ -2739,36 +2739,38 @@ def unrolled_run( # You know how people talk about "event loops"? This 'while' loop right # here is our event loop: while runner.tasks: + now = runner.clock.current_time() + if runner.runq: - timeout: float = 0 + virtual_timeout: float = 0 else: deadline = runner.deadlines.next_deadline() - timeout = runner.clock.deadline_to_sleep_time(deadline) - timeout = min(max(0, timeout), _MAX_TIMEOUT) + virtual_timeout = max(0, deadline - now) idle_primed = None if runner.waiting_for_idle: cushion, _ = runner.waiting_for_idle.keys()[0] - if cushion < timeout: - timeout = cushion + if cushion < virtual_timeout: + virtual_timeout = cushion idle_primed = IdlePrimedTypes.WAITING_FOR_IDLE - # We use 'elif' here because if there are tasks in - # wait_all_tasks_blocked, then those tasks will wake up without - # jumping the clock, so we don't need to autojump. - elif runner.clock.autojump_threshold < timeout: - timeout = runner.clock.autojump_threshold - idle_primed = IdlePrimedTypes.AUTOJUMP_CLOCK + + virtual_timeout = min(max(0, virtual_timeout), _MAX_TIMEOUT) + real_timeout = runner.clock.deadline_to_sleep_time(virtual_timeout) if "before_io_wait" in runner.instruments: - runner.instruments.call("before_io_wait", timeout) + runner.instruments.call("before_io_wait", real_timeout) # Driver will call io_manager.get_events(timeout) and pass it back # in through the yield - events = yield timeout + events = yield real_timeout + + new_now = runner.clock.current_time() + runner.clock.propagate(new_now - now, virtual_timeout) + runner.io_manager.process_events(events) if "after_io_wait" in runner.instruments: - runner.instruments.call("after_io_wait", timeout) + runner.instruments.call("after_io_wait", real_timeout) # Process cancellations due to deadline expiry now = runner.clock.current_time() @@ -2805,9 +2807,6 @@ def unrolled_run( runner.reschedule(task) else: break - else: - assert idle_primed is IdlePrimedTypes.AUTOJUMP_CLOCK - runner.clock.autojump() # Process all runnable tasks, but only the ones that are already # runnable now. Anything that becomes runnable during this cycle diff --git a/src/trio/_core/_tests/test_guest_mode.py b/src/trio/_core/_tests/test_guest_mode.py index c289eabde2..743eddc846 100644 --- a/src/trio/_core/_tests/test_guest_mode.py +++ b/src/trio/_core/_tests/test_guest_mode.py @@ -187,13 +187,6 @@ def current_time(self) -> float: def deadline_to_sleep_time(self, deadline: float) -> float: raise NotImplementedError() - @property - def autojump_threshold(self) -> float: - raise NotImplementedError() - - def autojump(self) -> None: - raise NotImplementedError() - def after_start_never_runs() -> None: # pragma: no cover pytest.fail("shouldn't get here") diff --git a/src/trio/_core/_tests/test_mock_clock.py b/src/trio/_core/_tests/test_mock_clock.py index a529446cd5..3a6e5a2ee0 100644 --- a/src/trio/_core/_tests/test_mock_clock.py +++ b/src/trio/_core/_tests/test_mock_clock.py @@ -24,9 +24,9 @@ def test_mock_clock() -> None: with pytest.raises(ValueError, match=r"^time can't go backwards$"): c.jump(-1) assert c.current_time() == 1.2 - assert c.deadline_to_sleep_time(1.1) == 0 - assert c.deadline_to_sleep_time(1.2) == 0 - assert c.deadline_to_sleep_time(1.3) > 999999 + assert c.deadline_to_sleep_time(-0.1) == 0 + assert c.deadline_to_sleep_time(0.0) == 0 + assert c.deadline_to_sleep_time(0.1) == 999999999 with pytest.raises(ValueError, match=r"^rate must be >= 0$"): c.rate = -1 @@ -36,15 +36,15 @@ def test_mock_clock() -> None: assert c.current_time() == 1.2 REAL_NOW += 1 assert c.current_time() == 3.2 - assert c.deadline_to_sleep_time(3.1) == 0 - assert c.deadline_to_sleep_time(3.2) == 0 - assert c.deadline_to_sleep_time(4.2) == 0.5 + assert c.deadline_to_sleep_time(-0.1) == 0 + assert c.deadline_to_sleep_time(0.0) == 0 + assert c.deadline_to_sleep_time(1.0) == 0.5 c.rate = 0.5 assert c.current_time() == 3.2 - assert c.deadline_to_sleep_time(3.1) == 0 - assert c.deadline_to_sleep_time(3.2) == 0 - assert c.deadline_to_sleep_time(4.2) == 2.0 + assert c.deadline_to_sleep_time(-0.1) == 0 + assert c.deadline_to_sleep_time(0.0) == 0 + assert c.deadline_to_sleep_time(1.0) == 2.0 c.jump(0.8) assert c.current_time() == 4.0 @@ -80,11 +80,12 @@ async def test_mock_clock_autojump(mock_clock: MockClock) -> None: t = _core.current_time() # this should wake up before the autojump threshold triggers, so time # shouldn't change + # TODO: technically, this waits for an infinite virtual time - how should `current_time()` change? await wait_all_tasks_blocked() assert t == _core.current_time() # this should too await wait_all_tasks_blocked(0.01) - assert t == _core.current_time() + assert t + 0.01 == _core.current_time() # set up a situation where the autojump task is blocked for a long long # time, to make sure that cancel-and-adjust-threshold logic is working @@ -179,15 +180,16 @@ async def waiter() -> None: async def test_initialization_doesnt_mutate_runner() -> None: + # TODO: is this test even necessary now? before = ( GLOBAL_RUN_CONTEXT.runner.clock, - GLOBAL_RUN_CONTEXT.runner.clock.autojump_threshold, + # GLOBAL_RUN_CONTEXT.runner.clock.autojump_threshold, ) MockClock(autojump_threshold=2, rate=3) after = ( GLOBAL_RUN_CONTEXT.runner.clock, - GLOBAL_RUN_CONTEXT.runner.clock.autojump_threshold, + # GLOBAL_RUN_CONTEXT.runner.clock.autojump_threshold, ) assert before == after From 76524e5569454daf4bf0f80af469b2022750f77d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 10 Dec 2025 21:04:40 +0000 Subject: [PATCH 3/3] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/trio/_abc.py | 1 - src/trio/_core/_mock_clock.py | 1 - 2 files changed, 2 deletions(-) diff --git a/src/trio/_abc.py b/src/trio/_abc.py index f36672fc4a..aa3fe84c9c 100644 --- a/src/trio/_abc.py +++ b/src/trio/_abc.py @@ -1,6 +1,5 @@ from __future__ import annotations -from math import inf import socket from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Generic, TypeVar diff --git a/src/trio/_core/_mock_clock.py b/src/trio/_core/_mock_clock.py index 1e52335557..1c32fe162b 100644 --- a/src/trio/_core/_mock_clock.py +++ b/src/trio/_core/_mock_clock.py @@ -1,7 +1,6 @@ import time from math import inf -from .. import _core from .._abc import Clock from .._util import final from ._run import GLOBAL_RUN_CONTEXT