diff --git a/src/runtime/node/node_fs_stat_watcher.rs b/src/runtime/node/node_fs_stat_watcher.rs index a2c85d1aaa6b..948385cb6fb2 100644 --- a/src/runtime/node/node_fs_stat_watcher.rs +++ b/src/runtime/node/node_fs_stat_watcher.rs @@ -279,10 +279,11 @@ impl StatWatcherScheduler { return; } - // reschedule the timer + // reschedule the timer — this tag opts out of fake timers, so the + // deadline lives in the real heap and must be in real-clock units. timer_all.update( elt, - &Timespec::ms_from_now(TimespecMockMode::AllowMockedTime, i64::from(interval)), + &Timespec::ms_from_now(TimespecMockMode::ForceRealTime, i64::from(interval)), ); } diff --git a/src/runtime/timer/mod.rs b/src/runtime/timer/mod.rs index 9309549ec06f..9e0aab5c4459 100644 --- a/src/runtime/timer/mod.rs +++ b/src/runtime/timer/mod.rs @@ -914,8 +914,11 @@ impl All { // deref and fire via raw deref (mirroring `drain_timers`). let (min_next_sec, min_next_nsec, min_tag) = unsafe { ((*min).next.sec, (*min).next.nsec, (*min).tag) }; + // Real clock: `self.timers` is the opt-out-of-fake-timers set, all + // armed in real-time units. Comparing against the mocked clock made + // internal pacing (GC, WTFTimer, test timeouts) spin on re-arm. let now = - *maybe_now.get_or_insert_with(|| Timespec::now(TimespecMockMode::AllowMockedTime)); + *maybe_now.get_or_insert_with(|| Timespec::now(TimespecMockMode::ForceRealTime)); // bun_event_loop carries its own Timespec stub; compare field-wise. let min_next = Timespec { @@ -983,7 +986,8 @@ impl All { let out = (|| { let timer = self.timers.peek()?; if !*has_set_now { - *now = Timespec::now(TimespecMockMode::AllowMockedTime); + // Real clock: this heap is the opt-out-of-fake-timers set. + *now = Timespec::now(TimespecMockMode::ForceRealTime); *has_set_now = true; } // SAFETY: peek returns a live heap node diff --git a/test/js/bun/test/test-timers-gc-spin-fixture.ts b/test/js/bun/test/test-timers-gc-spin-fixture.ts new file mode 100644 index 000000000000..fdf261a73813 --- /dev/null +++ b/test/js/bun/test/test-timers-gc-spin-fixture.ts @@ -0,0 +1,23 @@ +import { jest, test } from "bun:test"; + +// https://github.com/oven-sh/bun/pull/33359#discussion_r3556322148 +test("drain_timers terminates when mocked time > CLOCK_MONOTONIC uptime", async () => { + // Real event-loop ticks so the GcRepeating / WTFTimer / BunTest nodes are + // armed (real-time deadlines) before fake timers are installed. + for (let i = 0; i < 4; i++) await Bun.file(import.meta.path).text(); + + jest.useFakeTimers(); + try { + // Push the mocked monotonic clock past any plausible machine uptime + // (advanceTimersByTime caps at u32 ms, so loop in ~40-day chunks). + for (let i = 0; i < 100; i++) jest.advanceTimersByTime(40 * 24 * 3600 * 1000); + // A real I/O await reaches All::drain_timers. Pre-fix that loop cached + // `now = AllowMockedTime`, so every allow_fake_timers()==false node + // (GC, WTFTimer, test timeout) looked overdue; those that re-arm at + // ForceRealTime on fire were re-inserted still "overdue" and the loop spun. + for (let i = 0; i < 4; i++) await Bun.file(import.meta.path).text(); + } finally { + jest.useRealTimers(); + } + console.log("DRAIN_OK"); +}); diff --git a/test/js/bun/test/test-timers.test.ts b/test/js/bun/test/test-timers.test.ts index 54f01cc2c397..dc21b5425061 100644 --- a/test/js/bun/test/test-timers.test.ts +++ b/test/js/bun/test/test-timers.test.ts @@ -1,3 +1,6 @@ +import { bunEnv, bunExe } from "harness"; +import path from "node:path"; + test("we can go back in time", () => { const DateBeforeMocked = Date; const orig = new Date(); @@ -73,3 +76,21 @@ test("setSystemTime accepts pre-epoch and epoch times and resets with no argumen jest.useRealTimers(); } }); + +test("real timer heap is ticked against the real clock under useFakeTimers", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", path.join(import.meta.dir, "test-timers-gc-spin-fixture.ts")], + env: { ...bunEnv, BUN_GC_TIMER_DISABLE: undefined, BUN_GC_TIMER_INTERVAL: undefined }, + stdout: "pipe", + stderr: "pipe", + // Pre-fix the child spins at 100% CPU; bound it so it doesn't outlive the + // runner by long when the parent test times out on the unfixed build. + timeout: 20_000, + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + if (exitCode !== 0) console.error(stderr); + expect(stdout).toContain("DRAIN_OK"); + // null => exited on its own; non-null => killed by the spawn timeout (spun). + expect(proc.signalCode).toBeNull(); + expect(exitCode).toBe(0); +});