Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/runtime/cron.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,9 @@ job.ref(); // keep the process alive (default)

### Fake timers

In-process cron honors `jest.useFakeTimers()`. `setSystemTime()`, `advanceTimersByTime()`, and `runAllTimers()` control when it fires, so you can test scheduled callbacks without waiting on the real clock.
In-process cron honors `jest.useFakeTimers()`. `setSystemTime()`, `advanceTimersByTime()`, and `runOnlyPendingTimers()` control when it fires, so you can test scheduled callbacks without waiting on the real clock.

A cron job schedules its next run after every fire, so `runAllTimers()` never runs out of timers. It throws after `timerLimit` timers (100,000 by default), as it does in Jest. Use `advanceTimersByTime()` or `runOnlyPendingTimers()` instead.

---

Expand Down
17 changes: 16 additions & 1 deletion packages/bun-types/test.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,22 @@ declare module "bun:test" {
function fn<T extends (...args: any[]) => any>(func?: T): Mock<T>;
function setSystemTime(now?: number | Date): void;
function setTimeout(milliseconds: number): void;
function useFakeTimers(options?: { now?: number | Date } | "modern" | "legacy"): typeof vi;
function useFakeTimers(
options?:
| {
now?: number | Date;
/**
* The maximum number of timers that one `runAllTimers()` call runs.
* If timers remain after that, `runAllTimers()` throws an
* "assuming an infinite loop" error.
*
* @default 100_000
*/
timerLimit?: number;
}
| "modern"
| "legacy",
): typeof vi;
function useRealTimers(): typeof vi;
function advanceTimersByTime(milliseconds: number): typeof vi;
function advanceTimersToNextTimer(): typeof vi;
Expand Down
80 changes: 62 additions & 18 deletions src/runtime/test_runner/timers/FakeTimers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,18 @@ unsafe extern "C" {
#[derive(Default)]
pub struct FakeTimers {
active: bool,
/// Jest's `timerLimit`: the most timers one `runAllTimers()` call fires
/// before it throws. `useFakeTimers()` sets it on every activation.
timer_limit: u32,
/// The sorted fake timers. TimerHeap is not optimal here because we need these operations:
/// - peek/takeFirst (provided by TimerHeap)
/// - peekLast (cannot be implemented efficiently with TimerHeap)
/// - count (cannot be implemented efficiently with TimerHeap)
pub(crate) timers: TimerHeap,
}

const DEFAULT_TIMER_LIMIT: u32 = 100_000;

// `date_now_offset` is stored as `AtomicU64` (f64 bits) so the static is `Sync`
// without `static mut`.
pub(crate) struct CurrentTime {
Expand Down Expand Up @@ -174,8 +179,9 @@ impl FakeTimers {
self.active
}

fn activate(&mut self, js_now: f64, global: &JSGlobalObject) {
fn activate(&mut self, js_now: f64, timer_limit: u32, global: &JSGlobalObject) {
self.active = true;
self.timer_limit = timer_limit;
CURRENT_TIME.set(global, &Timespec::EPOCH, Some(js_now));
}

Expand Down Expand Up @@ -306,9 +312,27 @@ impl FakeTimers {
Self::execute_until(global, until)
}

/// A timer that re-arms itself on every fire never empties the heap:
/// `setInterval`, a recursive `setTimeout`, `Bun.cron`, or an interval that
/// a built-in module arms for itself (node:http's connections sweep). No
/// test timeout can interrupt this drain. Like Jest, fire at most
/// `timer_limit` timers, then throw if any remain.
fn execute_all_timers(global: &JSGlobalObject) -> JsResult<()> {
while Self::execute_next(global)? {}
Ok(())
// SAFETY: per-thread `timer::All`, live for the VM lifetime; the
// borrow ends at this statement, before any timer fires.
let limit = unsafe { (*timer_all()).fake_timers.timer_limit };
for _ in 0..limit {
if !Self::execute_next(global)? {
return Ok(());
}
}
// SAFETY: as above; no timer is firing.
if unsafe { (*timer_all()).fake_timers.timers.peek() }.is_none() {
return Ok(());
}
Err(global.throw(format_args!(
"Aborting after running {limit} timers, assuming an infinite loop!"
)))
}
}

Expand Down Expand Up @@ -354,6 +378,7 @@ fn set_fake_timer_marker(global: &JSGlobalObject, enabled: bool) -> JsResult<()>
fn use_fake_timers(global: &JSGlobalObject, frame: &CallFrame) -> JsResult<JSValue> {
// SAFETY: FFI call into C++ JSMock
let mut js_now = JSMock__getCurrentUnixTimeMs();
let mut timer_limit = DEFAULT_TIMER_LIMIT;

// Check if options object was provided
let args = frame.arguments_as_array::<1>();
Expand All @@ -365,27 +390,46 @@ fn use_fake_timers(global: &JSGlobalObject, frame: &CallFrame) -> JsResult<JSVal
return Err(global.throw_invalid_arguments(format_args!(
"useFakeTimers() expects an options object"
)));
} else if let Some(now) = options_value.get(global, "now")? {
if now.is_number() {
js_now = now.as_number();
} else if now.is_date() {
js_now = now.get_unix_timestamp();
} else {
return Err(global.throw_invalid_arguments(format_args!(
"'now' must be a number or Date"
)));
} else {
if let Some(now) = options_value.get(global, "now")? {
if now.is_number() {
js_now = now.as_number();
} else if now.is_date() {
js_now = now.get_unix_timestamp();
} else {
return Err(global.throw_invalid_arguments(format_args!(
"'now' must be a number or Date"
)));
}
// NaN is `JSGlobalObject::overridenDateNow`'s "no override" sentinel.
if !js_now.is_finite() {
return Err(global.throw_invalid_arguments(format_args!(
"'now' must be a finite number or a valid Date"
)));
}
}
// NaN is `JSGlobalObject::overridenDateNow`'s "no override" sentinel.
if !js_now.is_finite() {
return Err(global.throw_invalid_arguments(format_args!(
"'now' must be a finite number or a valid Date"
)));
if let Some(limit) = options_value.get(global, "timerLimit")? {
let n = if limit.is_number() {
limit.as_number()
} else {
f64::NAN
};
if !n.is_finite() || n < 1.0 || n > u32::MAX as f64 || n.trunc() != n {
return Err(global.throw_invalid_arguments(format_args!(
"'timerLimit' must be a positive integer"
)));
}
timer_limit = n as u32;
}
}
}

// SAFETY: per-thread `timer::All`; `activate` does not re-enter `All`.
unsafe { (*timer_all()).fake_timers.activate(js_now, global) };
unsafe {
(*timer_all())
.fake_timers
.activate(js_now, timer_limit, global)
};

// Set setTimeout.clock = true to signal that fake timers are enabled.
// This is used by testing-library/react to detect if jest.advanceTimersByTime should be called.
Expand Down
4 changes: 4 additions & 0 deletions test/integration/bun-types/fixture/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,10 @@ expectType(spy.mock.calls).is<any[][]>();
jest.spyOn(console, "log");
jest.fn(() => 123 as const);

jest.useFakeTimers({ now: new Date(0), timerLimit: 1000 });
// @ts-expect-error
jest.useFakeTimers({ timerLimit: "1000" });

xtest("", () => {});
xdescribe("", () => {});
xit("", () => {});
Expand Down
95 changes: 94 additions & 1 deletion test/js/bun/test/fake-timers/fake-timers.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { RedisClient, SQL } from "bun";
import { heapStats } from "bun:jsc";
import { setSystemTime } from "bun:test";
import { bunEnv, bunExe } from "harness";
import { bunEnv, bunExe, isASAN, isDebug } from "harness";
import { spawnSync as childProcessSpawnSync } from "node:child_process";
import { afterEach, describe, expect, test, vi } from "vitest";

Expand Down Expand Up @@ -173,6 +173,99 @@ describe("runAllTimers", () => {
vi.runAllTimers();
expect(order.takeOrderMessages()).toEqual(["9", "10", "14", "20"]);
});

test("throws once timerLimit timers have run and more remain", () => {
vi.useFakeTimers({ timerLimit: 2 });
const fired: number[] = [];
for (const ms of [10, 20, 30]) setTimeout(() => fired.push(ms), ms);
expect(() => vi.runAllTimers()).toThrow("Aborting after running 2 timers, assuming an infinite loop!");
expect({ fired, remaining: vi.getTimerCount() }).toEqual({ fired: [10, 20], remaining: 1 });
});

test.each([
["below", 2],
["at", 3],
])("does not throw when the last timer runs %s timerLimit", (_where, count) => {
vi.useFakeTimers({ timerLimit: 3 });
let fires = 0;
const tick = () => {
if (++fires < count) setTimeout(tick, 10);
};
setTimeout(tick, 10);
expect(vi.runAllTimers()).toBe(vi);
expect({ fires, remaining: vi.getTimerCount() }).toEqual({ fires: count, remaining: 0 });
});

test("each useFakeTimers() call starts from the default timerLimit", () => {
vi.useFakeTimers({ timerLimit: 1 });
vi.useFakeTimers();
const fired: number[] = [];
for (const ms of [10, 20, 30]) setTimeout(() => fired.push(ms), ms);
vi.runAllTimers();
expect(fired).toEqual([10, 20, 30]);
});

test.each([0, -1, 1.5, NaN, Infinity, 2 ** 32, "10", null, {}])(
"useFakeTimers({ timerLimit: %p }) throws",
timerLimit => {
expect(() => vi.useFakeTimers({ timerLimit: timerLimit as any })).toThrow(
"'timerLimit' must be a positive integer",
);
expect(vi.isFakeTimers()).toBe(false);
},
);

// Each source re-arms itself on every fire, so the heap never empties. It
// gives up after `giveUpAfter` fires only so that a build without the cap
// fails the assertion instead of spinning inside runAllTimers(), where no
// test timeout can interrupt it.
const giveUpAfter = 1000;
test.each<[string, (keepGoing: () => boolean) => void]>([
[
"setInterval",
keepGoing => {
const id = setInterval(() => {
if (!keepGoing()) clearInterval(id);
}, 10);
},
],
[
"a recursive setTimeout",
keepGoing => {
const tick = () => {
if (keepGoing()) setTimeout(tick, 10);
};
setTimeout(tick, 10);
},
],
[
"Bun.cron",
keepGoing => {
const job = Bun.cron("* * * * *", () => {
if (!keepGoing()) job.stop();
});
},
],
])("throws after timerLimit timers when %s re-arms itself forever", (_name, start) => {
vi.useFakeTimers({ timerLimit: 50 });
let fires = 0;
start(() => ++fires < giveUpAfter);
expect(() => vi.runAllTimers()).toThrow("Aborting after running 50 timers, assuming an infinite loop!");
expect({ fires, remaining: vi.getTimerCount() }).toEqual({ fires: 50, remaining: 1 });
});

// 100000 timers take about 12 seconds in a debug build, so this runs on
// plain release builds only.
test.skipIf(isDebug || isASAN)("the default timerLimit is 100000", () => {
vi.useFakeTimers();
let fires = 0;
const tick = () => {
if (++fires <= 100_000) setTimeout(tick, 1);
};
setTimeout(tick, 1);
expect(() => vi.runAllTimers()).toThrow("Aborting after running 100000 timers, assuming an infinite loop!");
expect({ fires, remaining: vi.getTimerCount() }).toEqual({ fires: 100_000, remaining: 1 });
});
});
describe("getTimerCount", () => {
test("returns correct count of pending timers", () => {
Expand Down
Loading