From 3d26edd7a404303ef927c8385715b5cd3e535c2f Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Fri, 14 Aug 2026 16:22:40 -0700 Subject: [PATCH 1/8] bun:test: give each VM its own fake clock `jest.useFakeTimers()` kept its clock in process-global statics (`CURRENT_TIME` in FakeTimers.rs and `bun_core::mock_time`) while the fake timer heap it drives is per-VM. Two workers (or a worker and the main thread) using fake timers therefore shared one clock: on debug builds the monotonicity assert in `FakeTimers::fire` panicked, on release one VM's `setSystemTime()`/`advanceTimersByTime()` moved the other's `Date.now()`, and a worker that exited with fake timers active left the main thread scheduling real timers against the fake epoch. Move the clock (`now`, `date_now_offset`) onto the per-VM `FakeTimers` next to the heap, and make `bun_core::mock_time` thread-local so `Timespec::now(AllowMockedTime)` reads the calling VM's clock. Single-VM behaviour is unchanged. --- src/bun_core/util.rs | 29 +++--- src/runtime/jsc_hooks.rs | 6 +- src/runtime/test_runner/timers/FakeTimers.rs | 91 +++++++------------ .../bun/test/fake-timers/fake-timers.test.ts | 78 ++++++++++++++++ 4 files changed, 130 insertions(+), 74 deletions(-) diff --git a/src/bun_core/util.rs b/src/bun_core/util.rs index 1e90288ed229..59064a245434 100644 --- a/src/bun_core/util.rs +++ b/src/bun_core/util.rs @@ -5114,49 +5114,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/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index edb355d3b465..cbc9ece4e85f 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..51e8141628b9 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}; @@ -26,64 +22,42 @@ pub struct FakeTimers { /// - 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, } -// `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 }; - -static CURRENT_TIME: CurrentTime = CurrentTime { - offset_raw: RwLock::new(MIN_TIMESPEC), - date_now_offset: AtomicU64::new(0f64.to_bits()), -}; - -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) +impl FakeTimers { + pub(crate) fn timespec_now(&self) -> Option { + self.now } - pub(crate) fn set(&self, global: &JSGlobalObject, offset: &Timespec, js: Option) { + fn set_now(&mut self, global: &JSGlobalObject, now: &Timespec, js: Option) { let vm = global.bun_vm().as_mut(); - { - *self.offset_raw.write() = *offset; - } + 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()); + vm.overridden_performance_now = Some(now.ns()); } - pub(crate) fn clear(&self, global: &JSGlobalObject) { + fn clear_now(&mut self, global: &JSGlobalObject) { let vm = global.bun_vm().as_mut(); - { - *self.offset_raw.write() = MIN_TIMESPEC; - } + 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; } @@ -99,13 +73,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.timespec_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); } @@ -165,12 +139,12 @@ impl FakeTimers { fn activate(&mut self, js_now: f64, global: &JSGlobalObject) { self.active = true; - CURRENT_TIME.set(global, &Timespec::EPOCH, Some(js_now)); + self.set_now(global, &Timespec::EPOCH, Some(js_now)); } fn deactivate(&mut self, global: &JSGlobalObject) -> ClearedTimers { let cleared = self.clear(); - CURRENT_TIME.clear(global); + self.clear_now(global); self.active = false; cleared } @@ -181,7 +155,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.clear_now(global); self.active = false; } @@ -239,17 +213,18 @@ 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(); + let prev = this.timespec_now(); debug_assert!(prev.is_some()); debug_assert!(now.eql(&prev.unwrap()) || now.greater(&prev.unwrap())); } - 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()) }; @@ -407,7 +382,8 @@ 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 { + // SAFETY: per-thread `timer::All`, live for the VM lifetime. + let Some(current) = (unsafe { (*timer_all()).fake_timers.timespec_now() }) else { return Err(global.throw_invalid_arguments(format_args!( "Fake timers not initialized. Initialize with useFakeTimers() first." ))); @@ -427,7 +403,8 @@ fn advance_timers_by_time(global: &JSGlobalObject, frame: &CallFrame) -> JsResul let target = current.add_ms_float(effective_advance); let advanced = FakeTimers::execute_until(global, target); - CURRENT_TIME.set(global, &target, None); + // SAFETY: as above; `set_now` does not re-enter `All`. + unsafe { (*timer_all()).fake_timers.set_now(global, &target, None) }; advanced?; Ok(frame.this()) 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..1f1d7c6eeaf9 100644 --- a/test/js/bun/test/fake-timers/fake-timers.test.ts +++ b/test/js/bun/test/fake-timers/fake-timers.test.ts @@ -521,6 +521,84 @@ 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("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("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(); From 9501f8051fcfd2f658a7619683554a8b654b0a11 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Fri, 14 Aug 2026 16:52:37 -0700 Subject: [PATCH 2/8] FakeTimers: drop redundant `active` flag, don't re-arm the clock after useRealTimers(); DNS cache uses real time - `advanceTimersByTime` set the clock to the target unconditionally after draining, so a fired callback that called `useRealTimers()` had the fake `Date.now()`/`performance.now()`/timer clock re-pinned behind its back with fake timers reported off. Only advance if still active. - With that, `active` was always `now.is_some()`; remove it and fold the "not active" check into one helper. - The DNS cache is process-global (every VM's JS thread plus the HTTP thread), so it cannot follow one VM's fake clock; stamp it with real time. - Tests: cover the useRealTimers()-in-callback case; make the worker tests exit non-zero on a worker error instead of hanging. --- src/runtime/dns_jsc/dns.rs | 5 +- src/runtime/test_runner/timers/FakeTimers.rs | 57 ++++++++----------- .../bun/test/fake-timers/fake-timers.test.ts | 12 ++++ 3 files changed, 41 insertions(+), 33 deletions(-) diff --git a/src/runtime/dns_jsc/dns.rs b/src/runtime/dns_jsc/dns.rs index 6a5f656dee1c..a94f7e51a27a 100644 --- a/src/runtime/dns_jsc/dns.rs +++ b/src/runtime/dns_jsc/dns.rs @@ -2317,8 +2317,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/test_runner/timers/FakeTimers.rs b/src/runtime/test_runner/timers/FakeTimers.rs index 51e8141628b9..1eb6bab0c507 100644 --- a/src/runtime/test_runner/timers/FakeTimers.rs +++ b/src/runtime/test_runner/timers/FakeTimers.rs @@ -16,7 +16,6 @@ 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) @@ -30,12 +29,11 @@ pub struct FakeTimers { } impl FakeTimers { - pub(crate) fn timespec_now(&self) -> Option { - self.now + pub(crate) fn is_active(&self) -> bool { + self.now.is_some() } fn set_now(&mut self, global: &JSGlobalObject, now: &Timespec, js: Option) { - let vm = global.bun_vm().as_mut(); self.now = Some(*now); // Mirror into T0 storage so `Timespec::now(AllowMockedTime)` sees // the fake clock. @@ -47,19 +45,17 @@ impl FakeTimers { 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(now.ns()); + global.bun_vm().as_mut().overridden_performance_now = Some(now.ns()); } fn clear_now(&mut self, global: &JSGlobalObject) { - let vm = global.bun_vm().as_mut(); 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. JSMock__setOverridenDateNow(global, f64::NAN); - vm.overridden_performance_now = None; + global.bun_vm().as_mut().overridden_performance_now = None; } } @@ -76,7 +72,7 @@ extern "C" fn Bun__FakeTimers__setSystemTime(ms: f64) { // 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.timespec_now() else { + let Some(current) = fake_timers.now else { return; }; fake_timers.date_now_offset = ms - current.ms() as f64; @@ -133,19 +129,13 @@ 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; self.set_now(global, &Timespec::EPOCH, Some(js_now)); } fn deactivate(&mut self, global: &JSGlobalObject) -> ClearedTimers { let cleared = self.clear(); self.clear_now(global); - self.active = false; cleared } @@ -156,7 +146,6 @@ impl FakeTimers { /// `TimeoutObject` pins and discard `AbortSignalTimeout` timers. pub(crate) fn reset_for_isolation(&mut self, global: &JSGlobalObject) { self.clear_now(global); - self.active = false; } /// Pop every fake timer. Popping only unlinks the nodes; the owners that @@ -220,7 +209,7 @@ impl FakeTimers { // before `EventLoopTimer::fire` re-enters it. let this = unsafe { &mut (*timer_all()).fake_timers }; if Environment::CI_ASSERT { - let prev = this.timespec_now(); + let prev = this.now; debug_assert!(prev.is_some()); debug_assert!(now.eql(&prev.unwrap()) || now.greater(&prev.unwrap())); } @@ -280,14 +269,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. @@ -374,7 +368,7 @@ fn advance_timers_to_next_timer(global: &JSGlobalObject, frame: &CallFrame) -> 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() { @@ -382,12 +376,6 @@ fn advance_timers_by_time(global: &JSGlobalObject, frame: &CallFrame) -> JsResul "advanceTimersToNextTimer() expects a number of milliseconds" ))); } - // SAFETY: per-thread `timer::All`, live for the VM lifetime. - let Some(current) = (unsafe { (*timer_all()).fake_timers.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 { @@ -403,8 +391,13 @@ fn advance_timers_by_time(global: &JSGlobalObject, frame: &CallFrame) -> JsResul let target = current.add_ms_float(effective_advance); let advanced = FakeTimers::execute_until(global, target); - // SAFETY: as above; `set_now` does not re-enter `All`. - unsafe { (*timer_all()).fake_timers.set_now(global, &target, None) }; + // SAFETY: per-thread `timer::All`; `set_now` does not re-enter `All`. + let fake_timers = unsafe { &mut (*timer_all()).fake_timers }; + // A fired callback may have called `useRealTimers()`; don't re-arm the + // clock behind its back. + if fake_timers.is_active() { + fake_timers.set_now(global, &target, None); + } advanced?; Ok(frame.this()) 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 1f1d7c6eeaf9..85b58d40b8aa 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,16 @@ describe("advanceTimersByTime", () => { expect(order.takeOrderMessages()).toEqual([]); vi.useRealTimers(); }); + + test("useRealTimers() from a fired callback is not undone when the advance completes", () => { + 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); + }); }); describe("runOnlyPendingTimers", () => { test("two setIntervals", () => { @@ -551,6 +561,7 @@ describe.concurrent("fake clock is per-VM", () => { 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) { @@ -583,6 +594,7 @@ describe.concurrent("fake clock is per-VM", () => { '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(() => { From 4248f3dbe046279fe731d1dac053c223fbe2b270 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Fri, 14 Aug 2026 17:04:38 -0700 Subject: [PATCH 3/8] FakeTimers: every useFakeTimers() installs a fresh clock; drains stop driving a clock a callback replaced Calling `useFakeTimers()` while fake timers were already active reset the clock to a new epoch but kept the timers scheduled against the old one. Match Jest and Vitest: drop them and start clean. Tag each installation with a generation so `advanceTimersByTime`, `runOnlyPendingTimers` and `runAllTimers` stop once a callback they fired has installed a fresh clock, instead of firing the new clock's timers up to the old target and then moving it there. --- src/runtime/test_runner/timers/FakeTimers.rs | 39 +++++++++++----- .../bun/test/fake-timers/fake-timers.test.ts | 44 +++++++++++++++++++ 2 files changed, 73 insertions(+), 10 deletions(-) diff --git a/src/runtime/test_runner/timers/FakeTimers.rs b/src/runtime/test_runner/timers/FakeTimers.rs index 1eb6bab0c507..5f901e509b4e 100644 --- a/src/runtime/test_runner/timers/FakeTimers.rs +++ b/src/runtime/test_runner/timers/FakeTimers.rs @@ -26,6 +26,9 @@ pub struct FakeTimers { 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, } impl FakeTimers { @@ -33,6 +36,11 @@ impl FakeTimers { self.now.is_some() } + fn generation() -> u32 { + // SAFETY: per-thread `timer::All`, live for the VM lifetime. + unsafe { (*timer_all()).fake_timers.generation } + } + fn set_now(&mut self, global: &JSGlobalObject, now: &Timespec, js: Option) { self.now = Some(*now); // Mirror into T0 storage so `Timespec::now(AllowMockedTime)` sees @@ -129,8 +137,13 @@ impl ClearedTimers { } impl FakeTimers { - fn activate(&mut self, js_now: f64, global: &JSGlobalObject) { + /// Like Jest and Vitest, every `useFakeTimers()` installs a fresh clock: + /// timers pending on a previous fake clock are dropped, not carried over. + 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 { @@ -209,9 +222,7 @@ impl FakeTimers { // before `EventLoopTimer::fire` re-enters it. let this = unsafe { &mut (*timer_all()).fake_timers }; if Environment::CI_ASSERT { - let prev = this.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))); } this.set_now(global, &now, None); // SAFETY: `next` is live; `fire` takes `*mut Self` (noalias re-entrancy) @@ -226,7 +237,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`. @@ -260,7 +275,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(()) } } @@ -335,8 +351,9 @@ fn use_fake_timers(global: &JSGlobalObject, frame: &CallFrame) -> JsResult 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); // SAFETY: per-thread `timer::All`; `set_now` does not re-enter `All`. let fake_timers = unsafe { &mut (*timer_all()).fake_timers }; - // A fired callback may have called `useRealTimers()`; don't re-arm the - // clock behind its back. - if fake_timers.is_active() { + // 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?; 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 85b58d40b8aa..faecb5fbbda7 100644 --- a/test/js/bun/test/fake-timers/fake-timers.test.ts +++ b/test/js/bun/test/fake-timers/fake-timers.test.ts @@ -134,6 +134,7 @@ describe("advanceTimersByTime", () => { }); 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); @@ -141,6 +142,49 @@ describe("advanceTimersByTime", () => { // 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); + }); +}); + +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", () => { From 804e203fd0b1adf6c105c6ea491f53fd78927579 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Fri, 14 Aug 2026 17:57:14 -0700 Subject: [PATCH 4/8] FakeTimers: reject non-finite `now`; a firing setInterval follows its clock when the callback swaps it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `useFakeTimers({ now })` with NaN/±Infinity or an invalid Date now throws instead of installing a clock whose Date.now() offset is NaN. - A `setInterval` is out of the heap while its callback runs, so the `clear()` in `useRealTimers()` / `useFakeTimers()` never saw it and it was rescheduled afterwards onto whichever heap was current, at a deadline from the old fake timeline: it kept running on the real clock after `useRealTimers()`, or survived into a freshly installed fake clock. `TimerObjectInternals::fire` now records which fake clock the interval was popped from and, if the callback uninstalled or replaced it, retires the interval like the rest of that clock's timers. --- src/runtime/test_runner/timers/FakeTimers.rs | 14 +++++++- src/runtime/timer/timer_object_internals.rs | 23 ++++++++++++- .../bun/test/fake-timers/fake-timers.test.ts | 33 +++++++++++++++++++ 3 files changed, 68 insertions(+), 2 deletions(-) diff --git a/src/runtime/test_runner/timers/FakeTimers.rs b/src/runtime/test_runner/timers/FakeTimers.rs index 5f901e509b4e..7ee41005cf24 100644 --- a/src/runtime/test_runner/timers/FakeTimers.rs +++ b/src/runtime/test_runner/timers/FakeTimers.rs @@ -36,6 +36,11 @@ impl FakeTimers { self.now.is_some() } + /// Which fake clock installation is current; `None` on the real clock. + pub(crate) fn clock_id(&self) -> Option { + self.now.map(|_| self.generation) + } + fn generation() -> u32 { // SAFETY: per-thread `timer::All`, live for the VM lifetime. unsafe { (*timer_all()).fake_timers.generation } @@ -138,7 +143,9 @@ impl ClearedTimers { impl FakeTimers { /// Like Jest and Vitest, every `useFakeTimers()` installs a fresh clock: - /// timers pending on a previous fake clock are dropped, not carried over. + /// timers pending on a previous fake clock are dropped, not carried over + /// (a `setInterval` mid-fire is not in the heap; `TimerObjectInternals::fire` + /// sees the `clock_id` change and declines to reschedule it). fn activate(&mut self, js_now: f64, global: &JSGlobalObject) -> ClearedTimers { let cleared = self.clear(); self.generation = self.generation.wrapping_add(1); @@ -348,6 +355,11 @@ fn use_fake_timers(global: &JSGlobalObject, frame: &CallFrame) -> JsResult { + // The callback uninstalled (`useRealTimers()`) or + // replaced (`useFakeTimers()`) the fake clock this + // interval was scheduled on; it goes with that + // clock's other timers instead of hopping heaps + // at a deadline from the old timeline. + if fake_clock_before_call.is_some() + // SAFETY: as for `fake_clock_before_call`. + && unsafe { (*state).timer.fake_timers.clock_id() } + != fake_clock_before_call + { + 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 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 faecb5fbbda7..a01920a0cc7b 100644 --- a/test/js/bun/test/fake-timers/fake-timers.test.ts +++ b/test/js/bun/test/fake-timers/fake-timers.test.ts @@ -167,6 +167,34 @@ describe("advanceTimersByTime", () => { 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("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", () => { @@ -825,4 +853,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); + }); }); From 394b45d8cec43354327970b1e0aabe2000dbfd03 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Fri, 14 Aug 2026 18:01:03 -0700 Subject: [PATCH 5/8] clippy: SAFETY comment on the inner unsafe block No-Verification-Needed: comment-only change --- src/runtime/timer/timer_object_internals.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/runtime/timer/timer_object_internals.rs b/src/runtime/timer/timer_object_internals.rs index c6d550142f4b..e1aba1b0a550 100644 --- a/src/runtime/timer/timer_object_internals.rs +++ b/src/runtime/timer/timer_object_internals.rs @@ -560,9 +560,9 @@ impl TimerObjectInternals { 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; `state` - // is the boxed per-thread `RuntimeState`. + // 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 From da4365ffb4ab8dd2ba920acdd17938cf277393b4 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Fri, 14 Aug 2026 18:02:35 -0700 Subject: [PATCH 6/8] setInterval: keep a refresh() made after the callback swapped the fake clock If the callback replaced or uninstalled the fake clock and then called `interval.refresh()`, the refresh had already scheduled the interval on the new clock; don't overwrite that with the pre-callback deadline from the old timeline. --- src/runtime/timer/timer_object_internals.rs | 37 +++++++++++-------- .../bun/test/fake-timers/fake-timers.test.ts | 19 ++++++++++ 2 files changed, 40 insertions(+), 16 deletions(-) diff --git a/src/runtime/timer/timer_object_internals.rs b/src/runtime/timer/timer_object_internals.rs index e1aba1b0a550..0da4565fed0a 100644 --- a/src/runtime/timer/timer_object_internals.rs +++ b/src/runtime/timer/timer_object_internals.rs @@ -611,19 +611,20 @@ 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 => { - // The callback uninstalled (`useRealTimers()`) or - // replaced (`useFakeTimers()`) the fake clock this - // interval was scheduled on; it goes with that - // clock's other timers instead of hopping heaps - // at a deadline from the old timeline. - if fake_clock_before_call.is_some() - // SAFETY: as for `fake_clock_before_call`. - && unsafe { (*state).timer.fake_timers.clock_id() } - != fake_clock_before_call - { + // 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 @@ -645,12 +646,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. 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 a01920a0cc7b..839e325262ee 100644 --- a/test/js/bun/test/fake-timers/fake-timers.test.ts +++ b/test/js/bun/test/fake-timers/fake-timers.test.ts @@ -181,6 +181,25 @@ describe("advanceTimersByTime", () => { 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); + }); + test("a firing setInterval whose callback calls useRealTimers() does not escape onto the real clock", async () => { vi.useFakeTimers({ now: 0 }); let fired = 0; From dd2e2384bcafa1fde11c9db1ee43ea556e829afc Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Fri, 14 Aug 2026 18:25:54 -0700 Subject: [PATCH 7/8] setInterval: release the wrapper pin when an interval is retired from inside its own callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `setInterval` keeps its JS wrapper Strong across the callback because it normally reschedules. When it is instead retired right there without going through `cancel()` — the callback swapped the fake clock, or set `_repeat = null` / `_idleTimeout = -1` on it — nothing downgraded that pin, so the wrapper stayed GC-rooted, its finalizer never released the native `TimeoutObject`, and both leaked for the rest of the process. Downgrade in the shared retire path (a no-op for the already-Weak setTimeout / clearInterval cases). Test: 200 rounds of both shapes leave ~0 live `Timeout` wrappers (previously 400). --- src/runtime/timer/timer_object_internals.rs | 5 ++++ .../bun/test/fake-timers/fake-timers.test.ts | 25 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/runtime/timer/timer_object_internals.rs b/src/runtime/timer/timer_object_internals.rs index 0da4565fed0a..ce6085e1b5ab 100644 --- a/src/runtime/timer/timer_object_internals.rs +++ b/src/runtime/timer/timer_object_internals.rs @@ -703,6 +703,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 839e325262ee..9b667ff8405b 100644 --- a/test/js/bun/test/fake-timers/fake-timers.test.ts +++ b/test/js/bun/test/fake-timers/fake-timers.test.ts @@ -200,6 +200,31 @@ describe("advanceTimersByTime", () => { 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; From 18356b16524543338c2d9f1454108a499cfd9739 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Fri, 14 Aug 2026 19:20:31 -0700 Subject: [PATCH 8/8] Bun.cron: stop a job whose own tick swaps the fake clock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The other repeating owner in the fake heap. A job is out of the heap while its tick runs, so the `clear()` in `useRealTimers()` / `useFakeTimers()` could not drop it and `schedule_next` re-armed it on whichever clock was current afterwards — surviving into a fresh fake clock at a deadline floored on the old `last_next_ms`, or escaping onto the real clock and keeping the process alive. `on_timer_fire` now records the fake clock the tick was popped from and, if the tick uninstalled or replaced it, stops the job (deferred while `in_fire`, finished by `schedule_next`) like the rest of that clock's timers. --- src/runtime/api/cron.rs | 19 ++++++++- src/runtime/test_runner/timers/FakeTimers.rs | 7 ++-- .../bun/test/fake-timers/fake-timers.test.ts | 40 +++++++++++++++++++ 3 files changed, 62 insertions(+), 4 deletions(-) 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/test_runner/timers/FakeTimers.rs b/src/runtime/test_runner/timers/FakeTimers.rs index 7ee41005cf24..af9181585283 100644 --- a/src/runtime/test_runner/timers/FakeTimers.rs +++ b/src/runtime/test_runner/timers/FakeTimers.rs @@ -143,9 +143,10 @@ impl ClearedTimers { impl FakeTimers { /// Like Jest and Vitest, every `useFakeTimers()` installs a fresh clock: - /// timers pending on a previous fake clock are dropped, not carried over - /// (a `setInterval` mid-fire is not in the heap; `TimerObjectInternals::fire` - /// sees the `clock_id` change and declines to reschedule it). + /// 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); 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 9b667ff8405b..157763d5f86c 100644 --- a/test/js/bun/test/fake-timers/fake-timers.test.ts +++ b/test/js/bun/test/fake-timers/fake-timers.test.ts @@ -558,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", () => {