Skip to content

Fix timers firing up to 1ms early - #755

Open
pctablet505 wants to merge 2 commits into
MagicStack:masterfrom
pctablet505:fix-early-timer-firing
Open

pctablet505 wants to merge 2 commits into
MagicStack:masterfrom
pctablet505:fix-early-timer-firing

Conversation

@pctablet505

@pctablet505 pctablet505 commented Jul 16, 2026 •

Copy link
Copy Markdown

Fixes #739

Under uvloop, asyncio.sleep(n) can return up to ~1 ms before n has actually elapsed. This change pads the timeout passed to libuv by 1 ms, so timers never fire early.

Problem

libuv refreshes loop->time as the monotonic clock floored to whole milliseconds, and uv_timer_start() schedules the callback at loop->time + timeout. Whatever fraction of a millisecond had already passed when the clock was sampled is lost, so the timer comes due early in real time. The issue shows asyncio.sleep(0.1) returning after 0.0991 s.

Example with a 100 ms timer started at real time 1000.9 ms:

loop->time at start due at real time slept
before 1000 (floored) 1000 + 100 = 1100 99.1 ms ❌
after 1000 (floored) 1000 + 100 + 1 = 1101 100.1 ms ✅

Fix

Only the value handed to libuv changes (self.timeout + 1). self.timeout, and therefore TimerHandle.when() / get_when(), still reports the requested deadline. The trade-off is that a timer can fire up to ~1 ms later than before, which is within libuv's 1 ms resolution.

This is a different truncation from #233, which was fixed by rounding the float-to-ms conversion in call_at(). That path is unchanged.

Tests

test_call_later_not_early runs asyncio.sleep(0.01) 300 times and asserts that each one lasts at least the delay, measured with time.monotonic(). The existing test_call_later_rounding (#233) can't catch this, because it measures with the loop's own millisecond-floored clock.

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 MagicStack#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.
@pctablet505
pctablet505 force-pushed the fix-early-timer-firing branch from 66f1983 to f2b3a0d Compare October 8, 2026 11:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

uvloop may sleep less than n for asyncio.sleep(n)

1 participant