Skip to content

bun:test: keep built-in modules' own timers out of fake timers (internal/timers) - #37987

Open
robobun wants to merge 2 commits into
mainfrom
farm/ad9e845f/internal-timers-fake-timers
Open

bun:test: keep built-in modules' own timers out of fake timers (internal/timers)#37987
robobun wants to merge 2 commits into
mainfrom
farm/ad9e845f/internal-timers-fake-timers

Conversation

@robobun

@robobun robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • While jest.useFakeTimers() is active, deadlines that Bun's built-in JS modules schedule for themselves never fire: net.Socket#setTimeout() / http request and server timeouts, the timeout option of child_process.exec() / execFile() / spawn(), http.Server's headers/request timeout sweep, http2 session timeouts, sql.close({ timeout }), SQL connect retries, QUIC idle timeouts, the readline escape-sequence timeout, and (until node:net, node:http: emit listen() results on nextTick so fake timers do not freeze listen() #37959) the listen() callback itself. They also show up in jest.getTimerCount(), are fired early by jest.runAllTimers() / advanceTimersByTime(), and are cancelled by jest.clearAllTimers() / useRealTimers().
  • Cause: these modules call the global setTimeout / setInterval. Fake timers are implemented inside the native setTimeout (timer::All::insert routes every TimeoutObject into the fake heap), so a timer created by src/js/node/net.ts is indistinguishable from one created by the test. For the same reason any user code that replaces globalThis.setTimeout (sinon-style fake timers) breaks these modules today.
  • In Node under Jest only the test's own timers are faked: lib/ holds private references to the real timer functions (its eslint config bans the timer globals), and fake timers only replace the globals. bun:test: keep runtime-internal timeouts out of the fake timer heap #37946 gave the native timer owners (Bun.spawn, SQL/Valkey connection timeouts, c-ares, ...) the same treatment; this PR covers the JS-side owners, which bun:test: keep runtime-internal timeouts out of the fake timer heap #37946 and node:net, node:http: emit listen() results on nextTick so fake timers do not freeze listen() #37959 both note as the remaining gap.

Fix

  • src/js/internal/timers.ts now exports setTimeout / setInterval / clearTimeout / clearInterval bound to native functions that user code cannot reach or replace. They create the same Timeout objects as the globals (ref / unref / refresh / clearTimeout all work, extra arguments are passed through), but the node is tagged EventLoopTimerTag::InternalTimeoutObject.
  • InternalTimeoutObject is a second tag for the TimeoutObject container: allow_fake_timers() is false for it, so All::insert keeps it in the real heap, and TimerObjectInternals::reschedule() / the setInterval re-arm in fire() now take their clock from the tag (Tag::clock(): mocked clock for fakeable tags, real clock otherwise). That keeps the clock a deadline is computed from tied to the heap it is inserted into; a real-heap timer armed from the mocked clock would be due immediately and re-arm due immediately. The tag dispatch sites (__bun_fire_timer, js_timer_flags_ptr, cancel_all_timeout_objects, remove_timer_by_id) accept both tags; FakeTimers::clear needs no arm because the tag never reaches the fake heap.
  • NodeTimers.cpp: the argument packing shared by the global setTimeout / setInterval is factored into scheduleTimer() and reused by functionSetTimeoutInternal / functionSetIntervalInternal, which call the new Bun__Timer__setTimeoutInternal / setIntervalInternal exports. The impl_timer_object! Default impl is replaced by passing the tag to init_with, so every TimeoutObject / ImmediateObject states its tag at construction.
  • The modules that schedule their own deadlines take the four functions from internal/timers (one destructure per module, which shadows the globals for the whole module): net, _http_server, http2, child_process, bun:sql + internal/sql/shared, internal/http (Date header cache), internal/quic, internal/streams/fast-utf8-stream, internal/cluster/child, internal/readline/emitKeypressEvents, internal/repl/history, and node:test's runner timeouts. The user-facing node:timers, timers/promises, Bun.sleep() and AbortSignal.timeout() are unchanged and still faked.
  • test/internal/source-lints/builtin-timer-globals.test.ts rejects any bare use of the four globals in src/js (outside the two modules implementing them), so new call sites cannot reintroduce the problem. On main it reports exactly the 68 uses this PR converts.
  • Verified with the new built-in modules are not affected by fake timers block in test/js/bun/test/fake-timers/fake-timers.test.ts (9 cases: the primitive itself, listen(), socket.setTimeout() surviving clearAllTimers() / useRealTimers(), request.setTimeout(), the headersTimeout sweep interval, execFile({ timeout }), and listen() with globalThis.setTimeout replaced); all 9 fail on main (getTimerCount() is off by one within milliseconds, or the awaited event never comes) and pass with the fix, as does the lint.
  • Also run on the debug build: the rest of fake-timers.test.ts (including the bun:test: keep runtime-internal timeouts out of the fake timer heap #37946 cases), test-timers.test.ts, test/js/node/timers, test/js/web/timers, test/internal/source-lints, node-http-server-timeouts, client-timeout-error, node-net, node-net-server, child_process, child-process-exec, sql-connect-error-reporting (retry timer), sql-close-pending-connection, and 31 upstream test-{child-process-*-timeout,fastutf8stream-periodicflush,http-*-timeout,http2-*-timeout,net-*timeout,net-autoselectfamily*,cluster-rr-*,readline-keys,repl-*history*} files. The only failures are ones main also has in this container (tests listening on localhost while connecting to 127.0.0.1, leak fixtures keyed on a bun-asan binary name, 500 ms budgets on a debug build).
  • Relationship to node:net, node:http: emit listen() results on nextTick so fake timers do not freeze listen() #37959: it moves the listen() emits to process.nextTick, which is independent of this change (different lines; once it lands those sites simply stop being timers, and the rest of net.ts / _http_server.ts still needs this).

Background

  • Fake timers in bun:test are native: jest.useFakeTimers() flips a flag in timer::All, after which insert() puts every node whose Tag::allow_fake_timers() is true into a second heap (fake_timers.timers) that only advanceTimersByTime() and friends drain, and makes Timespec::now(AllowMockedTime) return the mocked clock. The real heap is drained against Timespec::now(ForceRealTime).
  • EventLoopTimer is the intrusive heap node embedded in every timer owner; its Tag says which owner type embeds it, and the firing code recovers the owner from the node with that tag (__bun_fire_timer in src/runtime/dispatch.rs). TimeoutObject is the owner behind setTimeout / setInterval / Bun.sleep(); it previously had exactly one tag.
  • Built-in JS modules (src/js/) are bundled into the binary and run against the same globals as user code; $newCppFunction("file.cpp", "symbol", length) gives such a module a JSFunction wrapping a C++ host function, resolved when the module is loaded, which is how internal/timers gets references user code cannot observe or replace.

[review] gate passed · iteration 0 · 28 files touched

fails on main (without fix)
ASAN without fix: 10 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/internal/source-lints/builtin-timer-globals.test.ts test/js/bun/test/fake-timers/fake-timers.test.ts
bun test v1.4.0 (95e77116f)

test/internal/source-lints/builtin-timer-globals.test.ts:
(pass) builtin modules take setTimeout & co. from internal/timers > scanner self-check [54.10ms]
71 |           `src/js/${posixRel}:${line}: global ${name}; add it to the require("internal/timers") destructure of this module`,
72 |         );
73 |       }
74 |     }
75 | 
76 |     expect(violations).toEqual([]);
                            ^
