Skip to content

bun:test: cap jest.runAllTimers() at timerLimit (default 100000) - #42744

Open
robobun wants to merge 1 commit into
mainfrom
robobun/b3b4d497/fake-timers-run-all-timers-limit
Open

robobun wants to merge 1 commit into
mainfrom
robobun/b3b4d497/fake-timers-run-all-timers-limit

Conversation

@robobun

@robobun robobun commented Sep 14, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • jest.runAllTimers() never returns when a timer re-arms itself on every fire (setInterval, a recursive setTimeout, Bun.cron). bun test spins at 100% CPU, and no test timeout can interrupt one native call.
  • The cause is FakeTimers::execute_all_timers (src/runtime/test_runner/timers/FakeTimers.rs): while Self::execute_next(global)? {} has no bound.
  • Jest throws Aborting after running 100000 timers, assuming an infinite loop! here. Fuzz-found, no user report.

Fix

  • execute_all_timers fires at most timer_limit timers. If timers remain, it throws Jest's message.
  • The limit is 100000 by default. useFakeTimers({ timerLimit }) sets it, with Jest's option name and default. A value that is not a positive integer throws.
  • docs/runtime/cron.mdx no longer tells cron users to call runAllTimers(). A cron job always re-arms.
  • Verified: test/js/bun/test/fake-timers/fake-timers.test.ts (17 new cases, 14 fail on canary 1.4.3). Also test-timers.test.ts, in-process-cron.test.ts, bun-types.test.ts.

Background

