Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion docs/nuances.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions looptime/_internal/loops.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
10 changes: 10 additions & 0 deletions tests/test_time_moves.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))