feat: implement vi.advanceTimersToNextTick()

This commit is contained in:
pfg
2025-10-09 22:59:23 -07:00
parent e0a2a9ed84
commit 0380651913
6 changed files with 341 additions and 43 deletions

View File

@@ -191,6 +191,31 @@ declare module "bun:test" {
useFakeTimers: typeof jest.useFakeTimers;
useRealTimers: typeof jest.useRealTimers;
runAllTicks: typeof jest.runAllTicks;
/**
* Advances all timers to the next timer (earliest scheduled timer).
* Useful for stepping through timers one at a time.
*
* @returns this (the vi object) for chaining
*
* @example
* ```ts
* import { vi, test, expect } from "bun:test";
*
* test("vi.advanceTimersToNextTimer", () => {
* vi.useFakeTimers();
*
* let i = 0;
* setInterval(() => console.log(++i), 50);
*
* vi.advanceTimersToNextTimer() // log: 1
* .advanceTimersToNextTimer() // log: 2
* .advanceTimersToNextTimer(); // log: 3
*
* vi.useRealTimers();
* });
* ```
*/
advanceTimersToNextTimer(): typeof vi;
};
interface FunctionLike {

View File

@@ -578,6 +578,35 @@ pub const All = struct {
return .js_undefined;
}
/// Internal function that advances to the next timer in the vi_timers heap.
/// Returns true if a timer was fired, false if no timers remain.
fn advanceToNextViTimer(timer: *All, vm: *VirtualMachine) bool {
// Get the next timer from the heap
timer.lock.lock();
const event_timer = timer.vi_timers.deleteMin();
timer.lock.unlock();
if (event_timer) |et| {
// Set the virtual time to this timer's scheduled time
// This ensures nested timers scheduled during callbacks get the correct base time
timer.vi_current_time = et.next;
// Fire the timer at its scheduled time
const arm_result = et.fire(&et.next, vm);
// If the timer wants to rearm (like setInterval), reinsert it
if (arm_result == .rearm) {
timer.lock.lock();
timer.vi_timers.insert(et);
timer.lock.unlock();
}
return true;
}
return false;
}
pub fn runAllTimers(
globalThis: *JSGlobalObject,
_: *jsc.CallFrame,
@@ -588,27 +617,7 @@ pub const All = struct {
// Keep firing timers until there are no more
// We need to keep checking the heap because firing timers can schedule new timers
while (true) {
timer.lock.lock();
const event_timer = timer.vi_timers.deleteMin();
timer.lock.unlock();
if (event_timer == null) break;
// Set the virtual time to this timer's scheduled time
// This ensures nested timers scheduled during callbacks get the correct base time
timer.vi_current_time = event_timer.?.next;
// Fire the timer at its scheduled time
const arm_result = event_timer.?.fire(&event_timer.?.next, vm);
// If the timer wants to rearm (like setInterval), reinsert it
if (arm_result == .rearm) {
timer.lock.lock();
timer.vi_timers.insert(event_timer.?);
timer.lock.unlock();
}
}
while (advanceToNextViTimer(timer, vm)) {}
// Clear the virtual time when we're done
timer.vi_current_time = null;
@@ -616,6 +625,28 @@ pub const All = struct {
return .js_undefined;
}
pub fn advanceTimersToNextTimer(
globalThis: *JSGlobalObject,
callframe: *jsc.CallFrame,
) JSError!JSValue {
jsc.markBinding(@src());
const vm = globalThis.bunVM();
const timer = &vm.timer;
// Advance to the next timer (if any)
_ = advanceToNextViTimer(timer, vm);
// Keep the virtual time set for subsequent calls (don't clear it)
// Return the 'this' value (the vi object) for chaining
return callframe.this();
}
export fn Bun__Timer__initViTime(vm: *VirtualMachine) callconv(.C) void {
const timer = &vm.timer;
// Initialize virtual time to zero so timers are scheduled relative to time 0
timer.vi_current_time = timespec{ .sec = 0, .nsec = 0 };
}
export fn Bun__Timer__clearViTimers(vm: *VirtualMachine) callconv(.C) void {
const timer = &vm.timer;
timer.lock.lock();
@@ -636,7 +667,9 @@ pub const All = struct {
@export(&jsc.host_fn.wrap2(clearTimeout), .{ .name = "Bun__Timer__clearTimeout" });
@export(&jsc.host_fn.wrap2(clearInterval), .{ .name = "Bun__Timer__clearInterval" });
@export(&jsc.host_fn.wrap2(runAllTimers), .{ .name = "Bun__Timer__runAllTimers" });
@export(&jsc.host_fn.wrap2(advanceTimersToNextTimer), .{ .name = "Bun__Timer__advanceTimersToNextTimer" });
@export(&getNextID, .{ .name = "Bun__Timer__getNextID" });
_ = &Bun__Timer__initViTime;
_ = &Bun__Timer__clearViTimers;
}
};

View File

@@ -1423,6 +1423,8 @@ JSC_DEFINE_HOST_FUNCTION(jsMockFunctionWithImplementation, (JSC::JSGlobalObject
using namespace Bun;
using namespace JSC;
extern "C" void Bun__Timer__initViTime(void*);
// Enables fake timers and sets up Date mocking
BUN_DEFINE_HOST_FUNCTION(JSMock__jsUseFakeTimers, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callframe))
{
@@ -1433,6 +1435,14 @@ BUN_DEFINE_HOST_FUNCTION(JSMock__jsUseFakeTimers, (JSC::JSGlobalObject * globalO
// Initialize with current time if not already set
globalObject->overridenDateNow = globalObject->jsDateNow();
}
// Initialize vi_current_time so timers are scheduled relative to time 0
auto* bunGlobal = jsCast<Zig::GlobalObject*>(globalObject);
auto* vm = bunGlobal->bunVM();
if (vm) {
Bun__Timer__initViTime(vm);
}
return JSValue::encode(callframe->thisValue());
}

View File

@@ -260,6 +260,7 @@ pub const Jest = struct {
vi.put(globalObject, ZigString.static("isFakeTimers"), jsc.host_fn.NewFunction(globalObject, ZigString.static("isFakeTimers"), 0, JSMock__jsIsFakeTimers, false));
vi.put(globalObject, ZigString.static("runAllTimers"), jsc.host_fn.NewFunction(globalObject, ZigString.static("runAllTimers"), 0, Bun__Timer__runAllTimers, false));
vi.put(globalObject, ZigString.static("runAllTicks"), jsc.host_fn.NewFunction(globalObject, ZigString.static("runAllTicks"), 0, JSMock__jsRunAllTicks, false));
vi.put(globalObject, ZigString.static("advanceTimersToNextTimer"), jsc.host_fn.NewFunction(globalObject, ZigString.static("advanceTimersToNextTimer"), 0, Bun__Timer__advanceTimersToNextTimer, false));
module.put(globalObject, ZigString.static("vi"), vi);
}
@@ -278,6 +279,7 @@ pub const Jest = struct {
extern fn JSMock__jsUseFakeTimers(*JSGlobalObject, *CallFrame) callconv(jsc.conv) JSValue;
extern fn JSMock__jsUseRealTimers(*JSGlobalObject, *CallFrame) callconv(jsc.conv) JSValue;
extern fn Bun__Timer__runAllTimers(*JSGlobalObject, *CallFrame) callconv(jsc.conv) JSValue;
extern fn Bun__Timer__advanceTimersToNextTimer(*JSGlobalObject, *CallFrame) callconv(jsc.conv) JSValue;
pub fn call(
globalObject: *JSGlobalObject,

View File

@@ -3,22 +3,35 @@ import { vi, test, beforeAll, afterAll, expect } from "vitest";
beforeAll(() => vi.useFakeTimers());
afterAll(() => vi.useRealTimers());
test.each(Array.from({ length: 2 }).map((_, i) => i))("runAllTimers runs in order of time", i => {
const order: string[] = [];
const orderNum: number[] = [];
let base = 0;
const time = (d: number, l: string, cb: () => void) => {
const start = base;
class TimeHelper {
base: number = 0;
order: string[] = [];
orderNum: number[] = [];
time(d: number, l: string, cb: () => void) {
const start = this.base;
setTimeout(() => {
orderNum.push(start + d);
order.push(`${start + d}${l ? ` (${l})` : ""}`);
if (base != 0) throw new Error("base is not 0");
base = start + d;
this.addOrder(start + d, l);
if (this.base != 0) throw new Error("base is not 0");
this.base = start + d;
cb();
base = 0;
this.base = 0;
}, d);
};
}
addOrder(d: number, l: string) {
this.orderNum.push(d);
this.order.push(`${d}${l ? ` (${l})` : ""}`);
}
expectOrder() {
expect(this.orderNum).toEqual(this.orderNum.toSorted((a, b) => a - b));
const order = this.order;
this.order = [];
return expect(order);
}
}
test.each(["runAllTimers", "advanceTimersToNextTimer"])("%s runs in order of time", mode => {
const tester = new TimeHelper();
const time = tester.time.bind(tester);
time(1000, "", () => {
time(500, "", () => {});
@@ -41,18 +54,30 @@ test.each(Array.from({ length: 2 }).map((_, i) => i))("runAllTimers runs in orde
const interval = setInterval(() => {
if (intervalCount > 3) clearInterval(interval);
intervalCount += 1;
orderNum.push(intervalCount * 499);
order.push(`${intervalCount * 499} (interval)`);
tester.addOrder(intervalCount * 499, "interval");
setTimeout(() => {
orderNum.push(intervalCount * 499 + 25);
order.push(`${intervalCount * 499 + 25} (interval + 25)`);
tester.addOrder(intervalCount * 499 + 25, "interval + 25");
}, 25);
}, 499);
vi.runAllTimers();
if (mode === "runAllTimers") {
vi.runAllTimers();
} else if (mode === "advanceTimersToNextTimer") {
let orderLen = 0;
while (true) {
vi.advanceTimersToNextTimer();
if (tester.order.length > orderLen) {
expect(tester.order.length).toBe(orderLen + 1);
orderLen = tester.order.length;
} else if (tester.order.length === orderLen) {
break;
} else {
expect.fail();
}
}
}
expect(orderNum).toEqual(orderNum.toSorted((a, b) => a - b));
expect(order).toMatchInlineSnapshot(`
tester.expectOrder().toMatchInlineSnapshot(`
[
"0 (zero 1)",
"0 (zero 2)",
@@ -88,8 +113,6 @@ test("runAllTimers supports interval", () => {
if (ticks >= 10) clearInterval(interval);
}, 25);
vi.runAllTimers();
expect(ticks).toBe(10);
});

View File

@@ -0,0 +1,205 @@
import { expect, test, vi } from "bun:test";
test("vi.advanceTimersToNextTimer() advances to next timer", () => {
vi.useFakeTimers();
let i = 0;
setInterval(() => i++, 50);
expect(i).toBe(0);
vi.advanceTimersToNextTimer();
expect(i).toBe(1);
vi.advanceTimersToNextTimer();
expect(i).toBe(2);
vi.advanceTimersToNextTimer();
expect(i).toBe(3);
vi.useRealTimers();
});
test("vi.advanceTimersToNextTimer() is chainable", () => {
vi.useFakeTimers();
let i = 0;
setInterval(() => i++, 50);
expect(i).toBe(0);
vi.advanceTimersToNextTimer().advanceTimersToNextTimer().advanceTimersToNextTimer();
expect(i).toBe(3);
vi.useRealTimers();
});
test("vi.advanceTimersToNextTimer() handles multiple timers with different delays", () => {
vi.useFakeTimers();
const order: number[] = [];
setTimeout(() => order.push(1), 100);
setTimeout(() => order.push(2), 50);
setTimeout(() => order.push(3), 150);
expect(order).toEqual([]);
// Should fire in order of scheduled time, not order they were created
vi.advanceTimersToNextTimer(); // fires timer at 50ms
expect(order).toEqual([2]);
vi.advanceTimersToNextTimer(); // fires timer at 100ms
expect(order).toEqual([2, 1]);
vi.advanceTimersToNextTimer(); // fires timer at 150ms
expect(order).toEqual([2, 1, 3]);
vi.useRealTimers();
});
test("vi.advanceTimersToNextTimer() handles nested timers", () => {
vi.useFakeTimers();
const order: number[] = [];
setTimeout(() => {
order.push(1);
setTimeout(() => order.push(3), 50);
}, 100);
setTimeout(() => order.push(2), 150);
vi.advanceTimersToNextTimer(); // fires timer at 100ms, schedules new timer at current_time + 50ms
expect(order).toEqual([1]);
// When two timers are at the same time (150ms), they fire in the order they were scheduled (FIFO)
// Timer 2 was scheduled first (at time 0), so it fires before timer 3 (scheduled during callback at time 100)
vi.advanceTimersToNextTimer(); // fires timer at 150ms (timer 2, scheduled first)
expect(order).toEqual([1, 2]);
vi.advanceTimersToNextTimer(); // fires nested timer at 150ms (timer 3, scheduled during callback)
expect(order).toEqual([1, 2, 3]);
vi.useRealTimers();
});
test("vi.advanceTimersToNextTimer() does nothing when no timers are pending", () => {
vi.useFakeTimers();
let called = false;
setTimeout(() => {
called = true;
}, 100);
vi.advanceTimersToNextTimer();
expect(called).toBe(true);
// No more timers, this should be safe to call
vi.advanceTimersToNextTimer();
vi.advanceTimersToNextTimer();
vi.useRealTimers();
});
test("vi.advanceTimersToNextTimer() works with setInterval", () => {
vi.useFakeTimers();
let count = 0;
const intervalId = setInterval(() => {
count++;
if (count >= 3) {
clearInterval(intervalId);
}
}, 100);
expect(count).toBe(0);
vi.advanceTimersToNextTimer();
expect(count).toBe(1);
vi.advanceTimersToNextTimer();
expect(count).toBe(2);
vi.advanceTimersToNextTimer();
expect(count).toBe(3);
// Interval was cleared, no more timers
vi.advanceTimersToNextTimer();
expect(count).toBe(3);
vi.useRealTimers();
});
test("vi.advanceTimersToNextTimer() handles mix of setTimeout and setInterval", () => {
vi.useFakeTimers();
const events: string[] = [];
setTimeout(() => events.push("timeout-1"), 50);
const intervalId = setInterval(() => {
events.push("interval");
if (events.filter(e => e === "interval").length >= 2) {
clearInterval(intervalId);
}
}, 100);
setTimeout(() => events.push("timeout-2"), 200);
vi.advanceTimersToNextTimer(); // 50ms: timeout-1
expect(events).toEqual(["timeout-1"]);
vi.advanceTimersToNextTimer(); // 100ms: interval (1st)
expect(events).toEqual(["timeout-1", "interval"]);
vi.advanceTimersToNextTimer(); // 200ms: interval (2nd) and timeout-2 compete
// The interval at 200ms should fire first (it was scheduled first), then gets cleared
expect(events.length).toBe(3);
expect(events).toContain("interval");
vi.advanceTimersToNextTimer(); // 200ms: timeout-2
expect(events).toContain("timeout-2");
vi.useRealTimers();
});
test("vi.advanceTimersToNextTimer() executes timer callbacks with automatic microtask processing", () => {
vi.useFakeTimers();
const order: string[] = [];
setTimeout(() => {
order.push("timer");
process.nextTick(() => order.push("tick"));
}, 100);
// When a timer callback runs, microtasks (including nextTick) are processed automatically
// This is standard JavaScript execution behavior
vi.advanceTimersToNextTimer();
expect(order).toEqual(["timer", "tick"]);
vi.useRealTimers();
});
test("vi.advanceTimersToNextTimer() returns vi object for chaining with other vi methods", () => {
vi.useFakeTimers();
let timerCalled = false;
let tickCalled = false;
setTimeout(() => {
timerCalled = true;
process.nextTick(() => {
tickCalled = true;
});
}, 100);
// Microtasks run automatically after timer callback, so runAllTicks() is not needed here
// but chaining still works
vi.advanceTimersToNextTimer();
expect(timerCalled).toBe(true);
expect(tickCalled).toBe(true);
vi.useRealTimers();
});