Notes
  • Repro on canary 1.4.3 (b99371011), killed by an external timeout after 15 s:
    import { test, jest } from "bun:test";
    test("runAllTimers on an infinitely self-rescheduling timer", () => {
      jest.useFakeTimers();
      const re = () => { setTimeout(re, 1); };
      setTimeout(re, 1);
      try { jest.runAllTimers(); } catch (e) { console.log("guard THREW: " + e.message); }
      jest.useRealTimers();
    }, 5000);
    With this PR it prints guard THREW: Aborting after running 100000 timers, assuming an infinite loop!.
  • @jest/fake-timers passes loopLimit: fakeTimersConfig.timerLimit || 100_000 to @sinonjs/fake-timers, and clock.runAll() throws the message above. The unbounded loop came over unchanged from the first fake timers PR (Add fake timers for bun:test #23764), which ported sinon's loop-limit tests as describe.todo.
  • The general form of this hang is Infinite loop int test causes hang #21277 (a synchronous infinite loop in a test cannot time out). bun test: interrupt synchronous infinite loops on --timeout #30598 lets --timeout interrupt such loops. The cap is still useful with it: it fails in milliseconds with Jest's message and a stack at the runAllTimers() call, and it works outside bun test (bun -e, top-level code).
  • The node:http interaction: _http_server.ts setupConnectionsTracking arms setInterval(checkConnections, 30_000) on listening. With timerLimit: 50 and a listening server, runAllTimers() throws after 50 sweeps. The same script on canary 1.4.3 never returns. Jest under Node does not see that timer, because Node's internals do not use the faked globals.
  • The zero-delay case, probed on this branch with timerLimit: 5: a re-armed AbortSignal.timeout(0) fired 20000 times inside one advanceTimersByTime(1) call and inside one runOnlyPendingTimers() call. runAllTimers() threw after 5. setTimeout(fn, 0) is not affected, because setTimeout floors 0 to 1 ms.
  • The tests run in-process. Each self-re-arming source gives up after 1000 fires, so a build without the cap fails the toThrow assertion in milliseconds. It does not spin. An earlier draft ran each case in a child process with a spawn timeout. On a build without the cap the 5 s test timeout fired first, the runner exited, and the children kept spinning as orphans.
  • The default-limit case runs on plain release builds only. 100000 timers take about 12 s in a debug build and about 25 ms in a release build.
  • A chain that ends exactly at the limit does not throw. A suite that really runs more than 100000 timers in one call raises timerLimit. For the node:http timer the limit does not help, because that timer never stops.
  • Each useFakeTimers() call resets the limit to the default, as a new Jest useFakeTimers() call does.
  • Jest does not validate timerLimit (0, null and NaN mean the default, "10" coerces). This PR rejects a value that is not a positive integer, like the existing now validation. { timerLimit: undefined } means the default.
  • @sinonjs/fake-timers runAll() also throws when the chain ends exactly at the limit. This PR returns normally in that case.
  • Vitest's default loopLimit is 10000. vi and jest share these functions in Bun, so both use Jest's 100000.
  • bun:test: fill vitest shim gaps (suite, vitest, onTestFailed, vi members, async timers, modifier aliases) #40997 adds runAllTimersAsync() on top of execute_all_timers, so it gets the same cap.
  • cargo fmt does not reach FakeTimers.rs (a #[path] module inside an inline mod), so the new lines follow rustfmt style by hand.
  • Self-reviewed: the review kept the code as written and asked for changes to this text. The changes are the "Not covered" and "Known interaction" bullets, the provenance line, and the case count. It also asked to land bun:test: keep built-in modules' own timers out of fake timers (internal/timers) #37987 together with this PR. That PR belongs to another branch and conflicts with main today.

[human-review] gate passed · iteration 0 · 5 files touched

fails on main (without fix)
ASAN without fix: 13 failed, 1 skipped
$ 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 (b99371011)

test/js/bun/test/fake-timers/fake-timers.test.ts:
(pass) fake timers [32.96ms]
(pass) advanceTimersToNextTimer > one setTimeout [13.75ms]
(pass) advanceTimersToNextTimer > setInterval [13.53ms]
(pass) advanceTimersToNextTimer > sorted timeouts [22.30ms]
(pass) advanceTimersToNextTimer > alternating intervals [12.64ms]
(pass) advanceTimersByTime > setInterval [12.44ms]
(pass) advanceTimersByTime > advanceTimersByTime(NaN) throws and does not move the clock [9.57ms]
(pass) advanceTimersByTime > advanceTimersByTime(-1) throws and does not move the clock [1.98ms]
(pass) advanceTimersByTime > advanceTimersByTime(Infinity) throws and does not move the clock [1.49ms]
(pass) advanceTimersByTime > advanceTimersByTime(4294967296) throws and does not move the clock [1.42ms]
(pass) runOnlyPendingTimers > two setIntervals [14.40ms]
(pass) runAllTimers > two setIntervals [15.83ms]
176 | 
177 |   test("throws once timerLimit timers have run and more remain", () => {
178 |   
... (truncated)

release without fix: 14 FAILED
bun test v1.4.3-canary.1 (b99371011)

test/js/bun/test/fake-timers/fake-timers.test.ts:
(pass) fake timers [10.58ms]
(pass) advanceTimersToNextTimer > one setTimeout [0.24ms]
(pass) advanceTimersToNextTimer > setInterval [0.16ms]
(pass) advanceTimersToNextTimer > sorted timeouts [0.21ms]
(pass) advanceTimersToNextTimer > alternating intervals [0.15ms]
(pass) advanceTimersByTime > setInterval [0.13ms]
(pass) advanceTimersByTime > advanceTimersByTime(NaN) throws and does not move the clock [0.14ms]
(pass) advanceTimersByTime > advanceTimersByTime(-1) throws and does not move the clock [0.02ms]
(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.19ms]
(pass) runAllTimers > two setIntervals [0.10ms]
176 | 
177 |   test("throws once timerLimit timers have run and more remain", () => {
178 |     vi.useFakeTimers({ timerLimit: 2 });
179 |     const fired: number[] = [];
180 |     for (const ms of [10, 20, 30]) setTimeout(() => fired.push(ms), ms);
181 |     expect(() => vi.runAllTimers())
... (truncated)
passes on PR (with fix)
ASAN with fix: 1 skipped
$ 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 (b99371011)

test/js/bun/test/fake-timers/fake-timers.test.ts:
(pass) fake timers [25.01ms]
(pass) advanceTimersToNextTimer > one setTimeout [7.97ms]
(pass) advanceTimersToNextTimer > setInterval [7.89ms]
(pass) advanceTimersToNextTimer > sorted timeouts [13.53ms]
(pass) advanceTimersToNextTimer > alternating intervals [9.30ms]
(pass) advanceTimersByTime > setInterval [7.04ms]
(pass) advanceTimersByTime > advanceTimersByTime(NaN) throws and does not move the clock [6.16ms]
(pass) advanceTimersByTime > advanceTimersByTime(-1) throws and does not move the clock [1.26ms]
(pass) advanceTimersByTime > advanceTimersByTime(Infinity) throws and does not move the clock [0.89ms]
(pass) advanceTimersByTime > advanceTimersByTime(4294967296) throws and does not move the clock [0.93ms]
(pass) runOnlyPendingTimers > two setIntervals [9.19ms]
(pass) runAllTimers > two setIntervals [9.13ms]
(pass) runAllTimers > throws once timerLimit timers have run and more remain [7.47ms]
(pass) runAllT
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 673ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/5] gen generated_host_exports.rs
generated_host_exports.rs: 122 exports (host=5, lazy=10, generic=107, rust=0); 243 extern-C blocks audited
[1/5] cargo bun_runtime → libbun_runtime.a
�[1m�[92m   Compiling�[0m bun_runtime v0.0.0 (/workspace/bun/src/runtime)
�[1m�[92m    Finished�[0m `release` profile [optimized + debuginfo] target(s) in 4m 45s
[2/5] link bun-profile
[4/5] strip bun
[4/5] bun-profile --revision
1.4.3-canary.1+c065c12ae
[build] done
bun test v1.4.3-canary.1 (c065c12ae)

test/js/bun/test/fake-timers/fake-timers.test.ts:
(pass) fake timers [12.37ms]
(pass) advanceTimersToNextTimer > one setTimeout [0.26ms]
(pass) advanceTimersToNextTimer > setInterval [0.16ms]
(pass) advanceTimersToNextTimer > sorted timeouts [0.23ms]
(pass) advanceTimersToNextTimer > alternating intervals [0.16ms]
(pass) advanceTimersByTime > setInterval [0.12ms]
(pass) advanceTimersByTime > advanceTimersByTime(NaN) throws and does not move the clock [0.16ms]
(pass) advanceTimersByTime > advanceTimersByTime(-1) throws and
... (truncated)
diff hotspot
docs/runtime/cron.mdx                            |  4 +-
 packages/bun-types/test.d.ts                     | 17 ++++-
 src/runtime/test_runner/timers/FakeTimers.rs     | 80 +++++++++++++++-----
 test/integration/bun-types/fixture/test.ts       |  4 +
 test/js/bun/test/fake-timers/fake-timers.test.ts | 95 +++++++++++++++++++++++-
 5 files changed, 179 insertions(+), 21 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                              reads  edits  tests
docs/runtime/cron.mdx                                 1      1     22
packages/bun-types/test.d.ts                          1      2     22
src/runtime/test_runner/timers/FakeTimers.rs          3      6     22
test/integration/bun-types/fixture/test.ts            1      1     24
test/js/bun/test/fake-timers/fake-timers.test.ts      3      1     22

runAllTimers() drained the fake timer heap with an unbounded loop. A timer
that re-arms itself from its own callback (setInterval, a recursive
setTimeout, Bun.cron) keeps the heap non-empty, so the call never returned
and no test timeout could interrupt it.

Like Jest, run at most timerLimit timers per call and throw "Aborting after
running N timers, assuming an infinite loop!" if timers remain. The limit
defaults to 100000. useFakeTimers({ timerLimit }) sets it.
@robobun

robobun commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

Reproduced on canary 1.4.3 (b99371011). This test never finishes. An external timeout kills it after 15 s:

import { test, jest } from "bun:test";
test("runAllTimers on an infinitely self-rescheduling timer", () => {
  jest.useFakeTimers();
  const re = () => { setTimeout(re, 1); };
  setTimeout(re, 1);
  try { jest.runAllTimers(); } catch (e) { console.log("guard THREW: " + e.message); }
  jest.useRealTimers();
}, 5000);

With this branch it prints guard THREW: Aborting after running 100000 timers, assuming an infinite loop!.

  • bun bd test test/js/bun/test/fake-timers/fake-timers.test.ts: 78 pass, 1 skip. The 100000-timer case runs on plain release builds only.
  • The same file on canary 1.4.3: 14 of the 18 runAllTimers cases fail, all on assertions. None hangs.
  • Self-reviewed: the review kept the code as written and asked for text changes. They are the "Not covered" and "Known interaction" bullets in the PR body.
  • Question for a maintainer: this PR ships the public timerLimit option and its type in packages/bun-types/test.d.ts together with the default cap. The cap needs a way to raise it, and timerLimit is Jest's name for that.

@coderabbitai

coderabbitai Bot commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: e0f7da79-9e82-40c9-a751-afe3226f8ac9

📥 Commits

Reviewing files that changed from the base of the PR and between ae71791 and c065c12.

📒 Files selected for processing (5)
  • docs/runtime/cron.mdx
  • packages/bun-types/test.d.ts
  • src/runtime/test_runner/timers/FakeTimers.rs
  • test/integration/bun-types/fixture/test.ts
  • test/js/bun/test/fake-timers/fake-timers.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 5 remain after this review.


Walkthrough

Changes

Fake timers now accept a validated timerLimit. runAllTimers() processes at most that many timers and throws if timers remain. Types, runtime tests, integration tests, and cron documentation cover the new behavior.

Fake timer API and guidance

Layer / File(s) Summary
Timer limit contract and guidance
packages/bun-types/test.d.ts, test/integration/bun-types/fixture/test.ts, docs/runtime/cron.mdx
The Jest type accepts numeric timerLimit. Type tests cover valid and invalid values. Cron guidance recommends runOnlyPendingTimers() for recurring timers.

Runtime enforcement

Layer / File(s) Summary
Runtime timer-limit enforcement
src/runtime/test_runner/timers/FakeTimers.rs
FakeTimers stores a default limit of 100,000. useFakeTimers() validates configured limits and runAllTimers() throws when the limit is reached with timers remaining.

Validation

Layer / File(s) Summary
Timer-limit behavior coverage
test/js/bun/test/fake-timers/fake-timers.test.ts
Tests cover boundaries, invalid values, option reset behavior, rearming timers, Bun.cron, and the default limit.

Suggested reviewers: jarred-sumner

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to c065c

The timer-limit API’s documented behavior and validation remain consistent, with no actionable merge-blocking risk identified.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: capping jest.runAllTimers() with a configurable timerLimit that defaults to 100000.
Description check ✅ Passed The description explains the problem, the implementation, configuration behavior, limitations, and verification results. It does not use the template headings exactly, but it provides the required pur…

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

@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 PR and didn't find any bugs. Because packages/bun-types/test.d.ts is CODEOWNER-owned, a human sign-off is still needed.

What was reviewed:

  • execute_all_timers loop bound and the exactly-at-limit edge — peek() after the loop returns None, so no throw when the heap drains on the last iteration.
  • timerLimit validation — compares in f64 before the u32 cast; NaN/±Infinity/0/negatives/non-integers/> u32::MAX all rejected, and JSValue::get maps undefined to None so { timerLimit: undefined } falls through to the default.
  • now parsing after the else if → nested-if restructure — semantics unchanged, both options are now read independently.
  • Tests: added to the existing file, test.each for the invalid-value and self-re-arming matrices, the 100k-default case gated on isDebug || isASAN, and the give-up guard so an unfixed build fails the assertion instead of hanging.
Extended reasoning...

Overview

This PR caps jest.runAllTimers() / vi.runAllTimers() at a configurable timerLimit (default 100,000, matching Jest) so self-re-arming timers throw instead of spinning forever. It touches src/runtime/test_runner/timers/FakeTimers.rs (loop bound + option parsing, ~40 net lines), packages/bun-types/test.d.ts (adds timerLimit?: number with JSDoc), the bun-types fixture, test/js/bun/test/fake-timers/fake-timers.test.ts (17 new cases), and docs/runtime/cron.mdx.

Security risks

None. This is test-runner-only fake-timer control; the only user input is a numeric option that is fully range-validated in the wide type before casting to u32. No network, filesystem, auth, or crypto paths are involved.

Level of scrutiny

Moderate. The Rust change is small and follows the neighboring patterns exactly — the two new unsafe blocks mirror the existing timer_all() dereferences with matching SAFETY comments, and the borrow ends before any timer callback can re-enter. I traced execute_next to confirm false means the heap is empty, verified JSValue::get returns None for undefined so an explicit timerLimit: undefined uses the default, and checked that the now refactor is behavior-preserving. The error message text matches Jest's @ sinonjs/fake-timers message. The #[derive(Default)] gives timer_limit: 0, but activate() always sets it before execute_all_timers can be reached.

Other factors

Test coverage is thorough and follows repo conventions: added to the existing fake-timers.test.ts, uses test.each for matrices, combined .toEqual assertions, and gates the 100k-timer default case behind isDebug || isASAN. The self-re-arming tests give up after 1000 fires so an unfixed build fails the toThrow assertion in milliseconds rather than hanging — satisfying the "fails for the right reason without spinning" requirement. The .d.ts change is straightforward, but packages/bun-types/ is owned by a CODEOWNER per .github/CODEOWNERS, so approval should wait for that sign-off.

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