error: expect(received).toEqual(expected)

- []
+ [
+   "src/js/bun/sql.ts:401: global setTimeout; add it to the require("internal/timers") destructure of this module",
+   "src/js/bun/sql.ts:413: global clearTimeout; add it to the require("internal/timers") destructure of this module",
+   "src/js/bun/sql.ts:668: global setTimeout; add it to the require("internal/timers") destructure of this module",
+   "src/js/bun/sql.ts:681: global clearTimeout; add it to the require("internal/timers") destructure of this
... (truncated)

release without fix: 1 FAILED
bun test v1.4.0-canary.1 (9795e71af)

test/internal/source-lints/builtin-timer-globals.test.ts:
(pass) builtin modules take setTimeout & co. from internal/timers > scanner self-check [4.49ms]
71 |           `src/js/${posixRel}:${line}: global ${name}; add it to the require("internal/timers") destructure of this module`,
72 |         );
73 |       }
74 |     }
75 | 
76 |     expect(violations).toEqual([]);
                            ^
error: expect(received).toEqual(expected)

- []
+ [
+   "src/js/bun/sql.ts:401: global setTimeout; add it to the require("internal/timers") destructure of this module",
+   "src/js/bun/sql.ts:413: global clearTimeout; add it to the require("internal/timers") destructure of this module",
+   "src/js/bun/sql.ts:668: global setTimeout; add it to the require("internal/timers") destructure of this module",
+   "src/js/bun/sql.ts:681: global clearTimeout; add it to the require("internal/timers") destructure of this module",
+   "src/js/internal/cluster/child.ts:220: global setInterval; add it to the require("internal/timers") destructure of this module",
+   "src/js/internal/cluster/child.ts:226: global clearInterval; add it to the require("
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/internal/source-lints/builtin-timer-globals.test.ts test/js/bun/test/fake-timers/fake-timers.test.ts
bun test v1.4.0 (95e77116f)

test/internal/source-lints/builtin-timer-globals.test.ts:
(pass) builtin modules take setTimeout & co. from internal/timers > scanner self-check [56.83ms]
(pass) builtin modules take setTimeout & co. from internal/timers > src/js [1313.88ms]

test/js/bun/test/fake-timers/fake-timers.test.ts:
(pass) fake timers [27.74ms]
(pass) advanceTimersToNextTimer > one setTimeout [10.58ms]
(pass) advanceTimersToNextTimer > setInterval [8.02ms]
(pass) advanceTimersToNextTimer > sorted timeouts [15.99ms]
(pass) advanceTimersToNextTimer > alternating intervals [10.27ms]
(pass) advanceTimersByTime > setInterval [6.66ms]
(pass) runOnlyPendingTimers > two setIntervals [8.11ms]
(pass) runAllTimers > two setIntervals [9.79ms]
(pass) getTimerCount > returns correct count of pending timers [7.60ms]
(pass) getTimerCount > throws error if fake timers not active [4.49ms]
(pass) clearAllTimers > clears all pending timers [5.09ms
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 1225ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/28] gen generated_host_exports.rs
generated_host_exports.rs: 95 exports (host=3, lazy=10, generic=82, rust=0); 239 extern-C blocks audited
[2/28] gen cpp.rs (cppbind)
[3/28] gen JS modules (bundle-modules)
Preprocess modules (14814ms)
Bundle modules (274ms)
Postprocesss modules (1816ms)
Bundle Functions (2417ms)
Generate Code (48ms)

[19.42s] Bundled "src/js" for production
  2628 kb
  197 internal modules
  13 native modules
  91 internal functions across 17 files
[3/23] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_runtime v0.0.0 (/workspace/bun/src/runtime)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)

