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