Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
ca27d2e399 fix(timers): cache scheduling time to prevent setTimeout/setImmediate interleaving
When multiple `setTimeout(fn, delay)` calls were made in the same event
loop tick, each call to `reschedule()` independently called
`timespec.now()`, giving them slightly different expiry times. This
caused timers that should logically fire together to fire in separate
event loop iterations. If a callback in the first timer scheduled a
`setImmediate`, it could interleave between the two timer callbacks.

This adds a cached time mechanism (similar to Node.js's `uv_update_time`)
that ensures all timers scheduled within the same event loop tick share
the same base time. The cache is invalidated after the event loop wakes
from I/O polling, so the next tick gets a fresh timestamp.

Closes #26508

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-16 09:36:54 +00:00
4 changed files with 60 additions and 1 deletions

View File

@@ -31,6 +31,12 @@ pub const All = struct {
immediate_ref_count: i32 = 0,
uv_idle: if (Environment.isWindows) uv.uv_idle_t else void = if (Environment.isWindows) std.mem.zeroes(uv.uv_idle_t),
/// Cached time for the current event loop tick, similar to Node.js's
/// `uv_update_time`. This ensures that timers scheduled during the same
/// event loop tick use the same base time, so two `setTimeout(fn, 1)` calls
/// in the same tick always fire together in the same drain.
cached_now: timespec = .epoch,
// Event loop delay monitoring (not exposed to JS)
event_loop_delay: EventLoopDelayMonitor = .{},
@@ -60,6 +66,21 @@ pub const All = struct {
};
}
/// Return the cached current time for this event loop tick.
/// The cache is refreshed once per tick via `updateCachedNow` or lazily
/// on the first call within a tick.
pub fn getCachedNow(this: *All) timespec {
if (this.cached_now.sec == 0 and this.cached_now.nsec == 0) {
this.cached_now = timespec.now(.allow_mocked_time);
}
return this.cached_now;
}
/// Invalidate the cached time so it will be refreshed on next access.
pub fn invalidateCachedNow(this: *All) void {
this.cached_now = .epoch;
}
pub fn insert(this: *All, timer: *EventLoopTimer) void {
this.lock.lock();
defer this.lock.unlock();

View File

@@ -402,7 +402,9 @@ pub fn reschedule(this: *TimerObjectInternals, timer: JSValue, vm: *VirtualMachi
// https://github.com/nodejs/node/blob/a7cbb904745591c9a9d047a364c2c188e5470047/lib/internal/timers.js#L612
if (!this.shouldRescheduleTimer(repeat, idle_timeout)) return;
const now = timespec.now(.allow_mocked_time);
// Use cached time so that multiple timers scheduled in the same event loop
// tick get the same base time (matching Node.js's uv_update_time behavior).
const now = vm.timer.getCachedNow();
const scheduled_time = now.addMs(this.interval);
const was_active = this.eventLoopTimer().state == .ACTIVE;
if (was_active) {

View File

@@ -394,6 +394,13 @@ pub fn autoTick(this: *EventLoop) void {
}
}
// Invalidate the cached timer scheduling time so that timers created
// during this tick get a fresh base time. This ensures timers scheduled
// in the same JS execution context share the same base time (like
// Node.js's uv_update_time), preventing two setTimeout(fn, 1) calls
// from getting different expiry times due to microsecond differences.
ctx.timer.invalidateCachedNow();
if (Environment.isPosix) {
ctx.timer.drainTimers(ctx);
}
@@ -467,6 +474,8 @@ pub fn autoTickActive(this: *EventLoop) void {
loop.tickWithoutIdle();
}
ctx.timer.invalidateCachedNow();
if (Environment.isPosix) {
ctx.timer.drainTimers(ctx);
}

View File

@@ -0,0 +1,27 @@
import { expect, test } from "bun:test";
// https://github.com/oven-sh/bun/issues/26508
// setImmediate callbacks should not interleave between setTimeout callbacks
// that expire at the same logical time. Node.js runs all expired timers
// before processing immediates (check phase).
test("setImmediate should not run between two expired setTimeout callbacks", async () => {
// Run the test multiple times since the original bug was timing-dependent
// (~10-20% failure rate per run on debug builds).
for (let i = 0; i < 50; i++) {
const result = await new Promise<boolean>(resolve => {
let immediateRan = false;
const t1 = setTimeout(() => {
setImmediate(() => {
immediateRan = true;
});
});
const t2 = setTimeout(() => {
resolve(immediateRan);
});
t2._idleStart = t1._idleStart;
});
expect(result).toBe(false);
}
});