... (truncated)
diff hotspot
docs/test/dates-times.mdx                          |   2 +
 src/event_loop/EventLoopTimer.rs                   |  18 ++
 src/js/bun/sql.ts                                  |   1 +
 src/js/internal-for-testing.ts                     |   3 +
 src/js/internal/cluster/child.ts                   |   1 +
 src/js/internal/http.ts                            |   1 +
 src/js/internal/quic/quic.ts                       |   1 +
 src/js/internal/readline/emitKeypressEvents.js     |   2 +-
 src/js/internal/repl/history.js                    |   2 +-
 src/js/internal/sql/shared.ts                      |   1 +
 src/js/internal/streams/fast-utf8-stream.ts        |   1 +
 src/js/internal/timers.ts                          |  18 ++
 src/js/node/_http_server.ts                        |   1 +
 src/js/node/child_process.ts                       |   1 +
 src/js/node/http2.ts                               |   2 +-
 src/js/node/net.ts                                 |   2 +-
 src/js/node/test.ts                                |   5 +-
 src/jsc/bindings/headers.h                         |   2 +
 src/jsc/bindings/node/NodeTimers.cpp               |  94 ++++------
 src/jsc/bindings/node/NodeTimers.h                 |   4 +
 src/runtime/dispatch.rs                            |   2 +-
 src/runtime/timer/ImmediateObject.rs               |  16 +-
 src/runtime/timer/TimeoutObject.rs                 |  18 +-
 src/runtime/timer/Timer.rs                         | 108 +++++++++--
 src/runtime/timer/mod.rs                           |  40 ++--
 src/runtime/timer/timer_object_internals.rs        |  15 +-
 .../source-lints/builtin-timer-globals.test.ts     | 202 +++++++++++++++++++++
 test/js/bun/test/fake-timers/fake-timers.test.ts   | 190 ++++++++++++++++++-
 28 files changed, 633 insertions(+), 120 deletions(-)

gate history · 2 passed · 0 rejected · iteration 0

