timers: prevent setTimeout from firing callback early#62466
timers: prevent setTimeout from firing callback early#62466vijaygovindaraja wants to merge 1 commit intonodejs:mainfrom
Conversation
libuv's uv_now() truncates sub-millisecond time to integer milliseconds. When a timer is scheduled at a fractional millisecond boundary (e.g., real time 100.7ms, uv_now() returns 100), the timer can fire up to 1ms before the requested delay has actually elapsed when measured with high-resolution clocks like process.hrtime() or Date.now(). Add 1ms to the duration passed to uv_timer_start() in Environment::ScheduleTimer to compensate for this truncation. This ensures that setTimeout/setInterval callbacks never fire before the requested delay, at the cost of up to 1ms additional latency. The fix is applied at the C++ boundary (ScheduleTimer) rather than in JavaScript because it covers both scheduling paths: the initial schedule from JS (binding.scheduleTimer) and the rescheduling after timer processing in RunTimers. Fixes: nodejs#26578 Signed-off-by: V Govindarajan <vijay.govindarajan91@gmail.com>
|
Hi @nodejs/timers — this addresses the 7-year-old early-fire bug discussed in #26578. The fix adds 1ms to the duration in ScheduleTimer to compensate for uv_now() truncation, as @Fishrock123 and @apapirovski suggested. Happy to discuss the approach or make adjustments. |
|
The current implementation, as far as I can tell, is compatible with the setTimeout and time spec already. I'm not sure the value added by 1ms offset besides trying to enforce conformance to a behavior that isn't guaranteed by the spec. This also does nothing to solve the WSL problems that timers face, which are root caused to a similar problem around different clocks & granularities of measurement, and so I'm -1 on any alteration like this. |
|
@apapirovski Thanks for reviewing — I appreciate the detailed feedback. You're right that the HTML spec says the delay is a minimum and doesn't guarantee exact timing. However, the current behavior violates even that minimum guarantee — That said, I understand the concern about adding a blanket +1ms offset for a sub-millisecond edge case. A few questions to help me understand if there's a path forward:
Happy to close this PR and pursue whichever approach the team prefers. Thanks again for your time. |
|
By my reading of the spec, technically the timer is only required to be accurate when compared to the same clock with the same granularity (i.e., ms). The inherent problem is that everyone seems to want to measure using different granularity which nothing guarantees. uv_hrtime is likely to be expensive performance wise but I haven't tried it. Updating documentation could be reasonable. |
|
That makes sense — the spec guarantees accuracy within the same clock granularity, and the mismatch comes from users measuring with higher-precision clocks. I'll put together a documentation PR to clarify this behavior in the timers docs. Something along the lines of:
Would that framing work, or would you prefer different wording? Happy to adjust. |
|
Seems reasonable. Keep in mind I'm no longer an approver here so take my 2c for whatever they're worth haha. Hopefully a current set of maintainers can chime in. |
|
Following the discussion here, I've opened a docs-only PR at #62468 to clarify the millisecond precision behavior. Thanks @apapirovski for the guidance. |
Summary
setTimeoutoccasionally firing its callback before the requested delay has elapseduv_now()truncates sub-millisecond time to integer milliseconds, causing up to 1ms of "stolen" time when scheduling timersuv_timer_start()inEnvironment::ScheduleTimerThe Bug
When a timer is scheduled at a fractional millisecond boundary:
uv_now()returns 100 (truncated)uv_now() >= 100 + delayuv_now()reaches100 + delay, real elapsed time is only delay - 0.7msprocess.hrtime()orDate.now(), the callback appears to fire earlyThis is especially likely when
setImmediate()is involved (affectsuv_update_time()timing), as identified by @bnoordhuis.The Fix
src/env.cc—Environment::ScheduleTimer:This covers both scheduling paths:
binding.scheduleTimer(msecs)inlib/internal/timers.js)RunTimersinsrc/env.cc)Trade-off: Up to 1ms additional latency on timers. This is acceptable because:
setTimeout(fn, N)fires after ≥N ms is more important than minimizing latencyNo compounding for
setInterval: Each interval resets_idleStartto freshuv_now()(line 599/611 oflib/internal/timers.js), so the +1ms does not accumulate across intervals.Reproduction
From @Trott's comment:
Fails within ~20 seconds on macOS, Windows, and Linux (confirmed across Node.js 8.x through 24.x).
Fixes: #26578