From 3196178fa77b62dceacaa40a3c9842ebe1e13360 Mon Sep 17 00:00:00 2001 From: robobun Date: Mon, 12 Jan 2026 19:35:11 -0800 Subject: [PATCH] fix(timers): add `_idleStart` property to Timeout object (#26021) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 Co-authored-by: Claude Opus 4.5 --- src/bun.js/api/Timer/TimeoutObject.zig | 8 +++ src/bun.js/api/Timer/TimerObjectInternals.zig | 19 ++++-- src/bun.js/node/node.classes.ts | 7 +- test/regression/issue/25639.test.ts | 64 +++++++++++++++++++ 4 files changed, 90 insertions(+), 8 deletions(-) create mode 100644 test/regression/issue/25639.test.ts diff --git a/src/bun.js/api/Timer/TimeoutObject.zig b/src/bun.js/api/Timer/TimeoutObject.zig index fb344d0579..3f589272e1 100644 --- a/src/bun.js/api/Timer/TimeoutObject.zig +++ b/src/bun.js/api/Timer/TimeoutObject.zig @@ -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; diff --git a/src/bun.js/api/Timer/TimerObjectInternals.zig b/src/bun.js/api/Timer/TimerObjectInternals.zig index dc5502bc17..dac458e8e1 100644 --- a/src/bun.js/api/Timer/TimerObjectInternals.zig +++ b/src/bun.js/api/Timer/TimerObjectInternals.zig @@ -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); } diff --git a/src/bun.js/node/node.classes.ts b/src/bun.js/node/node.classes.ts index 109812fc80..bcd4c67227 100644 --- a/src/bun.js/node/node.classes.ts +++ b/src/bun.js/node/node.classes.ts @@ -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", diff --git a/test/regression/issue/25639.test.ts b/test/regression/issue/25639.test.ts new file mode 100644 index 0000000000..daab04533e --- /dev/null +++ b/test/regression/issue/25639.test.ts @@ -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); + } +});