evidence per changed file
file                                            reads  edits  tests
docs/test/dates-times.mdx                           1      1      0
src/event_loop/EventLoopTimer.rs                    5      7      0
src/js/bun/sql.ts                                   1      2      0
src/js/internal-for-testing.ts                      3      4      0
src/js/internal/cluster/child.ts                    1      1      0
src/js/internal/http.ts                             1      1      0
src/js/internal/quic/quic.ts                        1      1      0
src/js/internal/readline/emitKeypressEvents.js      1      1      0
src/js/internal/repl/history.js                     1      1      0
src/js/internal/sql/shared.ts                       1      2      0
src/js/internal/streams/fast-utf8-stream.ts         1      1      0
src/js/internal/timers.ts                           4      8      0
src/js/node/_http_server.ts                         2      1      0
src/js/node/child_process.ts                        1      1      0
src/js/node/http2.ts                                1      2      0
src/js/node/net.ts                                  2      1      0
(+ 12 more files)

Add internal/timers setTimeout/setInterval/clearTimeout/clearInterval,
backed by TimeoutObjects tagged InternalTimeoutObject: the same Timeout
objects the globals create, but never routed into the fake timer heap and
always armed from the real clock (Tag::clock). Switch the built-in modules
that schedule deadlines for themselves (net, _http_server, http2,
child_process, sql, quic, fast-utf8-stream, cluster, readline, repl
history, node:test) to them, and add a source lint that rejects bare uses
of the timer globals in src/js.
@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 10 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: b304f1d2-69ea-40ce-a46d-c4a752166101

📥 Commits

Reviewing files that changed from the base of the PR and between b7a777e and 95e7711.

📒 Files selected for processing (28)
  • docs/test/dates-times.mdx
  • src/event_loop/EventLoopTimer.rs
  • src/js/bun/sql.ts
  • src/js/internal-for-testing.ts
  • src/js/internal/cluster/child.ts
  • src/js/internal/http.ts
  • src/js/internal/quic/quic.ts
  • src/js/internal/readline/emitKeypressEvents.js
  • src/js/internal/repl/history.js
  • src/js/internal/sql/shared.ts
  • src/js/internal/streams/fast-utf8-stream.ts
  • src/js/internal/timers.ts
  • src/js/node/_http_server.ts
  • src/js/node/child_process.ts
  • src/js/node/http2.ts
  • src/js/node/net.ts
  • src/js/node/test.ts
  • src/jsc/bindings/headers.h
  • src/jsc/bindings/node/NodeTimers.cpp
  • src/jsc/bindings/node/NodeTimers.h
  • src/runtime/dispatch.rs
  • src/runtime/timer/ImmediateObject.rs
  • src/runtime/timer/TimeoutObject.rs
  • src/runtime/timer/Timer.rs
  • src/runtime/timer/mod.rs
  • src/runtime/timer/timer_object_internals.rs
  • test/internal/source-lints/builtin-timer-globals.test.ts
  • test/js/bun/test/fake-timers/fake-timers.test.ts

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

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 12:48 AM PT - Aug 13th, 2026

@robobun, your commit 95e77116f6e4b34490e5721d12d5cc6d4811827f passed in Build #93915! 🎉


🧪   To try this PR locally:

bunx bun-pr 37987

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

bun-37987 --bun

@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 it's a cross-cutting change to the timer subsystem — a new EventLoopTimerTag variant threaded through Rust dispatch/refcount/heap paths, a C++ NodeTimers.cpp refactor, and ~12 built-in JS modules switched to internal/timers — a human look would still be worthwhile.

Checked that every tag-dispatch site (__bun_fire_timer, js_timer_flags_ptr, cancel_all_timeout_objects, remove_timer_by_id) accepts InternalTimeoutObject, and that FakeTimers::clear correctly needs no arm since allow_fake_timers() keeps that tag out of the fake heap.
Traced init_withinternals.init()reschedule()self.clock(): the tag is written to event_loop_timer before reschedule() reads it, so internal timers arm from the real clock on first insert.
Verified the scheduleTimer() extraction in NodeTimers.cpp preserves the argument-packing / error paths and adds RELEASE_AND_RETURN on the tail call.
Confirmed the new debug_assert!(kind != Kind::SetImmediate) in TimeoutObject::init holds for all callers (sleep, set_timeout_or_interval).

Extended reasoning...

Overview

