From 56d755c4a1ac1b4c63c5bf90000d53ed2a5b890a Mon Sep 17 00:00:00 2001 From: Sergey Vasilyev Date: Wed, 14 Oct 2026 15:19:42 +0200 Subject: [PATCH] =?UTF-8?q?Add=20an=20extra=20tick=20for=20explicit=20time?= =?UTF-8?q?outs=20below=20resolution=20=E2=80=94=20to=20avoid=20infinite?= =?UTF-8?q?=20sleeps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The issue was caused by misalignment of looptime vs. asyncio: the fractional part of the sleep smaller than the resolution (1e-6 by default) was rounded down to 0, causing 0 ticks of the clock, while asyncio clearly expected the time to move beyond that point, which never happened. Now, it will happen — the clock can move slightly beyond the requested timeout, but at most by 1 tick of the resolution. A tricky aspect: sometimes, asyncio requested sleeps with floating point precision errors in them (because of its timeout math), and we cannot add 1x extra tick there, since this would break the precise time movements of looptime. Instead, we split that one sleep by an integer number of ticks as usual, and add 1x extra tick only if the requested timeout is explicitly smaller than the resolution, but greater than zero. In other words, when we are sure that asyncio asks for this tiny sleep, and does not inject it because of the floating point math. The sleeps and timeouts with no fractional parts smaller than the resoltion, should not be affected, as they remain in the integer zone and require no extra ticks. Signed-off-by: Sergey Vasilyev --- docs/nuances.rst | 7 ++++++- looptime/_internal/loops.py | 12 ++++++++++++ tests/test_time_moves.py | 10 ++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/docs/nuances.rst b/docs/nuances.rst index 8204241..883d568 100644 --- a/docs/nuances.rst +++ b/docs/nuances.rst @@ -237,7 +237,12 @@ Time arguments are converted to the internal integer form and back to the floating-point form when needed. The ``resolution`` (``float``) setting is the minimum supported time step. -All time steps smaller than that are rounded to the nearest value. +All time steps smaller than that are rounded to the nearest higher value, +i.e., they make an extra tick of the clock. This is needed to avoid looptime +freezing forever in a loop caused by asyncio sleeps below the resolution, +which would otherwise (no extra tick) cause the clock moving by 0 ticks, +thus never satisfying the asyncio's logic of reaching the target time, +which does the math in floating point values. The default is 1 microsecond, i.e., ``0.000001`` (``1e-6``), which is good enough for typical unit-tests while keeping the integers smaller than 32 bits diff --git a/looptime/_internal/loops.py b/looptime/_internal/loops.py index e99d27b..a86e540 100644 --- a/looptime/_internal/loops.py +++ b/looptime/_internal/loops.py @@ -340,6 +340,18 @@ def __sync( overtime = True real_step = self.__end - self.__now + # If the original timeout is truly smaller than the resolution, add 1x clock tick. + # If bigger, assume it is a floating point precision error in asyncio and will be gone. + # But do not ceil() the time! This will expose the floating point precision errors as ticks. + # In particular, issue #35: asyncio.sleep(1e-7) could freeze forever, + # since we moved the integer clock by 0 ticks (round(1e-7/resolution)=round(0.1)=0), + # but asyncio.BaseLoop._run_once() slept for the remaining time in floating point math, + # and therefore could not reach its own target time to stop properly. + if not overtime: + if real_step is not None and timeout is not None: + if 0 < timeout < self.__int2time(1): + real_step += 1 + # Normally, the real-clock step is the same as the loop-time step. real_timeout = self.__int2time(real_step) diff --git a/tests/test_time_moves.py b/tests/test_time_moves.py index b17f7ab..ba677b7 100644 --- a/tests/test_time_moves.py +++ b/tests/test_time_moves.py @@ -163,3 +163,13 @@ def test_backward_time_moves_raises_and_keeps_the_time(looptime_loop): warnings.filterwarnings('error', category=looptime.TimeWarning) looptime_loop.setup_looptime(start=123) assert looptime_loop.time() == 456 # unmodified + + +# Issue #35: freezes forever with certain sleeps below resolution. +@pytest.mark.parametrize('sleep', [ + 0.0000001, + 1.0000001, +]) +def test_timeouts_below_resolution(looptime_loop, sleep): + looptime_loop.setup_looptime(resolution=1e-6) # the same as in asyncio.BaseLoop._run_once() + looptime_loop.run_until_complete(asyncio.sleep(sleep))