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

Fix timers firing up to 1ms early#755
pctablet505 wants to merge 2 commits into
MagicStack:masterfrom
pctablet505:fix-early-timer-firing

Conversation

@pctablet505

Copy link
Copy Markdown

Fixes #739.

libuv's internal clock is refreshed as the floor of the current monotonic
time truncated to whole milliseconds, and uv_timer_start() schedules the
callback for loop->time + timeout. Since the fractional millisecond that
had already elapsed at the moment of sampling is discarded, a timer can end
up firing up to ~1ms before the requested delay has actually elapsed. This
is what causes asyncio.sleep(n) to occasionally return a bit under n,
as described in the issue.

The fix pads the timeout passed to uv_timer_start() by 1ms to compensate
for that truncation, while leaving the reported deadline
(get_when()/TimerHandle.when()) at the originally requested value.

Added a regression test that checks asyncio.sleep() never returns early
relative to a wall-clock (time.monotonic()) measurement, since the
existing rounding regression test (test_call_later_rounding, for #233)
compares against the loop's own internal clock and therefore can't observe
this particular truncation.

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 marked this pull request as ready for review July 17, 2026 12:47
transport._wait() only waits for the child to be reaped (SIGCHLD), not
for its stdout pipe to be drained and closed. Process exit and pipe EOF
reach libuv through independent kernel mechanisms and can be observed
in different event loop iterations, so the test could assert on
proto.stages before connection_lost had actually fired, sometimes
leaving the pipe/process transports alive past the end of the test.

Wait on the protocol's own connection_lost instead, which uvloop only
fires once the process has exited and all pipes have disconnected.
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