This PR keeps timers scheduled by Bun's built-in JS modules (net, http, child_process, http2, sql, etc.) out of jest.useFakeTimers(). It adds a second EventLoopTimerTag::InternalTimeoutObject variant for the same TimeoutObject container, exposes native setTimeout/setInterval/clearTimeout/clearInterval bindings via internal/timers, and updates ~12 built-in modules to destructure those instead of using the globals. Supporting changes: Tag::clock() derives the arming clock from allow_fake_timers(); impl_timer_object! drops its Default impl so the tag is passed at construction; NodeTimers.cpp factors the shared setTimeout/setInterval argument packing into scheduleTimer(); two new Rust host-exports (Bun__Timer__set{Timeout,Interval}Internal); a source-lint test that bans bare timer globals in src/js/; 9 new fake-timers integration tests; and a docs paragraph.

Security risks

None identified. The new host functions are only reachable via $newCppFunction from bundled built-in modules, not user code. No untrusted-input parsing, auth, or crypto paths are touched.

Level of scrutiny

High. This touches the timer heap's tag-dispatch invariant (tag → container type recovered via container_of), which is memory-safety-load-bearing: a missed match arm on the new tag would UAF or hit an unreachable!. It also refactors refcounted-object construction (init_with now takes tag), rewrites two hot C++ host functions into a shared helper, and changes user-observable bun:test behavior across a dozen node-compat modules. 28 files across three languages is well past the "simple/mechanical" bar.

Other factors

The change looks correct and well-motivated: it follows #37946's precedent for native timer owners, the tag-dispatch audit is complete (every site that matched TimeoutObject now accepts both tags; FakeTimers::clear is correctly excluded because the new tag never enters the fake heap and its fallback is a debug_assert!). The clock() derivation ties the arming clock to the heap the tag routes to, avoiding the "real-heap timer armed from mocked clock fires immediately" hazard the PR calls out. Test coverage is solid (9 integration tests that time out on main, plus a source-lint that prevents regressions). Still, the breadth (native timer plumbing + FFI + macro refactor + behavioral change) and the fact that a missed dispatch arm here is a memory-safety bug make this a case where a maintainer should sign off.

Comment on lines +183 to +186
/// A `TimeoutObject` scheduled by a built-in JS module through
/// `internal/timers` (socket idle timeouts, `child_process` kill timers,
/// ...): the same container as `TimeoutObject`, but a runtime-internal
/// timeout as far as `allow_fake_timers` is concerned.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +227 to +230
/// The clock an owner with this tag arms with (the rule stated on
/// [`Self::allow_fake_timers`]), for owners that exist under more than one
/// tag: a real-heap timer armed from the mocked clock is due immediately,
/// and re-arms due immediately.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +560 to +561
// The timers built-in modules schedule their own deadlines with; unlike the
// globals they are not touched by jest.useFakeTimers().

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/js/internal/timers.ts
Comment on lines +7 to +15
// Timers for the runtime's own deadlines (socket idle timeouts, listen()
// callbacks, child_process kill timers, ...). The globals belong to user code:
// jest.useFakeTimers() freezes, counts, advances and clears every timer created
// through them, and user code may replace them outright. These create the same
// Timeout objects but never take part in fake timers, like the private timer
// references Node's lib/ uses. Built-in modules take all four from here
// (test/internal/source-lints/builtin-timer-globals.test.ts); the global
// clearTimeout would clear these too, the private one just stays out of reach
// of replaced globals.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +13 to +16
// setTimeout(callback, delay, ...args) / setInterval(callback, delay, ...args).
// The extra arguments are packed the way Bun__JSTimeout__call (NodeTimerObject.cpp)
// unpacks them: undefined for none, the value itself for one, a JSCellButterfly
// for several.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +84 to +88
// The setTimeout/setInterval that built-in JS modules schedule their own
// deadlines with (src/js/internal/timers.ts). Same arguments and same Timeout
// object as the globals, but the timer is never handed to bun:test's fake
// timers, so socket timeouts, listen() callbacks and the like keep working
// while a test has jest.useFakeTimers() active.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +34 to +37
/// `tag` is `TimeoutObject` for the global `setTimeout`/`setInterval`/
/// `Bun.sleep` (subject to `jest.useFakeTimers()`) or
/// `InternalTimeoutObject` for `internal/timers` (always real time); see
/// [`EventLoopTimerTag::allow_fake_timers`]. Anything else is a bug.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +257 to +259
/// Body of `setTimeout`/`setInterval`; `tag` picks between the global
/// (fakeable) timers and the `internal/timers` ones, see
/// [`TimeoutObject::init`].

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +320 to +322
/// `internal/timers` `setTimeout`: a timer owned by a built-in JS module.
/// Not affected by `jest.useFakeTimers()` (not counted, advanced or
/// cleared by it), like the deadlines inside Node's `lib/`.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/runtime/timer/mod.rs
Comment on lines +113 to +115
/// picks `tag`/`kind`/`interval` and forwards here. The node keeps
/// `tag` for its whole life (`js_timer_flags_ptr` and the fire
/// dispatch recover the container from it).

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/runtime/timer/mod.rs
Comment on lines +551 to +553
/// [`TimerFlags`] slot for the three JS-timer container types
/// (`TimeoutObject`, under either of its tags / `ImmediateObject` /
/// `AbortSignalTimeout`), else `None`.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +891 to +893
/// Clock the deadlines of this timer are computed from: the one driving
/// the heap its tag routes it to (`EventLoopTimerTag::clock`), so an
/// `internal/timers` timer stays on real time under `jest.useFakeTimers()`.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

