diff --git a/docs/test/dates-times.mdx b/docs/test/dates-times.mdx index 47978cf7aa65..2db614730426 100644 --- a/docs/test/dates-times.mdx +++ b/docs/test/dates-times.mdx @@ -54,6 +54,8 @@ test("unlike in jest", () => { }); ``` +Like in Jest, fake timers apply to the timers your code creates with `setTimeout` and `setInterval`. The timers Bun's built-in modules schedule for themselves, such as `socket.setTimeout()`, the callback of `server.listen()` or the `timeout` option of `child_process.exec()`, keep running in real time while fake timers are active. + ## Reset the system time To reset the system time, pass no arguments to `setSystemTime`: diff --git a/src/event_loop/EventLoopTimer.rs b/src/event_loop/EventLoopTimer.rs index 2e49d3b8ce93..5f72fc47bb39 100644 --- a/src/event_loop/EventLoopTimer.rs +++ b/src/event_loop/EventLoopTimer.rs @@ -4,6 +4,7 @@ // — to convert at every assignment and risked silent layout drift). use Timespec as timespec; pub use bun_core::Timespec; +use bun_core::TimespecMockMode; // Re-export so higher tiers see the *same* type they pass to // `bun_io::heap::Intrusive` (a zero-sized local stub @@ -179,6 +180,11 @@ impl EventLoopTimer { #[derive(Copy, Clone, Eq, PartialEq, strum::IntoStaticStr)] pub enum Tag { TimeoutObject, + /// 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. + InternalTimeoutObject, ImmediateObject, StatWatcherScheduler, UpgradedDuplex, @@ -217,6 +223,18 @@ impl Tag { Tag::TimeoutObject | Tag::AbortSignalTimeout | Tag::CronJob ) } + + /// 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. + pub fn clock(self) -> TimespecMockMode { + if self.allow_fake_timers() { + TimespecMockMode::AllowMockedTime + } else { + TimespecMockMode::ForceRealTime + } + } } /// Stamp out one `unsafe fn $method(*const EventLoopTimer) -> *mut Self` per diff --git a/src/js/bun/sql.ts b/src/js/bun/sql.ts index c3a74582a259..0fd0d3fe43c4 100644 --- a/src/js/bun/sql.ts +++ b/src/js/bun/sql.ts @@ -10,6 +10,7 @@ const { SQLiteAdapter } = require("internal/sql/sqlite"); const { SQLHelper, parseOptions } = require("internal/sql/shared"); const { SQLError, PostgresError, SQLiteError, MySQLError } = require("internal/sql/errors"); +const { setTimeout, clearTimeout } = require("internal/timers"); const defineProperties = Object.defineProperties; diff --git a/src/js/internal-for-testing.ts b/src/js/internal-for-testing.ts index 9b1fed3e91d9..e08278f7e09d 100644 --- a/src/js/internal-for-testing.ts +++ b/src/js/internal-for-testing.ts @@ -557,6 +557,9 @@ export const arrayBufferViewHasBuffer = $newCppFunction( export const timerInternals = { timerClockMs: $newRustFunction("runtime/timer/Timer.rs", "internal_bindings.timerClockMs", 0), + // The timers built-in modules schedule their own deadlines with; unlike the + // globals they are not touched by jest.useFakeTimers(). + internalTimers: require("internal/timers"), }; // Raw datagram descriptor helpers for tests that need an unbound fd (which diff --git a/src/js/internal/cluster/child.ts b/src/js/internal/cluster/child.ts index 931814704426..eedce48f3fa1 100644 --- a/src/js/internal/cluster/child.ts +++ b/src/js/internal/cluster/child.ts @@ -2,6 +2,7 @@ const EventEmitter = require("node:events"); const Worker = require("internal/cluster/Worker"); const path = require("node:path"); const { kClusterOwner: owner_symbol, kInternalSendOptions } = require("internal/shared"); +const { setInterval, clearInterval } = require("internal/timers"); const onInternalMessage = $newRustFunction("node_cluster_binding.rs", "onInternalMessageChild", 2); const closeRawHandle = $newRustFunction("node_cluster_binding.rs", "clusterCloseHandle", 1); diff --git a/src/js/internal/http.ts b/src/js/internal/http.ts index 96dee383fc92..de49ec718199 100644 --- a/src/js/internal/http.ts +++ b/src/js/internal/http.ts @@ -1,4 +1,5 @@ const { isIPv4 } = require("internal/net/isIP"); +const { setTimeout } = require("internal/timers"); const { getHeader, diff --git a/src/js/internal/quic/quic.ts b/src/js/internal/quic/quic.ts index 8daf18082416..ad6c0f4398ec 100644 --- a/src/js/internal/quic/quic.ts +++ b/src/js/internal/quic/quic.ts @@ -37,6 +37,7 @@ const { isKeyObject, } = require("node:util/types"); const { SocketAddress, BlockList } = require("node:net"); +const { setTimeout, clearTimeout } = require("internal/timers"); // The native binding hands certificates over as DER bytes; expose them as // X509Certificate objects like Node does. diff --git a/src/js/internal/readline/emitKeypressEvents.js b/src/js/internal/readline/emitKeypressEvents.js index dbcb1240cad9..b7264cd7c64d 100644 --- a/src/js/internal/readline/emitKeypressEvents.js +++ b/src/js/internal/readline/emitKeypressEvents.js @@ -9,7 +9,7 @@ const { SafeStringIterator, Symbol } = primordials; const { charLengthAt, CSI, emitKeys } = require("internal/readline/utils"); const { kSawKeyPress } = require("internal/readline/interface"); -const { clearTimeout, setTimeout } = require("node:timers"); +const { clearTimeout, setTimeout } = require("internal/timers"); const { kEscape } = CSI; const { StringDecoder } = require("node:string_decoder"); diff --git a/src/js/internal/repl/history.js b/src/js/internal/repl/history.js index 9a857a6257e8..54a648fd420f 100644 --- a/src/js/internal/repl/history.js +++ b/src/js/internal/repl/history.js @@ -27,7 +27,7 @@ let debug = require("internal/repl/node-shims").debuglog("repl", fn => { debug = fn; }); const permission = require("internal/repl/node-shims"); -const { clearTimeout, setTimeout } = require("node:timers"); +const { clearTimeout, setTimeout } = require("internal/timers"); const { reverseString } = require("internal/readline/utils"); // The debounce is to guard against code pasted into the REPL. diff --git a/src/js/internal/sql/shared.ts b/src/js/internal/sql/shared.ts index 7dc59a8b0290..d2039140ac39 100644 --- a/src/js/internal/sql/shared.ts +++ b/src/js/internal/sql/shared.ts @@ -6,6 +6,7 @@ const { SQLQueryFlags, symbols: { _strings, _values }, } = require("internal/sql/query"); +const { setTimeout, clearTimeout } = require("internal/timers"); declare global { interface NumberConstructor { diff --git a/src/js/internal/streams/fast-utf8-stream.ts b/src/js/internal/streams/fast-utf8-stream.ts index 41363e7c5452..f96e5fe447c3 100644 --- a/src/js/internal/streams/fast-utf8-stream.ts +++ b/src/js/internal/streams/fast-utf8-stream.ts @@ -8,6 +8,7 @@ const { validateString, validateUint32, } = require("internal/validators"); +const { setTimeout, setInterval, clearInterval } = require("internal/timers"); const EventEmitter = require("node:events"); const path = require("node:path"); diff --git a/src/js/internal/timers.ts b/src/js/internal/timers.ts index ef48040fc6b8..018db0490c2b 100644 --- a/src/js/internal/timers.ts +++ b/src/js/internal/timers.ts @@ -4,6 +4,20 @@ const NumberIsFinite = Number.isFinite; const TIMEOUT_MAX = 2 ** 31 - 1; +// 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. +const setTimeout = $newCppFunction("node/NodeTimers.cpp", "functionSetTimeoutInternal", 1); +const setInterval = $newCppFunction("node/NodeTimers.cpp", "functionSetIntervalInternal", 1); +const clearTimeout = $newCppFunction("node/NodeTimers.cpp", "functionClearTimeout", 1); +const clearInterval = $newCppFunction("node/NodeTimers.cpp", "functionClearInterval", 1); + function getTimerDuration(msecs, name) { validateNumber(msecs, name); if (msecs < 0 || !NumberIsFinite(msecs)) { @@ -29,4 +43,8 @@ export default { // tests that inspect socket[kTimeout]. kTimeout: Symbol.for("::buntimeout::"), getTimerDuration, + setTimeout, + setInterval, + clearTimeout, + clearInterval, }; diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index 89b6db00bacd..849d6e988305 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -19,6 +19,7 @@ const { validateOneOf, } = require("internal/validators"); const { ConnResetException, hasObserver, startPerf, stopPerf, kInternalSendOptions } = require("internal/shared"); +const { setTimeout, clearTimeout, setInterval, clearInterval } = require("internal/timers"); const kServerResponseStatistics = Symbol("ServerResponseStatistics"); const { isPrimary } = require("internal/cluster/isPrimary"); diff --git a/src/js/node/child_process.ts b/src/js/node/child_process.ts index ed70f054cd77..c0cb20859e58 100644 --- a/src/js/node/child_process.ts +++ b/src/js/node/child_process.ts @@ -11,6 +11,7 @@ const { validateObject, validateOneOf, } = require("internal/validators"); +const { setTimeout, clearTimeout } = require("internal/timers"); var NetModule; diff --git a/src/js/node/http2.ts b/src/js/node/http2.ts index ff7409618401..6a4026eb9400 100644 --- a/src/js/node/http2.ts +++ b/src/js/node/http2.ts @@ -29,7 +29,7 @@ const { isTypedArray } = require("node:util/types"); const { hideFromStack, hasObserver, enqueueNodeEntry, PerformanceNodeEntry } = require("internal/shared"); const { STATUS_CODES } = require("internal/http"); -const { kTimeout, getTimerDuration } = require("internal/timers"); +const { kTimeout, getTimerDuration, setTimeout, clearTimeout } = require("internal/timers"); const tls = require("node:tls"); const net = require("node:net"); const fs = require("node:fs"); diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 58e05eb224ee..49453fbbfe5b 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -39,7 +39,7 @@ const { import type { Socket, SocketHandler, SocketListener } from "bun"; import type { Server as NetServer, Socket as NetSocket, ServerOpts } from "node:net"; import type { TLSSocket } from "node:tls"; -const { kTimeout, getTimerDuration } = require("internal/timers"); +const { kTimeout, getTimerDuration, setTimeout, clearTimeout } = require("internal/timers"); const { validateFunction, validateNumber, validateAbortSignal, validatePort, validateBoolean, validateInt32, validateString } = require("internal/validators"); // prettier-ignore const { isIPv4, isIPv6, isIP } = require("internal/net/isIP"); const { kArmHandshakeTimeout, kSecureConnectDone, kVerifyError } = require("internal/net/symbols"); diff --git a/src/js/node/test.ts b/src/js/node/test.ts index 75abf5fb79e3..a3a98dd3890e 100644 --- a/src/js/node/test.ts +++ b/src/js/node/test.ts @@ -25,9 +25,8 @@ const kDefaultName = ""; const kRootName = ""; const kDefaultFunction = () => {}; // The runner's own timers must keep working while `mock.timers` replaces the -// globals, so capture them at module load like Node's runner does. -const realSetTimeout = setTimeout; -const realClearTimeout = clearTimeout; +// globals or bun:test's fake timers are active, like Node's runner's do. +const { setTimeout: realSetTimeout, clearTimeout: realClearTimeout } = require("internal/timers"); const kDefaultOptions = kEmptyObject; // Matches Node's internal/timers TIMEOUT_MAX. const kTimeoutMax = 2 ** 31 - 1; diff --git a/src/jsc/bindings/headers.h b/src/jsc/bindings/headers.h index 208be793da65..bb40d41160c7 100644 --- a/src/jsc/bindings/headers.h +++ b/src/jsc/bindings/headers.h @@ -720,6 +720,8 @@ ZIG_DECL JSC::EncodedJSValue Bun__Timer__clearTimeout(JSC::JSGlobalObject* arg0, ZIG_DECL int32_t Bun__Timer__getNextID(); ZIG_DECL JSC::EncodedJSValue Bun__Timer__setInterval(JSC::JSGlobalObject* globalThis, JSC::EncodedJSValue callback, JSC::EncodedJSValue arguments, JSC::EncodedJSValue countdown); ZIG_DECL JSC::EncodedJSValue Bun__Timer__setTimeout(JSC::JSGlobalObject* globalThis, JSC::EncodedJSValue callback, JSC::EncodedJSValue arguments, JSC::EncodedJSValue countdown); +ZIG_DECL JSC::EncodedJSValue Bun__Timer__setIntervalInternal(JSC::JSGlobalObject* globalThis, JSC::EncodedJSValue callback, JSC::EncodedJSValue arguments, JSC::EncodedJSValue countdown); +ZIG_DECL JSC::EncodedJSValue Bun__Timer__setTimeoutInternal(JSC::JSGlobalObject* globalThis, JSC::EncodedJSValue callback, JSC::EncodedJSValue arguments, JSC::EncodedJSValue countdown); ZIG_DECL JSC::EncodedJSValue Bun__Timer__sleep(JSC::JSGlobalObject* globalThis, JSC::EncodedJSValue promise, JSC::EncodedJSValue countdown); ZIG_DECL JSC::EncodedJSValue Bun__Timer__setImmediate(JSC::JSGlobalObject* globalThis, JSC::EncodedJSValue callback, JSC::EncodedJSValue arguments); diff --git a/src/jsc/bindings/node/NodeTimers.cpp b/src/jsc/bindings/node/NodeTimers.cpp index ae97ecdc2b84..0e03dfd3c64b 100644 --- a/src/jsc/bindings/node/NodeTimers.cpp +++ b/src/jsc/bindings/node/NodeTimers.cpp @@ -2,23 +2,29 @@ #include "ErrorCode.h" #include "headers.h" +#include namespace Bun { using namespace JSC; -JSC_DEFINE_HOST_FUNCTION(functionSetTimeout, - (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) +using TimerScheduler = JSC::EncodedJSValue (*)(JSC::JSGlobalObject*, JSC::EncodedJSValue callback, JSC::EncodedJSValue arguments, JSC::EncodedJSValue countdown); + +// 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. +static JSC::EncodedJSValue scheduleTimer(JSC::JSGlobalObject* globalObject, JSC::CallFrame* callFrame, ASCIILiteral name, TimerScheduler schedule) { auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); JSC::JSValue job = callFrame->argument(0); JSC::JSValue num = callFrame->argument(1); JSC::JSValue arguments = jsUndefined(); - size_t argumentCount = callFrame->argumentCount(); - auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); - switch (argumentCount) { + + switch (callFrame->argumentCount()) { case 0: { - Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_TYPE, "setTimeout requires 1 argument (a function)"_s); + Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_TYPE, makeString(name, " requires 1 argument (a function)"_s)); return {}; } case 1: @@ -44,7 +50,7 @@ JSC_DEFINE_HOST_FUNCTION(functionSetTimeout, } if (!job.isObject() || !job.getObject()->isCallable()) [[unlikely]] { - Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_TYPE, "setTimeout expects a function"_s); + Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_TYPE, makeString(name, " expects a function"_s)); return {}; } @@ -60,64 +66,36 @@ JSC_DEFINE_HOST_FUNCTION(functionSetTimeout, } #endif - return Bun__Timer__setTimeout(globalObject, JSC::JSValue::encode(job), JSC::JSValue::encode(arguments), JSValue::encode(num)); + RELEASE_AND_RETURN(scope, schedule(globalObject, JSC::JSValue::encode(job), JSC::JSValue::encode(arguments), JSC::JSValue::encode(num))); } -JSC_DEFINE_HOST_FUNCTION(functionSetInterval, +JSC_DEFINE_HOST_FUNCTION(functionSetTimeout, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) { - auto& vm = JSC::getVM(globalObject); - JSC::JSValue job = callFrame->argument(0); - JSC::JSValue num = callFrame->argument(1); - JSC::JSValue arguments = jsUndefined(); - size_t argumentCount = callFrame->argumentCount(); - auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); - - switch (argumentCount) { - case 0: { - Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_TYPE, "setInterval requires 1 argument (a function)"_s); - return {}; - } - case 1: - case 2: { - break; - } - case 3: { - arguments = callFrame->argument(2); - break; - } - - default: { - ArgList argumentsList = ArgList(callFrame, 2); - auto* args = JSC::JSCellButterfly::tryCreateFromArgList(vm, argumentsList); - - if (!args) [[unlikely]] { - JSC::throwOutOfMemoryError(globalObject, scope); - return {}; - } - - arguments = JSValue(args); - } - } + return scheduleTimer(globalObject, callFrame, "setTimeout"_s, Bun__Timer__setTimeout); +} - if (!job.isObject() || !job.getObject()->isCallable()) [[unlikely]] { - Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_TYPE, "setInterval expects a function"_s); - return {}; - } +JSC_DEFINE_HOST_FUNCTION(functionSetInterval, + (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) +{ + return scheduleTimer(globalObject, callFrame, "setInterval"_s, Bun__Timer__setInterval); +} -#ifdef BUN_DEBUG - /** View the file name of the JS file that called this function - * from a debugger */ - SourceOrigin sourceOrigin = callFrame->callerSourceOrigin(vm); - auto fileNameUTF8 = sourceOrigin.string().utf8(); - const char* fileName = fileNameUTF8.data(); - static const char* lastFileName = nullptr; - if (lastFileName != fileName) { - lastFileName = fileName; - } -#endif +// 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. +JSC_DEFINE_HOST_FUNCTION(functionSetTimeoutInternal, + (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) +{ + return scheduleTimer(globalObject, callFrame, "setTimeout"_s, Bun__Timer__setTimeoutInternal); +} - return Bun__Timer__setInterval(globalObject, JSC::JSValue::encode(job), JSC::JSValue::encode(arguments), JSValue::encode(num)); +JSC_DEFINE_HOST_FUNCTION(functionSetIntervalInternal, + (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) +{ + return scheduleTimer(globalObject, callFrame, "setInterval"_s, Bun__Timer__setIntervalInternal); } // https://developer.mozilla.org/en-US/docs/Web/API/Window/setImmediate diff --git a/src/jsc/bindings/node/NodeTimers.h b/src/jsc/bindings/node/NodeTimers.h index eafe504d16e0..e8e0ce144c9e 100644 --- a/src/jsc/bindings/node/NodeTimers.h +++ b/src/jsc/bindings/node/NodeTimers.h @@ -11,4 +11,8 @@ JSC_DECLARE_HOST_FUNCTION(functionClearTimeout); JSC_DECLARE_HOST_FUNCTION(functionClearInterval); JSC_DECLARE_HOST_FUNCTION(functionClearImmediate); +// Reached only through $newCppFunction in src/js/internal/timers.ts. +JSC_DECLARE_HOST_FUNCTION(functionSetTimeoutInternal); +JSC_DECLARE_HOST_FUNCTION(functionSetIntervalInternal); + } // namespace Bun diff --git a/src/runtime/dispatch.rs b/src/runtime/dispatch.rs index df0996b0438a..7ccc0862cce8 100644 --- a/src/runtime/dispatch.rs +++ b/src/runtime/dispatch.rs @@ -949,7 +949,7 @@ pub(crate) unsafe fn __bun_fire_timer(t: *mut EventLoopTimer, now: *const ElTime } match tag { // ── JS-exposed timers (TimerObjectInternals::fire) ─────────────── - EventLoopTimerTag::TimeoutObject => { + EventLoopTimerTag::TimeoutObject | EventLoopTimerTag::InternalTimeoutObject => { let container = owner!(TimeoutObject, event_loop_timer); // SAFETY: container derived from a live `TimeoutObject`; do NOT // form `&mut *container` — `internals.fire` may `deref()` and free. diff --git a/src/runtime/timer/ImmediateObject.rs b/src/runtime/timer/ImmediateObject.rs index c0a744cc1636..b2eaa7ac55fc 100644 --- a/src/runtime/timer/ImmediateObject.rs +++ b/src/runtime/timer/ImmediateObject.rs @@ -1,14 +1,14 @@ use bun_jsc::virtual_machine::VirtualMachine; use bun_jsc::{JSGlobalObject, JSValue}; -use super::{Kind, TimerObjectInternals}; +use super::{EventLoopTimerTag, Kind, TimerObjectInternals}; // `jsc.Codegen.JSImmediate` — the C++ JSCell wrapper stays generated; this -// struct is the `m_ctx` payload. Struct + `RefCounted`/`Default` impls + the +// struct is the `m_ctx` payload. Struct + `RefCounted` impl + the // forwarder host-fns (`to_primitive`/`do_ref`/`do_unref`/`has_ref`/ // `get_destroyed`/`dispose`/`constructor`/`finalize`/`ref_`/`deref`/`deinit`/ // `init_with`) — see `impl_timer_object!` in `super` (timer/mod.rs). -super::impl_timer_object!(ImmediateObject, ImmediateObject, "Immediate"); +super::impl_timer_object!(ImmediateObject, "Immediate"); impl ImmediateObject { pub(crate) fn init( @@ -17,7 +17,15 @@ impl ImmediateObject { callback: JSValue, arguments: JSValue, ) -> JSValue { - Self::init_with(global, id, Kind::SetImmediate, 0, callback, arguments) + Self::init_with( + global, + EventLoopTimerTag::ImmediateObject, + id, + Kind::SetImmediate, + 0, + callback, + arguments, + ) } /// Thin forwarder to diff --git a/src/runtime/timer/TimeoutObject.rs b/src/runtime/timer/TimeoutObject.rs index 1bb093137b21..145696b8f6b8 100644 --- a/src/runtime/timer/TimeoutObject.rs +++ b/src/runtime/timer/TimeoutObject.rs @@ -1,6 +1,6 @@ use bun_jsc::{CallFrame, JSGlobalObject, JSValue, JsResult}; -use super::Kind; +use super::{EventLoopTimerTag, Kind}; /// `jsc.Codegen.JSTimeout` — the `.classes.ts` codegen module for this type. /// @@ -24,22 +24,32 @@ pub mod js { ); } -// Struct + `RefCounted`/`Default` impls + the forwarder host-fns +// Struct + `RefCounted` impl + the forwarder host-fns // (`to_primitive`/`do_ref`/`do_unref`/`has_ref`/`get_destroyed`/`dispose`/ // `constructor`/`finalize`/`ref_`/`deref`/`deinit`/`init_with`) — see // `impl_timer_object!` in `super` (timer/mod.rs). -super::impl_timer_object!(TimeoutObject, TimeoutObject, "Timeout"); +super::impl_timer_object!(TimeoutObject, "Timeout"); impl TimeoutObject { + /// `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. pub(crate) fn init( global: &JSGlobalObject, + tag: EventLoopTimerTag, id: i32, kind: Kind, interval: u32, callback: JSValue, arguments: JSValue, ) -> JSValue { - Self::init_with(global, id, kind, interval, callback, arguments) + debug_assert!(matches!( + tag, + EventLoopTimerTag::TimeoutObject | EventLoopTimerTag::InternalTimeoutObject + )); + debug_assert!(kind != Kind::SetImmediate); + Self::init_with(global, tag, id, kind, interval, callback, arguments) } #[bun_jsc::host_fn(method)] diff --git a/src/runtime/timer/Timer.rs b/src/runtime/timer/Timer.rs index 73eae0ce9d4a..a6ffd85b0477 100644 --- a/src/runtime/timer/Timer.rs +++ b/src/runtime/timer/Timer.rs @@ -225,6 +225,7 @@ impl All { let wrapped_promise = promise.with_async_context_if_needed(global); Ok(TimeoutObject::init( global, + EventLoopTimerTag::TimeoutObject, id, Kind::SetTimeout, countdown_int, @@ -253,8 +254,13 @@ impl All { )) } - pub(crate) fn set_timeout( + /// Body of `setTimeout`/`setInterval`; `tag` picks between the global + /// (fakeable) timers and the `internal/timers` ones, see + /// [`TimeoutObject::init`]. + fn set_timeout_or_interval( global: &JSGlobalObject, + tag: EventLoopTimerTag, + kind: Kind, callback: JSValue, arguments: JSValue, countdown: JSValue, @@ -270,37 +276,81 @@ impl All { all.js_value_to_countdown(global, countdown, CountdownOverflowBehavior::OneMs, true)?; Ok(TimeoutObject::init( global, + tag, id, - Kind::SetTimeout, + kind, countdown_int, wrapped_callback, arguments, )) } + pub(crate) fn set_timeout( + global: &JSGlobalObject, + callback: JSValue, + arguments: JSValue, + countdown: JSValue, + ) -> JsResult { + Self::set_timeout_or_interval( + global, + EventLoopTimerTag::TimeoutObject, + Kind::SetTimeout, + callback, + arguments, + countdown, + ) + } + pub(crate) fn set_interval( global: &JSGlobalObject, callback: JSValue, arguments: JSValue, countdown: JSValue, ) -> JsResult { - bun_jsc::mark_binding!(); - debug_assert!(!callback.is_empty() && !arguments.is_empty() && !countdown.is_empty()); - let all = timer_all_mut(); - let id = all.last_id; - all.last_id = all.last_id.wrapping_add(1); + Self::set_timeout_or_interval( + global, + EventLoopTimerTag::TimeoutObject, + Kind::SetInterval, + callback, + arguments, + countdown, + ) + } - let wrapped_callback = callback.with_async_context_if_needed(global); - let countdown_int = - all.js_value_to_countdown(global, countdown, CountdownOverflowBehavior::OneMs, true)?; - Ok(TimeoutObject::init( + /// `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/`. + pub(crate) fn set_timeout_internal( + global: &JSGlobalObject, + callback: JSValue, + arguments: JSValue, + countdown: JSValue, + ) -> JsResult { + Self::set_timeout_or_interval( global, - id, + EventLoopTimerTag::InternalTimeoutObject, + Kind::SetTimeout, + callback, + arguments, + countdown, + ) + } + + /// `internal/timers` `setInterval`; see [`Self::set_timeout_internal`]. + pub(crate) fn set_interval_internal( + global: &JSGlobalObject, + callback: JSValue, + arguments: JSValue, + countdown: JSValue, + ) -> JsResult { + Self::set_timeout_or_interval( + global, + EventLoopTimerTag::InternalTimeoutObject, Kind::SetInterval, - countdown_int, - wrapped_callback, + callback, arguments, - )) + countdown, + ) } fn remove_timer_by_id(&mut self, id: i32) -> Option<*mut TimeoutObject> { @@ -311,7 +361,11 @@ impl All { self.maps.set_interval.swap_remove_at(idx).1 }; // SAFETY: entry value points to EventLoopTimer embedded in a TimeoutObject - debug_assert!(unsafe { (*value).tag } == EventLoopTimerTag::TimeoutObject); + let tag = unsafe { (*value).tag }; + debug_assert!(matches!( + tag, + EventLoopTimerTag::TimeoutObject | EventLoopTimerTag::InternalTimeoutObject + )); // SAFETY: entry value points to TimeoutObject.event_loop_timer Some(unsafe { TimeoutObject::from_timer_ptr(value) }) } @@ -511,7 +565,7 @@ pub fn drain_timers_export(vm: *mut VirtualMachine) { } // `generate-host-exports.ts` -// scrapes the `// HOST_EXPORT` markers below and emits the seven thunks into +// scrapes the `// HOST_EXPORT` markers below and emits one thunk per marker into // `generated_host_exports.rs`, each routing through `host_fn::host_fn_result`. // // C++ callers (`src/jsc/bindings/node/NodeTimers.cpp`, `BunObject.cpp`) declare @@ -555,6 +609,26 @@ pub fn set_interval_export( All::set_interval(global, callback, arguments, countdown) } +// HOST_EXPORT(Bun__Timer__setTimeoutInternal, c) +pub fn set_timeout_internal_export( + global: &JSGlobalObject, + callback: JSValue, + arguments: JSValue, + countdown: JSValue, +) -> JsResult { + All::set_timeout_internal(global, callback, arguments, countdown) +} + +// HOST_EXPORT(Bun__Timer__setIntervalInternal, c) +pub fn set_interval_internal_export( + global: &JSGlobalObject, + callback: JSValue, + arguments: JSValue, + countdown: JSValue, +) -> JsResult { + All::set_interval_internal(global, callback, arguments, countdown) +} + // HOST_EXPORT(Bun__Timer__clearImmediate, c) pub fn clear_immediate_export(global: &JSGlobalObject, id: JSValue) -> JsResult { All::clear_immediate(global, id) diff --git a/src/runtime/timer/mod.rs b/src/runtime/timer/mod.rs index 61f9e37dcb67..f6655d288c36 100644 --- a/src/runtime/timer/mod.rs +++ b/src/runtime/timer/mod.rs @@ -44,7 +44,6 @@ pub mod timer; // - `#[bun_jsc::JsClass(name = $js_name)] pub struct $T { … }` // - `bun_event_loop::impl_timer_owner!($T; from_timer_ptr => event_loop_timer)` // - `impl RefCounted for $T` (intrusive `ref_count` field, `deinit` destructor) -// - `impl Default for $T` (`EventLoopTimer::init_paused(EventLoopTimerTag::$tag)`) // - `impl $T`: `ref_`/`deref`/`deinit`/`init_with`/`constructor`/`finalize` // and the forwarder host-fns `to_primitive`/`do_ref`/`do_unref`/`has_ref`/ // `get_destroyed`/`dispose`. @@ -56,7 +55,7 @@ pub mod timer; // macro is invoked *from the child module* (`super::impl_timer_object!(…)`), // so `super` at the expansion site resolves back here to `timer/mod.rs`. macro_rules! impl_timer_object { - ($T:ident, $tag:ident, $js_name:literal) => { + ($T:ident, $js_name:literal) => { #[::bun_jsc::JsClass(name = $js_name)] pub struct $T { pub ref_count: ::bun_ptr::RefCount, @@ -82,20 +81,6 @@ macro_rules! impl_timer_object { } } - impl ::core::default::Default for $T { - fn default() -> Self { - Self { - ref_count: ::bun_ptr::RefCount::init(), - // `init_paused`: next=EPOCH, state=PENDING, heap zeroed. - event_loop_timer: super::EventLoopTimer::init_paused( - super::EventLoopTimerTag::$tag, - ), - // Default-constructed here, then overwritten in `init()`. - internals: super::TimerObjectInternals::default(), - } - } - } - impl $T { // Re-export the refcount mixin's ops as inherent fns so // `TimerObjectInternals`'s `container_of` dispatch resolves. @@ -125,9 +110,12 @@ macro_rules! impl_timer_object { /// Shared body of `TimeoutObject::init` / `ImmediateObject::init`: /// heap-allocate → `to_js_ptr` → `internals.init` → /// inspector `did_schedule_async_call`. The per-type `init` fn - /// picks `kind`/`interval` and forwards here. + /// 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). pub fn init_with( global: &::bun_jsc::JSGlobalObject, + tag: super::EventLoopTimerTag, id: i32, kind: super::Kind, interval: u32, @@ -138,8 +126,13 @@ macro_rules! impl_timer_object { // `m_ctx` payload of the codegen'd JSCell wrapper. Ownership // transfers to the wrapper via `to_js_ptr`; freed by // `deref → deinit → heap::take`. - let payload: *mut Self = - ::bun_core::heap::into_raw(::std::boxed::Box::new(Self::default())); + let payload: *mut Self = ::bun_core::heap::into_raw(::std::boxed::Box::new(Self { + ref_count: ::bun_ptr::RefCount::init(), + // `init_paused`: next=EPOCH, state=PENDING, heap links null. + event_loop_timer: super::EventLoopTimer::init_paused(tag), + // Overwritten by `internals.init()` below. + internals: super::TimerObjectInternals::default(), + })); // SAFETY: `to_js_ptr` is the `#[JsClass]`-generated `*__create` // shim; `payload` is a fresh heap allocation whose ownership // transfers to the GC wrapper. @@ -555,8 +548,9 @@ pub use self::immediate_object::ImmediateObject; pub use self::timeout_object::TimeoutObject; /// Recover the -/// [`TimerFlags`] slot for the three JS-timer container tags -/// (`TimeoutObject` / `ImmediateObject` / `AbortSignalTimeout`), else `None`. +/// [`TimerFlags`] slot for the three JS-timer container types +/// (`TimeoutObject`, under either of its tags / `ImmediateObject` / +/// `AbortSignalTimeout`), else `None`. /// /// Returns a raw `NonNull` so the caller decides read vs. write: /// [`EventLoopTimer::less`] reads `.epoch()` on the heap-compare hot path; @@ -577,7 +571,7 @@ pub(crate) unsafe fn js_timer_flags_ptr( // SAFETY: caller contract — `t` is live; tag invariant per fn docs. unsafe { let p: *const TimerFlags = match (*t).tag { - EventLoopTimerTag::TimeoutObject => { + EventLoopTimerTag::TimeoutObject | EventLoopTimerTag::InternalTimeoutObject => { let parent = TimeoutObject::from_timer_ptr(t); addr_of!((*parent).internals.flags).cast() } @@ -1269,7 +1263,7 @@ impl All { stack.push(next); } match tag { - EventLoopTimerTag::TimeoutObject => { + EventLoopTimerTag::TimeoutObject | EventLoopTimerTag::InternalTimeoutObject => { // SAFETY: tag invariant — `node` IS the `event_loop_timer` // field of a live `TimeoutObject`. let parent = unsafe { TimeoutObject::from_timer_ptr(node) }; diff --git a/src/runtime/timer/timer_object_internals.rs b/src/runtime/timer/timer_object_internals.rs index 3f9d3e4332e6..d7b22997bbdc 100644 --- a/src/runtime/timer/timer_object_internals.rs +++ b/src/runtime/timer/timer_object_internals.rs @@ -549,10 +549,7 @@ impl TimerObjectInternals { if kind != KindBig::SetInterval { s.this_value.with_mut(|r| r.downgrade()); } else { - time_before_call = Timespec::ms_from_now( - TimespecMockMode::AllowMockedTime, - i64::from(s.interval.get()), - ); + time_before_call = Timespec::ms_from_now(s.clock(), i64::from(s.interval.get())); } this_object.ensure_still_alive(); @@ -768,7 +765,7 @@ impl TimerObjectInternals { let state = crate::jsc_hooks::runtime_state(); debug_assert!(!state.is_null(), "RuntimeState not installed"); - let now = Timespec::now(TimespecMockMode::AllowMockedTime); + let now = Timespec::now(self.clock()); let scheduled_time = now.add_ms(i64::from(self.interval.get())); let was_active = self.event_loop_timer_state() == EventLoopTimerState::ACTIVE; if was_active { @@ -891,6 +888,14 @@ impl TimerObjectInternals { unsafe { (*self.event_loop_timer()).state } } + /// 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()`. + fn clock(&self) -> TimespecMockMode { + // SAFETY: ptr into the live parent per `parent_ptr()`; read-only deref. + unsafe { (*self.event_loop_timer()).tag }.clock() + } + /// Write the owning `EventLoopTimer.state`. Paired write-side accessor for /// [`event_loop_timer_state`]; centralises the back-ref deref so call sites /// stay safe. diff --git a/test/internal/source-lints/builtin-timer-globals.test.ts b/test/internal/source-lints/builtin-timer-globals.test.ts new file mode 100644 index 000000000000..759f75bd3183 --- /dev/null +++ b/test/internal/source-lints/builtin-timer-globals.test.ts @@ -0,0 +1,202 @@ +import { Glob } from "bun"; +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import path from "node:path"; + +// The global setTimeout/setInterval/clearTimeout/clearInterval belong to user +// code: jest.useFakeTimers() freezes, counts, advances and clears every timer +// created through them, and user code may replace them. The runtime's own +// deadlines (socket idle timeouts, listen() callbacks, child_process kill +// timers, connection retries, ...) must keep firing regardless, so built-in +// modules take these four functions from require("internal/timers"), which +// schedules on the real clock no matter what a test does to the globals. This +// is the same rule Node's lib/ enforces with eslint's no-restricted-globals; +// capturing the globals at module load is not enough in Bun because fake timers +// are implemented inside the native setTimeout itself. +// +// A module that destructures one of these names from internal/timers shadows +// the global for the whole module, so every bare use in it is fine. In any +// other module a bare use of the name is a use of the global. + +const TIMER_GLOBALS = ["setTimeout", "setInterval", "clearTimeout", "clearInterval"] as const; + +// The modules implementing the user-facing timer APIs on top of the globals, +// and the module that defines the internal ones. +const IMPLEMENTERS = new Set(["node/timers.ts", "node/timers.promises.ts", "internal/timers.ts"]); + +const srcJs = path.resolve(import.meta.dir, "..", "..", "..", "src", "js"); + +describe("builtin modules take setTimeout & co. from internal/timers", () => { + test("scanner self-check", () => { + expect( + globalTimerUses(` + const { setTimeout } = require("internal/timers"); + const { kTimeout: timeoutSymbol, clearTimeout: realClearTimeout } = require("internal/timers"); + // clearTimeout(t) in a comment + /* or in a block comment + clearTimeout(t) */ + const s = "clearTimeout(" + 'setInterval(' + \`clearInterval( + clearInterval(\` + "a \\" setInterval(" + "http://x"; + Foo.prototype.setInterval = function setInterval(ms) {}; + class Socket { + setTimeout(msecs, callback) {} + clearInterval(a = 1, { b }): this {} + get clearTimeout() {} + } + const t: ReturnType = setTimeout(fn, 1); + realClearTimeout(t); + socket.setTimeout(1); + this.#clearInterval(t); + clearTimeout(t); // after code + const keep = setInterval; + promisify(clearInterval); + `), + ).toEqual([ + { line: 19, name: "clearTimeout" }, + { line: 20, name: "setInterval" }, + { line: 21, name: "clearInterval" }, + ]); + }); + + test("src/js", () => { + const violations: string[] = []; + let scanned = 0; + + for (const rel of [...new Glob("**/*.{js,ts}").scanSync({ cwd: srcJs })].sort()) { + const posixRel = rel.replaceAll("\\", "/"); + if (posixRel.endsWith(".d.ts") || IMPLEMENTERS.has(posixRel)) continue; + scanned++; + for (const { line, name } of globalTimerUses(readFileSync(path.join(srcJs, rel), "utf8"))) { + violations.push( + `src/js/${posixRel}:${line}: global ${name}; add it to the require("internal/timers") destructure of this module`, + ); + } + } + + expect(violations).toEqual([]); + // Guards against the scan going vacuous if the modules move. + expect(scanned).toBeGreaterThan(100); + }); +}); + +type Use = { line: number; name: string }; + +// Not a property access (`socket.setTimeout`, `this.#setTimeout`) or part of a +// longer identifier. The leading character is captured instead of using a +// lookbehind, which is very slow on large files in debug builds of JSC. +const REFERENCE = new RegExp(String.raw`(^|[^\w$.#])(${TIMER_GLOBALS.join("|")})\b`, "gm"); + +// Bare references to the timer globals that resolve to the global in this +// module, with 1-based line numbers. +function globalTimerUses(source: string): Use[] { + if (!TIMER_GLOBALS.some(name => source.includes(name))) return []; + const shadowed = internalTimerBindings(source); + const skip = commentsAndStrings(source); + const uses: Use[] = []; + let nextSkip = 0; + + for (const match of source.matchAll(REFERENCE)) { + const name = match[2]; + if (shadowed.has(name)) continue; + const start = match.index + match[1].length; + while (nextSkip < skip.length && skip[nextSkip].end <= start) nextSkip++; + if (nextSkip < skip.length && skip[nextSkip].start <= start) continue; + const end = start + name.length; + if (isDeclaration(source, start) || isPropertyKey(source, end) || isMethodDefinition(source, end)) continue; + uses.push({ line: lineOf(source, start), name }); + } + return uses; +} + +// Local names bound by `const { ..., setTimeout, kTimeout: alias, ... } = require("internal/timers")`. +function internalTimerBindings(source: string): Set { + const bound = new Set(); + const destructure = /\{([^{}]*)\}\s*=\s*require\(\s*["']internal\/timers["']\s*\)/g; + for (const [, body] of source.matchAll(destructure)) { + for (const entry of body.replace(/\/\/[^\n]*/g, "").split(",")) { + const [key, alias] = entry.split(":").map(s => s.trim()); + const local = (alias ?? key).split("=")[0].trim(); + if (local) bound.add(local); + } + } + return bound; +} + +type Range = { start: number; end: number }; + +// The comments and string/template literals of the file, in order, so that +// mentions inside them are not taken for code. Each token is located with +// indexOf from its opener (a regular expression over the whole file takes tens +// of seconds on the largest modules in a debug build). +function commentsAndStrings(source: string): Range[] { + const ranges: Range[] = []; + const opener = /["'`]|\/\/|\/\*/g; + for (let match = opener.exec(source); match !== null; match = opener.exec(source)) { + const start = match.index; + let end: number; + if (match[0] === "//") { + end = indexOrEnd(source, "\n", start); + } else if (match[0] === "/*") { + const close = source.indexOf("*/", start + 2); + end = close === -1 ? source.length : close + 2; + } else { + end = closingQuote(source, start, match[0]); + } + ranges.push({ start, end }); + opener.lastIndex = end; + } + return ranges; +} + +// End of the literal opened by the quote at `start` (just past the closing +// quote). Backslash-escaped quotes do not close it. A ' or " with no closing +// quote on its line (a quote character inside a regex literal, typically) is +// taken to end at the newline. +function closingQuote(source: string, start: number, quote: string): number { + const lineEnd = quote === "`" ? source.length : indexOrEnd(source, "\n", start); + for (let from = start + 1; ; ) { + const close = source.indexOf(quote, from); + if (close === -1 || close > lineEnd) return lineEnd; + let backslashes = 0; + while (source[close - 1 - backslashes] === "\\") backslashes++; + if (backslashes % 2 === 0) return close + 1; + from = close + 1; + } +} + +function indexOrEnd(source: string, needle: string, from: number): number { + const index = source.indexOf(needle, from); + return index === -1 ? source.length : index; +} + +// `function setTimeout(` (a function named like the global), `get setTimeout()`, +// or `typeof setTimeout` in a type position. +function isDeclaration(source: string, start: number): boolean { + return /\b(?:typeof|function|get|set)\s+$/.test(source.slice(Math.max(0, start - 64), start)); +} + +// `{ setTimeout: x }` / `case` labels: the name is a key, not a reference. +function isPropertyKey(source: string, end: number): boolean { + return /^\s*:/.test(source.slice(end, end + 64)); +} + +// `setTimeout(msecs, callback) {` / `setTimeout(ms): this {`: a method named +// like the global, not a call of it. The text after the parameter list tells +// the two apart; a call expression is never followed by `{` or a type annotation. +function isMethodDefinition(source: string, end: number): boolean { + let i = end; + while (i < source.length && /\s/.test(source[i])) i++; + if (source[i] !== "(") return false; + let depth = 0; + for (; i < source.length; i++) { + if (source[i] === "(") depth++; + else if (source[i] === ")" && --depth === 0) break; + } + return /^\)\s*(?:\{|:)/.test(source.slice(i, i + 64)); +} + +function lineOf(source: string, index: number): number { + let line = 1; + for (let at = source.indexOf("\n"); at !== -1 && at < index; at = source.indexOf("\n", at + 1)) line++; + return line; +} diff --git a/test/js/bun/test/fake-timers/fake-timers.test.ts b/test/js/bun/test/fake-timers/fake-timers.test.ts index 9b78720b9c1c..36d5726de494 100644 --- a/test/js/bun/test/fake-timers/fake-timers.test.ts +++ b/test/js/bun/test/fake-timers/fake-timers.test.ts @@ -1,7 +1,11 @@ import { RedisClient, SQL } from "bun"; +import { timerInternals } from "bun:internal-for-testing"; import { heapStats } from "bun:jsc"; import { bunEnv, bunExe } from "harness"; -import { spawnSync as childProcessSpawnSync } from "node:child_process"; +import { spawnSync as childProcessSpawnSync, execFile } from "node:child_process"; +import { once } from "node:events"; +import http from "node:http"; +import net from "node:net"; import { afterEach, describe, expect, test, vi } from "vitest"; afterEach(() => vi.useRealTimers()); @@ -692,3 +696,187 @@ describe("useFakeTimers with options", () => { expect(vi.isFakeTimers()).toBe(false); }); }); + +// Only timers user code creates through the globals are faked. The deadlines +// built-in modules schedule for themselves (through internal/timers) keep +// running on the real clock, as they do in Node under Jest's fake timers, and +// are invisible to getTimerCount()/runAllTimers()/clearAllTimers(). Every test +// here awaits the event the runtime is supposed to produce; before the fix the +// timer behind it sat frozen in the fake heap and the test timed out. +describe("built-in modules are not affected by fake timers", () => { + async function listening(server: T): Promise { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + return (server.address() as net.AddressInfo).port; + } + + test("internal/timers setTimeout fires in real time and is not a fake timer", async () => { + const { setTimeout: setInternalTimeout } = timerInternals.internalTimers; + vi.useFakeTimers(); + const { promise, resolve } = Promise.withResolvers(); + setInternalTimeout((...args: string[]) => resolve(args), 1, "a", "b", "c"); + expect(vi.getTimerCount()).toBe(0); + // Neither fires it early nor cancels it. + vi.runAllTimers(); + vi.clearAllTimers(); + expect(await promise).toEqual(["a", "b", "c"]); + }); + + test("internal/timers setInterval keeps firing in real time", async () => { + const { setInterval: setInternalInterval, clearInterval: clearInternalInterval } = timerInternals.internalTimers; + vi.useFakeTimers(); + const { promise, resolve } = Promise.withResolvers(); + let fired = 0; + const interval = setInternalInterval(() => { + if (++fired === 3) { + clearInternalInterval(interval); + resolve(); + } + }, 1); + expect(vi.getTimerCount()).toBe(0); + vi.runAllTimers(); + await promise; + expect(fired).toBe(3); + }); + + test("internal/timers returns the same kind of Timeout the globals do", () => { + const { setTimeout: setInternalTimeout, clearTimeout: clearInternalTimeout } = timerInternals.internalTimers; + const internal = setInternalTimeout(() => {}, 1_000_000); + const global = setTimeout(() => {}, 1_000_000); + try { + expect(Object.getPrototypeOf(internal)).toBe(Object.getPrototypeOf(global)); + expect(internal.hasRef()).toBe(true); + expect(internal.unref().hasRef()).toBe(false); + expect(internal.refresh()).toBe(internal); + } finally { + clearInternalTimeout(internal); + // The global clearTimeout clears internal timers too. + clearTimeout(internal); + clearTimeout(global); + } + expect((internal as any)._destroyed).toBe(true); + }); + + test("net.Server and http.Server emit 'listening'", async () => { + vi.useFakeTimers(); + const netServer = net.createServer(); + const httpServer = http.createServer(); + try { + const { promise: callback, resolve } = Promise.withResolvers(); + netServer.listen(0, "127.0.0.1", resolve); + await Promise.all([callback, once(netServer, "listening"), listening(httpServer)]); + expect(vi.getTimerCount()).toBe(0); + } finally { + netServer.close(); + httpServer.close(); + } + }); + + test("socket.setTimeout() fires, and clearAllTimers()/useRealTimers() do not cancel it", async () => { + const accepted: net.Socket[] = []; + const server = net.createServer(socket => { + socket.on("error", () => {}); + accepted.push(socket); + }); + const port = await listening(server); + vi.useFakeTimers(); + const socket = net.connect(port, "127.0.0.1"); + try { + await once(socket, "connect"); + const fakeTimers = vi.getTimerCount(); + socket.setTimeout(1); + expect(vi.getTimerCount()).toBe(fakeTimers); + vi.clearAllTimers(); + vi.useRealTimers(); + await once(socket, "timeout"); + } finally { + socket.destroy(); + for (const s of accepted) s.destroy(); + server.close(); + } + }); + + test("http request.setTimeout() emits 'timeout' while the server stays silent", async () => { + const held: net.Socket[] = []; + const server = net.createServer(socket => { + socket.on("error", () => {}); + held.push(socket); + }); + const port = await listening(server); + vi.useFakeTimers(); + const req = http.get({ host: "127.0.0.1", port }); + req.on("error", () => {}); + try { + const [socket] = await once(req, "socket"); + if (socket.connecting) await once(socket, "connect"); + const fakeTimers = vi.getTimerCount(); + req.setTimeout(1); + expect(vi.getTimerCount()).toBe(fakeTimers); + await once(req, "timeout"); + } finally { + req.destroy(); + for (const socket of held) socket.destroy(); + server.close(); + } + }); + + test("http.Server headersTimeout sweep (an internal setInterval) still runs", async () => { + vi.useFakeTimers(); + // The sweep has to come around many times (re-arming itself each time) + // before the stalled request head is old enough to expire. + const server = http.createServer({ connectionsCheckingInterval: 1, headersTimeout: 20 }, (req, res) => + res.end("unexpected"), + ); + const { promise: clientError, resolve } = Promise.withResolvers(); + server.on("clientError", (err: any, socket) => { + resolve(err.code); + socket.destroy(); + }); + let socket: net.Socket | undefined; + try { + const port = await listening(server); + // 'listening' armed the sweep interval; it is not a fake timer. + expect(vi.getTimerCount()).toBe(0); + socket = net.connect(port, "127.0.0.1"); + socket.on("error", () => {}); + await once(socket, "connect"); + // A request head that never completes. + socket.write("GET / HTTP/1.1\r\nHost: a\r\n"); + expect(await clientError).toBe("ERR_HTTP_REQUEST_TIMEOUT"); + await once(socket, "close"); + } finally { + socket?.destroy(); + server.closeAllConnections(); + server.close(); + } + }); + + test("child_process.execFile({ timeout }) kills the child", async () => { + vi.useFakeTimers(); + const { promise, resolve } = Promise.withResolvers<{ killed: boolean; signal: string | null | undefined }>(); + const child = execFile(bunExe(), ["-e", "setTimeout(() => {}, 1_000_000)"], { timeout: 1, env: bunEnv }, error => + resolve({ killed: child.killed, signal: error?.signal }), + ); + expect(vi.getTimerCount()).toBe(0); + expect(await promise).toEqual({ killed: true, signal: "SIGTERM" }); + }); + + // The same private references also keep the runtime working when user code + // replaces the globals (sinon-style fake timers, or any other monkeypatch). + test("listen() works after globalThis.setTimeout was replaced", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `globalThis.setTimeout = globalThis.setInterval = () => { throw new Error("built-in module used the global timer"); }; + const server = require("node:net").createServer(); + server.listen(0, "127.0.0.1", () => { console.log("listening"); server.close(); });`, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ stdout: "listening\n", stderr: "", exitCode: 0 }); + }); +});