Skip to content

bun:test fake timers: never move the fake clock backwards - #42045

Open
robobun wants to merge 1 commit into
mainfrom
robobun/0393e3ae/fake-timers-monotonic-clock
Open

robobun wants to merge 1 commit into
mainfrom
robobun/0393e3ae/fake-timers-monotonic-clock

Conversation

@robobun

@robobun robobun commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • A timer callback that calls a jest timer control (reported: jest.runOnlyPendingTimers() in a setInterval callback during jest.advanceTimersByTime(10)) can move the fake clock past timers the outer drain still has to fire. Debug builds abort with assertion failed: now.eql(&prev.unwrap()) || now.greater(&prev.unwrap()) in FakeTimers::fire. Release builds let Date.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 nested advanceTimersByTime(100) (105 back to 10) and re-pinned a fake Date.now() after a callback's useRealTimers().

Fix

  • fire() fires a popped timer at max(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.
  • Correct because the clock only moves forward and every timer due by the target still fires, in heap order. Jest 29 never rewinds either. Two unrelated differences from Jest stay, so the tests pin Bun's values (see Notes).
  • Verified: 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-timers and --isolate suites.
  • Self-reviewed: 1 concern raised, 1 addressed (an advance that ends exactly on its target still re-syncs the clock, as before).

Background

  • The jest timer controls pop due timers from the fake heap and run them synchronously through fire(), which first sets the fake clock. Date.now(), performance.now() and new timer deadlines read that clock.
  • A setInterval is 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 }):

shape Bun before Bun after Jest 29
setInterval(10) whose first tick calls advanceTimersByTime(50), cleared on tick 3, outer runAllTimers() iv@10, iv@20, iv@30, clock 10, 60, 20, 30 (debug: abort) iv@10, iv@60 (late), iv@70, ends at 70 iv@10, then iv@20 and iv@30 nested inside tick 1, ends at 60
setInterval(1) whose callback calls runOnlyPendingTimers(), plus setTimeout(10), outer advanceTimersByTime(10) iv@1, to@10, iv@2, iv@3, iv@4, ends at 10 (debug: abort) iv@1, to@10, iv@10 (late), ends at 10, interval pending at 11 iv@1, iv@2, iv@3, iv@4 (each nested one level deeper), to@10, ends at 40
setTimeout(5) whose callback calls advanceTimersByTime(100), plus setTimeout(50), outer advanceTimersByTime(10) a@5, b@50, a-after@105, ends at 10 a@5, b@50, a-after@105, ends at 105 a@5, b@50, a-after@105, ends at 110
same, nested runOnlyPendingTimers() / runAllTimers() / advanceTimersToNextTimer() a-after@50, ends at 10 a-after@50, ends at 50 a-after@50, ends at 55
setTimeout(5) whose callback calls useRealTimers(), outer advanceTimersByTime(2 ** 31) Date.now() is T0 + 2^31 and performance.now() is 2^31 afterwards, isFakeTimers() false both real both real (Jest also still fires the old clock's remaining timers; Bun's useRealTimers() drops them, unchanged here)

