From 2523e353cf147239cda353a51a46257234530bd3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 10 Jul 2026 05:01:26 +0000 Subject: [PATCH 1/3] timer: drive the real heap from the real clock under fake timers All::drain_timers and All::get_timeout compared self.timers (the opt-out-of-fake-timers heap: GC controller, WTFTimer, bun:test timeouts, StatWatcherScheduler) against Timespec::now(AllowMockedTime). Every node in that heap is armed with ForceRealTime, so once jest.advanceTimersByTime pushed the mocked clock past CLOCK_MONOTONIC uptime the nodes looked permanently overdue, and any that re-armed on fire (GcRepeating, WTFTimer) were re-inserted still 'overdue' and the drain loop spun at 100% CPU. The GcRepeating case is new to #33359 (the GC timers used to be kernel timerfds and fired on real time regardless); the WTFTimer case was latent before that. StatWatcherScheduler is the one allow_fake_timers()==false tag that still armed with AllowMockedTime; flip it so its deadlines are in the same units as the heap they live in. --- src/runtime/node/node_fs_stat_watcher.rs | 5 ++-- src/runtime/timer/mod.rs | 8 ++++-- .../bun/test/test-timers-gc-spin-fixture.ts | 28 +++++++++++++++++++ test/js/bun/test/test-timers.test.ts | 25 +++++++++++++++++ 4 files changed, 62 insertions(+), 4 deletions(-) create mode 100644 test/js/bun/test/test-timers-gc-spin-fixture.ts 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..6d3757b6615a --- /dev/null +++ b/test/js/bun/test/test-timers-gc-spin-fixture.ts @@ -0,0 +1,28 @@ +import { jest, test } from "bun:test"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// https://github.com/oven-sh/bun/pull/33359#discussion_r3556322148 +test("drain_timers terminates when mocked time > CLOCK_MONOTONIC uptime", async () => { + const f = join(tmpdir(), `gc-spin-probe-${process.pid}.txt`); + await Bun.write(f, "x"); + + // 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(f).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(f).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..b8b6e324d403 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,25 @@ 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 in drain_timers at 100% CPU; bound it so the + // assertions below fail with a clean diff instead of a runner timeout. + 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); + }, + 30_000, +); From 6527213484478faf3cff3df8bb345d7e22f2fcd9 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 05:03:25 +0000 Subject: [PATCH 2/3] [autofix.ci] apply automated fixes --- test/js/bun/test/test-timers.test.ts | 38 +++++++++++++--------------- 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/test/js/bun/test/test-timers.test.ts b/test/js/bun/test/test-timers.test.ts index b8b6e324d403..e9c10afba3fe 100644 --- a/test/js/bun/test/test-timers.test.ts +++ b/test/js/bun/test/test-timers.test.ts @@ -77,24 +77,20 @@ test("setSystemTime accepts pre-epoch and epoch times and resets with no argumen } }); -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 in drain_timers at 100% CPU; bound it so the - // assertions below fail with a clean diff instead of a runner timeout. - 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); - }, - 30_000, -); +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 in drain_timers at 100% CPU; bound it so the + // assertions below fail with a clean diff instead of a runner timeout. + 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); +}, 30_000); From 53c511c120a6618ee1c8dc2f3e1b902314d685f4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 10 Jul 2026 05:16:54 +0000 Subject: [PATCH 3/3] test: read import.meta.path instead of writing to tmpdir; drop test-level timeout --- test/js/bun/test/test-timers-gc-spin-fixture.ts | 9 ++------- test/js/bun/test/test-timers.test.ts | 6 +++--- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/test/js/bun/test/test-timers-gc-spin-fixture.ts b/test/js/bun/test/test-timers-gc-spin-fixture.ts index 6d3757b6615a..fdf261a73813 100644 --- a/test/js/bun/test/test-timers-gc-spin-fixture.ts +++ b/test/js/bun/test/test-timers-gc-spin-fixture.ts @@ -1,15 +1,10 @@ import { jest, test } from "bun:test"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; // https://github.com/oven-sh/bun/pull/33359#discussion_r3556322148 test("drain_timers terminates when mocked time > CLOCK_MONOTONIC uptime", async () => { - const f = join(tmpdir(), `gc-spin-probe-${process.pid}.txt`); - await Bun.write(f, "x"); - // 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(f).text(); + for (let i = 0; i < 4; i++) await Bun.file(import.meta.path).text(); jest.useFakeTimers(); try { @@ -20,7 +15,7 @@ test("drain_timers terminates when mocked time > CLOCK_MONOTONIC uptime", async // `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(f).text(); + for (let i = 0; i < 4; i++) await Bun.file(import.meta.path).text(); } finally { jest.useRealTimers(); } diff --git a/test/js/bun/test/test-timers.test.ts b/test/js/bun/test/test-timers.test.ts index e9c10afba3fe..dc21b5425061 100644 --- a/test/js/bun/test/test-timers.test.ts +++ b/test/js/bun/test/test-timers.test.ts @@ -83,8 +83,8 @@ test("real timer heap is ticked against the real clock under useFakeTimers", asy env: { ...bunEnv, BUN_GC_TIMER_DISABLE: undefined, BUN_GC_TIMER_INTERVAL: undefined }, stdout: "pipe", stderr: "pipe", - // Pre-fix the child spins in drain_timers at 100% CPU; bound it so the - // assertions below fail with a clean diff instead of a runner timeout. + // 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]); @@ -93,4 +93,4 @@ test("real timer heap is ticked against the real clock under useFakeTimers", asy // null => exited on its own; non-null => killed by the spawn timeout (spun). expect(proc.signalCode).toBeNull(); expect(exitCode).toBe(0); -}, 30_000); +});