@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. It's a carefully constructed change, but it touches core timer infrastructure (a new EventLoopTimerTag variant for the same TimeoutObject container, updates to every tag-dispatch site in dispatch.rs/mod.rs/timer_object_internals.rs, and the impl_timer_object! construction path) plus a dozen built-in modules, so a human look at the approach is warranted. There are also ~11 outstanding comment-cop notes about comment length that haven't been addressed.

What was reviewed:

  • Tag-dispatch coverage: __bun_fire_timer, js_timer_flags_ptr, cancel_all_timeout_objects, remove_timer_by_id, and FakeTimers::clear all handle the new tag (or correctly don't need to).
  • Tag::clock() ties the arming clock to the heap the tag routes to; fire()'s setInterval re-arm and reschedule() both use it, so an internal timer under fake timers doesn't spin on AllowMockedTime.
  • The scheduleTimer refactor in NodeTimers.cpp preserves the old argument-packing and error paths; RELEASE_AND_RETURN on the tail call is correct.
  • The per-module require("internal/timers") destructures shadow the globals module-wide, and the new source-lint enforces this going forward.
Extended reasoning...

Overview

This PR keeps timers scheduled by Bun's built-in JS modules (net, http, child_process, http2, sql, etc.) out of jest.useFakeTimers()'s fake heap. It does so by introducing a second EventLoopTimerTag::InternalTimeoutObject for the existing TimeoutObject container, wiring new C++ host functions (functionSetTimeoutInternal/functionSetIntervalInternal) and Rust exports (Bun__Timer__setTimeoutInternal/setIntervalInternal) through to internal/timers.ts, and having 12 built-in modules destructure their timer functions from there instead of using the globals. It also refactors NodeTimers.cpp to share argument packing across the four setTimeout/setInterval variants, replaces the impl_timer_object! Default impl with a tag parameter on init_with, adds a Tag::clock() helper so re-arm/refresh compute deadlines against the correct clock, and updates every tag-dispatch site (__bun_fire_timer, js_timer_flags_ptr, cancel_all_timeout_objects, remove_timer_by_id) to accept both tags. A new source-lint test and 9 integration tests are included.

Security risks

None identified. The new internal timer functions are only reachable via $newCppFunction from bundled built-in modules; user code cannot call them. No untrusted input parsing, auth, or crypto is touched.

Level of scrutiny

High. The timer subsystem is core runtime infrastructure with unsafe Rust throughout: intrusive pairing-heap nodes, tag-keyed container_of recovery in the fire dispatch, intrusive refcounts, and re-entrancy under JS callbacks. Adding a second tag for the same container type means every existing match tag { TimeoutObject => ... } site had to be audited — a missed one is a use-after-free or a wrong-container cast. The PR appears to have covered them consistently (dispatch, flags-ptr, teardown, id-map removal), and the clock() helper correctly ties the arming clock to the heap the tag routes to, but this is exactly the kind of change where a maintainer should confirm the approach (second tag vs. a flag on TimerObjectInternals) and that no dispatch site was missed.

Other factors

  • The github-actions comment-cop bot left ~11 inline comments flagging paragraph-length code comments across the new/changed files. These are style-only, but they're outstanding automated feedback the author hasn't responded to.
  • CI (#93915) was still building at review time.
  • The change is ~630 insertions / ~120 deletions across 28 files — well above the "simple, mechanical" threshold for auto-approval.
  • Test coverage is strong (9 new integration tests exercising net/http/child_process under fake timers, plus a source-lint that fails on any future regression), and the PR description documents extensive local test runs against the affected modules.

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