Conversation
A timer callback can itself call jest.runOnlyPendingTimers(), advanceTimersByTime(), runAllTimers() or advanceTimersToNextTimer() and move the fake clock past timers that the outer drain still has to fire. FakeTimers::fire stamped the clock with each popped timer's deadline, so the outer drain then set the clock back: debug builds hit the monotonicity assert in fire(), release builds showed Date.now() and performance.now() going 1 -> 10 -> 2 -> 3 -> 4 inside one advanceTimersByTime(10). advanceTimersByTime() also set the clock to its own target after the drain, which rewound a nested advance and re-pinned a fake clock after a callback had called useRealTimers(). fire() now fires a popped timer at max(deadline, current time), like an overdue real timer, and advanceTimersByTime() only moves the clock forward, and only while fake timers are still installed.
|
Warning Review limit reached
On-demand reviews are free for the next 12 days. After that, they cost $0.25 per reviewed file. Or wait 21 minutes for your next included review. View limit detailsLimit details: You’ve used all 10 included reviews currently available. Review configuration: ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Essentials Run ID: 📒 Files selected for processing (2)
Comment |
|
Status: ready for review. CI (build 113126): the new tests and the rest of the suite pass on every lane that ran them. The two red entries are unrelated to this diff: Reproduced on release 1.4.3 and on a debug ASAN build of main ( import { test, jest } from "bun:test";
test("re-entrant runOnlyPendingTimers during advanceTimersByTime", () => {
jest.useFakeTimers({ now: 1000000 });
const seen = []; let n = 0;
const h = setInterval(() => { n++; seen.push(["iv", n, Date.now() - 1000000]); if (n >= 4) clearInterval(h); jest.runOnlyPendingTimers(); }, 1);
setTimeout(() => seen.push(["to", Date.now() - 1000000]), 10);
jest.advanceTimersByTime(10);
console.log(JSON.stringify(seen)); // 1.4.3: [["iv",1,1],["to",10],["iv",2,2],["iv",3,3],["iv",4,4]]
jest.useRealTimers();
});With this branch the same file prints |
There was a problem hiding this comment.
I reviewed this and didn't find any bugs. Deferring to a human because the fix makes a deliberate compat choice — overdue timers fire "late" at the current clock rather than matching Jest/sinon's nested-budget-shift and interval-reschedule-before-callback behavior — which the PR documents but a maintainer should ratify, and it notes a textual conflict with #38740.
What was reviewed:
fire()now clamps tomax(deadline, current)andadvance_timers_by_timeonly re-syncs when the clock hasn't passed the target and fake timers are still installed — checked that these are the only post-drainCURRENT_TIME.setsites, so all sibling controls (runAllTimers,runOnlyPendingTimers,advanceTimersToNextTimer) are covered viafire().EventLoopTimer::firenow receives the clampednowinstead of the raw deadline —timespecis an alias ofTimespec, so the type is unchanged; interval reschedule fromnowmatches the test expectations and Node's overdue-interval semantics.- New tests cover the variant matrix (setInterval/setTimeout × all four nested controls × the
useRealTimersteardown) with exact-value array assertions.
Extended reasoning...
Overview
The PR fixes a re-entrancy bug in bun:test fake timers (src/runtime/test_runner/timers/FakeTimers.rs): when a timer callback itself calls a jest/vi timer-control API during an outer drain, the fake clock could move backwards — aborting on a debug_assert! in CI/debug builds and producing non-monotonic Date.now() in release. Two hunks: fire() now sets the clock to max(popped-deadline, current) instead of unconditionally to the deadline, and advance_timers_by_time() only writes its target back if the clock hasn't already passed it and fake timers weren't torn down mid-drain (get_timespec_now().is_some_and(...)). Seven new test cases in test/js/bun/test/fake-timers/fake-timers.test.ts pin the exact observed sequences.
Security risks
None. This is test-runner-only mocked-clock logic; no untrusted input parsing, no I/O, no auth/crypto surface. The unsafe blocks touched are unchanged in their contracts (still dereferencing a just-popped heap node); the only substantive change inside them is which Timespec reference is passed to EventLoopTimer::fire, and timespec/ElTimespec/Timespec are all the same struct.
Level of scrutiny
Moderate. The code change is small and mechanically sound, but it encodes a behavioral decision: rather than replicating Jest 29 / sinon's semantics (shift the outer tick budget by however far the nested call moved the clock; reschedule intervals before running their callback so nested controls re-enter them), Bun fires the overdue timer once at the current time and reschedules from there. The PR description tabulates the divergence and argues both remaining differences are orthogonal to the clock-goes-backwards bug. That reasoning is coherent, and monotonicity is unambiguously correct, but whether "fire late" vs. "match Jest" is the right long-term compat stance is a maintainer call, especially given the noted overlap with #38740 (per-VM fake clock).
Other factors
I confirmed the fix covers the whole class: CURRENT_TIME.set appears in exactly three places (activate, fire, advance_timers_by_time), and the other timer-control host functions all drain through fire() without a separate post-drain clock write, so no sibling site was missed. The tests follow repo conventions — added to the existing fake-timers.test.ts, use test.each for the four-method matrix, assert whole arrays with .toEqual, and check both Date.now() and performance.now(). The useRealTimers-inside-callback test uses toBeGreaterThanOrEqual(realDate) / toBeLessThan(2 ** 31) bounds rather than sleeping, which is the right shape for a real-clock assertion. The old CI-only debug_assert! on monotonicity is replaced by unconditional runtime clamping, which is the correct move per REVIEW.md ("debug assertions compile out — validation of runtime invariants that user-triggered re-entrancy can violate must survive release").
|
Updated 12:36 PM PT - Sep 8th, 2026
❌ @robobun, your commit fb18488 has 2 failures in
🧪 To try this PR locally: bunx bun-pr 42045That installs a local version of the PR into your bun-42045 --bun |
|
On the compat point the review defers on: the choice to ratify is whether an outer |
|
I worked the same bug from a different report (a nested The one difference is what the outer call does after a callback moved the clock:
One trade-off to weigh before choosing the duration form: a If the clamp is the decision, nothing from the branch is needed. If sinon parity is wanted, the |
…llback 1ms later (#42728) ### Problem - Under `jest.useFakeTimers()`, `advanceTimersByTime()` and `runOnlyPendingTimers()` never return when a timer callback arms the next timer with no delay: an `AbortSignal.timeout(0)` armed again by its own `abort` listener, or a `while (!done) await Bun.sleep(0)` loop. Bun spins inside one host call, so no test timeout fires. Fuzz-found, no user report. - `FakeTimers::execute_until` (`src/runtime/test_runner/timers/FakeTimers.rs:274`) fires every timer due at or before its target. Such a timer lands on the current fake instant, so it is due again. ### Fix - While a fake timer's callback runs, a timer armed with no delay gets 1ms (`FakeTimers::min_delay_ms`). Both arm sites that accept 0 use it: `AbortSignal::Timeout::init` (new `RuntimeHooks` slot) and `TimerObjectInternals::reschedule` (`Bun.sleep`). - `@sinonjs/fake-timers` has this rule (`addTimer`: `delay || (duringTick ? 1 : 0)`). Each re-armed timer is 1ms later, so the drain ends. Real timers and interval re-arms do not change. - Verified: `test/js/bun/test/fake-timers/fake-timers.test.ts` (11 new cases, 8 fail without the fix). Also the rest of that directory, `test-timers`, `sleep`, cron, `web/abort`. - Self-reviewed: 15 concerns raised, 9 addressed, 5 needed no change, 1 rejected. ### Background - The `jest` timer controls pop due timers from a fake heap and run each through `FakeTimers::fire`, which first sets the fake clock. - `setTimeout(fn, 0)` is always 1ms. Only `AbortSignal.timeout(0)` and `Bun.sleep(0)` arm with no delay. - `RuntimeHooks` is the function table through which `bun_jsc` (`AbortSignal`) calls up into `bun_runtime` (the timer heap). - With no event loop task on the stack (the first tests of a file), `fire` also drains the callback's microtasks. The `Bun.sleep(0)` loop re-arms there. <details><summary>Notes</summary> Repro (the bound keeps it from hanging): ```js const { jest } = Bun.jest(); jest.useFakeTimers(); let fires = 0; const arm = () => AbortSignal.timeout(0).addEventListener("abort", () => { if (++fires < 20000) arm(); }); arm(); jest.advanceTimersByTime(1); // same with jest.runOnlyPendingTimers() console.log(fires, performance.now()); ``` | | 1.4.3 | this PR | lolex 5.1.2, `setTimeout(fn, 0)` re-armed | |---|---|---|---| | `advanceTimersByTime(1)` / `tick(1)` | 20000 fires, all at 0 | 2 fires, at 0 and 1 | 2 fires, at 0 and 1 | | `runOnlyPendingTimers()` / `runToLast()` | 20000 fires, all at 0 | 1 fire, at 0 | 1 fire, at 0 | | `advanceTimersToNextTimer()` twice / `next()` twice | fires at 0 and 0 | fires at 0 and 1 | fires at 0 and 1 | The `Bun.sleep(0)` loop, as the first test of a file: `setTimeout(() => (done = true), 3)`, then `(async () => { while (!done) await Bun.sleep(0); })()`, then `advanceTimersByTime(5)`. 1.4.3 polls forever at instant 0. This PR polls at 0, 0, 1, 2 and returns with the clock at 5. After a test that awaited a real timer, the controls run inside an event loop task and do not drain microtasks between timers. There the loop never spun, and it does not change. Behavior changes beyond the hang. Each equals what `setTimeout(fn, 0)` already does in Bun and in Jest: - An `AbortSignal.timeout(0)` that a fake timer callback arms fires 1ms after that callback. Before, it fired at the same fake instant, inside the same `advanceTimersByTime()` call. - A `Bun.sleep(0)` (any value below 1) that a fake timer callback calls resolves 1ms after that callback. - `advanceTimersToNextTimer()` moves the clock to the re-armed timer, 1ms later. Before, the clock stayed. The rule is on the requested delay, and not on the deadline. A first version compared the deadline with the fake clock in `All::insert`. That also moved a `setInterval(fn, 10)` whose callback calls `advanceTimersByTime(10)` (its next deadline equals the clock) from 10, 20, 30 to 10, 21, 31, and an `_idleStart` write that lands on the clock. Neither is a zero delay. Two of the new tests hold the interval case. `firing` is a depth counter and not a flag. A listener can call a nested timer control, which fires another timer and returns into the listener. A flag is clear from then on, and a zero-delay timer that the listener arms afterwards spins the outer drain again. One of the new tests covers this. The count spans the whole `EventLoopTimer::fire` call, which includes the microtask drain on return. Timer control calls that still do not return, and where they stand: - `runAllTimers()` with a timer that always re-arms (`setInterval`, `Bun.cron`, the repro above). Unbounded by design, in Jest too. Jest stops at `timerLimit`. #34325 added that cap and is closed. This PR does not change `runAllTimers()`. `tick()` has no loop limit in Jest or sinon, so a count cap on `execute_until` is not the right shape. - A callback that writes `_idleStart` so that a timer is due at the current instant, in a cycle. That is an explicit absolute deadline. Not changed. - The test timeout cannot interrupt any synchronous spin: #30598, #21277. That is the rejected review concern. It is a backstop for all of these, and a separate change. Related PRs. None of them is a prerequisite. Each pair needs a textual merge only: - #42045 (never move the fake clock backwards) changes `fire()` and `advance_timers_by_time`. Its tests arm no zero-delay timer, so this change cannot move their values. - #38740 (per-VM fake clock) moves the clock into `FakeTimers`. It also covers a callback that installs the fake clock again. - #41571 adds an `execute_until(now)` step to `advanceTimersToNextTimer()`. Without this rule that step has the same hang. - #40187 (timer: remove unsafe) rewrites the timer module. `node:test` `mock.timers` (#32631) is a separate implementation in `src/js/internal/test_runner/mock_timers.ts`. It replaces `AbortSignal.timeout` with its own timers and has its own `tick()`. This PR does not touch it. Suites run on the debug ASAN build: `test/js/bun/test/fake-timers/` (the sinon port gives the same pass, fail and todo sets with `--todo` as 1.4.3), `test/js/bun/test/test-timers.test.ts`, `test/js/bun/util/sleep.test.ts`, `test/js/bun/cron/in-process-cron.test.ts`, `test/js/bun/cron/cron-local-time.test.ts`, `test/regression/issue/26284.test.ts`, `test/regression/issue/25869.test.ts`, `test/js/third_party/jsonwebtoken/`, `test/js/web/abort/`, the fake timers case of `test/cli/test/isolation.test.ts`, `test/internal/source-lints/`, and `cargo clippy -p bun_jsc -p bun_runtime`. `test/js/web/timers/setTimeout.test.js`, `setInterval.test.js` and `timer-gc-roots.test.ts` have 5 failures on my machine under the debug ASAN build (RSS thresholds and one 5s timeout). The same 5 fail with `src/` at `main`. </details> <!-- robobun:evidence:begin --> --- **[human-review]** gate passed · iteration 0 · 6 files touched <details><summary>fails on main (without fix)</summary> ```console ASAN without fix: 8 FAILED $ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/bun/test/fake-timers/fake-timers.test.ts bun test v1.4.3 (09bb546) test/js/bun/test/fake-timers/fake-timers.test.ts: (pass) fake timers [25.32ms] (pass) advanceTimersToNextTimer > one setTimeout [9.05ms] (pass) advanceTimersToNextTimer > setInterval [8.07ms] (pass) advanceTimersToNextTimer > sorted timeouts [13.08ms] (pass) advanceTimersToNextTimer > alternating intervals [9.95ms] (pass) advanceTimersByTime > setInterval [6.35ms] (pass) advanceTimersByTime > advanceTimersByTime(NaN) throws and does not move the clock [5.91ms] (pass) advanceTimersByTime > advanceTimersByTime(-1) throws and does not move the clock [1.24ms] (pass) advanceTimersByTime > advanceTimersByTime(Infinity) throws and does not move the clock [0.91ms] (pass) advanceTimersByTime > advanceTimersByTime(4294967296) throws and does not move the clock [1.47ms] (pass) runOnlyPendingTimers > two setIntervals [7.85ms] (pass) runAllTimers > two setIntervals [9.60ms] (pass) getTimerCount > returns correct count of pending timers [8.65ms] (pass) getTimerCount > throw ... (truncated) release without fix: 8 FAILED bun test v1.4.3-canary.1 (09bb546) test/js/bun/test/fake-timers/fake-timers.test.ts: (pass) fake timers [10.41ms] (pass) advanceTimersToNextTimer > one setTimeout [0.14ms] (pass) advanceTimersToNextTimer > setInterval [0.09ms] (pass) advanceTimersToNextTimer > sorted timeouts [0.11ms] (pass) advanceTimersToNextTimer > alternating intervals [0.08ms] (pass) advanceTimersByTime > setInterval [0.07ms] (pass) advanceTimersByTime > advanceTimersByTime(NaN) throws and does not move the clock [0.10ms] (pass) advanceTimersByTime > advanceTimersByTime(-1) throws and does not move the clock (pass) advanceTimersByTime > advanceTimersByTime(Infinity) throws and does not move the clock (pass) advanceTimersByTime > advanceTimersByTime(4294967296) throws and does not move the clock (pass) runOnlyPendingTimers > two setIntervals [0.13ms] (pass) runAllTimers > two setIntervals [0.07ms] (pass) getTimerCount > returns correct count of pending timers [0.06ms] (pass) getTimerCount > throws error if fake timers not active [0.02ms] (pass) clearAllTimers > clears all pending timers [0.05ms] (pass) clearAllTimers > throws error if fake timers not active [0.02ms] (pass) AbortSignal.timeout ... (truncated) ``` </details> <details><summary>passes on PR (with fix)</summary> ```console ASAN with fix: all passed $ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/bun/test/fake-timers/fake-timers.test.ts bun test v1.4.3 (09bb546) test/js/bun/test/fake-timers/fake-timers.test.ts: (pass) fake timers [26.39ms] (pass) advanceTimersToNextTimer > one setTimeout [9.05ms] (pass) advanceTimersToNextTimer > setInterval [8.23ms] (pass) advanceTimersToNextTimer > sorted timeouts [13.11ms] (pass) advanceTimersToNextTimer > alternating intervals [10.00ms] (pass) advanceTimersByTime > setInterval [6.67ms] (pass) advanceTimersByTime > advanceTimersByTime(NaN) throws and does not move the clock [6.56ms] (pass) advanceTimersByTime > advanceTimersByTime(-1) throws and does not move the clock [1.29ms] (pass) advanceTimersByTime > advanceTimersByTime(Infinity) throws and does not move the clock [0.91ms] (pass) advanceTimersByTime > advanceTimersByTime(4294967296) throws and does not move the clock [1.56ms] (pass) runOnlyPendingTimers > two setIntervals [8.18ms] (pass) runAllTimers > two setIntervals [9.79ms] (pass) getTimerCount > returns correct count of pending timers [8.87ms] (pass) getTimerCount > thro ... (truncated) release with fix: all passed $ bun scripts/build.ts --profile=release [configured] bun-profile → bun (stripped) target linux-x64-gnu build type Release build dir ./build/release revision b23c2c8 features baseline 23 deps, 131 codegen, 1176 objects in 692ms ninja: Entering directory `/workspace/bun/build/release' [1/1248] install /workspace/bun bun install v1.4.3-canary.1 (09bb546) Checked 22 installs across 61 packages (no changes) [14.00ms] [2/1248] install /workspace/bun/packages/bun-error bun install v1.4.3-canary.1 (09bb546) Checked 1 install across 2 packages (no changes) [1.00ms] [3/1248] gen ErrorCode+*.h [4/1248] gen bindgenv2 [5/1248] install /workspace/bun/src/node-fallbacks bun install v1.4.3-canary.1 (09bb546) Checked 111 installs across 104 packages (no changes) [4.00ms] [6/1248] fetch zlib [zlib] up to date [7/1248] fetch tinycc [tinycc] up to date [8/1247] fetch libjpeg-turbo [libjpeg-turbo] up to date [9/1220] gen node-fallbacks/react-refresh.js Bundled 1 module in 7ms react-refresh.js 4.81 KB (entry point) [10/1220] gen .bind.ts → GeneratedBindings.cpp [11/1220] gen bake.{client,server,error}.js -> bake.client.js, bake.server ... (truncated) ``` </details> <details><summary>diff hotspot</summary> ``` src/jsc/AbortSignal.rs | 1 + src/jsc/VirtualMachine.rs | 9 ++ src/runtime/jsc_hooks.rs | 11 ++ src/runtime/test_runner/timers/FakeTimers.rs | 13 ++ src/runtime/timer/timer_object_internals.rs | 5 +- test/js/bun/test/fake-timers/fake-timers.test.ts | 189 ++++++++++++++++++++++- 6 files changed, 226 insertions(+), 2 deletions(-) ``` </details> **gate history** · 1 passed · 0 rejected · iteration 0 <details><summary>evidence per changed file</summary> ``` file reads edits tests src/jsc/AbortSignal.rs 2 2 24 src/jsc/VirtualMachine.rs 4 5 23 src/runtime/jsc_hooks.rs 2 3 23 src/runtime/test_runner/timers/FakeTimers.rs 3 7 22 src/runtime/timer/timer_object_internals.rs 3 1 22 test/js/bun/test/fake-timers/fake-timers.test.ts 5 5 22 ``` </details> <!-- robobun:evidence:end --> --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
…llback 1ms later (oven-sh#42728) ### Problem - Under `jest.useFakeTimers()`, `advanceTimersByTime()` and `runOnlyPendingTimers()` never return when a timer callback arms the next timer with no delay: an `AbortSignal.timeout(0)` armed again by its own `abort` listener, or a `while (!done) await Bun.sleep(0)` loop. Bun spins inside one host call, so no test timeout fires. Fuzz-found, no user report. - `FakeTimers::execute_until` (`src/runtime/test_runner/timers/FakeTimers.rs:274`) fires every timer due at or before its target. Such a timer lands on the current fake instant, so it is due again. ### Fix - While a fake timer's callback runs, a timer armed with no delay gets 1ms (`FakeTimers::min_delay_ms`). Both arm sites that accept 0 use it: `AbortSignal::Timeout::init` (new `RuntimeHooks` slot) and `TimerObjectInternals::reschedule` (`Bun.sleep`). - `@sinonjs/fake-timers` has this rule (`addTimer`: `delay || (duringTick ? 1 : 0)`). Each re-armed timer is 1ms later, so the drain ends. Real timers and interval re-arms do not change. - Verified: `test/js/bun/test/fake-timers/fake-timers.test.ts` (11 new cases, 8 fail without the fix). Also the rest of that directory, `test-timers`, `sleep`, cron, `web/abort`. - Self-reviewed: 15 concerns raised, 9 addressed, 5 needed no change, 1 rejected. ### Background - The `jest` timer controls pop due timers from a fake heap and run each through `FakeTimers::fire`, which first sets the fake clock. - `setTimeout(fn, 0)` is always 1ms. Only `AbortSignal.timeout(0)` and `Bun.sleep(0)` arm with no delay. - `RuntimeHooks` is the function table through which `bun_jsc` (`AbortSignal`) calls up into `bun_runtime` (the timer heap). - With no event loop task on the stack (the first tests of a file), `fire` also drains the callback's microtasks. The `Bun.sleep(0)` loop re-arms there. <details><summary>Notes</summary> Repro (the bound keeps it from hanging): ```js const { jest } = Bun.jest(); jest.useFakeTimers(); let fires = 0; const arm = () => AbortSignal.timeout(0).addEventListener("abort", () => { if (++fires < 20000) arm(); }); arm(); jest.advanceTimersByTime(1); // same with jest.runOnlyPendingTimers() console.log(fires, performance.now()); ``` | | 1.4.3 | this PR | lolex 5.1.2, `setTimeout(fn, 0)` re-armed | |---|---|---|---| | `advanceTimersByTime(1)` / `tick(1)` | 20000 fires, all at 0 | 2 fires, at 0 and 1 | 2 fires, at 0 and 1 | | `runOnlyPendingTimers()` / `runToLast()` | 20000 fires, all at 0 | 1 fire, at 0 | 1 fire, at 0 | | `advanceTimersToNextTimer()` twice / `next()` twice | fires at 0 and 0 | fires at 0 and 1 | fires at 0 and 1 | The `Bun.sleep(0)` loop, as the first test of a file: `setTimeout(() => (done = true), 3)`, then `(async () => { while (!done) await Bun.sleep(0); })()`, then `advanceTimersByTime(5)`. 1.4.3 polls forever at instant 0. This PR polls at 0, 0, 1, 2 and returns with the clock at 5. After a test that awaited a real timer, the controls run inside an event loop task and do not drain microtasks between timers. There the loop never spun, and it does not change. Behavior changes beyond the hang. Each equals what `setTimeout(fn, 0)` already does in Bun and in Jest: - An `AbortSignal.timeout(0)` that a fake timer callback arms fires 1ms after that callback. Before, it fired at the same fake instant, inside the same `advanceTimersByTime()` call. - A `Bun.sleep(0)` (any value below 1) that a fake timer callback calls resolves 1ms after that callback. - `advanceTimersToNextTimer()` moves the clock to the re-armed timer, 1ms later. Before, the clock stayed. The rule is on the requested delay, and not on the deadline. A first version compared the deadline with the fake clock in `All::insert`. That also moved a `setInterval(fn, 10)` whose callback calls `advanceTimersByTime(10)` (its next deadline equals the clock) from 10, 20, 30 to 10, 21, 31, and an `_idleStart` write that lands on the clock. Neither is a zero delay. Two of the new tests hold the interval case. `firing` is a depth counter and not a flag. A listener can call a nested timer control, which fires another timer and returns into the listener. A flag is clear from then on, and a zero-delay timer that the listener arms afterwards spins the outer drain again. One of the new tests covers this. The count spans the whole `EventLoopTimer::fire` call, which includes the microtask drain on return. Timer control calls that still do not return, and where they stand: - `runAllTimers()` with a timer that always re-arms (`setInterval`, `Bun.cron`, the repro above). Unbounded by design, in Jest too. Jest stops at `timerLimit`. oven-sh#34325 added that cap and is closed. This PR does not change `runAllTimers()`. `tick()` has no loop limit in Jest or sinon, so a count cap on `execute_until` is not the right shape. - A callback that writes `_idleStart` so that a timer is due at the current instant, in a cycle. That is an explicit absolute deadline. Not changed. - The test timeout cannot interrupt any synchronous spin: oven-sh#30598, oven-sh#21277. That is the rejected review concern. It is a backstop for all of these, and a separate change. Related PRs. None of them is a prerequisite. Each pair needs a textual merge only: - oven-sh#42045 (never move the fake clock backwards) changes `fire()` and `advance_timers_by_time`. Its tests arm no zero-delay timer, so this change cannot move their values. - oven-sh#38740 (per-VM fake clock) moves the clock into `FakeTimers`. It also covers a callback that installs the fake clock again. - oven-sh#41571 adds an `execute_until(now)` step to `advanceTimersToNextTimer()`. Without this rule that step has the same hang. - oven-sh#40187 (timer: remove unsafe) rewrites the timer module. `node:test` `mock.timers` (oven-sh#32631) is a separate implementation in `src/js/internal/test_runner/mock_timers.ts`. It replaces `AbortSignal.timeout` with its own timers and has its own `tick()`. This PR does not touch it. Suites run on the debug ASAN build: `test/js/bun/test/fake-timers/` (the sinon port gives the same pass, fail and todo sets with `--todo` as 1.4.3), `test/js/bun/test/test-timers.test.ts`, `test/js/bun/util/sleep.test.ts`, `test/js/bun/cron/in-process-cron.test.ts`, `test/js/bun/cron/cron-local-time.test.ts`, `test/regression/issue/26284.test.ts`, `test/regression/issue/25869.test.ts`, `test/js/third_party/jsonwebtoken/`, `test/js/web/abort/`, the fake timers case of `test/cli/test/isolation.test.ts`, `test/internal/source-lints/`, and `cargo clippy -p bun_jsc -p bun_runtime`. `test/js/web/timers/setTimeout.test.js`, `setInterval.test.js` and `timer-gc-roots.test.ts` have 5 failures on my machine under the debug ASAN build (RSS thresholds and one 5s timeout). The same 5 fail with `src/` at `main`. </details> <!-- robobun:evidence:begin --> --- **[human-review]** gate passed · iteration 0 · 6 files touched <details><summary>fails on main (without fix)</summary> ```console ASAN without fix: 8 FAILED $ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/bun/test/fake-timers/fake-timers.test.ts bun test v1.4.3 (09bb546) test/js/bun/test/fake-timers/fake-timers.test.ts: (pass) fake timers [25.32ms] (pass) advanceTimersToNextTimer > one setTimeout [9.05ms] (pass) advanceTimersToNextTimer > setInterval [8.07ms] (pass) advanceTimersToNextTimer > sorted timeouts [13.08ms] (pass) advanceTimersToNextTimer > alternating intervals [9.95ms] (pass) advanceTimersByTime > setInterval [6.35ms] (pass) advanceTimersByTime > advanceTimersByTime(NaN) throws and does not move the clock [5.91ms] (pass) advanceTimersByTime > advanceTimersByTime(-1) throws and does not move the clock [1.24ms] (pass) advanceTimersByTime > advanceTimersByTime(Infinity) throws and does not move the clock [0.91ms] (pass) advanceTimersByTime > advanceTimersByTime(4294967296) throws and does not move the clock [1.47ms] (pass) runOnlyPendingTimers > two setIntervals [7.85ms] (pass) runAllTimers > two setIntervals [9.60ms] (pass) getTimerCount > returns correct count of pending timers [8.65ms] (pass) getTimerCount > throw ... (truncated) release without fix: 8 FAILED bun test v1.4.3-canary.1 (09bb546) test/js/bun/test/fake-timers/fake-timers.test.ts: (pass) fake timers [10.41ms] (pass) advanceTimersToNextTimer > one setTimeout [0.14ms] (pass) advanceTimersToNextTimer > setInterval [0.09ms] (pass) advanceTimersToNextTimer > sorted timeouts [0.11ms] (pass) advanceTimersToNextTimer > alternating intervals [0.08ms] (pass) advanceTimersByTime > setInterval [0.07ms] (pass) advanceTimersByTime > advanceTimersByTime(NaN) throws and does not move the clock [0.10ms] (pass) advanceTimersByTime > advanceTimersByTime(-1) throws and does not move the clock (pass) advanceTimersByTime > advanceTimersByTime(Infinity) throws and does not move the clock (pass) advanceTimersByTime > advanceTimersByTime(4294967296) throws and does not move the clock (pass) runOnlyPendingTimers > two setIntervals [0.13ms] (pass) runAllTimers > two setIntervals [0.07ms] (pass) getTimerCount > returns correct count of pending timers [0.06ms] (pass) getTimerCount > throws error if fake timers not active [0.02ms] (pass) clearAllTimers > clears all pending timers [0.05ms] (pass) clearAllTimers > throws error if fake timers not active [0.02ms] (pass) AbortSignal.timeout ... (truncated) ``` </details> <details><summary>passes on PR (with fix)</summary> ```console ASAN with fix: all passed $ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/bun/test/fake-timers/fake-timers.test.ts bun test v1.4.3 (09bb546) test/js/bun/test/fake-timers/fake-timers.test.ts: (pass) fake timers [26.39ms] (pass) advanceTimersToNextTimer > one setTimeout [9.05ms] (pass) advanceTimersToNextTimer > setInterval [8.23ms] (pass) advanceTimersToNextTimer > sorted timeouts [13.11ms] (pass) advanceTimersToNextTimer > alternating intervals [10.00ms] (pass) advanceTimersByTime > setInterval [6.67ms] (pass) advanceTimersByTime > advanceTimersByTime(NaN) throws and does not move the clock [6.56ms] (pass) advanceTimersByTime > advanceTimersByTime(-1) throws and does not move the clock [1.29ms] (pass) advanceTimersByTime > advanceTimersByTime(Infinity) throws and does not move the clock [0.91ms] (pass) advanceTimersByTime > advanceTimersByTime(4294967296) throws and does not move the clock [1.56ms] (pass) runOnlyPendingTimers > two setIntervals [8.18ms] (pass) runAllTimers > two setIntervals [9.79ms] (pass) getTimerCount > returns correct count of pending timers [8.87ms] (pass) getTimerCount > thro ... (truncated) release with fix: all passed $ bun scripts/build.ts --profile=release [configured] bun-profile → bun (stripped) target linux-x64-gnu build type Release build dir ./build/release revision b23c2c8 features baseline 23 deps, 131 codegen, 1176 objects in 692ms ninja: Entering directory `/workspace/bun/build/release' [1/1248] install /workspace/bun bun install v1.4.3-canary.1 (09bb546) Checked 22 installs across 61 packages (no changes) [14.00ms] [2/1248] install /workspace/bun/packages/bun-error bun install v1.4.3-canary.1 (09bb546) Checked 1 install across 2 packages (no changes) [1.00ms] [3/1248] gen ErrorCode+*.h [4/1248] gen bindgenv2 [5/1248] install /workspace/bun/src/node-fallbacks bun install v1.4.3-canary.1 (09bb546) Checked 111 installs across 104 packages (no changes) [4.00ms] [6/1248] fetch zlib [zlib] up to date [7/1248] fetch tinycc [tinycc] up to date [8/1247] fetch libjpeg-turbo [libjpeg-turbo] up to date [9/1220] gen node-fallbacks/react-refresh.js Bundled 1 module in 7ms react-refresh.js 4.81 KB (entry point) [10/1220] gen .bind.ts → GeneratedBindings.cpp [11/1220] gen bake.{client,server,error}.js -> bake.client.js, bake.server ... (truncated) ``` </details> <details><summary>diff hotspot</summary> ``` src/jsc/AbortSignal.rs | 1 + src/jsc/VirtualMachine.rs | 9 ++ src/runtime/jsc_hooks.rs | 11 ++ src/runtime/test_runner/timers/FakeTimers.rs | 13 ++ src/runtime/timer/timer_object_internals.rs | 5 +- test/js/bun/test/fake-timers/fake-timers.test.ts | 189 ++++++++++++++++++++++- 6 files changed, 226 insertions(+), 2 deletions(-) ``` </details> **gate history** · 1 passed · 0 rejected · iteration 0 <details><summary>evidence per changed file</summary> ``` file reads edits tests src/jsc/AbortSignal.rs 2 2 24 src/jsc/VirtualMachine.rs 4 5 23 src/runtime/jsc_hooks.rs 2 3 23 src/runtime/test_runner/timers/FakeTimers.rs 3 7 22 src/runtime/timer/timer_object_internals.rs 3 1 22 test/js/bun/test/fake-timers/fake-timers.test.ts 5 5 22 ``` </details> <!-- robobun:evidence:end --> --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Problem
jesttimer control (reported:jest.runOnlyPendingTimers()in asetIntervalcallback duringjest.advanceTimersByTime(10)) can move the fake clock past timers the outer drain still has to fire. Debug builds abort withassertion failed: now.eql(&prev.unwrap()) || now.greater(&prev.unwrap())inFakeTimers::fire. Release builds letDate.now()read 1, 10, 2, 3, 4 inside one advance.FakeTimers::fire(src/runtime/test_runner/timers/FakeTimers.rs:252) set the clock to each popped timer's deadline.advance_timers_by_time(:448) then set it to its own target. That also rewound a nestedadvanceTimersByTime(100)(105 back to 10) and re-pinned a fakeDate.now()after a callback'suseRealTimers().Fix
fire()fires a popped timer atmax(deadline, current time), like an overdue real timer.advanceTimersByTime()sets the clock to its target only when the clock is not past it and fake timers are still installed.test/js/bun/test/fake-timers/fake-timers.test.ts(7 new cases, all fail on 1.4.3). Also the rest of that directory, the cron, jsonwebtoken,test-timersand--isolatesuites.Background
jesttimer controls pop due timers from the fake heap and run them synchronously throughfire(), which first sets the fake clock.Date.now(),performance.now()and new timer deadlines read that clock.setIntervalis out of the heap while its callback runs and goes back in afterwards, as in Node. A nested control cannot see it, and its next deadline can already be behind the clock.Notes
Jest 29.7 (
@sinonjs/fake-timers) on the shapes of the new tests,T0 = useFakeTimers({ now }):setInterval(10)whose first tick callsadvanceTimersByTime(50), cleared on tick 3, outerrunAllTimers()setInterval(1)whose callback callsrunOnlyPendingTimers(), plussetTimeout(10), outeradvanceTimersByTime(10)setTimeout(5)whose callback callsadvanceTimersByTime(100), plussetTimeout(50), outeradvanceTimersByTime(10)runOnlyPendingTimers()/runAllTimers()/advanceTimersToNextTimer()setTimeout(5)whose callback callsuseRealTimers(), outeradvanceTimersByTime(2 ** 31)Date.now()isT0 + 2^31andperformance.now()is2^31afterwards,isFakeTimers()falseuseRealTimers()drops them, unchanged here)The two remaining differences from Jest:
doTickshiftstickToby however muchclock.nowmoved during a callback. That exists to compensate forsetSystemTime()inside a callback (sinon keys timers on the system time, Bun keys them on a separate monotonic clock and already handles that case by rebasing theDate.now()offset). A nested tick trips the same compensation, so the outer call keeps spending its remaining budget after the nested jump (110 instead of 105, and 40 in the interval case where every nesting level compensates). Jest's legacy timers do the same throughmsToRun.callAt += interval) before it runs the callback, so a nested control sees the interval and fires it again, re-entering the callback recursively. Bun, like Node, keeps the interval out of the heap during its callback and re-inserts it afterwards.Neither is about the clock running backwards. Matching either is a separate decision.
#38740 (per-VM fake clock) keeps the monotonicity assert in
fire()and does not cover re-entrant controls. It also stopsadvanceTimersByTime()from re-pinning the clock afteruseRealTimers(), which the second hunk here does as well; the two will need a small textual merge.test/js/web/timers/setTimeout.test.js/setInterval.test.jsleak cases fail locally under the debug ASAN build with and without this change (RSS threshold); they do not use fake timers.