The two remaining differences from Jest:

  • sinon's doTick shifts tickTo by however much clock.now moved during a callback. That exists to compensate for setSystemTime() 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 the Date.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 through msToRun.
  • sinon reschedules an interval (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 stops advanceTimersByTime() from re-pinning the clock after useRealTimers(), 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.js leak cases fail locally under the debug ASAN build with and without this change (RSS threshold); they do not use fake timers.

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.
@coderabbitai

coderabbitai Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

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.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 374c8b18-7524-416f-9bc6-fdeb8681f7d7

📥 Commits

Reviewing files that changed from the base of the PR and between d745f03 and fb18488.

📒 Files selected for processing (2)
  • src/runtime/test_runner/timers/FakeTimers.rs
  • test/js/bun/test/fake-timers/fake-timers.test.ts

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the claude label Sep 8, 2026
@robobun

robobun commented Sep 8, 2026 •

Copy link
Copy Markdown
Collaborator Author

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: test/cli/install/bun-patch.test.ts on Windows x64 (long-label bun patch cases, also flaky on main, reported separately) and test/js/node/test/parallel/test-crypto-dh-leak.js on x64-asan (pre-existing on main).

Reproduced on release 1.4.3 and on a debug ASAN build of main (Malloc=1, 3/3 SIGABRT at FakeTimers.rs:261) with:

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 [["iv",1,1],["to",10],["iv",2,10]] and leaves the interval pending at 11 ms. The 7 new cases in test/js/bun/test/fake-timers/fake-timers.test.ts fail on 1.4.3 and pass on the branch.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 to max(deadline, current) and advance_timers_by_time only re-syncs when the clock hasn't passed the target and fake timers are still installed — checked that these are the only post-drain CURRENT_TIME.set sites, so all sibling controls (runAllTimers, runOnlyPendingTimers, advanceTimersToNextTimer) are covered via fire().
  • EventLoopTimer::fire now receives the clamped now instead of the raw deadline — timespec is an alias of Timespec, so the type is unchanged; interval reschedule from now matches the test expectations and Node's overdue-interval semantics.
  • New tests cover the variant matrix (setInterval/setTimeout × all four nested controls × the useRealTimers teardown) 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").

@robobun

robobun commented Sep 8, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:36 PM PT - Sep 8th, 2026

❌ @robobun, your commit fb18488 has 2 failures in Build #113126 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 42045

That installs a local version of the PR into your bun-42045 executable, so you can run:

bun-42045 --bun

@robobun

robobun commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator Author

On the compat point the review defers on: the choice to ratify is whether an outer advanceTimersByTime(n) ends at start + n (this PR, unless a nested call already went further) or keeps spending its remaining budget after a nested jump (Jest 29 / sinon: 110 instead of 105 in the Notes table). If the Jest behaviour is preferred, it is a contained follow-up: execute_until would extend until by however far each fired callback moved the clock, and advance_timers_by_time would use the extended value. The max(deadline, now) clamp in fire() is needed either way, because Bun re-inserts a setInterval after its callback and that deadline can already be behind the clock.

@robobun

robobun commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author

I worked the same bug from a different report (a nested advanceTimersByTime() rewinding the clock, [ 50, 1050, 100 ] on 1.4.3) and landed on the same fire() change. I am not opening a second PR. The branch is robobun/a984b9b6/fake-timers-nested-advance (36442c8) in case a maintainer prefers its semantics for the outer call.

The one difference is what the outer call does after a callback moved the clock:

  • This PR keeps the absolute target from entry and parks at max(target, now). advanceTimersByTime(10) with a nested advanceTimersByTime(100) at 5 ends at 105.
  • The branch advances by duration: the loop rereads the clock after each callback and spends the rest of the span from wherever the callback left it. The same case ends at 110, and runOnlyPendingTimers() becomes advance(last - now). These are the values @sinonjs/fake-timers 15.4.0 (Jest modern timers, vitest) and Jest's legacy msToRun loop produce. The six tests on the branch are pinned to sinon's output.

One trade-off to weigh before choosing the duration form: a setInterval whose callback always advances the clock past its own next deadline comes back overdue every time, costs the outer loop no budget, and the outer advanceTimersByTime() never returns. This PR and 1.4.3 terminate in that case. sinon overflows the stack instead, because it re-fires the interval inside the nested tick(). So the duration form would want a loop limit like Jest's legacy maxLoops on top.

If the clamp is the decision, nothing from the branch is needed. If sinon parity is wanted, the advance_by loop and its tests can be folded in here.

Jarred-Sumner pushed a commit that referenced this pull request Sep 15, 2026
…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>
usrbinkat pushed a commit to usrbinkat/bun that referenced this pull request Sep 15, 2026
…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>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants