diff --git a/src/bun_core/util.rs b/src/bun_core/util.rs
index 8a599cc75379..02ccba71ca77 100644
--- a/src/bun_core/util.rs
+++ b/src/bun_core/util.rs
@@ -5092,49 +5092,50 @@ pub mod timespec_mode {
/// Mocked-time storage. The data lives at T0 so `Timespec::now` reads it
/// directly; the test-runner (`useFakeTimers`) writes via `set`/`clear`
-/// from `bun_runtime::test_runner::timers::FakeTimers::CurrentTime`.
-/// Sentinel `i64::MIN` / `NaN` ⇒ not mocked.
+/// from `bun_runtime::test_runner::timers::FakeTimers`. Thread-local so each
+/// VM (main thread, each `Worker`) has its own fake clock, matching the
+/// per-thread fake `TimerHeap` it drives.
pub mod mock_time {
- use core::sync::atomic::{AtomicI64, AtomicU64, Ordering};
+ use core::cell::Cell;
- static MOCKED_TIME_NS: AtomicI64 = AtomicI64::new(i64::MIN);
- // Mocked wall-clock `Date.now()` in ms, stored as f64 bits; NaN = unset.
- static MOCKED_WALL_MS: AtomicU64 = AtomicU64::new(f64::NAN.to_bits());
+ std::thread_local! {
+ static MOCKED_TIME_NS: Cell> = const { Cell::new(None) };
+ /// Mocked wall-clock `Date.now()` in ms.
+ static MOCKED_WALL_MS: Cell > = const { Cell::new(None) };
+ }
/// Set the mocked monotonic time (nanoseconds). Called by fake-timers.
#[inline]
pub fn set(ns: i64) {
- MOCKED_TIME_NS.store(ns, Ordering::Relaxed);
+ MOCKED_TIME_NS.set(Some(ns));
}
/// Clear the mocked time so `Timespec::now(AllowMockedTime)` reads the
/// real clock again.
#[inline]
pub fn clear() {
- MOCKED_TIME_NS.store(i64::MIN, Ordering::Relaxed);
+ MOCKED_TIME_NS.set(None);
}
/// Current mocked time, or `None` if not mocked.
#[inline]
pub fn get() -> Option {
- let v = MOCKED_TIME_NS.load(Ordering::Relaxed);
- if v == i64::MIN { None } else { Some(v) }
+ MOCKED_TIME_NS.get()
}
/// Set the mocked wall-clock time (`Date.now()` in ms). Called by
/// fake-timers alongside `set` so calendar-based consumers and the
/// monotonic timer heap move through the same mock.
#[inline]
pub fn set_wall_ms(ms: f64) {
- MOCKED_WALL_MS.store(ms.to_bits(), Ordering::Relaxed);
+ MOCKED_WALL_MS.set(Some(ms));
}
/// Clear the mocked wall-clock time.
#[inline]
pub fn clear_wall() {
- MOCKED_WALL_MS.store(f64::NAN.to_bits(), Ordering::Relaxed);
+ MOCKED_WALL_MS.set(None);
}
/// Current mocked wall-clock time in ms, or `None` if not mocked.
#[inline]
pub(crate) fn wall_ms() -> Option {
- let v = f64::from_bits(MOCKED_WALL_MS.load(Ordering::Relaxed));
- if v.is_nan() { None } else { Some(v) }
+ MOCKED_WALL_MS.get()
}
}
diff --git a/src/runtime/api/cron.rs b/src/runtime/api/cron.rs
index ea9ede4d3ad9..818b125ac279 100644
--- a/src/runtime/api/cron.rs
+++ b/src/runtime/api/cron.rs
@@ -39,7 +39,7 @@ use bun_resolver::fs::RealFS;
#[cfg(not(windows))]
use crate::api::bun::process::SpawnResultExt as _;
use crate::api::bun::process::{self as spawn, Process, Rusage, SpawnOptions, Status};
-use crate::timer::{EventLoopTimer, EventLoopTimerState, EventLoopTimerTag};
+use crate::timer::{EventLoopTimer, EventLoopTimerState, EventLoopTimerTag, InHeap};
use bun_core::ZStr;
use bun_core::strings;
use bun_io::pipe_reader::BufferedReaderParent;
@@ -1810,6 +1810,14 @@ impl CronJob {
// holds the raw pointer (not `&mut`) so re-entrant JS can re-borrow.
let _ev_guard = vm.enter_event_loop_scope();
+ // The fake clock this tick was popped from, if the job is a fake timer
+ // (`in_heap` still names the heap until it is re-inserted or removed).
+ let fake_clock_before_call = if this_ref.event_loop_timer.get().in_heap == InHeap::Fake {
+ timer_all().fake_timers.clock_id()
+ } else {
+ None
+ };
+
this_ref.in_fire.set(true);
// A top-level call: what the tick throws is reported here (before the
// job is re-armed, so an `uncaughtException` handler's `stop()` is
@@ -1818,6 +1826,15 @@ impl CronJob {
let result =
vm.event_loop_mut()
.run_callback_with_result(cb, &this_ref.global, js_this, &[]);
+ // The tick uninstalled (`useRealTimers()`) or replaced
+ // (`useFakeTimers()`) that clock. Already popped, this job was out of
+ // `FakeTimers::clear`'s reach; stop it like the rest of that clock's
+ // timers (deferred while `in_fire`, finished by `schedule_next`).
+ if fake_clock_before_call.is_some()
+ && timer_all().fake_timers.clock_id() != fake_clock_before_call
+ {
+ Self::self_stop(this, vm);
+ }
this_ref.in_fire.set(false);
// terminate() may have arrived while the callback was running; bail out
diff --git a/src/runtime/dns_jsc/dns.rs b/src/runtime/dns_jsc/dns.rs
index d43b39322c6b..81bfee646985 100644
--- a/src/runtime/dns_jsc/dns.rs
+++ b/src/runtime/dns_jsc/dns.rs
@@ -2264,8 +2264,11 @@ pub mod internal {
// To preserve memory, we use a 32 bit timestamp
// However, we're almost out of time to use 32 bit timestamps for anything
// So we set the epoch to January 1st, 2024 instead.
+ //
+ // Real time: the cache is process-global, shared by every VM's JS
+ // thread and the HTTP thread, so no single VM's fake clock applies.
fn get_cache_timestamp() -> u32 {
- (bun::Timespec::now(bun::TimespecMockMode::AllowMockedTime).ms_unsigned() / 1000) as u32
+ (bun::Timespec::now(bun::TimespecMockMode::ForceRealTime).ms_unsigned() / 1000) as u32
}
fn is_nearly_full(&self) -> bool {
diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs
index e7c48b864048..3c1251fbc3f0 100644
--- a/src/runtime/jsc_hooks.rs
+++ b/src/runtime/jsc_hooks.rs
@@ -404,7 +404,7 @@ unsafe fn init_runtime_state(
RUNTIME_STATE.with(|c| c.set(state));
// `Timespec::now_allow_mocked_time` reads `bun_core::mock_time` directly;
- // `FakeTimers::CurrentTime::{set,clear}` write that storage so timers
+ // `FakeTimers::{set_now,clear_now}` write that storage so timers
// scheduled under `jest.useFakeTimers()` use the mocked epoch.
// ── vm.transpiler ────────────────────────────────────────────────────
@@ -1773,8 +1773,8 @@ fn stop_active_handles(vm: &mut VirtualMachine, reason: StopReason) -> SweepResu
// JS thread, no re-entry while we hold the field borrow.
if !all.is_null() && unsafe { (*all).fake_timers.is_active() } {
let global = vm.global();
- // SAFETY: as above; only touches `fake_timers.active` and the
- // `CURRENT_TIME` static.
+ // SAFETY: as above; only touches `fake_timers`' clock state and
+ // the VM, never the heaps.
unsafe { (*all).fake_timers.reset_for_isolation(global) };
}
}
diff --git a/src/runtime/test_runner/timers/FakeTimers.rs b/src/runtime/test_runner/timers/FakeTimers.rs
index 8d17f5d714b9..af9181585283 100644
--- a/src/runtime/test_runner/timers/FakeTimers.rs
+++ b/src/runtime/test_runner/timers/FakeTimers.rs
@@ -1,7 +1,3 @@
-use std::sync::atomic::{AtomicU64, Ordering};
-
-use bun_threading::RwLock;
-
use bun_core::Environment;
use bun_core::Timespec;
use bun_jsc::{CallFrame, JSFunction, JSGlobalObject, JSHostFn, JSValue, JsResult};
@@ -20,72 +16,59 @@ unsafe extern "C" {
#[derive(Default)]
pub struct FakeTimers {
- active: bool,
/// 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,
+ /// The fake monotonic clock; starts at 0 on `useFakeTimers()`, `None`
+ /// while real timers are in use.
+ now: Option,
+ /// `Date.now()` minus `now.ms()`.
+ date_now_offset: f64,
+ /// Bumped by every `useFakeTimers()`, so a drain loop can tell that a
+ /// callback it fired swapped in a fresh clock and stop driving it.
+ generation: u32,
}
-// `date_now_offset` is stored as `AtomicU64` (f64 bits) so the static is `Sync`
-// without `static mut`.
-pub(crate) struct CurrentTime {
- /// starts at 0. offset in milliseconds.
- offset_raw: RwLock,
- date_now_offset: AtomicU64,
-}
-
-const MIN_TIMESPEC: Timespec = Timespec { sec: i64::MIN, nsec: i64::MIN };
+impl FakeTimers {
+ pub(crate) fn is_active(&self) -> bool {
+ self.now.is_some()
+ }
-static CURRENT_TIME: CurrentTime = CurrentTime {
- offset_raw: RwLock::new(MIN_TIMESPEC),
- date_now_offset: AtomicU64::new(0f64.to_bits()),
-};
+ /// Which fake clock installation is current; `None` on the real clock.
+ pub(crate) fn clock_id(&self) -> Option {
+ self.now.map(|_| self.generation)
+ }
-impl CurrentTime {
- pub(crate) fn get_timespec_now(&self) -> Option {
- let value = *self.offset_raw.read();
- if value.eql(&MIN_TIMESPEC) {
- return None;
- }
- Some(value)
+ fn generation() -> u32 {
+ // SAFETY: per-thread `timer::All`, live for the VM lifetime.
+ unsafe { (*timer_all()).fake_timers.generation }
}
- pub(crate) fn set(&self, global: &JSGlobalObject, offset: &Timespec, js: Option) {
- let vm = global.bun_vm().as_mut();
- {
- *self.offset_raw.write() = *offset;
- }
+ fn set_now(&mut self, global: &JSGlobalObject, now: &Timespec, js: Option) {
+ self.now = Some(*now);
// Mirror into T0 storage so `Timespec::now(AllowMockedTime)` sees
// the fake clock.
- bun_core::mock_time::set(offset.ns() as i64);
- let timespec_ms: f64 = offset.ms() as f64;
- let mut date_now_offset = f64::from_bits(self.date_now_offset.load(Ordering::Relaxed));
+ bun_core::mock_time::set(now.ns() as i64);
+ let timespec_ms: f64 = now.ms() as f64;
if let Some(js) = js {
- date_now_offset = js.floor() - timespec_ms;
- self.date_now_offset.store(date_now_offset.to_bits(), Ordering::Relaxed);
+ self.date_now_offset = js.floor() - timespec_ms;
}
- let date_now = date_now_offset + timespec_ms;
- // SAFETY: FFI call into C++ JSMock; global is a valid &JSGlobalObject
+ let date_now = self.date_now_offset + timespec_ms;
JSMock__setOverridenDateNow(global, date_now);
bun_core::mock_time::set_wall_ms(date_now);
-
- vm.overridden_performance_now = Some(offset.ns());
+ global.bun_vm().as_mut().overridden_performance_now = Some(now.ns());
}
- pub(crate) fn clear(&self, global: &JSGlobalObject) {
- let vm = global.bun_vm().as_mut();
- {
- *self.offset_raw.write() = MIN_TIMESPEC;
- }
+ fn clear_now(&mut self, global: &JSGlobalObject) {
+ self.now = None;
bun_core::mock_time::clear();
bun_core::mock_time::clear_wall();
// NaN is JSGlobalObject::overridenDateNow's "no override" sentinel; a
// real -1 would pin Date.now() at 1969-12-31T23:59:59.999Z.
- // SAFETY: FFI call into C++ JSMock; global is a valid &JSGlobalObject
JSMock__setOverridenDateNow(global, f64::NAN);
- vm.overridden_performance_now = None;
+ global.bun_vm().as_mut().overridden_performance_now = None;
}
}
@@ -99,13 +82,13 @@ extern "C" fn Bun__FakeTimers__setSystemTime(ms: f64) {
if ms.is_nan() {
return;
}
- let Some(current) = CURRENT_TIME.get_timespec_now() else {
+ // SAFETY: called from `jest.setSystemTime` on the JS thread, whose
+ // per-thread `timer::All` is live; nothing here re-enters `All`.
+ let fake_timers = unsafe { &mut (*timer_all()).fake_timers };
+ let Some(current) = fake_timers.now else {
return;
};
- let date_now_offset = ms - current.ms() as f64;
- CURRENT_TIME
- .date_now_offset
- .store(date_now_offset.to_bits(), Ordering::Relaxed);
+ fake_timers.date_now_offset = ms - current.ms() as f64;
bun_core::mock_time::set_wall_ms(ms);
}
@@ -159,19 +142,21 @@ impl ClearedTimers {
}
impl FakeTimers {
- pub(crate) fn is_active(&self) -> bool {
- self.active
- }
-
- fn activate(&mut self, js_now: f64, global: &JSGlobalObject) {
- self.active = true;
- CURRENT_TIME.set(global, &Timespec::EPOCH, Some(js_now));
+ /// Like Jest and Vitest, every `useFakeTimers()` installs a fresh clock:
+ /// timers pending on a previous fake clock are dropped, not carried over.
+ /// A repeating timer mid-fire is not in the heap: its owner
+ /// (`TimerObjectInternals::fire`, `CronJob::on_timer_fire`) sees the
+ /// `clock_id` change across the callback and retires it instead.
+ fn activate(&mut self, js_now: f64, global: &JSGlobalObject) -> ClearedTimers {
+ let cleared = self.clear();
+ self.generation = self.generation.wrapping_add(1);
+ self.set_now(global, &Timespec::EPOCH, Some(js_now));
+ cleared
}
fn deactivate(&mut self, global: &JSGlobalObject) -> ClearedTimers {
let cleared = self.clear();
- CURRENT_TIME.clear(global);
- self.active = false;
+ self.clear_now(global);
cleared
}
@@ -181,8 +166,7 @@ impl FakeTimers {
/// JS has stopped) can walk the still-populated fake heap and release
/// `TimeoutObject` pins and discard `AbortSignalTimeout` timers.
pub(crate) fn reset_for_isolation(&mut self, global: &JSGlobalObject) {
- CURRENT_TIME.clear(global);
- self.active = false;
+ self.clear_now(global);
}
/// Pop every fake timer. Popping only unlinks the nodes; the owners that
@@ -239,17 +223,16 @@ impl FakeTimers {
/// timer whose callback threw is reported and the drain goes on; only the
/// VM's termination stops it, thrown to the `jest` host function driving it.
fn fire(global: &JSGlobalObject, next: *mut EventLoopTimer) -> JsResult<()> {
- let _vm = global.bun_vm();
-
// SAFETY: `next` was just popped from our heap; live until callback completes.
let now_el = unsafe { (*next).next };
let now = from_el_timespec(&now_el);
+ // SAFETY: `timer_all()` is the live per-thread `All`; the borrow ends
+ // before `EventLoopTimer::fire` re-enters it.
+ let this = unsafe { &mut (*timer_all()).fake_timers };
if Environment::CI_ASSERT {
- let prev = CURRENT_TIME.get_timespec_now();
- debug_assert!(prev.is_some());
- debug_assert!(now.eql(&prev.unwrap()) || now.greater(&prev.unwrap()));
+ debug_assert!(this.now.is_some_and(|prev| !prev.greater(&now)));
}
- CURRENT_TIME.set(global, &now, None);
+ this.set_now(global, &now, None);
// SAFETY: `next` is live; `fire` takes `*mut Self` (noalias re-entrancy)
// and an erased `*mut ()` for the VM.
let fired = unsafe { EventLoopTimer::fire(next, &now_el, bun_jsc::virtual_machine::VirtualMachine::get_mut_ptr().cast()) };
@@ -262,7 +245,11 @@ impl FakeTimers {
fn execute_until(global: &JSGlobalObject, until: Timespec) -> JsResult<()> {
let all = timer_all();
+ let generation = Self::generation();
'outer: loop {
+ if Self::generation() != generation {
+ break;
+ }
let next = 'blk: {
// SAFETY: `all` is the live per-thread `All`; each borrow
// lasts one statement and none spans `fire`.
@@ -296,7 +283,8 @@ impl FakeTimers {
}
fn execute_all_timers(global: &JSGlobalObject) -> JsResult<()> {
- while Self::execute_next(global)? {}
+ let generation = Self::generation();
+ while Self::execute_next(global)? && Self::generation() == generation {}
Ok(())
}
}
@@ -305,14 +293,19 @@ impl FakeTimers {
// JS Functions
// ===
-fn error_unless_fake_timers(global: &JSGlobalObject) -> JsResult<()> {
+/// The current fake clock, or a thrown "not active" error.
+fn fake_now(global: &JSGlobalObject) -> JsResult {
// SAFETY: per-thread `timer::All`, live for the VM lifetime.
- if unsafe { (*timer_all()).fake_timers.is_active() } {
- return Ok(());
+ match unsafe { (*timer_all()).fake_timers.now } {
+ Some(now) => Ok(now),
+ None => Err(global.throw(format_args!(
+ "Fake timers are not active. Call useFakeTimers() first."
+ ))),
}
- Err(global.throw(format_args!(
- "Fake timers are not active. Call useFakeTimers() first."
- )))
+}
+
+fn error_unless_fake_timers(global: &JSGlobalObject) -> JsResult<()> {
+ fake_now(global).map(|_| ())
}
/// Set or remove the "clock" property on setTimeout to indicate that fake timers are active.
@@ -363,11 +356,17 @@ fn use_fake_timers(global: &JSGlobalObject, frame: &CallFrame) -> JsResult J
#[bun_jsc::host_fn]
fn advance_timers_by_time(global: &JSGlobalObject, frame: &CallFrame) -> JsResult {
- error_unless_fake_timers(global)?;
+ let current = fake_now(global)?;
let arg = frame.arguments_as_array::<1>()[0];
if !arg.is_number() {
@@ -407,11 +406,6 @@ fn advance_timers_by_time(global: &JSGlobalObject, frame: &CallFrame) -> JsResul
"advanceTimersToNextTimer() expects a number of milliseconds"
)));
}
- let Some(current) = CURRENT_TIME.get_timespec_now() else {
- return Err(global.throw_invalid_arguments(format_args!(
- "Fake timers not initialized. Initialize with useFakeTimers() first."
- )));
- };
let arg_number = arg.as_number();
let max_advance = u32::MAX;
if arg_number < 0.0 || arg_number > max_advance as f64 {
@@ -426,8 +420,16 @@ fn advance_timers_by_time(global: &JSGlobalObject, frame: &CallFrame) -> JsResul
let effective_advance = if arg_number == 0.0 { 1.0 } else { arg_number };
let target = current.add_ms_float(effective_advance);
+ let generation = FakeTimers::generation();
let advanced = FakeTimers::execute_until(global, target);
- CURRENT_TIME.set(global, &target, None);
+ // SAFETY: per-thread `timer::All`; `set_now` does not re-enter `All`.
+ let fake_timers = unsafe { &mut (*timer_all()).fake_timers };
+ // Land on `target` only if this is still the clock we were advancing: a
+ // fired callback may have called `useRealTimers()` (or installed a fresh
+ // clock with `useFakeTimers()`).
+ if fake_timers.is_active() && fake_timers.generation == generation {
+ fake_timers.set_now(global, &target, None);
+ }
advanced?;
Ok(frame.this())
diff --git a/src/runtime/timer/timer_object_internals.rs b/src/runtime/timer/timer_object_internals.rs
index 039f7b610e36..846bdc3dbb20 100644
--- a/src/runtime/timer/timer_object_internals.rs
+++ b/src/runtime/timer/timer_object_internals.rs
@@ -21,7 +21,7 @@ use core::cell::Cell;
use crate::jsc::virtual_machine::VirtualMachine;
use super::{
- ElTimespec, EventLoopTimer, EventLoopTimerState, ID, ImmediateObject, Kind, KindBig,
+ ElTimespec, EventLoopTimer, EventLoopTimerState, ID, ImmediateObject, InHeap, Kind, KindBig,
TimeoutObject,
};
@@ -559,6 +559,15 @@ impl TimerObjectInternals {
let state = crate::jsc_hooks::runtime_state();
debug_assert!(!state.is_null(), "RuntimeState not installed");
+ // The fake clock this timer was popped from, if it was a fake timer
+ // (`in_heap` still names the heap until it is re-inserted or removed).
+ // SAFETY: `event_loop_timer()` points into the live parent.
+ let fake_clock_before_call = if unsafe { (*s.event_loop_timer()).in_heap } == InHeap::Fake {
+ // SAFETY: `state` is the boxed per-thread `RuntimeState`.
+ unsafe { (*state).timer.fake_timers.clock_id() }
+ } else {
+ None
+ };
// SAFETY: `vm` is live; `event_loop()` returns `*mut` to the embedded
// EventLoop. Re-entrancy is permitted by the raw-ptr contract above.
@@ -603,9 +612,22 @@ impl TimerObjectInternals {
if !s.should_reschedule_timer(repeat, idle_timeout) {
break 'is_timer_done true;
}
+ // The callback uninstalled (`useRealTimers()`) or replaced
+ // (`useFakeTimers()`) the fake clock this interval was
+ // scheduled on, so `time_before_call` is on a timeline
+ // that no longer exists.
+ let fake_clock_replaced = fake_clock_before_call.is_some()
+ // SAFETY: as for `fake_clock_before_call`.
+ && unsafe { (*state).timer.fake_timers.clock_id() }
+ != fake_clock_before_call;
// `ref_()` above pins the parent across the deref.
match s.event_loop_timer_state() {
EventLoopTimerState::FIRED => {
+ // Goes with the replaced clock's other timers
+ // instead of hopping heaps at a stale deadline.
+ if fake_clock_replaced {
+ break 'is_timer_done true;
+ }
// If we didn't clear the setInterval, reschedule it starting from
// SAFETY: `state` is the boxed per-thread `RuntimeState`;
// single-threaded JS heap so no concurrent `&mut` to
@@ -625,12 +647,16 @@ impl TimerObjectInternals {
}
EventLoopTimerState::ACTIVE => {
// The developer called timer.refresh() synchronously in the callback.
- // SAFETY: as above.
- unsafe {
- (*state)
- .timer
- .update(s.event_loop_timer(), &time_before_call)
- };
+ // After a clock swap that refresh is already the
+ // schedule on the new clock; keep it.
+ if !fake_clock_replaced {
+ // SAFETY: as above.
+ unsafe {
+ (*state)
+ .timer
+ .update(s.event_loop_timer(), &time_before_call)
+ };
+ }
// Balance out the ref count.
// the transition from "FIRED" -> "ACTIVE" caused it to increment.
@@ -678,6 +704,11 @@ impl TimerObjectInternals {
if is_timer_done {
s.set_enable_keeping_event_loop_alive(vm, false);
+ // A `setInterval` keeps its wrapper Strong across the callback
+ // (it normally reschedules); one retired here without going
+ // through `cancel()` must drop that pin or the wrapper never
+ // finalizes. No-op for the already-Weak paths.
+ s.this_value.with_mut(|r| r.downgrade());
// The timer will not be re-entered into the event loop at this point.
s.deref();
}
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 9b78720b9c1c..157763d5f86c 100644
--- a/test/js/bun/test/fake-timers/fake-timers.test.ts
+++ b/test/js/bun/test/fake-timers/fake-timers.test.ts
@@ -132,6 +132,132 @@ describe("advanceTimersByTime", () => {
expect(order.takeOrderMessages()).toEqual([]);
vi.useRealTimers();
});
+
+ test("useRealTimers() from a fired callback is not undone when the advance completes", () => {
+ const realBefore = performance.now();
+ vi.useFakeTimers({ now: 0 });
+ setTimeout(() => vi.useRealTimers(), 10);
+ vi.advanceTimersByTime(100);
+ expect(vi.isFakeTimers()).toBe(false);
+ // Still on the real clock, not re-pinned to the fake epoch + 100ms.
+ expect(Date.now()).toBeGreaterThan(1e12);
+ expect(performance.now()).not.toBe(100);
+ expect(performance.now()).toBeGreaterThanOrEqual(realBefore);
+ });
+
+ test("useFakeTimers() from a fired callback installs a fresh clock the outer advance stops driving", () => {
+ vi.useFakeTimers({ now: 0 });
+ const fired: string[] = [];
+ setTimeout(() => {
+ fired.push("reinstall");
+ vi.useFakeTimers({ now: 5000 });
+ setTimeout(() => fired.push("on new clock"), 50);
+ }, 10);
+ setTimeout(() => fired.push("dropped with old clock"), 20);
+ vi.advanceTimersByTime(100);
+ // The outer advance belonged to the old clock: it neither fires timers on
+ // the new one nor moves it to the old target.
+ expect(fired).toEqual(["reinstall"]);
+ expect({ date: Date.now(), perf: performance.now(), count: vi.getTimerCount() }).toEqual({
+ date: 5000,
+ perf: 0,
+ count: 1,
+ });
+ vi.advanceTimersByTime(50);
+ expect(fired).toEqual(["reinstall", "on new clock"]);
+ expect(Date.now()).toBe(5050);
+ });
+
+ test("a firing setInterval whose callback installs a fresh clock is dropped with the old one", () => {
+ vi.useFakeTimers({ now: 0 });
+ let fired = 0;
+ setInterval(() => {
+ fired++;
+ vi.useFakeTimers({ now: 5000 });
+ }, 100);
+ vi.advanceTimersByTime(100);
+ expect({ fired, count: vi.getTimerCount() }).toEqual({ fired: 1, count: 0 });
+ vi.advanceTimersByTime(1000);
+ expect(fired).toBe(1);
+ });
+
+ test("…unless the callback refresh()es it, which schedules it on the new clock", () => {
+ vi.useFakeTimers({ now: 0 });
+ let fired = 0;
+ const interval = setInterval(() => {
+ if (++fired === 1) {
+ vi.useFakeTimers({ now: 5000 });
+ interval.refresh();
+ }
+ }, 100);
+ vi.advanceTimersByTime(100);
+ expect({ fired, count: vi.getTimerCount() }).toEqual({ fired: 1, count: 1 });
+ // One full period on the new clock, not the old timeline's next deadline.
+ vi.advanceTimersByTime(99);
+ expect(fired).toBe(1);
+ vi.advanceTimersByTime(1);
+ expect({ fired, now: Date.now() }).toEqual({ fired: 2, now: 5100 });
+ clearInterval(interval);
+ });
+
+ // A setInterval retired from inside its own callback — because the callback
+ // swapped the fake clock, or cleared `_repeat` — is out of every heap and
+ // unreachable from JS, so its Timeout wrapper must be collectable. It used
+ // to stay pinned by the native side for the rest of the process.
+ test("a setInterval retired from inside its own callback does not leak its Timeout", () => {
+ const N = 200;
+ const liveTimeouts = () => {
+ Bun.gc(true);
+ Bun.gc(true);
+ return heapStats().objectTypeCounts.Timeout ?? 0;
+ };
+ const before = liveTimeouts();
+ for (let i = 0; i < N; i++) {
+ vi.useFakeTimers({ now: 0 });
+ // Fires first (insertion order) while the clock is still the same.
+ setInterval(function (this: any) {
+ this._repeat = null;
+ }, 10);
+ setInterval(() => vi.useFakeTimers({ now: 1 }), 10);
+ vi.advanceTimersByTime(10);
+ vi.useRealTimers();
+ }
+ expect(liveTimeouts() - before).toBeLessThan(2 * N * 0.1);
+ });
+
+ test("a firing setInterval whose callback calls useRealTimers() does not escape onto the real clock", async () => {
+ vi.useFakeTimers({ now: 0 });
+ let fired = 0;
+ const interval = setInterval(() => {
+ fired++;
+ vi.useRealTimers();
+ }, 5);
+ vi.advanceTimersByTime(5);
+ expect({ fired, fake: vi.isFakeTimers() }).toEqual({ fired: 1, fake: false });
+ // An escaped 5ms interval would have gone round several times by now.
+ await Bun.sleep(50);
+ clearInterval(interval);
+ expect(fired).toBe(1);
+ });
+});
+
+describe("useFakeTimers while already active", () => {
+ test("installs a fresh clock and drops timers pending on the old one", () => {
+ vi.useFakeTimers({ now: 1000 });
+ let fired = 0;
+ setTimeout(() => fired++, 10);
+ vi.advanceTimersByTime(5);
+ expect(vi.getTimerCount()).toBe(1);
+
+ vi.useFakeTimers({ now: 9000 });
+ expect({ date: Date.now(), perf: performance.now(), count: vi.getTimerCount() }).toEqual({
+ date: 9000,
+ perf: 0,
+ count: 0,
+ });
+ vi.runAllTimers();
+ expect(fired).toBe(0);
+ });
});
describe("runOnlyPendingTimers", () => {
test("two setIntervals", () => {
@@ -432,6 +558,46 @@ describe("Bun.cron() job dropped from the fake heap", () => {
});
},
);
+
+ // A job whose own tick swaps the clock is out of the heap at that moment,
+ // so dropping "the heap" misses it; it has to be stopped like the rest.
+ test("a firing job whose tick installs a fresh clock is stopped with the old one", () => {
+ vi.useFakeTimers({ now: 0 });
+ let fired = 0;
+ using job = Bun.cron("* * * * *", () => {
+ fired++;
+ vi.useFakeTimers({ now: 0 });
+ });
+ vi.advanceTimersByTime(60_000);
+ expect({ fired, count: vi.getTimerCount() }).toEqual({ fired: 1, count: 0 });
+ vi.advanceTimersByTime(10 * 60_000);
+ expect(fired).toBe(1);
+ });
+
+ test("a firing job whose tick calls useRealTimers() does not keep the process alive", async () => {
+ await using proc = Bun.spawn({
+ cmd: [
+ bunExe(),
+ "-e",
+ `const { jest } = Bun.jest();
+ jest.useFakeTimers({ now: 0 });
+ Bun.cron("* * * * *", () => { console.log("tick"); jest.useRealTimers(); });
+ jest.advanceTimersByTime(60_000);
+ console.log("exiting", jest.isFakeTimers());`,
+ ],
+ env: bunEnv,
+ stdout: "pipe",
+ stderr: "pipe",
+ timeout: 10_000,
+ });
+ const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
+ expect({ stdout, stderr, exitCode, signalCode: proc.signalCode }).toEqual({
+ stdout: "tick\nexiting false\n",
+ stderr: "",
+ exitCode: 0,
+ signalCode: null,
+ });
+ });
});
describe("isFakeTimers", () => {
test("returns true when fake timers are active", () => {
@@ -521,6 +687,86 @@ describe("Date.now() mocking", () => {
});
});
+describe.concurrent("fake clock is per-VM", () => {
+ test("workers with fake timers do not share a clock", async () => {
+ // Each worker pins its own system time, drains its pending timers, and
+ // checks that Date.now() landed where *its* clock says it should. With a
+ // shared clock the other worker's setSystemTime()/timer fires leak in.
+ const workerSrc = /* js */ `
+ const { jest } = Bun.jest(__filename);
+ const { parentPort, workerData } = require("worker_threads");
+ const { now, target } = workerData;
+ const mismatches = [];
+ for (let r = 0; r < 400; r++) {
+ jest.useFakeTimers({ now });
+ let n = 0;
+ setTimeout(() => n++, 1000);
+ const iv = setInterval(() => { if (n++ > 3) clearInterval(iv); }, 100);
+ jest.setSystemTime(target);
+ jest.runOnlyPendingTimers();
+ const after = Date.now();
+ if (after !== target + 1000) mismatches.push({ round: r, after, expected: target + 1000 });
+ jest.useRealTimers();
+ }
+ parentPort.postMessage(mismatches);
+ `;
+ const mainSrc = /* js */ `
+ const { Worker } = require("worker_threads");
+ const results = {};
+ const configs = [{ now: 0, target: 1e12 }, { now: 2 ** 40, target: 0 }];
+ let done = 0;
+ for (const workerData of configs) {
+ const w = new Worker(${JSON.stringify(workerSrc)}, { eval: true, workerData });
+ w.on("error", err => { console.error(err); process.exit(1); });
+ w.on("message", mismatches => {
+ results[workerData.now] = mismatches;
+ if (++done === configs.length) {
+ console.log(JSON.stringify(results));
+ process.exit(0);
+ }
+ });
+ }
+ `;
+ await using proc = Bun.spawn({
+ cmd: [bunExe(), "-e", mainSrc],
+ env: bunEnv,
+ stdout: "pipe",
+ stderr: "pipe",
+ });
+ const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
+ expect(stderr).toBe("");
+ expect(JSON.parse(stdout)).toEqual({ "0": [], [2 ** 40]: [] });
+ expect(exitCode).toBe(0);
+ });
+
+ test("a worker's fake clock does not leak into main-thread timer scheduling", async () => {
+ // The worker exits with fake timers still active and its clock advanced.
+ // A real 20ms timer armed on the main thread afterwards must be scheduled
+ // against the real monotonic clock, not the worker's fake epoch (which is
+ // far in the past relative to process uptime and would fire it at once).
+ const src = /* js */ `
+ const { Worker } = require("worker_threads");
+ const w = new Worker(
+ 'const { jest } = Bun.jest("worker"); jest.useFakeTimers({ now: 0 }); jest.advanceTimersByTime(5000); require("worker_threads").parentPort.postMessage(Date.now());',
+ { eval: true },
+ );
+ w.on("error", err => { console.error(err); process.exit(1); });
+ w.on("message", workerNow => {
+ const armed = performance.now();
+ setTimeout(() => {
+ console.log(JSON.stringify({ workerNow, firedEarly: performance.now() - armed < 19, mainDateReal: Date.now() > 1.7e12 }));
+ process.exit(0);
+ }, 20);
+ });
+ `;
+ await using proc = Bun.spawn({ cmd: [bunExe(), "-e", src], env: bunEnv, stdout: "pipe", stderr: "pipe" });
+ const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
+ expect(stderr).toBe("");
+ expect(JSON.parse(stdout)).toEqual({ workerNow: 5000, firedEarly: false, mainDateReal: true });
+ expect(exitCode).toBe(0);
+ });
+});
+
describe("performance.now() mocking", () => {
test("performance.now() should be mocked when fake timers are active", () => {
vi.useFakeTimers();
@@ -691,4 +937,9 @@ describe("useFakeTimers with options", () => {
expect(() => vi.useFakeTimers(123 as any)).toThrow("useFakeTimers() expects an options object");
expect(vi.isFakeTimers()).toBe(false);
});
+
+ test.each([NaN, Infinity, -Infinity, new Date("invalid")])("useFakeTimers({ now: %p }) throws", now => {
+ expect(() => vi.useFakeTimers({ now })).toThrow("'now' must be a finite number or a valid Date");
+ expect(vi.isFakeTimers()).toBe(false);
+ });
});