From f2b3a0d52a8fc8c98dbf895772e6c16d91cdf036 Mon Sep 17 00:00:00 2001 From: pctablet505 <55033230+pctablet505@users.noreply.github.com> Date: Thu, 16 Jul 2026 22:20:36 +0530 Subject: [PATCH] Fix timers firing up to 1ms early libuv resamples its internal clock as the floor of the current monotonic time truncated to whole milliseconds, then schedules a timer for loop->time + timeout. Since the fractional millisecond already elapsed at the moment of sampling is discarded, the timer can fire up to ~1ms before the requested delay has actually passed. This made asyncio.sleep(n) occasionally return slightly under n, as reported in #739. Pad the timeout handed to uv_timer_start() by 1ms to compensate, while leaving the reported deadline (get_when()/TimerHandle.when()) as the originally requested value. --- tests/test_base.py | 18 ++++++++++++++++++ uvloop/handles/timer.pyx | 12 +++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/tests/test_base.py b/tests/test_base.py index b226a84fd..e66f31801 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -220,6 +220,24 @@ def cb(): finished = int(round(self.loop.time() * 1000)) self.assertGreaterEqual(finished - started, 69) + def test_call_later_not_early(self): + # Refs #739: asyncio.sleep()/call_later() must never fire before + # the requested delay has actually elapsed, as measured by a + # wall-clock source independent of the loop's own (millisecond- + # truncated) internal clock. The underlying bug only manifests + # when the loop's internal clock happens to be sampled close to + # a millisecond boundary, so run enough iterations to make a + # regression here reliably observable. + async def main(): + delay = 0.01 + for _ in range(300): + started = time.monotonic() + await asyncio.sleep(delay) + elapsed = time.monotonic() - started + self.assertGreaterEqual(elapsed, delay) + + self.loop.run_until_complete(main()) + def test_call_at(self): if (os.environ.get('TRAVIS_OS_NAME') or os.environ.get('GITHUB_WORKFLOW')): diff --git a/uvloop/handles/timer.pyx b/uvloop/handles/timer.pyx index 86d46ef02..d6e434247 100644 --- a/uvloop/handles/timer.pyx +++ b/uvloop/handles/timer.pyx @@ -50,9 +50,19 @@ cdef class UVTimer(UVHandle): uv.uv_update_time(self._loop.uvloop) # void self.start_t = uv.uv_now(self._loop.uvloop) + # libuv's internal clock (loop->time, just refreshed above) is + # the current monotonic time truncated ("floored") to whole + # milliseconds, discarding up to just under 1ms. uv_timer_start() + # schedules the callback for loop->time + timeout, so without + # compensation the timer can fire up to ~1ms sooner than + # `self.timeout` milliseconds of actual wall-clock time have + # elapsed. Pad the timeout given to libuv by 1ms so the timer + # never fires earlier than requested; self.timeout (and thus + # get_when()/TimerHandle.when()) is left untouched so it keeps + # reporting the originally requested deadline. err = uv.uv_timer_start(self._handle, __uvtimer_callback, - self.timeout, 0) + self.timeout + 1, 0) if err < 0: exc = convert_error(err) self._fatal_error(exc, True)