fix(timers): add _idleStart property to Timeout object (#26021)

## Summary

- Add `_idleStart` property (getter/setter) to the Timeout object
returned by `setTimeout()` and `setInterval()`
- The property returns a monotonic timestamp (in milliseconds)
representing when the timer was created
- This mimics Node.js's behavior where `_idleStart` is the libuv
timestamp at timer creation time

## Test plan

- [x] Verified test fails with `USE_SYSTEM_BUN=1 bun test
test/regression/issue/25639.test.ts`
- [x] Verified test passes with `bun bd test
test/regression/issue/25639.test.ts`
- [x] Manual verification:
  ```bash
  # Bun with fix - _idleStart exists
./build/debug/bun-debug -e "const t = setTimeout(() => {}, 0);
console.log('_idleStart' in t, typeof t._idleStart); clearTimeout(t)"
  # Output: true number
  
  # Node.js reference - same behavior
node -e "const t = setTimeout(() => {}, 0); console.log('_idleStart' in
t, typeof t._idleStart); clearTimeout(t)"
  # Output: true number
  ```

Closes #25639

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
robobun
2026-01-12 19:35:11 -08:00
committed by GitHub
parent d530ed993d
commit 3196178fa7
4 changed files with 90 additions and 8 deletions

View File

@@ -118,6 +118,14 @@ pub fn set_repeat(_: *Self, thisValue: JSValue, globalThis: *JSGlobalObject, val
Self.js.repeatSetCached(thisValue, globalThis, value);
}
pub fn get_idleStart(_: *Self, thisValue: JSValue, _: *JSGlobalObject) JSValue {
return Self.js.idleStartGetCached(thisValue).?;
}
pub fn set_idleStart(_: *Self, thisValue: JSValue, globalThis: *JSGlobalObject, value: JSValue) void {
Self.js.idleStartSetCached(thisValue, globalThis, value);
}
pub fn dispose(self: *Self, globalThis: *JSGlobalObject, _: *jsc.CallFrame) bun.JSError!JSValue {
self.internals.cancel(globalThis.bunVM());
return .js_undefined;

View File

@@ -242,7 +242,7 @@ fn convertToInterval(this: *TimerObjectInternals, global: *JSGlobalObject, timer
this.strong_this.set(global, timer);
this.flags.kind = .setInterval;
this.interval = new_interval;
this.reschedule(timer, vm);
this.reschedule(timer, vm, global);
}
pub fn run(this: *TimerObjectInternals, globalThis: *jsc.JSGlobalObject, timer: JSValue, callback: JSValue, arguments: JSValue, async_id: u64, vm: *jsc.VirtualMachine) bool {
@@ -293,8 +293,8 @@ pub fn init(
TimeoutObject.js.idleTimeoutSetCached(timer, global, .jsNumber(interval));
TimeoutObject.js.repeatSetCached(timer, global, if (kind == .setInterval) .jsNumber(interval) else .null);
// this increments the refcount
this.reschedule(timer, vm);
// this increments the refcount and sets _idleStart
this.reschedule(timer, vm, global);
}
this.strong_this.set(global, timer);
@@ -328,7 +328,7 @@ pub fn doRefresh(this: *TimerObjectInternals, globalObject: *jsc.JSGlobalObject,
}
this.strong_this.set(globalObject, this_value);
this.reschedule(this_value, VirtualMachine.get());
this.reschedule(this_value, VirtualMachine.get(), globalObject);
return this_value;
}
@@ -371,7 +371,7 @@ fn shouldRescheduleTimer(this: *TimerObjectInternals, repeat: JSValue, idle_time
return true;
}
pub fn reschedule(this: *TimerObjectInternals, timer: JSValue, vm: *VirtualMachine) void {
pub fn reschedule(this: *TimerObjectInternals, timer: JSValue, vm: *VirtualMachine, globalThis: *JSGlobalObject) void {
if (this.flags.kind == .setImmediate) return;
const idle_timeout = TimeoutObject.js.idleTimeoutGetCached(timer).?;
@@ -380,7 +380,8 @@ 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.msFromNow(.allow_mocked_time, this.interval);
const now = timespec.now(.allow_mocked_time);
const scheduled_time = now.addMs(this.interval);
const was_active = this.eventLoopTimer().state == .ACTIVE;
if (was_active) {
vm.timer.remove(this.eventLoopTimer());
@@ -388,9 +389,13 @@ pub fn reschedule(this: *TimerObjectInternals, timer: JSValue, vm: *VirtualMachi
this.ref();
}
vm.timer.update(this.eventLoopTimer(), &now);
vm.timer.update(this.eventLoopTimer(), &scheduled_time);
this.flags.has_cleared_timer = false;
// Set _idleStart to the current monotonic timestamp in milliseconds
// This mimics Node.js's behavior where _idleStart is the libuv timestamp when the timer was scheduled
TimeoutObject.js.idleStartSetCached(timer, globalThis, .jsNumber(now.msUnsigned()));
if (this.flags.has_js_ref) {
this.setEnableKeepingEventLoopAlive(vm, true);
}

View File

@@ -184,13 +184,18 @@ export default [
setter: "set_repeat",
this: true,
},
_idleStart: {
getter: "get_idleStart",
setter: "set_idleStart",
this: true,
},
["@@dispose"]: {
fn: "dispose",
length: 0,
invalidThisBehavior: InvalidThisBehavior.NoOp,
},
},
values: ["arguments", "callback", "idleTimeout", "repeat"],
values: ["arguments", "callback", "idleTimeout", "repeat", "idleStart"],
}),
define({
name: "Immediate",

View File

@@ -0,0 +1,64 @@
import { expect, test } from "bun:test";
// GitHub Issue #25639: setTimeout Timeout object missing _idleStart property
// Next.js 16 uses _idleStart to coordinate timers for Cache Components
test("setTimeout returns Timeout object with _idleStart property", () => {
const timer = setTimeout(() => {}, 100);
try {
// Verify _idleStart exists and is a number
expect("_idleStart" in timer).toBe(true);
expect(typeof timer._idleStart).toBe("number");
// _idleStart should be a positive timestamp
expect(timer._idleStart).toBeGreaterThan(0);
} finally {
clearTimeout(timer);
}
});
test("setInterval returns Timeout object with _idleStart property", () => {
const timer = setInterval(() => {}, 100);
try {
// Verify _idleStart exists and is a number
expect("_idleStart" in timer).toBe(true);
expect(typeof timer._idleStart).toBe("number");
// _idleStart should be a positive timestamp
expect(timer._idleStart).toBeGreaterThan(0);
} finally {
clearInterval(timer);
}
});
test("_idleStart is writable (Next.js modifies it to coordinate timers)", () => {
const timer = setTimeout(() => {}, 100);
try {
const originalIdleStart = timer._idleStart;
expect(typeof originalIdleStart).toBe("number");
// Next.js sets _idleStart to coordinate timers
const newIdleStart = originalIdleStart - 100;
timer._idleStart = newIdleStart;
expect(timer._idleStart).toBe(newIdleStart);
} finally {
clearTimeout(timer);
}
});
test("timers created at different times have different _idleStart values", async () => {
const timer1 = setTimeout(() => {}, 100);
// Wait a bit to ensure different timestamp
await Bun.sleep(10);
const timer2 = setTimeout(() => {}, 100);
try {
expect(timer2._idleStart).toBeGreaterThanOrEqual(timer1._idleStart);
} finally {
clearTimeout(timer1);
clearTimeout(timer2);
}
});