Skip to content

Fix timers started from a callback firing early on epoll and IOCP - #255

Open
endel wants to merge 1 commit into
mitchellh:mainfrom
endel:timer-after-wait
Open

endel wants to merge 1 commit into
mitchellh:mainfrom
endel:timer-after-wait

Conversation

@endel

@endel endel commented Oct 23, 2026

Copy link
Copy Markdown

epoll and IOCP work out a new timer's deadline from cached_now, which tick updates before it waits. A timer started from a callback is therefore counted from before the wait: it fires early by however long the wait took, and at once if the wait took longer than the timer. Timer.run documents next_ms as "from now".

kqueue had the same bug and was fixed in #214; io_uring (through its now_outdated flag) and wasi_poll already count from the current time. libuv, whose cached loop time this follows, also updates it as soon as the poll returns (uv__io_poll_check, right after epoll_pwait).

We hit this in quic-zig, which re-arms a timer from its socket callback. On epoll it has to call update_now() first, or a deadline set after a long wait wakes the loop straight away.

Fix

io_uring's approach: a now_outdated flag, set when the wait returns and cleared by update_now. timer_next updates the time first when the flag is set. That costs at most one clock read per loop iteration, and only in an iteration whose callbacks start a timer (kqueue reads the clock on every timer_next). As on kqueue and io_uring, now() called after such a timer returns the updated time.

Test

A new TimerTests case, run on every backend and the dynamic API: a thread notifies an Async after 50 ms, its callback starts a 20 ms timer, and the test checks that the timer took at least 19 ms. The millisecond of slack is for the test's clock (std.Io.Clock.awake) not being the backend's. It is skipped on WASI, which has no threads.

Without the epoll change, or with only the line that sets the flag after the wait removed, the test fails on epoll; it passes on io_uring and kqueue.

  • macOS (aarch64, kqueue): zig build test, 60 passed
  • Linux (aarch64, epoll and io_uring): 146 passed, 3 skipped
  • zig build -Demit-example -Demit-bench, and zig build for every target in the CI matrix, which compiles the IOCP change

IOCP's change mirrors epoll's line for line; its test is left to CI on Windows. CI on main fails until #250 moves the workflow to Zig 0.17, so this PR's checks will fail the same way until then.

🤖 Generated with Claude Code

https://claude.ai/code/session_015YcSvZdqwMncezgbb64ihg

Both worked out a new timer's deadline from the time cached before the
loop waited, so a timer started from an I/O callback fired early by the
length of the wait, and at once if the wait was longer. kqueue (mitchellh#214),
io_uring and wasi_poll already count from the current time.

Assisted-by: Claude Opus 5.5
endel added a commit to endel/quic-zig that referenced this pull request Oct 23, 2026
Carries mitchellh/libxev#255 (epoll counted a timer started from an I/O
callback from before the wait, so it fired early) and #256 (with libc
linked, every loop clock read on Linux was a clock_gettime syscall).

Assisted-by: Claude Opus 5.5
endel added a commit to endel/quic-zig that referenced this pull request Oct 23, 2026
libxev now counts a timer started after a wait from the current time
(mitchellh/libxev#255, in the quic-zig-2026-10-23 pin).

Assisted-by: Claude Opus 5.5
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.

1 participant