From 3708eac8172d1133e48448effe7796f80ab8a958 Mon Sep 17 00:00:00 2001 From: V Govindarajan Date: Fri, 27 Mar 2026 11:36:23 -0700 Subject: [PATCH] timers: prevent setTimeout from firing callback early 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: https://github.com/nodejs/node/issues/26578 Signed-off-by: V Govindarajan --- src/env.cc | 6 ++- test/parallel/test-timers-no-early-fire.js | 45 ++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 test/parallel/test-timers-no-early-fire.js diff --git a/src/env.cc b/src/env.cc index 04807ffae13437..137ef87d781b41 100644 --- a/src/env.cc +++ b/src/env.cc @@ -1505,7 +1505,11 @@ void Environment::RequestInterruptFromV8() { void Environment::ScheduleTimer(int64_t duration_ms) { if (started_cleanup_) return; - uv_timer_start(timer_handle(), RunTimers, duration_ms, 0); + // Add 1ms to compensate for libuv's uv_now() truncating sub-millisecond + // time. Without this, timers can fire up to 1ms before the requested + // delay when measured with high-resolution clocks (process.hrtime(), + // Date.now()). See: https://github.com/nodejs/node/issues/26578 + uv_timer_start(timer_handle(), RunTimers, duration_ms + 1, 0); } void Environment::ToggleTimerRef(bool ref) { diff --git a/test/parallel/test-timers-no-early-fire.js b/test/parallel/test-timers-no-early-fire.js new file mode 100644 index 00000000000000..e7cdd36938fee0 --- /dev/null +++ b/test/parallel/test-timers-no-early-fire.js @@ -0,0 +1,45 @@ +'use strict'; + +// This test verifies that setTimeout never fires its callback before the +// requested delay has elapsed, as measured by process.hrtime.bigint(). +// +// The bug: libuv's uv_now() truncates sub-millisecond time to integer +// milliseconds. When a timer is scheduled at real time 100.7ms, uv_now() +// returns 100. The timer fires when uv_now() >= 100 + delay, but real +// elapsed time is only delay - 0.7ms. The interaction with setImmediate() +// makes this more likely to occur because it affects when uv_update_time() +// is called. +// +// See: https://github.com/nodejs/node/issues/26578 + +const common = require('../common'); +const assert = require('assert'); + +const DELAY_MS = 100; +const ITERATIONS = 50; + +let completed = 0; + +function test() { + const start = process.hrtime.bigint(); + + setTimeout(common.mustCall(() => { + const elapsed = process.hrtime.bigint() - start; + const elapsedMs = Number(elapsed) / 1e6; + + assert( + elapsedMs >= DELAY_MS, + `setTimeout(${DELAY_MS}) fired after only ${elapsedMs.toFixed(3)}ms` + ); + + completed++; + if (completed < ITERATIONS) { + // Use setImmediate to schedule the next iteration, which is critical + // to reproducing the original bug (the interaction between + // setImmediate and setTimeout affects uv_update_time() timing). + setImmediate(test); + } + }), DELAY_MS); +} + +test();