From c065c12aef8793bcf589d71891abaa78c6dcd7e0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:46:25 +0000 Subject: [PATCH] bun:test: cap jest.runAllTimers() at timerLimit (default 100000) runAllTimers() drained the fake timer heap with an unbounded loop. A timer that re-arms itself from its own callback (setInterval, a recursive setTimeout, Bun.cron) keeps the heap non-empty, so the call never returned and no test timeout could interrupt it. Like Jest, run at most timerLimit timers per call and throw "Aborting after running N timers, assuming an infinite loop!" if timers remain. The limit defaults to 100000. useFakeTimers({ timerLimit }) sets it. --- docs/runtime/cron.mdx | 4 +- packages/bun-types/test.d.ts | 17 +++- src/runtime/test_runner/timers/FakeTimers.rs | 80 ++++++++++++---- test/integration/bun-types/fixture/test.ts | 4 + .../bun/test/fake-timers/fake-timers.test.ts | 95 ++++++++++++++++++- 5 files changed, 179 insertions(+), 21 deletions(-) diff --git a/docs/runtime/cron.mdx b/docs/runtime/cron.mdx index c6caea9e9ba0..3db7cc1fef50 100644 --- a/docs/runtime/cron.mdx +++ b/docs/runtime/cron.mdx @@ -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. --- diff --git a/packages/bun-types/test.d.ts b/packages/bun-types/test.d.ts index 2ce1cba4695b..f2c9411bfc6f 100644 --- a/packages/bun-types/test.d.ts +++ b/packages/bun-types/test.d.ts @@ -98,7 +98,22 @@ declare module "bun:test" { function fn any>(func?: T): Mock; 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; diff --git a/src/runtime/test_runner/timers/FakeTimers.rs b/src/runtime/test_runner/timers/FakeTimers.rs index 9604d487b296..fa1df0f06187 100644 --- a/src/runtime/test_runner/timers/FakeTimers.rs +++ b/src/runtime/test_runner/timers/FakeTimers.rs @@ -21,6 +21,9 @@ 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) @@ -28,6 +31,8 @@ pub struct FakeTimers { 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 { @@ -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)); } @@ -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!" + ))) } } @@ -354,6 +378,7 @@ fn set_fake_timer_marker(global: &JSGlobalObject, enabled: bool) -> JsResult<()> fn use_fake_timers(global: &JSGlobalObject, frame: &CallFrame) -> JsResult { // 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>(); @@ -365,27 +390,46 @@ fn use_fake_timers(global: &JSGlobalObject, frame: &CallFrame) -> JsResult 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. diff --git a/test/integration/bun-types/fixture/test.ts b/test/integration/bun-types/fixture/test.ts index 8725d1db569a..ded177402b75 100644 --- a/test/integration/bun-types/fixture/test.ts +++ b/test/integration/bun-types/fixture/test.ts @@ -349,6 +349,10 @@ expectType(spy.mock.calls).is(); 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("", () => {}); diff --git a/test/js/bun/test/fake-timers/fake-timers.test.ts b/test/js/bun/test/fake-timers/fake-timers.test.ts index 068388d9b7bc..e126b6aca2b0 100644 --- a/test/js/bun/test/fake-timers/fake-timers.test.ts +++ b/test/js/bun/test/fake-timers/fake-timers.test.ts @@ -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"; @@ -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", () => {