bun:test: keep built-in modules' own timers out of fake timers (internal/timers)
Closed
RO
4d ago+633+633+012345678901234567890123456789-120-120-012345678901234567890123456789
Problem
- While
jest.useFakeTimers()is active, deadlines that Bun's built-in JS modules schedule for themselves never fire:net.Socket#setTimeout()/httprequest and server timeouts, thetimeoutoption ofchild_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) thelisten()callback itself. They also show up injest.getTimerCount(), are fired early byjest.runAllTimers()/advanceTimersByTime(), and are cancelled byjest.clearAllTimers()/useRealTimers(). - Cause: these modules call the global
setTimeout/setInterval. Fake timers are implemented inside the nativesetTimeout(timer::All::insertroutes everyTimeoutObjectinto the fake heap), so a timer created bysrc/js/node/net.tsis indistinguishable from one created by the test. For the same reason any user code that replacesglobalThis.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.tsnow exportssetTimeout/setInterval/clearTimeout/clearIntervalbound to native functions that user code cannot reach or replace. They create the sameTimeoutobjects as the globals (ref/unref/refresh/clearTimeoutall work, extra arguments are passed through), but the node is taggedEventLoopTimerTag::InternalTimeoutObject.InternalTimeoutObjectis a second tag for theTimeoutObjectcontainer:allow_fake_timers()is false for it, soAll::insertkeeps it in the real heap, andTimerObjectInternals::reschedule()/ thesetIntervalre-arm infire()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::clearneeds no arm because the tag never reaches the fake heap.NodeTimers.cpp: the argument packing shared by the globalsetTimeout/setIntervalis factored intoscheduleTimer()and reused byfunctionSetTimeoutInternal/functionSetIntervalInternal, which call the newBun__Timer__setTimeoutInternal/setIntervalInternalexports. Theimpl_timer_object!Defaultimpl is replaced by passing the tag toinit_with, so everyTimeoutObject/ImmediateObjectstates 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, andnode:test's runner timeouts. The user-facingnode:timers,timers/promises,Bun.sleep()andAbortSignal.timeout()are unchanged and still faked. test/internal/source-lints/builtin-timer-globals.test.tsrejects any bare use of the four globals insrc/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 timersblock intest/js/bun/test/fake-timers/fake-timers.test.ts(9 cases: the primitive itself,listen(),socket.setTimeout()survivingclearAllTimers()/useRealTimers(),request.setTimeout(), theheadersTimeoutsweep interval,execFile({ timeout }), andlisten()withglobalThis.setTimeoutreplaced); 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 upstreamtest-{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 onlocalhostwhile connecting to127.0.0.1, leak fixtures keyed on abun-asanbinary name, 500 ms budgets on a debug build). - Relationship to #37959: it moves the
listen()emits toprocess.nextTick, which is independent of this change (different lines; once it lands those sites simply stop being timers, and the rest ofnet.ts/_http_server.tsstill needs this).
Background
- Fake timers in
bun:testare native:jest.useFakeTimers()flips a flag intimer::All, after whichinsert()puts every node whoseTag::allow_fake_timers()is true into a second heap (fake_timers.timers) that onlyadvanceTimersByTime()and friends drain, and makesTimespec::now(AllowMockedTime)return the mocked clock. The real heap is drained againstTimespec::now(ForceRealTime). EventLoopTimeris the intrusive heap node embedded in every timer owner; itsTagsays which owner type embeds it, and the firing code recovers the owner from the node with that tag (__bun_fire_timerinsrc/runtime/dispatch.rs).TimeoutObjectis the owner behindsetTimeout/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 howinternal/timersgets 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)