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

Closed
RO
4d ago
+633-120

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 #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. #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 #37946 and #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 #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 #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)

   Compiling bun_core v0.0.0 (/workspace/bun/src/bun_core)
   Compiling bun_runtime v0.0.0 (/workspace/bun/src/runtime)
   Compiling bun_errno v0.0.0 (/workspace/bun/src/errno)
   Compiling bun_ptr v0.0.0 (/workspace/bun/src/ptr)
   Compiling 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)