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); + }); });