From d1e12a42798443e0077013859a47c0436dcd015d Mon Sep 17 00:00:00 2001 From: robobun Date: Sun, 5 Jul 2026 06:47:20 +0000 Subject: [PATCH 01/13] uws: drop us_timer_t on epoll/kqueue in favor of bun's timer heap A us_timer_t cost an entire file descriptor on Linux (timerfd) and a pair of kevent64 syscalls per arm on macOS/FreeBSD. Four were live in a normal process: the socket-timeout sweep on the JS thread, the two GC controller timers, and a second sweep on the HTTP client thread. - GarbageCollectionController's two timers become EventLoopTimer nodes embedded in the controller, scheduled on the per-VM timer heap. No new allocation: both nodes are fields, not boxes. - EventLoop.forever_timer only existed to keep num_polls non-zero so us_loop_run_bun_tick would park instead of returning immediately; its callback was a no-op. On posix that is now a plain num_polls bump. tick_possibly_forever() polls bounded by the timer heap's next deadline and drains it afterwards, via a new poll_and_drain_timers runtime hook. - The socket-timeout sweep becomes an absolute deadline in us_internal_loop_data_t, folded into the epoll_pwait2/kevent64 timeout and dispatched from the same tick. This is the existing quic_next_tick_us pattern, and it works on loops that have no timer heap behind them (the HTTP client thread, the CLI mini event loops). us_create_timer/us_timer_set/us_timer_close and friends are now libuv-only, along with the Rust uws::Timer wrapper. No behavior change on Windows. --- .../bun-usockets/src/eventing/epoll_kqueue.c | 178 +++--------- .../src/internal/eventing/epoll_kqueue.h | 1 - packages/bun-usockets/src/internal/internal.h | 10 + .../bun-usockets/src/internal/loop_data.h | 16 +- packages/bun-usockets/src/libusockets.h | 8 +- packages/bun-usockets/src/loop.c | 67 +++++ src/event_loop/EventLoopTimer.rs | 6 + src/jsc/GarbageCollectionController.rs | 268 ++++++++++-------- src/jsc/VirtualMachine.rs | 27 +- src/jsc/event_loop.rs | 68 +++-- src/runtime/dispatch.rs | 13 + src/runtime/jsc_hooks.rs | 59 ++++ src/uws/lib.rs | 4 +- src/uws_sys/InternalLoopData.rs | 13 +- src/uws_sys/Timer.rs | 51 +--- src/uws_sys/lib.rs | 5 + test/js/bun/event-loop-timers.test.ts | 96 +++++++ 17 files changed, 565 insertions(+), 325 deletions(-) create mode 100644 test/js/bun/event-loop-timers.test.ts diff --git a/packages/bun-usockets/src/eventing/epoll_kqueue.c b/packages/bun-usockets/src/eventing/epoll_kqueue.c index 7e347a378d5b..5a64fee1d609 100644 --- a/packages/bun-usockets/src/eventing/epoll_kqueue.c +++ b/packages/bun-usockets/src/eventing/epoll_kqueue.c @@ -101,18 +101,6 @@ void us_internal_poll_set_type(struct us_poll_t *p, int poll_type) { p->state.poll_type = poll_type | (p->state.poll_type & POLL_TYPE_POLLING_MASK); } -/* Timer */ -void *us_timer_ext(struct us_timer_t *timer) { - return ((struct us_internal_callback_t *) timer) + 1; -} - -struct us_loop_t *us_timer_loop(struct us_timer_t *t) { - struct us_internal_callback_t *internal_cb = (struct us_internal_callback_t *) t; - - return internal_cb->loop; -} - - #if defined(LIBUS_USE_EPOLL) #include @@ -243,9 +231,9 @@ static void us_internal_dispatch_ready_polls(struct us_loop_t *loop) { const uint16_t flags = loop->ready_polls[i].flags; struct kevent_flags bits = { #if defined(__APPLE__) - .readable = (filter == EVFILT_READ || filter == EVFILT_TIMER || filter == EVFILT_MACHPORT), + .readable = (filter == EVFILT_READ || filter == EVFILT_MACHPORT), #else - .readable = (filter == EVFILT_READ || filter == EVFILT_TIMER || filter == EVFILT_USER), + .readable = (filter == EVFILT_READ || filter == EVFILT_USER), #endif .writable = (filter == EVFILT_WRITE), .error = !!(flags & EV_ERROR), @@ -320,26 +308,49 @@ static void us_internal_drain_ready_polls(struct us_loop_t *loop) { } } -void us_loop_run(struct us_loop_t *loop) { - us_loop_integrate(loop); +/* The socket-timeout sweep has no timerfd/EVFILT_TIMER behind it: bound the + * poll by its deadline when it is sooner than `timeout` (NULL == forever). + * `storage` is the caller's stack slot for the clamped value. */ +static const struct timespec *us_internal_clamp_to_sweep(struct us_loop_t *loop, const struct timespec *timeout, struct timespec *storage) { + long long ns = us_internal_sweep_timeout_ns(loop); + if (ns < 0) { + return timeout; + } + /* Field-wise, not widened to nanoseconds: tv_sec is a 64-bit second count + * and a far-future timeout would overflow the multiply. */ + long long sweep_sec = ns / 1000000000LL; + long long sweep_nsec = ns % 1000000000LL; + if (timeout && (timeout->tv_sec < sweep_sec || + (timeout->tv_sec == sweep_sec && timeout->tv_nsec <= sweep_nsec))) { + return timeout; + } + storage->tv_sec = (time_t) sweep_sec; + storage->tv_nsec = (long) sweep_nsec; + return storage; +} +void us_loop_run(struct us_loop_t *loop) { /* While we have non-fallthrough polls we shouldn't fall through */ while (loop->num_polls) { loop->data.tick_depth++; /* Emit pre callback */ us_internal_loop_pre(loop); + struct timespec sweep_ts; + const struct timespec *timeout = us_internal_clamp_to_sweep(loop, NULL, &sweep_ts); + /* Fetch ready polls */ #ifdef LIBUS_USE_EPOLL - loop->num_ready_polls = bun_epoll_pwait2(loop->fd, loop->ready_polls, LIBUS_MAX_READY_POLLS, NULL); + loop->num_ready_polls = bun_epoll_pwait2(loop->fd, loop->ready_polls, LIBUS_MAX_READY_POLLS, timeout); #else do { - loop->num_ready_polls = kevent64(loop->fd, NULL, 0, loop->ready_polls, LIBUS_MAX_READY_POLLS, 0, NULL); + loop->num_ready_polls = kevent64(loop->fd, NULL, 0, loop->ready_polls, LIBUS_MAX_READY_POLLS, 0, timeout); } while (IS_EINTR(loop->num_ready_polls)); #endif us_internal_dispatch_ready_polls(loop); us_internal_drain_ready_polls(loop); + us_internal_sweep_if_due(loop); /* Emit post callback */ us_internal_loop_post(loop); @@ -355,14 +366,6 @@ void us_loop_run_bun_tick(struct us_loop_t *loop, const struct timespec* timeout loop->data.tick_depth++; - struct us_internal_callback_t *timer_callback = (struct us_internal_callback_t*)loop->data.sweep_timer; - - // Only integrate the loop if we haven't already. - // Otherwise we will keep restarting the timer. - if(!timer_callback->cb) { - us_loop_integrate(loop); - } - /* Emit pre callback */ us_internal_loop_pre(loop); @@ -381,6 +384,11 @@ void us_loop_run_bun_tick(struct us_loop_t *loop, const struct timespec* timeout } } + /* Same story for the socket-timeout sweep: Bun's timer heap doesn't know + * about it (the HTTP thread has no heap at all), so bound the poll here. */ + struct timespec sweep_ts; + timeout = us_internal_clamp_to_sweep(loop, timeout, &sweep_ts); + const unsigned int had_wakeups = __atomic_exchange_n(&loop->pending_wakeups, 0, __ATOMIC_ACQUIRE); const int will_idle_inside_event_loop = had_wakeups == 0 && (!timeout || (timeout->tv_nsec != 0 || timeout->tv_sec != 0)); if (will_idle_inside_event_loop && loop->data.jsc_vm) @@ -408,6 +416,7 @@ void us_loop_run_bun_tick(struct us_loop_t *loop, const struct timespec* timeout us_internal_dispatch_ready_polls(loop); us_internal_drain_ready_polls(loop); + us_internal_sweep_if_due(loop); /* Emit post callback */ us_internal_loop_post(loop); @@ -595,122 +604,15 @@ size_t us_internal_accept_poll_event(struct us_poll_t *p) { } while (IS_EINTR(read_length)); return buf; #else - /* Kqueue has no underlying FD for timers or user events */ + /* Kqueue has no underlying FD for user events */ return 0; #endif } -/* Timer */ -#ifdef LIBUS_USE_EPOLL -struct us_timer_t *us_create_timer(struct us_loop_t *loop, int fallthrough, unsigned int ext_size) { - struct us_poll_t *p = us_create_poll(loop, fallthrough, sizeof(struct us_internal_callback_t) + ext_size); - memset(p, 0, sizeof(struct us_internal_callback_t) + ext_size); - int timerfd = timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK | TFD_CLOEXEC); - if (timerfd == -1) { - return NULL; - } - us_poll_init(p, timerfd, POLL_TYPE_CALLBACK); - - struct us_internal_callback_t *cb = (struct us_internal_callback_t *) p; - cb->loop = loop; - cb->cb_expects_the_loop = 0; - cb->leave_poll_ready = 0; - cb->has_added_timer_to_event_loop = 0; - - return (struct us_timer_t *) cb; -} -#else -struct us_timer_t *us_create_timer(struct us_loop_t *loop, int fallthrough, unsigned int ext_size) { - struct us_internal_callback_t *cb = us_calloc(1, sizeof(struct us_internal_callback_t) + ext_size); - - cb->loop = loop; - cb->cb_expects_the_loop = 0; - cb->leave_poll_ready = 0; - - /* Bug: us_internal_poll_set_type does not SET the type, it only CHANGES it */ - cb->p.state.poll_type = POLL_TYPE_POLLING_IN; - us_internal_poll_set_type((struct us_poll_t *) cb, POLL_TYPE_CALLBACK); - - if (!fallthrough) { - loop->num_polls++; - } - - return (struct us_timer_t *) cb; -} -#endif - -#ifdef LIBUS_USE_EPOLL -void us_timer_close(struct us_timer_t *timer, int fallthrough) { - struct us_internal_callback_t *cb = (struct us_internal_callback_t *) timer; - - us_poll_stop(&cb->p, cb->loop); - close(us_poll_fd(&cb->p)); - - /* (regular) sockets are the only polls which are not freed immediately */ - if(fallthrough){ - us_free(timer); - }else { - us_poll_free((struct us_poll_t *) timer, cb->loop); - } -} - -void us_timer_set(struct us_timer_t *t, void (*cb)(struct us_timer_t *t), int ms, int repeat_ms) { - struct us_internal_callback_t *internal_cb = (struct us_internal_callback_t *) t; - - internal_cb->cb = (void (*)(struct us_internal_callback_t *)) cb; - - struct itimerspec timer_spec = { - {repeat_ms / 1000, (long) (repeat_ms % 1000) * (long) 1000000}, - {ms / 1000, (long) (ms % 1000) * (long) 1000000} - }; - - timerfd_settime(us_poll_fd((struct us_poll_t *) t), 0, &timer_spec, NULL); - - // Avoid the system call overhead of re-adding this timer to the event loop only to receive EEXIST - if (internal_cb->loop->data.sweep_timer == t) { - if (internal_cb->has_added_timer_to_event_loop) { - return; - } - internal_cb->has_added_timer_to_event_loop = 1; - } - us_poll_start((struct us_poll_t *) t, internal_cb->loop, LIBUS_SOCKET_READABLE); -} -#else -void us_timer_close(struct us_timer_t *timer, int fallthrough) { - struct us_internal_callback_t *internal_cb = (struct us_internal_callback_t *) timer; - - struct kevent64_s event; - EV_SET64(&event, (uint64_t) (void*) internal_cb, EVFILT_TIMER, EV_DELETE, 0, 0, (uint64_t)internal_cb, 0, 0); - int ret; - do { - ret = kevent64(internal_cb->loop->fd, &event, 1, &event, 1, KEVENT_FLAG_ERROR_EVENTS, NULL); - } while (IS_EINTR(ret)); - - - /* (regular) sockets are the only polls which are not freed immediately */ - if(fallthrough){ - us_free(timer); - }else { - us_poll_free((struct us_poll_t *) timer, internal_cb->loop); - } -} - -void us_timer_set(struct us_timer_t *t, void (*cb)(struct us_timer_t *t), int ms, int repeat_ms) { - struct us_internal_callback_t *internal_cb = (struct us_internal_callback_t *) t; - - internal_cb->cb = (void (*)(struct us_internal_callback_t *)) cb; - - /* Bug: repeat_ms must be the same as ms, or 0 */ - struct kevent64_s event; - uint64_t ptr = (uint64_t)(void*)internal_cb; - EV_SET64(&event, ptr, EVFILT_TIMER, EV_ADD | (repeat_ms ? 0 : EV_ONESHOT), 0, ms, (uint64_t)internal_cb, 0, 0); - - int ret; - do { - ret = kevent64(internal_cb->loop->fd, &event, 1, &event, 1, KEVENT_FLAG_ERROR_EVENTS, NULL); - } while (IS_EINTR(ret)); -} -#endif +/* There is no us_timer_t here: it cost a timerfd (one fd each) or an + * EVFILT_TIMER registration (several syscalls per arm). Callers schedule on + * bun.JSC.EventLoopTimer instead, and the socket-timeout sweep is a deadline in + * us_internal_loop_data_t folded into the poll timeout. */ /* Async (internal helper for loop's wakeup feature) */ #ifdef LIBUS_USE_EPOLL diff --git a/packages/bun-usockets/src/internal/eventing/epoll_kqueue.h b/packages/bun-usockets/src/internal/eventing/epoll_kqueue.h index f7ed8e835fee..daad8a330e96 100644 --- a/packages/bun-usockets/src/internal/eventing/epoll_kqueue.h +++ b/packages/bun-usockets/src/internal/eventing/epoll_kqueue.h @@ -22,7 +22,6 @@ #ifdef LIBUS_USE_EPOLL #include -#include #include #define LIBUS_SOCKET_READABLE EPOLLIN #define LIBUS_SOCKET_WRITABLE EPOLLOUT diff --git a/packages/bun-usockets/src/internal/internal.h b/packages/bun-usockets/src/internal/internal.h index a6bace29201c..cffef302bec9 100644 --- a/packages/bun-usockets/src/internal/internal.h +++ b/packages/bun-usockets/src/internal/internal.h @@ -149,6 +149,12 @@ void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, in void us_internal_timer_sweep(us_loop_r loop); void us_internal_enable_sweep_timer(struct us_loop_t *loop); void us_internal_disable_sweep_timer(struct us_loop_t *loop); +#ifndef LIBUS_USE_LIBUV +/* POSIX sweep scheduling: no us_timer_t, just a deadline folded into the + * epoll/kqueue timeout. Defined in loop.c, driven from epoll_kqueue.c. */ +long long us_internal_sweep_timeout_ns(struct us_loop_t *loop); +void us_internal_sweep_if_due(struct us_loop_t *loop); +#endif void us_internal_free_closed_sockets(us_loop_r loop); void us_internal_loop_link_group(struct us_loop_t *loop, struct us_socket_group_t *group); void us_internal_loop_unlink_group(struct us_loop_t *loop, struct us_socket_group_t *group); @@ -373,7 +379,11 @@ struct us_internal_callback_t { int cb_expects_the_loop; int leave_poll_ready; void (*cb)(struct us_internal_callback_t *cb); +#ifdef LIBUS_USE_LIBUV + /* us_timer_set's one-shot guard for the sweep timer. POSIX has no + * us_timer_t at all (see loop_data.h sweep_next_tick_ns). */ unsigned has_added_timer_to_event_loop; +#endif }; #endif diff --git a/packages/bun-usockets/src/internal/loop_data.h b/packages/bun-usockets/src/internal/loop_data.h index 4294a6636be4..5454bd16b2ef 100644 --- a/packages/bun-usockets/src/internal/loop_data.h +++ b/packages/bun-usockets/src/internal/loop_data.h @@ -36,7 +36,14 @@ typedef void* zig_mutex_t; struct us_quic_socket_context_s; struct us_internal_loop_data_t { +#ifdef LIBUS_USE_LIBUV struct us_timer_t *sweep_timer; +#else + /* Absolute CLOCK_MONOTONIC nanoseconds of the next socket-timeout sweep, + * or -1 when no sockets are linked. Folded into the epoll/kqueue timeout + * and checked after the poll — no timerfd, no EVFILT_TIMER. */ + long long sweep_next_tick_ns; +#endif int sweep_timer_count; struct us_internal_async *wakeup_async; struct us_socket_group_t *head; @@ -52,11 +59,12 @@ struct us_internal_loop_data_t { * the gap between loop_post and getTimeout is sub-µs so storing the * relative diff is precise enough. */ long long quic_next_tick_us; - /* libuv only: a fallthrough us_timer_t armed to quic_next_tick_us so the - * uv loop wakes for lsquic's time-driven state. POSIX folds the deadline - * into the epoll_pwait2 timeout via getTimeout() instead, so this stays - * NULL there. */ +#ifdef LIBUS_USE_LIBUV + /* A fallthrough us_timer_t armed to quic_next_tick_us so the uv loop wakes + * for lsquic's time-driven state. POSIX folds the deadline into the + * epoll_pwait2 timeout via getTimeout() instead. */ struct us_timer_t *quic_timer; +#endif struct us_socket_group_t *iterator; char *recv_buf; char *send_buf; diff --git a/packages/bun-usockets/src/libusockets.h b/packages/bun-usockets/src/libusockets.h index c1ccae19acfe..515d13ecd5b9 100644 --- a/packages/bun-usockets/src/libusockets.h +++ b/packages/bun-usockets/src/libusockets.h @@ -203,7 +203,11 @@ void *us_udp_socket_user(struct us_udp_socket_t *s); /* Binds the UDP socket to an interface and port */ int us_udp_socket_bind(struct us_udp_socket_t *s, const char *hostname, unsigned int port); -/* Public interfaces for timers */ +/* Public interfaces for timers. libuv (Windows) only: on epoll/kqueue a + * us_timer_t cost a file descriptor (timerfd) or a pair of kevent64 syscalls + * per arm, so it no longer exists — schedule on bun.JSC.EventLoopTimer. Gated + * on the platform because the backend is selected further down this header. */ +#ifdef _WIN32 /* Create a new high precision, low performance timer. May fail and return null */ struct us_timer_t *us_create_timer(us_loop_r loop, int fallthrough, unsigned int ext_size); @@ -221,6 +225,8 @@ void us_timer_set(struct us_timer_t *timer, void (*cb)(struct us_timer_t *t), in /* Returns the loop for this timer */ struct us_loop_t *us_timer_loop(struct us_timer_t *t); +#endif + /* ────────────────────────────────────────────────────────────────────────── * Socket groups & dispatch * diff --git a/packages/bun-usockets/src/loop.c b/packages/bun-usockets/src/loop.c index 967494184b24..6c6b43284563 100644 --- a/packages/bun-usockets/src/loop.c +++ b/packages/bun-usockets/src/loop.c @@ -21,6 +21,7 @@ #include #include #include +#include #ifndef WIN32 #include #endif @@ -45,6 +46,8 @@ extern const size_t Bun__lock__size; extern void Bun__internal_ensureDateHeaderTimerIsEnabled(struct us_loop_t *loop); +#ifdef LIBUS_USE_LIBUV + void sweep_timer_cb(struct us_internal_callback_t *cb); // when the sweep timer is disabled, we don't need to do anything @@ -65,11 +68,71 @@ void us_internal_disable_sweep_timer(struct us_loop_t *loop) { } } +#else + +/* POSIX has no us_timer_t: the sweep is a plain deadline folded into the + * epoll/kqueue timeout (us_internal_sweep_timeout_ns) and dispatched from the + * same tick (us_internal_sweep_if_due). */ + +#define LIBUS_TIMEOUT_GRANULARITY_NS ((long long) LIBUS_TIMEOUT_GRANULARITY * 1000000000LL) + +static long long us_internal_monotonic_ns(void) { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (long long) ts.tv_sec * 1000000000LL + (long long) ts.tv_nsec; +} + +void us_internal_enable_sweep_timer(struct us_loop_t *loop) { + loop->data.sweep_timer_count++; + if (loop->data.sweep_timer_count == 1) { + loop->data.sweep_next_tick_ns = us_internal_monotonic_ns() + LIBUS_TIMEOUT_GRANULARITY_NS; + Bun__internal_ensureDateHeaderTimerIsEnabled(loop); + } +} + +void us_internal_disable_sweep_timer(struct us_loop_t *loop) { + loop->data.sweep_timer_count--; + if (loop->data.sweep_timer_count == 0) { + loop->data.sweep_next_tick_ns = -1; + } +} + +/* Nanoseconds until the next sweep, or -1 when disarmed. Clamped at 0 for an + * already-overdue deadline so the caller polls without blocking. */ +long long us_internal_sweep_timeout_ns(struct us_loop_t *loop) { + if (loop->data.sweep_next_tick_ns < 0) { + return -1; + } + long long diff = loop->data.sweep_next_tick_ns - us_internal_monotonic_ns(); + return diff > 0 ? diff : 0; +} + +void us_internal_sweep_if_due(struct us_loop_t *loop) { + if (loop->data.sweep_next_tick_ns < 0) { + return; + } + long long now = us_internal_monotonic_ns(); + if (now < loop->data.sweep_next_tick_ns) { + return; + } + /* Re-arm before dispatching: a timeout handler may unlink the last socket + * and us_internal_disable_sweep_timer would then disarm us — writing the + * next deadline afterwards would resurrect a dead timer. */ + loop->data.sweep_next_tick_ns = now + LIBUS_TIMEOUT_GRANULARITY_NS; + us_internal_timer_sweep(loop); +} + +#endif + /* The loop has 2 fallthrough polls */ void us_internal_loop_data_init(struct us_loop_t *loop, void (*wakeup_cb)(struct us_loop_t *loop), void (*pre_cb)(struct us_loop_t *loop), void (*post_cb)(struct us_loop_t *loop)) { // We allocate with calloc, so we only need to initialize the specific fields in use. +#ifdef LIBUS_USE_LIBUV loop->data.sweep_timer = us_create_timer(loop, 1, 0); +#else + loop->data.sweep_next_tick_ns = -1; +#endif loop->data.sweep_timer_count = 0; loop->data.recv_buf = malloc(LIBUS_RECV_BUFFER_LENGTH + LIBUS_RECV_BUFFER_PADDING * 2); loop->data.send_buf = malloc(LIBUS_SEND_BUFFER_LENGTH); @@ -95,8 +158,10 @@ void us_internal_loop_data_free(struct us_loop_t *loop) { free(loop->data.recv_buf); free(loop->data.send_buf); +#ifdef LIBUS_USE_LIBUV us_timer_close(loop->data.sweep_timer, 0); if (loop->data.quic_timer) us_timer_close(loop->data.quic_timer, 0); +#endif us_internal_async_close(loop->data.wakeup_async); } @@ -325,9 +390,11 @@ void us_internal_free_closed_sockets(struct us_loop_t *loop) { loop->data.closed_connecting_head = NULL; } +#ifdef LIBUS_USE_LIBUV void sweep_timer_cb(struct us_internal_callback_t *cb) { us_internal_timer_sweep(cb->loop); } +#endif __attribute__((always_inline)) long long us_loop_iteration_number(struct us_loop_t *loop) { return loop->data.iteration_nr; diff --git a/src/event_loop/EventLoopTimer.rs b/src/event_loop/EventLoopTimer.rs index c2397060c68b..52a2e1c11a5b 100644 --- a/src/event_loop/EventLoopTimer.rs +++ b/src/event_loop/EventLoopTimer.rs @@ -203,6 +203,10 @@ pub enum Tag { BunTest, EventLoopDelayMonitor, CronJob, + /// One-shot "collect soon" nudge from `GarbageCollectionController`. + GCTimer, + /// Repeating heap-growth poll from `GarbageCollectionController`. + GCRepeatingTimer, } impl Tag { @@ -212,6 +216,8 @@ impl Tag { | Tag::BunTest // for test timeouts | Tag::EventLoopDelayMonitor // probably important | Tag::StatWatcherScheduler + | Tag::GCTimer // internal + | Tag::GCRepeatingTimer // internal => false, _ => true, } diff --git a/src/jsc/GarbageCollectionController.rs b/src/jsc/GarbageCollectionController.rs index dce4f5f3d100..264b68eb33e7 100644 --- a/src/jsc/GarbageCollectionController.rs +++ b/src/jsc/GarbageCollectionController.rs @@ -20,36 +20,48 @@ use core::ffi::c_int; +use bun_core::{Timespec, TimespecMockMode}; +use bun_event_loop::EventLoopTimer::{ + EventLoopTimer, State as TimerState, Tag as TimerTag, Timespec as ElTimespec, +}; use bun_uws as uws; use crate::VM; use crate::virtual_machine::VirtualMachine; +/// Interval of the repeating timer once the heap has been stable for 30 ticks. +const SLOW_REPEAT_INTERVAL_MS: i32 = 30_000; + pub struct GarbageCollectionController { - // Raw FFI handle created by `uws::Timer::create_fallthrough` in `init`, - // freed in Drop. Stored as `Option>` (None = uninit). - pub gc_timer: Option>, + /// Intrusive node in the owning VM's timer heap (`Timer::All`). Embedded, + /// never separately allocated; `bun_runtime::dispatch` recovers + /// `*mut Self` from it via `container_of`. Neither timer keeps the event + /// loop alive (both were fallthrough `us_timer_t`s before). + pub gc_timer: EventLoopTimer, pub gc_last_heap_size: usize, pub gc_last_heap_size_on_repeating_timer: usize, pub heap_size_didnt_change_for_repeating_timer_ticks_count: u8, pub gc_timer_state: GCTimerState, - // Raw FFI handle created by `uws::Timer::create_fallthrough` in `init`, - // freed in Drop. - pub gc_repeating_timer: Option>, + pub gc_repeating_timer: EventLoopTimer, pub gc_timer_interval: i32, pub gc_repeating_timer_fast: bool, pub disabled: bool, } +bun_event_loop::impl_timer_owner!(GarbageCollectionController; + from_gc_timer_ptr => gc_timer, + from_gc_repeating_timer_ptr => gc_repeating_timer, +); + impl Default for GarbageCollectionController { fn default() -> Self { Self { - gc_timer: None, + gc_timer: EventLoopTimer::init_paused(TimerTag::GCTimer), gc_last_heap_size: 0, gc_last_heap_size_on_repeating_timer: 0, heap_size_didnt_change_for_repeating_timer_ticks_count: 0, gc_timer_state: GCTimerState::Pending, - gc_repeating_timer: None, + gc_repeating_timer: EventLoopTimer::init_paused(TimerTag::GCRepeatingTimer), gc_timer_interval: 0, gc_repeating_timer_fast: true, disabled: false, @@ -64,60 +76,45 @@ pub enum GcRepeatSetting { } impl GarbageCollectionController { - /// Recover `&mut Self` from a uws timer's ext slot. Single audited deref - /// for the two `extern "C"` callbacks below so they stay safe-bodied. + /// (Re)arm `timer` for `ms` from now in `vm`'s timer heap. /// - /// `timer` is the live uws timer whose ext data was set to - /// `*mut GarbageCollectionController` in [`Self::init`]; the controller is - /// a BACKREF that strictly outlives the timer (`deinit()` closes the timer - /// before `self` is dropped). `Timer` is an `opaque_ffi!` ZST handle, so - /// [`uws::Timer::opaque_mut`] is the centralised non-null deref proof for - /// the handle itself; only the recovered `*mut Self` needs the audited - /// deref below. - #[inline] - fn from_timer_ext<'a>(timer: *mut uws::Timer) -> &'a mut Self { - let ptr = uws::Timer::opaque_mut(timer).as_::<*mut Self>(); - // SAFETY: BACKREF — see doc comment above. - unsafe { &mut *ptr } - } - - /// Accessor for the init-once `gc_timer` handle. Consolidates the four - /// open-coded `(*self..unwrap().as_ptr())` deref sites into one - /// SAFETY block so call sites are safe. - #[inline] - fn gc_timer_mut(&mut self) -> &mut uws::Timer { - // SAFETY: `gc_timer` is set in `init()` (via `Timer::create_fallthrough`) - // before any code path reaches a deref site, and remains a live FFI - // handle until `deinit()` closes it. The Timer lives on the uws heap, - // not inside `self`, so the returned `&mut` cannot alias `self`. - unsafe { &mut *self.gc_timer.expect("gc_timer set in init()").as_ptr() } + /// Deadlines follow the mocked clock because `All::next` compares against + /// it: pinning them to real time would make every drain under a + /// fast-forwarded `jest.useFakeTimers()` clock re-fire immediately. + /// + /// # Safety + /// `vm` is the live VM owning this controller, with `runtime_state` + /// installed; `timer` is one of the two `EventLoopTimer` slots embedded in + /// that VM's `gc_controller` and is not otherwise borrowed here. + unsafe fn schedule(vm: *mut VirtualMachine, timer: *mut EventLoopTimer, ms: i32) { + let next = Timespec::now(TimespecMockMode::AllowMockedTime).add_ms(i64::from(ms)); + // SAFETY: per fn contract. `timer_remove`/`timer_insert` re-deref + // `timer` per-field, so no `&mut *timer` may be live across them. + unsafe { + if (*timer).state == TimerState::ACTIVE { + VirtualMachine::timer_remove(vm, timer); + } + (*timer).next = ElTimespec { + sec: next.sec, + nsec: next.nsec, + }; + VirtualMachine::timer_insert(vm, timer); + } } - /// Accessor for the init-once `gc_repeating_timer` handle (see - /// [`gc_timer_mut`] for the invariant). + /// The interval the repeating timer currently re-arms itself with. #[inline] - fn gc_repeating_timer_mut(&mut self) -> &mut uws::Timer { - // SAFETY: same invariant as `gc_timer_mut` — set in `init()`, live - // until `deinit()`, FFI-heap-owned. - unsafe { - &mut *self - .gc_repeating_timer - .expect("gc_repeating_timer set in init()") - .as_ptr() + fn repeat_interval(&self) -> i32 { + if self.gc_repeating_timer_fast { + self.gc_timer_interval + } else { + SLOW_REPEAT_INTERVAL_MS } } pub fn init(&mut self, vm: &mut VirtualMachine) { // SAFETY: uws::Loop::get() returns the live process-global loop. let actual = unsafe { &mut *uws::Loop::get() }; - self.gc_timer = Some(uws::Timer::create_fallthrough( - actual, - std::ptr::from_mut::(self), - )); - self.gc_repeating_timer = Some(uws::Timer::create_fallthrough( - actual, - std::ptr::from_mut::(self), - )); actual.internal_loop_data.jsc_vm = vm.jsc_vm.cast(); // `Transpiler::init` is deferred to the high-tier @@ -150,21 +147,25 @@ impl GarbageCollectionController { self.disabled = env.is_some_and(|e| e.has(b"BUN_GC_TIMER_DISABLE")); - if !self.disabled { - let ext = std::ptr::from_mut::(self); - self.gc_repeating_timer_mut().set( - ext, - Some(on_gc_repeating_timer), - gc_timer_interval, - gc_timer_interval, - ); + // `init_runtime_state` (and with it `Timer::All`) has already run: + // `VirtualMachine::init` calls it before `ensure_waker()`, which is + // what gets us here. A null state means there is no high tier at all + // (bun_jsc unit tests) and therefore no heap to schedule on. + if !self.disabled && !vm.runtime_state.is_null() { + let this: *mut Self = self; + let vm: *mut VirtualMachine = vm; + // SAFETY: the slot is an unaliased field of `*this`, which is + // embedded in `*vm`; the heap is live (checked above). + unsafe { Self::schedule(vm, &raw mut (*this).gc_repeating_timer, gc_timer_interval) }; } } pub fn schedule_gc_timer(&mut self) { self.gc_timer_state = GCTimerState::Scheduled; - let ext = std::ptr::from_mut::(self); - self.gc_timer_mut().set(ext, Some(on_gc_timer), 16, 0); + let this: *mut Self = self; + // SAFETY: JS-thread-only; the TLS VM is the one embedding `*this`, and + // `gc_timer` is an unaliased field of it. + unsafe { Self::schedule(VirtualMachine::get_mut_ptr(), &raw mut (*this).gc_timer, 16) }; } pub fn bun_vm(&mut self) -> &mut VirtualMachine { @@ -176,18 +177,29 @@ impl GarbageCollectionController { VirtualMachine::get().as_mut() } - /// Explicit teardown. Idempotent — `Drop` forwards here. - /// Kept as an inherent method because callers (web_worker, VM exit path) - /// need to release the uws timers before the owning VM storage is freed. + /// Explicit teardown. Idempotent — `Drop` forwards here. Must run while + /// the owning VM's `runtime_state` (and with it the timer heap) is still + /// installed; both call sites (`web_worker`, the VM exit path) do. pub fn deinit(&mut self) { - // SAFETY: timers were created via uws::Timer::create_fallthrough; close:: - // frees the fallthrough timer. `take()` ensures we close at most once. + let Some(vm) = VirtualMachine::get_or_null() else { + return; + }; + // SAFETY: `get_or_null` returned the live per-thread VM. + if unsafe { (*vm).runtime_state }.is_null() { + return; + } + let this: *mut Self = self; + // SAFETY: both slots are embedded fields of `*this`; `timer_remove` + // leaves them CANCELLED, so a second call (`Drop` after an explicit + // `deinit`) sees a non-ACTIVE state and does nothing. unsafe { - if let Some(t) = self.gc_timer.take() { - uws::Timer::close::(t.as_ptr()); - } - if let Some(t) = self.gc_repeating_timer.take() { - uws::Timer::close::(t.as_ptr()); + for timer in [ + &raw mut (*this).gc_timer, + &raw mut (*this).gc_repeating_timer, + ] { + if (*timer).state == TimerState::ACTIVE { + VirtualMachine::timer_remove(vm, timer); + } } } } @@ -204,19 +216,25 @@ impl GarbageCollectionController { // When the heap size is increasing, we always switch to fast mode // When the heap size has been the same or less for 30 seconds, we switch to slow mode pub fn update_gc_repeat_timer(&mut self, setting: GcRepeatSetting) { - if setting == GcRepeatSetting::Fast && !self.gc_repeating_timer_fast { - self.gc_repeating_timer_fast = true; - let ext = std::ptr::from_mut::(self); - let interval = self.gc_timer_interval; - self.gc_repeating_timer_mut() - .set(ext, Some(on_gc_repeating_timer), interval, interval); - self.heap_size_didnt_change_for_repeating_timer_ticks_count = 0; - } else if setting == GcRepeatSetting::Slow && self.gc_repeating_timer_fast { - self.gc_repeating_timer_fast = false; - let ext = std::ptr::from_mut::(self); - self.gc_repeating_timer_mut() - .set(ext, Some(on_gc_repeating_timer), 30_000, 30_000); - self.heap_size_didnt_change_for_repeating_timer_ticks_count = 0; + match setting { + GcRepeatSetting::Fast if !self.gc_repeating_timer_fast => { + self.gc_repeating_timer_fast = true; + } + GcRepeatSetting::Slow if self.gc_repeating_timer_fast => { + self.gc_repeating_timer_fast = false; + } + _ => return, + } + self.heap_size_didnt_change_for_repeating_timer_ticks_count = 0; + let interval = self.repeat_interval(); + let this: *mut Self = self; + // SAFETY: see `schedule_gc_timer`. + unsafe { + Self::schedule( + VirtualMachine::get_mut_ptr(), + &raw mut (*this).gc_repeating_timer, + interval, + ); } } @@ -272,38 +290,66 @@ impl GarbageCollectionController { vm.collect_async(); self.gc_last_heap_size = vm.block_bytes_allocated(); } -} -impl Drop for GarbageCollectionController { - fn drop(&mut self) { - self.deinit(); + /// `EventLoopTimer::fire` dispatch arm for [`TimerTag::GCTimer`]. + /// + /// # Safety + /// `this` is the container of the `gc_timer` slot just popped from the + /// timer heap: the live per-thread VM's `gc_controller`. + pub unsafe fn on_gc_timer(this: *mut Self) { + // SAFETY: per fn contract — `this` is live and unaliased here. + let this = unsafe { &mut *this }; + this.gc_timer.state = TimerState::FIRED; + if this.disabled { + return; + } + this.gc_timer_state = GCTimerState::RunOnNextTick; } -} -pub(crate) extern "C" fn on_gc_timer(timer: *mut uws::Timer) { - let this = GarbageCollectionController::from_timer_ext(timer); - if this.disabled { - return; + /// `EventLoopTimer::fire` dispatch arm for [`TimerTag::GCRepeatingTimer`]. + /// + /// # Safety + /// `this` is the container of the `gc_repeating_timer` slot just popped + /// from `vm`'s timer heap: `vm`'s own `gc_controller`. + pub unsafe fn on_gc_repeating_timer(this: *mut Self, vm: *mut VirtualMachine) { + { + // SAFETY: per fn contract — `this` is live; this borrow ends before + // the re-entrant `schedule()` below. + let me = unsafe { &mut *this }; + me.gc_repeating_timer.state = TimerState::FIRED; + if me.disabled { + return; + } + let prev_heap_size = me.gc_last_heap_size_on_repeating_timer; + me.perform_gc(); + me.gc_last_heap_size_on_repeating_timer = me.gc_last_heap_size; + if prev_heap_size == me.gc_last_heap_size_on_repeating_timer { + me.heap_size_didnt_change_for_repeating_timer_ticks_count = me + .heap_size_didnt_change_for_repeating_timer_ticks_count + .saturating_add(1); + if me.heap_size_didnt_change_for_repeating_timer_ticks_count >= 30 { + // make the timer interval longer + me.update_gc_repeat_timer(GcRepeatSetting::Slow); + } + } else { + me.heap_size_didnt_change_for_repeating_timer_ticks_count = 0; + me.update_gc_repeat_timer(GcRepeatSetting::Fast); + } + } + // `update_gc_repeat_timer` only re-arms across a Fast↔Slow transition, + // so the steady-state tick has to re-arm itself to keep repeating. + // SAFETY: per fn contract; re-arming an already-ACTIVE node is a + // remove+insert, which `schedule` handles. + unsafe { + let interval = (*this).repeat_interval(); + Self::schedule(vm, &raw mut (*this).gc_repeating_timer, interval); + } } - this.gc_timer_state = GCTimerState::RunOnNextTick; } -pub(crate) extern "C" fn on_gc_repeating_timer(timer: *mut uws::Timer) { - let this = GarbageCollectionController::from_timer_ext(timer); - let prev_heap_size = this.gc_last_heap_size_on_repeating_timer; - this.perform_gc(); - this.gc_last_heap_size_on_repeating_timer = this.gc_last_heap_size; - if prev_heap_size == this.gc_last_heap_size_on_repeating_timer { - this.heap_size_didnt_change_for_repeating_timer_ticks_count = this - .heap_size_didnt_change_for_repeating_timer_ticks_count - .saturating_add(1); - if this.heap_size_didnt_change_for_repeating_timer_ticks_count >= 30 { - // make the timer interval longer - this.update_gc_repeat_timer(GcRepeatSetting::Slow); - } - } else { - this.heap_size_didnt_change_for_repeating_timer_ticks_count = 0; - this.update_gc_repeat_timer(GcRepeatSetting::Fast); +impl Drop for GarbageCollectionController { + fn drop(&mut self) { + self.deinit(); } } diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 5af7f93e15e2..2a4bc6bcc417 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -1517,10 +1517,11 @@ impl VirtualMachine { // self.event_loop().tick(); if self.should_destruct_main_thread_on_exit() { + #[cfg(windows)] if let Some(t) = self.event_loop_mut().forever_timer.take() { // SAFETY: `t` is the live usockets timer created in - // `EventLoop::auto_tick`; `close::()` (fallthrough) - // frees it without re-entering the loop. + // `EventLoop::tick_possibly_forever`; `close::()` + // (fallthrough) frees it without re-entering the loop. unsafe { uws::Timer::close::(t.as_ptr()) }; } // Drain `TimeoutObject`s / `ImmediateObject`s from `All.timers` @@ -1660,6 +1661,11 @@ pub struct RuntimeHooks { /// `handleRejectedPromises` and falls through to `tickWithoutIdle` when /// idle — folding it into `auto_tick` would change shutdown semantics. pub auto_tick_active: unsafe fn(vm: *mut VirtualMachine), + /// `eventLoop().tickPossiblyForever()`'s poll step: block in the uSockets + /// loop, bounded by `Timer::All`'s soonest deadline, then drain whatever + /// came due. Unlike `auto_tick_active` it parks even when the loop has no + /// active handles — the caller has already pinned a poll for that. + pub poll_and_drain_timers: unsafe fn(vm: *mut VirtualMachine), /// `printException` / `printErrorlikeObject` — formats `value` (or its /// wrapped `JSC::Exception`) to stderr via `ConsoleObject::Formatter`. /// High tier @@ -2271,6 +2277,23 @@ impl VirtualMachine { } } + /// Park in the I/O loop until the soonest timer deadline (or forever when + /// the heap is empty), then fire whatever came due. Needs `Timer::All`, so + /// it dispatches through [`RuntimeHooks::poll_and_drain_timers`]. + #[inline] + pub fn poll_and_drain_timers(&mut self) { + if let Some(hooks) = runtime_hooks() { + // SAFETY: hook contract — `self` is the live per-thread VM. + unsafe { (hooks.poll_and_drain_timers)(self) }; + } else { + // No high tier (unit tests) — there is no timer heap to bound the + // wait with, so poll the I/O loop without idling. + let loop_ = self.event_loop_mut().usockets_loop(); + // SAFETY: `usockets_loop()` returns the live per-thread uws loop. + unsafe { (*loop_).tick_without_idle() }; + } + } + /// `reloadEntryPoint(entry_path)` — set `main`, generate the synthetic /// `bun:main` entry, run preloads, and kick off module evaluation. pub fn reload_entry_point( diff --git a/src/jsc/event_loop.rs b/src/jsc/event_loop.rs index 46763240a0e1..10c7ff59c8ec 100644 --- a/src/jsc/event_loop.rs +++ b/src/jsc/event_loop.rs @@ -75,8 +75,14 @@ pub struct EventLoop { // BACKREF — owning `*VirtualMachine` (EventLoop is a value field of it). pub virtual_machine: Option>, pub waker: Option, - // `?*uws.Timer` FFI handle. + /// `tick_possibly_forever()` has to park the loop even when nothing is + /// registered with it. libuv needs a live ref'd handle for that; epoll and + /// kqueue just need `num_polls != 0`, which is all this (no-op) timer ever + /// did for them. + #[cfg(windows)] pub forever_timer: Option>, + #[cfg(not(windows))] + pub holds_forever_poll: bool, pub deferred_tasks: DeferredTaskQueue::DeferredTaskQueue, #[cfg(windows)] // `?*uws.Loop` FFI handle. @@ -115,7 +121,10 @@ impl Default for EventLoop { global: None, virtual_machine: None, waker: None, + #[cfg(windows)] forever_timer: None, + #[cfg(not(windows))] + holds_forever_poll: false, deferred_tasks: DeferredTaskQueue::DeferredTaskQueue::default(), #[cfg(windows)] uws_loop: None, @@ -1051,6 +1060,40 @@ impl EventLoop { Ok(result) } + /// Keep one poll registered with the loop so the upcoming + /// `us_loop_run_bun_tick` actually parks instead of returning immediately + /// (it bails out on `num_polls == 0`). Idempotent. + #[cfg(not(windows))] + fn hold_forever_poll(&mut self, loop_: &mut uws::Loop) { + if !self.holds_forever_poll { + // Mirrors the non-fallthrough `us_create_timer` this replaced: + // `num_polls += 1`, never released (the timer was only closed at + // `global_exit`). + loop_.inc(); + self.holds_forever_poll = true; + } + } + + #[cfg(windows)] + fn hold_forever_poll(&mut self, loop_: &mut uws::Loop) { + if self.forever_timer.is_none() { + let mut t = uws::Timer::create( + loop_, + std::ptr::from_mut::(self).cast::(), + ); + // SAFETY: t is a fresh non-null timer handle + unsafe { + t.as_mut().set( + std::ptr::from_mut::(self).cast::(), + Some(noop_forever_timer), + 1000 * 60 * 4, + 1000 * 60 * 4, + ) + }; + self.forever_timer = Some(t); + } + } + pub fn tick_possibly_forever(&mut self) { let loop_ptr = self.usockets_loop(); // SAFETY: usockets_loop() returns a live uws loop for the VM lifetime. @@ -1065,27 +1108,15 @@ impl EventLoop { } if !loop_.is_active() { - if self.forever_timer.is_none() { - let mut t = uws::Timer::create( - loop_, - std::ptr::from_mut::(self).cast::(), - ); - // SAFETY: t is a fresh non-null timer handle - unsafe { - t.as_mut().set( - std::ptr::from_mut::(self).cast::(), - Some(noop_forever_timer), - 1000 * 60 * 4, - 1000 * 60 * 4, - ) - }; - self.forever_timer = Some(t); - } + self.hold_forever_poll(loop_); } self.process_gc_timer(); self.process_gc_timer(); - loop_.tick(); + // Park in the I/O loop, bounded by the soonest timer deadline, then + // fire whatever came due. The body needs `Timer::All`, so it goes + // through `RuntimeHooks::poll_and_drain_timers`. + self.vm_ref().as_mut().poll_and_drain_timers(); self.vm_ref().as_mut().on_after_event_loop(); self.tick_concurrent(); @@ -1177,6 +1208,7 @@ pub fn get_active_tasks(global_object: &JSGlobalObject, _frame: &CallFrame) -> J Ok(result) } +#[cfg(windows)] extern "C" fn noop_forever_timer(_: *mut uws::Timer) { // do nothing } diff --git a/src/runtime/dispatch.rs b/src/runtime/dispatch.rs index 9ddfc8c0454b..7881185ffb67 100644 --- a/src/runtime/dispatch.rs +++ b/src/runtime/dispatch.rs @@ -911,6 +911,8 @@ pub(crate) unsafe fn __bun_run_wtf_timer( /// `t` after the per-arm call returns. #[unsafe(no_mangle)] pub unsafe fn __bun_fire_timer(t: *mut EventLoopTimer, now: *const ElTimespec, vm: *mut ()) { + use bun_jsc::garbage_collection_controller::GarbageCollectionController; + use crate::timer::{ImmediateObject, TimeoutObject, TimerObjectInternals, WTFTimer}; /// Recover the embedding container from `t` (the popped timer slot). @@ -1090,6 +1092,17 @@ pub unsafe fn __bun_fire_timer(t: *mut EventLoopTimer, now: *const ElTimespec, v let c: *mut CronJob = owner!(CronJob, event_loop_timer); CronJob::on_timer_fire(c, VirtualMachine::get()); } + EventLoopTimerTag::GCTimer => { + let c = owner!(GarbageCollectionController, gc_timer); + // SAFETY: per fn contract — `c` is the VM's embedded controller. + unsafe { GarbageCollectionController::on_gc_timer(c) }; + } + EventLoopTimerTag::GCRepeatingTimer => { + let c = owner!(GarbageCollectionController, gc_repeating_timer); + // SAFETY: per fn contract — `c` is `vm`'s embedded controller, and + // `vm` owns the heap the node was just popped from. + unsafe { GarbageCollectionController::on_gc_repeating_timer(c, vm) }; + } } } diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index 7caf3e55e844..60ee97023666 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -1102,6 +1102,64 @@ unsafe fn auto_tick_active(vm: *mut VirtualMachine) { unsafe { (*vm).on_after_event_loop() }; } +/// `RuntimeHooks::poll_and_drain_timers` — the poll step of +/// [`bun_jsc::event_loop::EventLoop::tick_possibly_forever`]. Blocks in the +/// uSockets loop bounded by `Timer::All`'s soonest deadline (forever when the +/// heap is empty) and fires whatever came due. +/// +/// Deliberately not gated on `loop.is_active()`: the caller pins a poll so the +/// tick parks, which is the whole point of `tick_possibly_forever`. Without the +/// drain, an already-overdue timer would leave `get_timeout` returning a zero +/// deadline and spin the caller's `loop { … tick_possibly_forever() }`. +/// +/// # Safety +/// `vm` is the live per-thread VM. +unsafe fn poll_and_drain_timers(vm: *mut VirtualMachine) { + // SAFETY: per fn contract — `vm` is the live per-thread VM. + let el: *mut bun_jsc::event_loop::EventLoop = unsafe { &*vm }.event_loop; + // SAFETY: `el` is the live per-thread event loop (field of `*vm`). + let loop_ = unsafe { (*el).usockets_loop() }; + + let state = runtime_state(); + if state.is_null() { + // SAFETY: `loop_` is the live per-thread uws loop. + unsafe { (*loop_).tick_without_idle() }; + return; + } + + // SAFETY: `el` is the live per-thread event loop. + let has_pending_immediate = !unsafe { &*el }.immediate_tasks.is_empty(); + // SAFETY: `loop_` is the live per-thread uws loop. + let quic_next_tick_us = unsafe { + let ild = &(*loop_).internal_loop_data; + if ild.quic_head.is_null() { + None + } else { + Some(ild.quic_next_tick_us) + } + }; + let mut timespec = bun_core::Timespec { sec: 0, nsec: 0 }; + // SAFETY: `state` is the live per-thread `RuntimeState`; see the Note on + // `auto_tick` re: aliased-&mut across `fire()`. + let have_timeout = unsafe { + timer::All::get_timeout( + &mut (*state).timer, + &mut timespec, + has_pending_immediate, + quic_next_tick_us, + vm.cast(), + ) + }; + // SAFETY: `loop_` is the live per-thread uws loop. + unsafe { (*loop_).tick_with_timeout(if have_timeout { Some(×pec) } else { None }) }; + + #[cfg(unix)] + // SAFETY: see above. + unsafe { + timer::All::drain_timers(&mut (*state).timer, vm.cast()) + }; +} + /// `printException` / `printErrorlikeObject` — formats `value` to stderr via /// `ConsoleObject::Formatter`. Dispatched here so the high tier owns the /// formatter. @@ -1393,6 +1451,7 @@ pub(crate) static __BUN_RUNTIME_HOOKS: RuntimeHooks = RuntimeHooks { ensure_debugger, auto_tick, auto_tick_active, + poll_and_drain_timers, print_exception, timer_insert, timer_remove, diff --git a/src/uws/lib.rs b/src/uws/lib.rs index 56b3965368cc..a098654536d1 100644 --- a/src/uws/lib.rs +++ b/src/uws/lib.rs @@ -17,9 +17,11 @@ use bun_core::ZStr; // `SocketAddress`) stay defined here; `bun_uws_sys::socket` has lifetime- // bearing variants of the same names that are not yet reconciled. +#[cfg(windows)] +pub use bun_uws_sys::Timer; pub use bun_uws_sys::{ AnyWebSocket, BodyReaderMixin, ConnectingSocket, ListenSocket, NewApp, RawWebSocket, Request, - Timer, WebSocketBehavior, us_socket_stream_buffer_t, us_socket_t, uws_res, + WebSocketBehavior, us_socket_stream_buffer_t, us_socket_t, uws_res, }; /// `#[uws_callback]` — wraps a `&self`/`&mut self` method in an `extern "C"` diff --git a/src/uws_sys/InternalLoopData.rs b/src/uws_sys/InternalLoopData.rs index 74df8437d991..d71c2ea4076d 100644 --- a/src/uws_sys/InternalLoopData.rs +++ b/src/uws_sys/InternalLoopData.rs @@ -1,6 +1,8 @@ use core::ffi::{c_char, c_int, c_void}; -use crate::{ConnectingSocket, Loop, SocketGroup, Timer, udp, us_socket_t}; +#[cfg(windows)] +use crate::Timer; +use crate::{ConnectingSocket, Loop, SocketGroup, udp, us_socket_t}; /// Layout placeholder for the `mutex` field of `us_internal_loop_data_t`. /// Must match `zig_mutex_t` in `packages/bun-usockets/src/internal/loop_data.h` @@ -22,12 +24,21 @@ bun_opaque::opaque_ffi! { #[repr(C)] pub struct InternalLoopData { + /// libuv only: the `us_timer_t` driving the 4s socket-timeout sweep. + #[cfg(windows)] pub sweep_timer: *mut Timer, + /// Absolute `CLOCK_MONOTONIC` nanoseconds of the next socket-timeout + /// sweep, or `-1` when no sockets are linked. epoll/kqueue has no + /// `us_timer_t`: C folds this straight into the poll timeout. + #[cfg(not(windows))] + pub sweep_next_tick_ns: i64, pub sweep_timer_count: i32, pub wakeup_async: *mut us_internal_async, pub head: *mut SocketGroup, pub quic_head: *mut c_void, pub quic_next_tick_us: i64, + /// libuv only: see `quic_next_tick_us`. + #[cfg(windows)] pub quic_timer: *mut Timer, pub iterator: *mut SocketGroup, pub recv_buf: *mut u8, diff --git a/src/uws_sys/Timer.rs b/src/uws_sys/Timer.rs index 0dcfa09ca714..6306c17edab4 100644 --- a/src/uws_sys/Timer.rs +++ b/src/uws_sys/Timer.rs @@ -11,9 +11,9 @@ bun_core::declare_scope!(uws, visible); // // Use `JSC.EventLoopTimer` instead. // -// This code will be deleted eventually! It is very inefficient on POSIX. On -// Linux, it holds an entire file descriptor for every single timer. On macOS, -// it's several system calls. +// Windows (libuv) only. On epoll/kqueue this type no longer exists: it held an +// entire file descriptor per timer on Linux, and cost several system calls per +// arm on macOS. bun_opaque::opaque_ffi! { pub struct Timer; } impl Timer { @@ -37,26 +37,6 @@ impl Timer { }) } - pub fn create_fallthrough(loop_: &mut Loop, _ptr: T) -> NonNull { - // never fallthrough poll - // the problem is uSockets hardcodes it on the other end - // so we can never free non-fallthrough polls - // SAFETY: `loop_` is a valid loop pointer. - let t = unsafe { - us_create_timer( - loop_, - 1, - c_uint::try_from(size_of::()).expect("int cast"), - ) - }; - NonNull::new(t).unwrap_or_else(|| { - panic!( - "us_create_timer: returned null: {}", - std::io::Error::last_os_error().raw_os_error().unwrap_or(0) - ) - }) - } - pub fn set( &mut self, ptr: T, @@ -82,30 +62,6 @@ impl Timer { // use `this` afterward). unsafe { us_timer_close(this, FALLTHROUGH as i32) }; } - - pub fn ext(&mut self) -> Option<&mut T> { - unsafe { - // SAFETY: us_timer_ext returns a pointer to the ext slot (`*?*anyopaque`); - // deref + unwrap, then cast to *mut T. Caller guarantees T matches the - // type used at create()/set(). - let slot: *mut Option> = us_timer_ext(self).cast(); - Some(&mut *(*slot).expect("unreachable").as_ptr().cast::()) - } - } - - // Named `as_` because `as` is a Rust keyword. - pub fn as_(&mut self) -> T { - unsafe { - // SAFETY: the ext slot was allocated with `size_of::()` and - // written via [`set`] as a bare `T`, so read it as `T` directly. - // Wrapping in `Option` here would over-read and misinterpret - // the bytes (`Option<*mut T>` has no niche, so it is two words - // while the slot is one). Callers pass pointer-ish `T` and - // tolerate a (debug-asserted) null read. - let slot: *mut T = us_timer_ext(self).cast(); - slot.read() - } - } } unsafe extern "C" { @@ -124,5 +80,4 @@ unsafe extern "C" { ms: i32, repeat_ms: i32, ); - pub safe fn us_timer_loop(t: &mut Timer) -> *mut Loop; } diff --git a/src/uws_sys/lib.rs b/src/uws_sys/lib.rs index 164c88481c81..ad2b00a6f7ed 100644 --- a/src/uws_sys/lib.rs +++ b/src/uws_sys/lib.rs @@ -379,6 +379,10 @@ pub mod socket_group; pub mod socket_kind; #[path = "thunk.rs"] pub mod thunk; +// `us_timer_t` only exists on the libuv backend: on epoll/kqueue it cost an +// entire file descriptor (timerfd) or a pair of kevent64 syscalls per arm, so +// it is gone. Schedule on `bun_event_loop::EventLoopTimer` instead. +#[cfg(windows)] #[path = "Timer.rs"] pub mod timer; #[path = "udp.rs"] @@ -444,6 +448,7 @@ pub use internal_loop_data::InternalLoopData; pub use loop_::WindowsLoop; pub use loop_::{Loop, PosixLoop}; pub use socket_kind::SocketKind; +#[cfg(windows)] pub use timer::Timer; #[cfg(not(windows))] pub type WindowsLoop = loop_::PosixLoop; // unified on non-Windows diff --git a/test/js/bun/event-loop-timers.test.ts b/test/js/bun/event-loop-timers.test.ts new file mode 100644 index 000000000000..2e8caf7919fc --- /dev/null +++ b/test/js/bun/event-loop-timers.test.ts @@ -0,0 +1,96 @@ +// uSockets' us_timer_t no longer exists on epoll/kqueue: everything that used +// to need one now schedules on Bun's own event-loop timer heap. On Linux that +// means the process must not hold a single timerfd, no matter how much of the +// runtime is spun up. It used to hold four: the JS thread's socket-timeout +// sweep plus its two GC timers, and one more sweep on the HTTP client thread. +import { expect, test } from "bun:test"; +import { bunEnv, bunExe } from "harness"; + +const COUNT_TIMERFDS = /* js */ ` + function countTimerFds() { + const { readdirSync, readlinkSync } = require("fs"); + let n = 0; + for (const fd of readdirSync("/proc/self/fd")) { + let link; + try { link = readlinkSync("/proc/self/fd/" + fd); } catch { continue; } + if (link.startsWith("anon_inode:[timerfd]")) n++; + } + return n; + } +`; + +async function countTimerFdsIn(body: string) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", COUNT_TIMERFDS + body], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout: stdout.trim(), stderr, exitCode }; +} + +test.concurrent.skipIf(process.platform !== "linux")("idle runtime holds no timerfd", async () => { + // Allocating churns the heap, which is what arms the GC controller's timers. + const { stdout, exitCode } = await countTimerFdsIn(` + for (let i = 0; i < 100; i++) new Uint8Array(4096); + console.log(countTimerFds()); + `); + expect(stdout).toBe("0"); + expect(exitCode).toBe(0); +}); + +test.concurrent.skipIf(process.platform !== "linux")( + "a live server, the HTTP client thread, and JS timers hold no timerfd", + async () => { + const { stdout, exitCode } = await countTimerFdsIn(` + using server = Bun.serve({ port: 0, fetch: () => new Response("ok") }); + // fetch() spins up the HTTP client thread, which owns a second uws loop + // and therefore a second socket-timeout sweep. + const res = await fetch(server.url); + if ((await res.text()) !== "ok") throw new Error("bad response"); + const interval = setInterval(() => {}, 10); + const timeout = setTimeout(() => {}, 60_000); + await Bun.sleep(1); + console.log(countTimerFds()); + clearInterval(interval); + clearTimeout(timeout); + `); + expect(stdout).toBe("0"); + expect(exitCode).toBe(0); + }, +); + +// The sweep that expires idle sockets used to ride on that timerfd. It is now a +// deadline folded into the epoll/kqueue wait, so prove it still fires. uSockets' +// sweep granularity is 4 seconds (LIBUS_TIMEOUT_GRANULARITY), which is the floor +// on how fast this can be observed — hence the explicit budget, matching the +// idleTimeout tests in test/js/bun/http/serve.test.ts. +test.concurrent( + "Bun.serve idleTimeout still expires an idle connection", + async () => { + using server = Bun.serve({ + port: 0, + idleTimeout: 1, + fetch: () => new Response("ok"), + }); + + const { promise, resolve, reject } = Promise.withResolvers(); + await Bun.connect({ + hostname: server.hostname, + port: server.port, + socket: { + // An incomplete request line: the server never replies, so the sweep is + // the only thing that can close this connection. + open: socket => void socket.write("GET / HTT"), + close: () => resolve("closed"), + error: (_socket, err) => reject(err), + connectError: (_socket, err) => reject(err), + data: () => reject(new Error("server should not have responded")), + }, + }); + + expect(await promise).toBe("closed"); + }, + 30_000, +); From c859391e236660888a8e16acec19a65904fb595f Mon Sep 17 00:00:00 2001 From: robobun Date: Sun, 5 Jul 2026 07:27:30 +0000 Subject: [PATCH 02/13] gc: match #32447's EventLoopTimer naming and lazy-arm Converge the GarbageCollectionController half of this change on the shape Jarred already landed in #32447 so whichever goes first rebases cleanly: GcOneShot/GcRepeating tags, arm(), and arming the repeating timer on the first process_gc_timer() tick rather than in init() (keeps the timer heap untouched until the event loop is wired, which matters for Windows' ensure_uv_timer). --- src/event_loop/EventLoopTimer.rs | 9 +- src/jsc/GarbageCollectionController.rs | 219 ++++++++++--------------- src/runtime/dispatch.rs | 26 +-- 3 files changed, 107 insertions(+), 147 deletions(-) diff --git a/src/event_loop/EventLoopTimer.rs b/src/event_loop/EventLoopTimer.rs index 52a2e1c11a5b..2c084578d660 100644 --- a/src/event_loop/EventLoopTimer.rs +++ b/src/event_loop/EventLoopTimer.rs @@ -203,10 +203,8 @@ pub enum Tag { BunTest, EventLoopDelayMonitor, CronJob, - /// One-shot "collect soon" nudge from `GarbageCollectionController`. - GCTimer, - /// Repeating heap-growth poll from `GarbageCollectionController`. - GCRepeatingTimer, + GcOneShot, + GcRepeating, } impl Tag { @@ -216,8 +214,7 @@ impl Tag { | Tag::BunTest // for test timeouts | Tag::EventLoopDelayMonitor // probably important | Tag::StatWatcherScheduler - | Tag::GCTimer // internal - | Tag::GCRepeatingTimer // internal + | Tag::GcOneShot | Tag::GcRepeating // internal GC pacing => false, _ => true, } diff --git a/src/jsc/GarbageCollectionController.rs b/src/jsc/GarbageCollectionController.rs index 264b68eb33e7..548374f5af07 100644 --- a/src/jsc/GarbageCollectionController.rs +++ b/src/jsc/GarbageCollectionController.rs @@ -20,35 +20,33 @@ use core::ffi::c_int; -use bun_core::{Timespec, TimespecMockMode}; -use bun_event_loop::EventLoopTimer::{ - EventLoopTimer, State as TimerState, Tag as TimerTag, Timespec as ElTimespec, -}; +use bun_event_loop::EventLoopTimer::{EventLoopTimer, State as TimerState, Tag as TimerTag}; use bun_uws as uws; use crate::VM; use crate::virtual_machine::VirtualMachine; /// Interval of the repeating timer once the heap has been stable for 30 ticks. -const SLOW_REPEAT_INTERVAL_MS: i32 = 30_000; +const SLOW_REPEAT_INTERVAL_MS: i64 = 30_000; pub struct GarbageCollectionController { - /// Intrusive node in the owning VM's timer heap (`Timer::All`). Embedded, - /// never separately allocated; `bun_runtime::dispatch` recovers - /// `*mut Self` from it via `container_of`. Neither timer keeps the event - /// loop alive (both were fallthrough `us_timer_t`s before). + /// 16ms one-shot: when it fires, the next `process_gc_timer()` will + /// `collect_async()`. Embedded intrusive node — re-armed via the in-process + /// timer heap (no `timerfd_settime`/`epoll_ctl` per re-arm). pub gc_timer: EventLoopTimer, + /// 1s/30s repeating: drives `perform_gc()` and the fast↔slow backoff. + pub gc_repeating_timer: EventLoopTimer, pub gc_last_heap_size: usize, pub gc_last_heap_size_on_repeating_timer: usize, pub heap_size_didnt_change_for_repeating_timer_ticks_count: u8, pub gc_timer_state: GCTimerState, - pub gc_repeating_timer: EventLoopTimer, pub gc_timer_interval: i32, pub gc_repeating_timer_fast: bool, pub disabled: bool, } -bun_event_loop::impl_timer_owner!(GarbageCollectionController; +bun_event_loop::impl_timer_owner!( + GarbageCollectionController; from_gc_timer_ptr => gc_timer, from_gc_repeating_timer_ptr => gc_repeating_timer, ); @@ -56,12 +54,12 @@ bun_event_loop::impl_timer_owner!(GarbageCollectionController; impl Default for GarbageCollectionController { fn default() -> Self { Self { - gc_timer: EventLoopTimer::init_paused(TimerTag::GCTimer), + gc_timer: EventLoopTimer::init_paused(TimerTag::GcOneShot), + gc_repeating_timer: EventLoopTimer::init_paused(TimerTag::GcRepeating), gc_last_heap_size: 0, gc_last_heap_size_on_repeating_timer: 0, heap_size_didnt_change_for_repeating_timer_ticks_count: 0, gc_timer_state: GCTimerState::Pending, - gc_repeating_timer: EventLoopTimer::init_paused(TimerTag::GCRepeatingTimer), gc_timer_interval: 0, gc_repeating_timer_fast: true, disabled: false, @@ -76,42 +74,6 @@ pub enum GcRepeatSetting { } impl GarbageCollectionController { - /// (Re)arm `timer` for `ms` from now in `vm`'s timer heap. - /// - /// Deadlines follow the mocked clock because `All::next` compares against - /// it: pinning them to real time would make every drain under a - /// fast-forwarded `jest.useFakeTimers()` clock re-fire immediately. - /// - /// # Safety - /// `vm` is the live VM owning this controller, with `runtime_state` - /// installed; `timer` is one of the two `EventLoopTimer` slots embedded in - /// that VM's `gc_controller` and is not otherwise borrowed here. - unsafe fn schedule(vm: *mut VirtualMachine, timer: *mut EventLoopTimer, ms: i32) { - let next = Timespec::now(TimespecMockMode::AllowMockedTime).add_ms(i64::from(ms)); - // SAFETY: per fn contract. `timer_remove`/`timer_insert` re-deref - // `timer` per-field, so no `&mut *timer` may be live across them. - unsafe { - if (*timer).state == TimerState::ACTIVE { - VirtualMachine::timer_remove(vm, timer); - } - (*timer).next = ElTimespec { - sec: next.sec, - nsec: next.nsec, - }; - VirtualMachine::timer_insert(vm, timer); - } - } - - /// The interval the repeating timer currently re-arms itself with. - #[inline] - fn repeat_interval(&self) -> i32 { - if self.gc_repeating_timer_fast { - self.gc_timer_interval - } else { - SLOW_REPEAT_INTERVAL_MS - } - } - pub fn init(&mut self, vm: &mut VirtualMachine) { // SAFETY: uws::Loop::get() returns the live process-global loop. let actual = unsafe { &mut *uws::Loop::get() }; @@ -146,26 +108,30 @@ impl GarbageCollectionController { } self.disabled = env.is_some_and(|e| e.has(b"BUN_GC_TIMER_DISABLE")); + } - // `init_runtime_state` (and with it `Timer::All`) has already run: - // `VirtualMachine::init` calls it before `ensure_waker()`, which is - // what gets us here. A null state means there is no high tier at all - // (bun_jsc unit tests) and therefore no heap to schedule on. - if !self.disabled && !vm.runtime_state.is_null() { - let this: *mut Self = self; - let vm: *mut VirtualMachine = vm; - // SAFETY: the slot is an unaliased field of `*this`, which is - // embedded in `*vm`; the heap is live (checked above). - unsafe { Self::schedule(vm, &raw mut (*this).gc_repeating_timer, gc_timer_interval) }; + /// Remove `t` from the heap if linked, set its deadline to `now + ms`, and + /// insert. JS-thread only. + /// + /// The deadline follows the mocked clock because `All::next` compares + /// against it: a real-time deadline under a fast-forwarded + /// `jest.useFakeTimers()` clock would re-fire on every drain. + fn arm(vm: *mut VirtualMachine, t: *mut EventLoopTimer, ms: i64) { + // SAFETY: `t` is one of the two embedded nodes of the per-VM controller, + // address-stable for the VM lifetime; JS-thread only. `timer_remove` / + // `timer_insert` re-deref `t` per-field, so no `&mut *t` is held here. + unsafe { + if (*t).state == TimerState::ACTIVE { + VirtualMachine::timer_remove(vm, t); + } + (*t).next = bun_core::Timespec::now_allow_mocked_time().add_ms(ms); + VirtualMachine::timer_insert(vm, t); } } pub fn schedule_gc_timer(&mut self) { self.gc_timer_state = GCTimerState::Scheduled; - let this: *mut Self = self; - // SAFETY: JS-thread-only; the TLS VM is the one embedding `*this`, and - // `gc_timer` is an unaliased field of it. - unsafe { Self::schedule(VirtualMachine::get_mut_ptr(), &raw mut (*this).gc_timer, 16) }; + Self::arm(VirtualMachine::get_mut_ptr(), &raw mut self.gc_timer, 16); } pub fn bun_vm(&mut self) -> &mut VirtualMachine { @@ -177,28 +143,22 @@ impl GarbageCollectionController { VirtualMachine::get().as_mut() } - /// Explicit teardown. Idempotent — `Drop` forwards here. Must run while - /// the owning VM's `runtime_state` (and with it the timer heap) is still - /// installed; both call sites (`web_worker`, the VM exit path) do. + /// Explicit teardown. Idempotent — `Drop` forwards here. + /// Kept as an inherent method because callers (web_worker, VM exit path) + /// must unlink the timers from the per-VM heap before that heap is dropped + /// in `deinit_runtime_state`. pub fn deinit(&mut self) { + // A `Drop` that runs after the VM left its thread-local slot has no heap + // left to unlink from — and the nodes die with the VM anyway. let Some(vm) = VirtualMachine::get_or_null() else { return; }; - // SAFETY: `get_or_null` returned the live per-thread VM. - if unsafe { (*vm).runtime_state }.is_null() { - return; - } - let this: *mut Self = self; - // SAFETY: both slots are embedded fields of `*this`; `timer_remove` - // leaves them CANCELLED, so a second call (`Drop` after an explicit - // `deinit`) sees a non-ACTIVE state and does nothing. - unsafe { - for timer in [ - &raw mut (*this).gc_timer, - &raw mut (*this).gc_repeating_timer, - ] { - if (*timer).state == TimerState::ACTIVE { - VirtualMachine::timer_remove(vm, timer); + for t in [&raw mut self.gc_timer, &raw mut self.gc_repeating_timer] { + // SAFETY: JS-thread; nodes are linked iff state == ACTIVE, and + // `timer_remove` leaves them CANCELLED so a second call is a no-op. + unsafe { + if (*t).state == TimerState::ACTIVE { + VirtualMachine::timer_remove(vm, t); } } } @@ -216,23 +176,24 @@ impl GarbageCollectionController { // When the heap size is increasing, we always switch to fast mode // When the heap size has been the same or less for 30 seconds, we switch to slow mode pub fn update_gc_repeat_timer(&mut self, setting: GcRepeatSetting) { - match setting { + let (interval, want_fast) = match setting { GcRepeatSetting::Fast if !self.gc_repeating_timer_fast => { - self.gc_repeating_timer_fast = true; + (i64::from(self.gc_timer_interval), true) } GcRepeatSetting::Slow if self.gc_repeating_timer_fast => { - self.gc_repeating_timer_fast = false; + (SLOW_REPEAT_INTERVAL_MS, false) } _ => return, - } + }; + self.gc_repeating_timer_fast = want_fast; self.heap_size_didnt_change_for_repeating_timer_ticks_count = 0; - let interval = self.repeat_interval(); - let this: *mut Self = self; - // SAFETY: see `schedule_gc_timer`. - unsafe { - Self::schedule( + // When called from inside `on_gc_repeating_timer` the node has just + // been popped (state set to FIRED at the top of the callback) — skip + // the re-arm; the callback's tail re-inserts at the new interval. + if self.gc_repeating_timer.state == TimerState::ACTIVE { + Self::arm( VirtualMachine::get_mut_ptr(), - &raw mut (*this).gc_repeating_timer, + &raw mut self.gc_repeating_timer, interval, ); } @@ -243,6 +204,16 @@ impl GarbageCollectionController { if self.disabled { return; } + // Lazy-arm the repeating timer on the first event-loop tick instead of + // in `init()`, so the timer heap is never touched before the event loop + // is fully wired (matters for Windows' `ensure_uv_timer`). + if self.gc_repeating_timer.state == TimerState::PENDING { + Self::arm( + VirtualMachine::get_mut_ptr(), + &raw mut self.gc_repeating_timer, + i64::from(self.gc_timer_interval), + ); + } let vm = VirtualMachine::get().jsc_vm(); self.process_gc_timer_with_heap_size(vm, vm.block_bytes_allocated()); } @@ -291,13 +262,12 @@ impl GarbageCollectionController { self.gc_last_heap_size = vm.block_bytes_allocated(); } - /// `EventLoopTimer::fire` dispatch arm for [`TimerTag::GCTimer`]. + /// `Tag::GcOneShot` fire body. /// /// # Safety - /// `this` is the container of the `gc_timer` slot just popped from the - /// timer heap: the live per-thread VM's `gc_controller`. + /// `this` is the live per-VM controller; JS-thread only. pub unsafe fn on_gc_timer(this: *mut Self) { - // SAFETY: per fn contract — `this` is live and unaliased here. + // SAFETY: per fn contract. let this = unsafe { &mut *this }; this.gc_timer.state = TimerState::FIRED; if this.disabled { @@ -306,44 +276,37 @@ impl GarbageCollectionController { this.gc_timer_state = GCTimerState::RunOnNextTick; } - /// `EventLoopTimer::fire` dispatch arm for [`TimerTag::GCRepeatingTimer`]. + /// `Tag::GcRepeating` fire body. /// /// # Safety - /// `this` is the container of the `gc_repeating_timer` slot just popped - /// from `vm`'s timer heap: `vm`'s own `gc_controller`. + /// `this` is the live per-VM controller; `vm` is the per-thread VM. pub unsafe fn on_gc_repeating_timer(this: *mut Self, vm: *mut VirtualMachine) { - { - // SAFETY: per fn contract — `this` is live; this borrow ends before - // the re-entrant `schedule()` below. - let me = unsafe { &mut *this }; - me.gc_repeating_timer.state = TimerState::FIRED; - if me.disabled { - return; - } - let prev_heap_size = me.gc_last_heap_size_on_repeating_timer; - me.perform_gc(); - me.gc_last_heap_size_on_repeating_timer = me.gc_last_heap_size; - if prev_heap_size == me.gc_last_heap_size_on_repeating_timer { - me.heap_size_didnt_change_for_repeating_timer_ticks_count = me - .heap_size_didnt_change_for_repeating_timer_ticks_count - .saturating_add(1); - if me.heap_size_didnt_change_for_repeating_timer_ticks_count >= 30 { - // make the timer interval longer - me.update_gc_repeat_timer(GcRepeatSetting::Slow); - } - } else { - me.heap_size_didnt_change_for_repeating_timer_ticks_count = 0; - me.update_gc_repeat_timer(GcRepeatSetting::Fast); + // SAFETY: per fn contract. + let this = unsafe { &mut *this }; + this.gc_repeating_timer.state = TimerState::FIRED; + + let prev_heap_size = this.gc_last_heap_size_on_repeating_timer; + this.perform_gc(); + this.gc_last_heap_size_on_repeating_timer = this.gc_last_heap_size; + if prev_heap_size == this.gc_last_heap_size_on_repeating_timer { + this.heap_size_didnt_change_for_repeating_timer_ticks_count = this + .heap_size_didnt_change_for_repeating_timer_ticks_count + .saturating_add(1); + if this.heap_size_didnt_change_for_repeating_timer_ticks_count >= 30 { + // make the timer interval longer + this.update_gc_repeat_timer(GcRepeatSetting::Slow); } + } else { + this.heap_size_didnt_change_for_repeating_timer_ticks_count = 0; + this.update_gc_repeat_timer(GcRepeatSetting::Fast); } - // `update_gc_repeat_timer` only re-arms across a Fast↔Slow transition, - // so the steady-state tick has to re-arm itself to keep repeating. - // SAFETY: per fn contract; re-arming an already-ACTIVE node is a - // remove+insert, which `schedule` handles. - unsafe { - let interval = (*this).repeat_interval(); - Self::schedule(vm, &raw mut (*this).gc_repeating_timer, interval); - } + + let interval = if this.gc_repeating_timer_fast { + i64::from(this.gc_timer_interval) + } else { + SLOW_REPEAT_INTERVAL_MS + }; + Self::arm(vm, &raw mut this.gc_repeating_timer, interval); } } diff --git a/src/runtime/dispatch.rs b/src/runtime/dispatch.rs index 7881185ffb67..22049be0b6ab 100644 --- a/src/runtime/dispatch.rs +++ b/src/runtime/dispatch.rs @@ -164,6 +164,7 @@ use bun_sql_jsc::postgres::PostgresSQLConnection; use crate::test_runner::bun_test::{BunTest, BunTestPtr}; use crate::timer::{DateHeaderTimer, EventLoopDelayMonitor}; use bun_jsc::abort_signal::Timeout as AbortSignalTimeout; +use bun_jsc::garbage_collection_controller::GarbageCollectionController; #[cfg(not(windows))] use bun_io::pipe_writer::PosixPipeWriter; // brings `on_poll` into scope for FileSinkPoll/StaticPipeWriterPoll/etc. @@ -911,8 +912,6 @@ pub(crate) unsafe fn __bun_run_wtf_timer( /// `t` after the per-arm call returns. #[unsafe(no_mangle)] pub unsafe fn __bun_fire_timer(t: *mut EventLoopTimer, now: *const ElTimespec, vm: *mut ()) { - use bun_jsc::garbage_collection_controller::GarbageCollectionController; - use crate::timer::{ImmediateObject, TimeoutObject, TimerObjectInternals, WTFTimer}; /// Recover the embedding container from `t` (the popped timer slot). @@ -977,6 +976,18 @@ pub unsafe fn __bun_fire_timer(t: *mut EventLoopTimer, now: *const ElTimespec, v AbortSignalTimeout::run(c, vm) }) } + EventLoopTimerTag::GcOneShot => { + timer_arm!(GarbageCollectionController, gc_timer, |c, _now, _vm| { + GarbageCollectionController::on_gc_timer(c) + }) + } + EventLoopTimerTag::GcRepeating => { + timer_arm!( + GarbageCollectionController, + gc_repeating_timer, + |c, _now, vm| GarbageCollectionController::on_gc_repeating_timer(c, vm) + ) + } EventLoopTimerTag::DateHeaderTimer => { timer_arm!(DateHeaderTimer, event_loop_timer, |c, _now, vm| (*c) .run(&mut *vm)) @@ -1092,17 +1103,6 @@ pub unsafe fn __bun_fire_timer(t: *mut EventLoopTimer, now: *const ElTimespec, v let c: *mut CronJob = owner!(CronJob, event_loop_timer); CronJob::on_timer_fire(c, VirtualMachine::get()); } - EventLoopTimerTag::GCTimer => { - let c = owner!(GarbageCollectionController, gc_timer); - // SAFETY: per fn contract — `c` is the VM's embedded controller. - unsafe { GarbageCollectionController::on_gc_timer(c) }; - } - EventLoopTimerTag::GCRepeatingTimer => { - let c = owner!(GarbageCollectionController, gc_repeating_timer); - // SAFETY: per fn contract — `c` is `vm`'s embedded controller, and - // `vm` owns the heap the node was just popped from. - unsafe { GarbageCollectionController::on_gc_repeating_timer(c, vm) }; - } } } From e8925fd171d8d81a85fc4774374e7d7a726f4753 Mon Sep 17 00:00:00 2001 From: robobun Date: Sun, 5 Jul 2026 08:30:47 +0000 Subject: [PATCH 03/13] gc: unlink the GC timers before JSC teardown, not after MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GC controller's timers are now heap nodes, so gc_controller.deinit() removes them from the per-VM timer heap. Both teardown paths called it *after* JSC teardown, which is where ~RunLoop::Timer frees the WTFTimer nodes sharing that heap — and WTFTimer::cancel skips its unlink once the script execution context is unregistered, so those nodes are freed while still linked. Removing a GC node afterwards walks into freed siblings: WRITE of size 8 ... heap-use-after-free #0 Intrusive::combine_siblings src/io/heap.rs:255 #2 Intrusive::remove src/io/heap.rs:166 #5 All::remove src/runtime/timer/mod.rs:780 #8 GarbageCollectionController::deinit #9 VirtualMachine::global_exit freed by: #7 Box::drop #10 WTFTimer::deinit #12 WTF::RunLoop::TimerBase::~TimerBase() Nothing touched the heap that late before, because the nodes were uws timers. Move deinit() next to cancel_all_timers in both paths, which is the window the codebase already reserves for exactly this, and make deinit() terminal so nothing re-arms after the nodes leave the heap. Only reproduces under BUN_DESTRUCT_VM_ON_EXIT, which the x64-asan lane sets; add a regression test behind it. --- src/jsc/GarbageCollectionController.rs | 10 +++++++--- src/jsc/VirtualMachine.rs | 5 ++++- src/jsc/web_worker.rs | 10 ++++------ test/js/bun/event-loop-timers.test.ts | 22 +++++++++++++++++++++- 4 files changed, 36 insertions(+), 11 deletions(-) diff --git a/src/jsc/GarbageCollectionController.rs b/src/jsc/GarbageCollectionController.rs index 548374f5af07..32c7040ddd29 100644 --- a/src/jsc/GarbageCollectionController.rs +++ b/src/jsc/GarbageCollectionController.rs @@ -144,10 +144,14 @@ impl GarbageCollectionController { } /// Explicit teardown. Idempotent — `Drop` forwards here. - /// Kept as an inherent method because callers (web_worker, VM exit path) - /// must unlink the timers from the per-VM heap before that heap is dropped - /// in `deinit_runtime_state`. + /// + /// Must run while the per-VM timer heap is still intact, i.e. BEFORE JSC + /// teardown: `~RunLoop::Timer` unlinks and frees the `WTFTimer` nodes + /// sharing that heap, so an unlink afterwards walks freed siblings. Both + /// callers (`global_exit`, `web_worker`) do it next to `cancel_all_timers`. pub fn deinit(&mut self) { + // Terminal: nothing may re-arm the nodes after they leave the heap. + self.disabled = true; // A `Drop` that runs after the VM left its thread-local slot has no heap // left to unlink from — and the nodes die with the VM anyway. let Some(vm) = VirtualMachine::get_or_null() else { diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 2a4bc6bcc417..8d8d9cba8b50 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -1537,6 +1537,10 @@ impl VirtualMachine { // `destroy()`, well after `global_exit`). unsafe { (hooks.cancel_all_timers)(core::ptr::from_mut(self)) }; } + // Same window, same reason: the GC timers are heap nodes too, and + // `~RunLoop::Timer` below frees the `WTFTimer` nodes they share the + // heap with. + self.gc_controller.deinit(); // Detached worker threads may still be in startVM()/spin() using // the process-global resolver BSSMap singletons. transpiler.deinit() // below frees those singletons, so request termination of every @@ -1596,7 +1600,6 @@ impl VirtualMachine { // loop, which is live for the process lifetime. unsafe { (*uws::Loop::get()).drain_closed_sockets() }; - self.gc_controller.deinit(); self.destroy(); } bun_core::Global::exit(u32::from(self.exit_handler.exit_code)) diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index ceb045b8f5e6..80ad06e8e4e8 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -1237,6 +1237,10 @@ impl WebWorker { // worker thread is still installed (torn down in `destroy()`). unsafe { (hooks.cancel_all_timers)(vm_ptr) }; } + // Same window, same reason: the GC timers are heap nodes too, and + // `WebWorker__teardownJSCVM` below frees the `WTFTimer` nodes they + // share the heap with. + vm.gc_controller.deinit(); // Embedded socket groups must drain while JSC is still alive — // closeAll() fires on_close → JS callbacks. RareData.deinit() runs // after teardownJSCVM and only deinit()s (asserts empty in debug). @@ -1282,12 +1286,6 @@ impl WebWorker { // SAFETY: loop owned by this thread's VM; no concurrent access. unsafe { (*loop_).internal_loop_data.jsc_vm = core::ptr::null_mut() }; } - if !vm_ptr.is_null() { - // SAFETY: vm_ptr valid; sole owner. - // Must precede Loop.shutdown so uv_close isn't called twice on the - // GC timer. - unsafe { (*vm_ptr).gc_controller.deinit() }; - } #[cfg(windows)] { // Per-thread libuv loop teardown; closes any handles still open on diff --git a/test/js/bun/event-loop-timers.test.ts b/test/js/bun/event-loop-timers.test.ts index 2e8caf7919fc..a3e418b08f82 100644 --- a/test/js/bun/event-loop-timers.test.ts +++ b/test/js/bun/event-loop-timers.test.ts @@ -4,7 +4,7 @@ // runtime is spun up. It used to hold four: the JS thread's socket-timeout // sweep plus its two GC timers, and one more sweep on the HTTP client thread. import { expect, test } from "bun:test"; -import { bunEnv, bunExe } from "harness"; +import { bunEnv, bunExe, isASAN } from "harness"; const COUNT_TIMERFDS = /* js */ ` function countTimerFds() { @@ -94,3 +94,23 @@ test.concurrent( }, 30_000, ); + +// The GC controller's timers live on the same heap as the `WTFTimer` nodes that +// `~RunLoop::Timer` frees during JSC teardown, so they have to be unlinked +// before it runs. Under `BUN_DESTRUCT_VM_ON_EXIT` that teardown actually +// happens, and getting the order wrong is a use-after-free in the pairing heap. +test.concurrent.skipIf(!isASAN)("destructing the VM on exit does not corrupt the timer heap", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", `setTimeout(() => {}, 1); await Bun.sleep(5); console.log("ok");`], + env: { ...bunEnv, BUN_DESTRUCT_VM_ON_EXIT: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ + stdout: stdout.trim(), + asan: stderr.includes("AddressSanitizer") ? stderr.slice(0, 400) : null, + signalCode: proc.signalCode ?? null, + exitCode, + }).toEqual({ stdout: "ok", asan: null, signalCode: null, exitCode: 0 }); +}); From fff623ac241e67d22982f750b05c0f3337752e72 Mon Sep 17 00:00:00 2001 From: robobun Date: Sun, 5 Jul 2026 10:01:20 +0000 Subject: [PATCH 04/13] gc: keep the GC timers on us_timer_t under libuv Moving them onto the per-VM timer heap broke Windows: every heap insert runs All::ensure_uv_timer(), which restarts the event loop's one shared uv_timer for the soonest deadline. The GC controller arms often, so a JS timer that is already due keeps getting its wakeup pushed out, and test-timers-immediate-queue starved (hit=930 instead of 10). It failed on all three Windows lanes from the first commit of this branch. There was never a reason to touch libuv here: a us_timer_t there is a uv_timer_t, which costs neither a file descriptor nor a syscall per arm. Only epoll/kqueue pay timerfd/EVFILT_TIMER, and that is what this branch set out to remove. So the scheduling backend is per-platform now, behind arm_one_shot / rearm_repeating / ensure_repeating_armed / unschedule, with the state machine and the fast/slow backoff shared. libuv keeps exactly the code it had on main. poll_and_drain_timers likewise collapses to the tick() the caller used to do inline, since on libuv the heap does not bound uv_run. --- src/jsc/GarbageCollectionController.rs | 394 ++++++++++++++++++------- src/runtime/dispatch.rs | 18 +- src/runtime/jsc_hooks.rs | 77 ++--- src/uws_sys/Timer.rs | 31 ++ 4 files changed, 379 insertions(+), 141 deletions(-) diff --git a/src/jsc/GarbageCollectionController.rs b/src/jsc/GarbageCollectionController.rs index 32c7040ddd29..94807bc6d265 100644 --- a/src/jsc/GarbageCollectionController.rs +++ b/src/jsc/GarbageCollectionController.rs @@ -17,9 +17,18 @@ //! //! Thread Safety: This type must be unique per JavaScript thread and is not //! thread-safe. Each VirtualMachine instance should have its own controller. +//! +//! The two timers are scheduled differently per platform. On epoll/kqueue they +//! are intrusive nodes on the per-VM timer heap, because a `us_timer_t` there +//! costs a file descriptor (timerfd) or a pair of kevent64 syscalls per arm. On +//! libuv a `us_timer_t` is just a `uv_timer_t` — neither — and putting them on +//! the heap instead routes every GC arm through `All::ensure_uv_timer`, which +//! restarts the event loop's single shared `uv_timer` and starves JS timers +//! that are already due (`test-timers-immediate-queue`). use core::ffi::c_int; +#[cfg(not(windows))] use bun_event_loop::EventLoopTimer::{EventLoopTimer, State as TimerState, Tag as TimerTag}; use bun_uws as uws; @@ -27,15 +36,24 @@ use crate::VM; use crate::virtual_machine::VirtualMachine; /// Interval of the repeating timer once the heap has been stable for 30 ticks. -const SLOW_REPEAT_INTERVAL_MS: i64 = 30_000; +const SLOW_REPEAT_INTERVAL_MS: i32 = 30_000; +/// Delay of the one-shot "collect on the next tick" nudge. +const ONE_SHOT_INTERVAL_MS: i32 = 16; pub struct GarbageCollectionController { - /// 16ms one-shot: when it fires, the next `process_gc_timer()` will - /// `collect_async()`. Embedded intrusive node — re-armed via the in-process - /// timer heap (no `timerfd_settime`/`epoll_ctl` per re-arm). + /// One-shot: when it fires, the next `process_gc_timer()` will + /// `collect_async()`. + #[cfg(not(windows))] pub gc_timer: EventLoopTimer, - /// 1s/30s repeating: drives `perform_gc()` and the fast↔slow backoff. + /// Repeating: drives `perform_gc()` and the fast↔slow backoff. + #[cfg(not(windows))] pub gc_repeating_timer: EventLoopTimer, + // Raw FFI handles created by `uws::Timer::create_fallthrough` in `init`, + // freed in Drop. Stored as `Option>` (None = uninit). + #[cfg(windows)] + pub gc_timer: Option>, + #[cfg(windows)] + pub gc_repeating_timer: Option>, pub gc_last_heap_size: usize, pub gc_last_heap_size_on_repeating_timer: usize, pub heap_size_didnt_change_for_repeating_timer_ticks_count: u8, @@ -45,6 +63,7 @@ pub struct GarbageCollectionController { pub disabled: bool, } +#[cfg(not(windows))] bun_event_loop::impl_timer_owner!( GarbageCollectionController; from_gc_timer_ptr => gc_timer, @@ -54,8 +73,14 @@ bun_event_loop::impl_timer_owner!( impl Default for GarbageCollectionController { fn default() -> Self { Self { + #[cfg(not(windows))] gc_timer: EventLoopTimer::init_paused(TimerTag::GcOneShot), + #[cfg(not(windows))] gc_repeating_timer: EventLoopTimer::init_paused(TimerTag::GcRepeating), + #[cfg(windows)] + gc_timer: None, + #[cfg(windows)] + gc_repeating_timer: None, gc_last_heap_size: 0, gc_last_heap_size_on_repeating_timer: 0, heap_size_didnt_change_for_repeating_timer_ticks_count: 0, @@ -73,12 +98,238 @@ pub enum GcRepeatSetting { Slow, } +// ── scheduling backend: epoll/kqueue (the per-VM timer heap) ───────────────── +#[cfg(not(windows))] +impl GarbageCollectionController { + /// Remove `t` from the heap if linked, set its deadline to `now + ms`, and + /// insert. JS-thread only. + /// + /// The deadline follows the mocked clock because `All::next` compares + /// against it: pinning it to real time would make every drain under a + /// fast-forwarded `jest.useFakeTimers()` clock re-fire immediately. + fn arm(vm: *mut VirtualMachine, t: *mut EventLoopTimer, ms: i32) { + // SAFETY: `t` is one of the two embedded nodes of the per-VM controller, + // address-stable for the VM lifetime; JS-thread only. `timer_remove` / + // `timer_insert` re-deref `t` per-field, so no `&mut *t` is held here. + unsafe { + if (*t).state == TimerState::ACTIVE { + VirtualMachine::timer_remove(vm, t); + } + (*t).next = bun_core::Timespec::now_allow_mocked_time().add_ms(i64::from(ms)); + VirtualMachine::timer_insert(vm, t); + } + } + + /// Nothing to allocate: both nodes are embedded fields. + fn create_timers(&mut self) {} + + fn arm_one_shot(&mut self) { + Self::arm( + VirtualMachine::get_mut_ptr(), + &raw mut self.gc_timer, + ONE_SHOT_INTERVAL_MS, + ); + } + + /// Re-arm the repeating timer at a new interval. A no-op when called from + /// inside `on_gc_repeating_timer` — the node has just been popped (state + /// `FIRED`) and the callback's tail re-inserts it at the new interval. + fn rearm_repeating(&mut self, ms: i32) { + if self.gc_repeating_timer.state == TimerState::ACTIVE { + Self::arm( + VirtualMachine::get_mut_ptr(), + &raw mut self.gc_repeating_timer, + ms, + ); + } + } + + /// Arm the repeating timer on the first event-loop tick rather than in + /// `init()`, so the heap is never touched before the event loop is wired. + fn ensure_repeating_armed(&mut self) { + if self.gc_repeating_timer.state == TimerState::PENDING { + let interval = self.repeat_interval(); + Self::arm( + VirtualMachine::get_mut_ptr(), + &raw mut self.gc_repeating_timer, + interval, + ); + } + } + + /// Unlink both nodes from the per-VM heap. + /// + /// Must run while that heap is still intact, i.e. BEFORE JSC teardown: + /// `~RunLoop::Timer` unlinks and frees the `WTFTimer` nodes sharing it, so + /// an unlink afterwards walks freed siblings. Both callers (`global_exit`, + /// `web_worker`) do it next to `cancel_all_timers`. + fn unschedule(&mut self) { + // A `Drop` that runs after the VM left its thread-local slot has no heap + // left to unlink from — and the nodes die with the VM anyway. + let Some(vm) = VirtualMachine::get_or_null() else { + return; + }; + for t in [&raw mut self.gc_timer, &raw mut self.gc_repeating_timer] { + // SAFETY: JS-thread; nodes are linked iff state == ACTIVE, and + // `timer_remove` leaves them CANCELLED so a second call is a no-op. + unsafe { + if (*t).state == TimerState::ACTIVE { + VirtualMachine::timer_remove(vm, t); + } + } + } + } + + /// `Tag::GcOneShot` fire body. + /// + /// # Safety + /// `this` is the live per-VM controller; JS-thread only. + pub unsafe fn on_gc_timer(this: *mut Self) { + // SAFETY: per fn contract. + let this = unsafe { &mut *this }; + this.gc_timer.state = TimerState::FIRED; + this.on_one_shot_fired(); + } + + /// `Tag::GcRepeating` fire body. + /// + /// # Safety + /// `this` is the live per-VM controller; `vm` is the per-thread VM. + pub unsafe fn on_gc_repeating_timer(this: *mut Self, vm: *mut VirtualMachine) { + { + // SAFETY: per fn contract — `this` is live; this borrow ends before + // the re-entrant `arm()` below. + let me = unsafe { &mut *this }; + me.gc_repeating_timer.state = TimerState::FIRED; + if me.disabled { + return; + } + me.on_repeating_fired(); + } + // `rearm_repeating` only fires across a Fast↔Slow transition and skips + // the popped node anyway, so the steady-state tick re-arms here. + // SAFETY: per fn contract. + unsafe { + let interval = (*this).repeat_interval(); + Self::arm(vm, &raw mut (*this).gc_repeating_timer, interval); + } + } +} + +// ── scheduling backend: libuv (a us_timer_t is a uv_timer_t) ───────────────── +#[cfg(windows)] +impl GarbageCollectionController { + /// Recover `&mut Self` from a uws timer's ext slot. Single audited deref + /// for the two `extern "C"` callbacks below so they stay safe-bodied. + /// + /// `timer` is the live uws timer whose ext data was set to + /// `*mut GarbageCollectionController` in [`Self::init`]; the controller is + /// a BACKREF that strictly outlives the timer (`deinit()` closes the timer + /// before `self` is dropped). + #[inline] + fn from_timer_ext<'a>(timer: *mut uws::Timer) -> &'a mut Self { + let ptr = uws::Timer::opaque_mut(timer).as_::<*mut Self>(); + // SAFETY: BACKREF — see doc comment above. + unsafe { &mut *ptr } + } + + /// Accessor for the init-once `gc_timer` handle. + #[inline] + fn gc_timer_mut(&mut self) -> &mut uws::Timer { + // SAFETY: `gc_timer` is set in `init()` (via `Timer::create_fallthrough`) + // before any code path reaches a deref site, and remains a live FFI + // handle until `deinit()` closes it. The Timer lives on the uws heap, + // not inside `self`, so the returned `&mut` cannot alias `self`. + unsafe { &mut *self.gc_timer.expect("gc_timer set in init()").as_ptr() } + } + + /// Accessor for the init-once `gc_repeating_timer` handle. + #[inline] + fn gc_repeating_timer_mut(&mut self) -> &mut uws::Timer { + // SAFETY: same invariant as `gc_timer_mut`. + unsafe { + &mut *self + .gc_repeating_timer + .expect("gc_repeating_timer set in init()") + .as_ptr() + } + } + + fn create_timers(&mut self) { + // SAFETY: uws::Loop::get() returns the live process-global loop. + let actual = unsafe { &mut *uws::Loop::get() }; + self.gc_timer = Some(uws::Timer::create_fallthrough( + actual, + std::ptr::from_mut::(self), + )); + self.gc_repeating_timer = Some(uws::Timer::create_fallthrough( + actual, + std::ptr::from_mut::(self), + )); + } + + fn arm_one_shot(&mut self) { + let ext = std::ptr::from_mut::(self); + self.gc_timer_mut() + .set(ext, Some(on_gc_timer), ONE_SHOT_INTERVAL_MS, 0); + } + + fn rearm_repeating(&mut self, ms: i32) { + let ext = std::ptr::from_mut::(self); + self.gc_repeating_timer_mut() + .set(ext, Some(on_gc_repeating_timer), ms, ms); + } + + /// The uv_timer repeats on its own; `init()` arms it once. + fn ensure_repeating_armed(&mut self) {} + + fn unschedule(&mut self) { + // SAFETY: timers were created via uws::Timer::create_fallthrough; close:: + // frees the fallthrough timer. `take()` ensures we close at most once. + unsafe { + if let Some(t) = self.gc_timer.take() { + uws::Timer::close::(t.as_ptr()); + } + if let Some(t) = self.gc_repeating_timer.take() { + uws::Timer::close::(t.as_ptr()); + } + } + } +} + +#[cfg(windows)] +pub(crate) extern "C" fn on_gc_timer(timer: *mut uws::Timer) { + GarbageCollectionController::from_timer_ext(timer).on_one_shot_fired(); +} + +#[cfg(windows)] +pub(crate) extern "C" fn on_gc_repeating_timer(timer: *mut uws::Timer) { + let this = GarbageCollectionController::from_timer_ext(timer); + if this.disabled { + return; + } + this.on_repeating_fired(); +} + +// ── platform-independent policy ───────────────────────────────────────────── impl GarbageCollectionController { + /// The interval the repeating timer currently runs at. + #[inline] + fn repeat_interval(&self) -> i32 { + if self.gc_repeating_timer_fast { + self.gc_timer_interval + } else { + SLOW_REPEAT_INTERVAL_MS + } + } + pub fn init(&mut self, vm: &mut VirtualMachine) { // SAFETY: uws::Loop::get() returns the live process-global loop. let actual = unsafe { &mut *uws::Loop::get() }; actual.internal_loop_data.jsc_vm = vm.jsc_vm.cast(); + self.create_timers(); + // `Transpiler::init` is deferred to the high-tier // `init_runtime_state` hook (which runs *after* `ensure_waker` → // this `init`), so `vm.transpiler.env` is still the zeroed null ptr @@ -108,30 +359,18 @@ impl GarbageCollectionController { } self.disabled = env.is_some_and(|e| e.has(b"BUN_GC_TIMER_DISABLE")); - } - /// Remove `t` from the heap if linked, set its deadline to `now + ms`, and - /// insert. JS-thread only. - /// - /// The deadline follows the mocked clock because `All::next` compares - /// against it: a real-time deadline under a fast-forwarded - /// `jest.useFakeTimers()` clock would re-fire on every drain. - fn arm(vm: *mut VirtualMachine, t: *mut EventLoopTimer, ms: i64) { - // SAFETY: `t` is one of the two embedded nodes of the per-VM controller, - // address-stable for the VM lifetime; JS-thread only. `timer_remove` / - // `timer_insert` re-deref `t` per-field, so no `&mut *t` is held here. - unsafe { - if (*t).state == TimerState::ACTIVE { - VirtualMachine::timer_remove(vm, t); - } - (*t).next = bun_core::Timespec::now_allow_mocked_time().add_ms(ms); - VirtualMachine::timer_insert(vm, t); + // libuv arms here (the uv_timer repeats itself); the heap backend waits + // for the first tick, see `ensure_repeating_armed`. + #[cfg(windows)] + if !self.disabled { + self.rearm_repeating(gc_timer_interval); } } pub fn schedule_gc_timer(&mut self) { self.gc_timer_state = GCTimerState::Scheduled; - Self::arm(VirtualMachine::get_mut_ptr(), &raw mut self.gc_timer, 16); + self.arm_one_shot(); } pub fn bun_vm(&mut self) -> &mut VirtualMachine { @@ -143,29 +382,13 @@ impl GarbageCollectionController { VirtualMachine::get().as_mut() } - /// Explicit teardown. Idempotent — `Drop` forwards here. - /// - /// Must run while the per-VM timer heap is still intact, i.e. BEFORE JSC - /// teardown: `~RunLoop::Timer` unlinks and frees the `WTFTimer` nodes - /// sharing that heap, so an unlink afterwards walks freed siblings. Both - /// callers (`global_exit`, `web_worker`) do it next to `cancel_all_timers`. + /// Explicit teardown. Idempotent — `Drop` forwards here. Callers + /// (web_worker, the VM exit path) must run it before JSC teardown; see + /// `unschedule`. pub fn deinit(&mut self) { - // Terminal: nothing may re-arm the nodes after they leave the heap. + // Terminal: nothing may re-arm the timers after they are torn down. self.disabled = true; - // A `Drop` that runs after the VM left its thread-local slot has no heap - // left to unlink from — and the nodes die with the VM anyway. - let Some(vm) = VirtualMachine::get_or_null() else { - return; - }; - for t in [&raw mut self.gc_timer, &raw mut self.gc_repeating_timer] { - // SAFETY: JS-thread; nodes are linked iff state == ACTIVE, and - // `timer_remove` leaves them CANCELLED so a second call is a no-op. - unsafe { - if (*t).state == TimerState::ACTIVE { - VirtualMachine::timer_remove(vm, t); - } - } - } + self.unschedule(); } // We want to always run GC once in awhile @@ -180,27 +403,15 @@ impl GarbageCollectionController { // When the heap size is increasing, we always switch to fast mode // When the heap size has been the same or less for 30 seconds, we switch to slow mode pub fn update_gc_repeat_timer(&mut self, setting: GcRepeatSetting) { - let (interval, want_fast) = match setting { - GcRepeatSetting::Fast if !self.gc_repeating_timer_fast => { - (i64::from(self.gc_timer_interval), true) - } - GcRepeatSetting::Slow if self.gc_repeating_timer_fast => { - (SLOW_REPEAT_INTERVAL_MS, false) - } + let want_fast = match setting { + GcRepeatSetting::Fast if !self.gc_repeating_timer_fast => true, + GcRepeatSetting::Slow if self.gc_repeating_timer_fast => false, _ => return, }; self.gc_repeating_timer_fast = want_fast; self.heap_size_didnt_change_for_repeating_timer_ticks_count = 0; - // When called from inside `on_gc_repeating_timer` the node has just - // been popped (state set to FIRED at the top of the callback) — skip - // the re-arm; the callback's tail re-inserts at the new interval. - if self.gc_repeating_timer.state == TimerState::ACTIVE { - Self::arm( - VirtualMachine::get_mut_ptr(), - &raw mut self.gc_repeating_timer, - interval, - ); - } + let interval = self.repeat_interval(); + self.rearm_repeating(interval); } #[inline] @@ -208,16 +419,7 @@ impl GarbageCollectionController { if self.disabled { return; } - // Lazy-arm the repeating timer on the first event-loop tick instead of - // in `init()`, so the timer heap is never touched before the event loop - // is fully wired (matters for Windows' `ensure_uv_timer`). - if self.gc_repeating_timer.state == TimerState::PENDING { - Self::arm( - VirtualMachine::get_mut_ptr(), - &raw mut self.gc_repeating_timer, - i64::from(self.gc_timer_interval), - ); - } + self.ensure_repeating_armed(); let vm = VirtualMachine::get().jsc_vm(); self.process_gc_timer_with_heap_size(vm, vm.block_bytes_allocated()); } @@ -266,51 +468,31 @@ impl GarbageCollectionController { self.gc_last_heap_size = vm.block_bytes_allocated(); } - /// `Tag::GcOneShot` fire body. - /// - /// # Safety - /// `this` is the live per-VM controller; JS-thread only. - pub unsafe fn on_gc_timer(this: *mut Self) { - // SAFETY: per fn contract. - let this = unsafe { &mut *this }; - this.gc_timer.state = TimerState::FIRED; - if this.disabled { + /// Shared body of the one-shot timer's callback. + fn on_one_shot_fired(&mut self) { + if self.disabled { return; } - this.gc_timer_state = GCTimerState::RunOnNextTick; + self.gc_timer_state = GCTimerState::RunOnNextTick; } - /// `Tag::GcRepeating` fire body. - /// - /// # Safety - /// `this` is the live per-VM controller; `vm` is the per-thread VM. - pub unsafe fn on_gc_repeating_timer(this: *mut Self, vm: *mut VirtualMachine) { - // SAFETY: per fn contract. - let this = unsafe { &mut *this }; - this.gc_repeating_timer.state = TimerState::FIRED; - - let prev_heap_size = this.gc_last_heap_size_on_repeating_timer; - this.perform_gc(); - this.gc_last_heap_size_on_repeating_timer = this.gc_last_heap_size; - if prev_heap_size == this.gc_last_heap_size_on_repeating_timer { - this.heap_size_didnt_change_for_repeating_timer_ticks_count = this + /// Shared body of the repeating timer's callback. + fn on_repeating_fired(&mut self) { + let prev_heap_size = self.gc_last_heap_size_on_repeating_timer; + self.perform_gc(); + self.gc_last_heap_size_on_repeating_timer = self.gc_last_heap_size; + if prev_heap_size == self.gc_last_heap_size_on_repeating_timer { + self.heap_size_didnt_change_for_repeating_timer_ticks_count = self .heap_size_didnt_change_for_repeating_timer_ticks_count .saturating_add(1); - if this.heap_size_didnt_change_for_repeating_timer_ticks_count >= 30 { + if self.heap_size_didnt_change_for_repeating_timer_ticks_count >= 30 { // make the timer interval longer - this.update_gc_repeat_timer(GcRepeatSetting::Slow); + self.update_gc_repeat_timer(GcRepeatSetting::Slow); } } else { - this.heap_size_didnt_change_for_repeating_timer_ticks_count = 0; - this.update_gc_repeat_timer(GcRepeatSetting::Fast); + self.heap_size_didnt_change_for_repeating_timer_ticks_count = 0; + self.update_gc_repeat_timer(GcRepeatSetting::Fast); } - - let interval = if this.gc_repeating_timer_fast { - i64::from(this.gc_timer_interval) - } else { - SLOW_REPEAT_INTERVAL_MS - }; - Self::arm(vm, &raw mut this.gc_repeating_timer, interval); } } diff --git a/src/runtime/dispatch.rs b/src/runtime/dispatch.rs index 22049be0b6ab..1d00d6703744 100644 --- a/src/runtime/dispatch.rs +++ b/src/runtime/dispatch.rs @@ -164,6 +164,8 @@ use bun_sql_jsc::postgres::PostgresSQLConnection; use crate::test_runner::bun_test::{BunTest, BunTestPtr}; use crate::timer::{DateHeaderTimer, EventLoopDelayMonitor}; use bun_jsc::abort_signal::Timeout as AbortSignalTimeout; +// libuv keeps the GC timers as `us_timer_t`s, so the heap never sees them there. +#[cfg(not(windows))] use bun_jsc::garbage_collection_controller::GarbageCollectionController; #[cfg(not(windows))] @@ -976,17 +978,29 @@ pub unsafe fn __bun_fire_timer(t: *mut EventLoopTimer, now: *const ElTimespec, v AbortSignalTimeout::run(c, vm) }) } + // libuv keeps the GC timers as `us_timer_t`s, so these tags never reach + // the heap there. EventLoopTimerTag::GcOneShot => { + #[cfg(not(windows))] timer_arm!(GarbageCollectionController, gc_timer, |c, _now, _vm| { GarbageCollectionController::on_gc_timer(c) - }) + }); + #[cfg(windows)] + if cfg!(debug_assertions) { + unreachable!("GcOneShot timer on Windows"); + } } EventLoopTimerTag::GcRepeating => { + #[cfg(not(windows))] timer_arm!( GarbageCollectionController, gc_repeating_timer, |c, _now, vm| GarbageCollectionController::on_gc_repeating_timer(c, vm) - ) + ); + #[cfg(windows)] + if cfg!(debug_assertions) { + unreachable!("GcRepeating timer on Windows"); + } } EventLoopTimerTag::DateHeaderTimer => { timer_arm!(DateHeaderTimer, event_loop_timer, |c, _now, vm| (*c) diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index 60ee97023666..efc93d4fc7e0 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -1112,6 +1112,10 @@ unsafe fn auto_tick_active(vm: *mut VirtualMachine) { /// drain, an already-overdue timer would leave `get_timeout` returning a zero /// deadline and spin the caller's `loop { … tick_possibly_forever() }`. /// +/// On libuv the heap has no say in how long `uv_run` blocks (`All.uv_timer` +/// does) and it drains from `on_uv_timer`, so there this is just the `tick()` +/// the caller used to do inline. +/// /// # Safety /// `vm` is the live per-thread VM. unsafe fn poll_and_drain_timers(vm: *mut VirtualMachine) { @@ -1120,44 +1124,51 @@ unsafe fn poll_and_drain_timers(vm: *mut VirtualMachine) { // SAFETY: `el` is the live per-thread event loop (field of `*vm`). let loop_ = unsafe { (*el).usockets_loop() }; - let state = runtime_state(); - if state.is_null() { + #[cfg(windows)] + { + let _ = vm; // SAFETY: `loop_` is the live per-thread uws loop. - unsafe { (*loop_).tick_without_idle() }; - return; + unsafe { (*loop_).tick() }; } - // SAFETY: `el` is the live per-thread event loop. - let has_pending_immediate = !unsafe { &*el }.immediate_tasks.is_empty(); - // SAFETY: `loop_` is the live per-thread uws loop. - let quic_next_tick_us = unsafe { - let ild = &(*loop_).internal_loop_data; - if ild.quic_head.is_null() { - None - } else { - Some(ild.quic_next_tick_us) + #[cfg(not(windows))] + { + let state = runtime_state(); + if state.is_null() { + // SAFETY: `loop_` is the live per-thread uws loop. + unsafe { (*loop_).tick_without_idle() }; + return; } - }; - let mut timespec = bun_core::Timespec { sec: 0, nsec: 0 }; - // SAFETY: `state` is the live per-thread `RuntimeState`; see the Note on - // `auto_tick` re: aliased-&mut across `fire()`. - let have_timeout = unsafe { - timer::All::get_timeout( - &mut (*state).timer, - &mut timespec, - has_pending_immediate, - quic_next_tick_us, - vm.cast(), - ) - }; - // SAFETY: `loop_` is the live per-thread uws loop. - unsafe { (*loop_).tick_with_timeout(if have_timeout { Some(×pec) } else { None }) }; - #[cfg(unix)] - // SAFETY: see above. - unsafe { - timer::All::drain_timers(&mut (*state).timer, vm.cast()) - }; + // SAFETY: `el` is the live per-thread event loop. + let has_pending_immediate = !unsafe { &*el }.immediate_tasks.is_empty(); + // SAFETY: `loop_` is the live per-thread uws loop. + let quic_next_tick_us = unsafe { + let ild = &(*loop_).internal_loop_data; + if ild.quic_head.is_null() { + None + } else { + Some(ild.quic_next_tick_us) + } + }; + let mut timespec = bun_core::Timespec { sec: 0, nsec: 0 }; + // SAFETY: `state` is the live per-thread `RuntimeState`; see the Note on + // `auto_tick` re: aliased-&mut across `fire()`. + let have_timeout = unsafe { + timer::All::get_timeout( + &mut (*state).timer, + &mut timespec, + has_pending_immediate, + quic_next_tick_us, + vm.cast(), + ) + }; + // SAFETY: `loop_` is the live per-thread uws loop. + unsafe { (*loop_).tick_with_timeout(if have_timeout { Some(×pec) } else { None }) }; + + // SAFETY: see above. + unsafe { timer::All::drain_timers(&mut (*state).timer, vm.cast()) }; + } } /// `printException` / `printErrorlikeObject` — formats `value` to stderr via diff --git a/src/uws_sys/Timer.rs b/src/uws_sys/Timer.rs index 6306c17edab4..9df36bd485c0 100644 --- a/src/uws_sys/Timer.rs +++ b/src/uws_sys/Timer.rs @@ -37,6 +37,23 @@ impl Timer { }) } + pub fn create_fallthrough(loop_: &mut Loop, _ptr: T) -> NonNull { + // SAFETY: `loop_` is a valid loop pointer. + let t = unsafe { + us_create_timer( + loop_, + 1, + c_uint::try_from(size_of::()).expect("int cast"), + ) + }; + NonNull::new(t).unwrap_or_else(|| { + panic!( + "us_create_timer: returned null: {}", + std::io::Error::last_os_error().raw_os_error().unwrap_or(0) + ) + }) + } + pub fn set( &mut self, ptr: T, @@ -53,6 +70,20 @@ impl Timer { } } + // Named `as_` because `as` is a Rust keyword. + pub fn as_(&mut self) -> T { + unsafe { + // SAFETY: the ext slot was allocated with `size_of::()` and + // written via [`set`] as a bare `T`, so read it as `T` directly. + // Wrapping in `Option` here would over-read and misinterpret + // the bytes (`Option<*mut T>` has no niche, so it is two words + // while the slot is one). Callers pass pointer-ish `T` and + // tolerate a (debug-asserted) null read. + let slot: *mut T = us_timer_ext(self).cast(); + slot.read() + } + } + // Not `impl Drop` — FFI opaque handle with a const-generic param; // destruction is an explicit C call and Drop cannot take parameters. Per PORTING.md // FFI-handle exception, expose `unsafe fn close(*mut Self)` instead of `deinit(&mut self)`. From d90af65f6e692ef49e2284e72272812841cd3eea Mon Sep 17 00:00:00 2001 From: robobun Date: Sun, 5 Jul 2026 11:17:03 +0000 Subject: [PATCH 05/13] test: surface child stderr when the timerfd assertions fail --- test/js/bun/event-loop-timers.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/js/bun/event-loop-timers.test.ts b/test/js/bun/event-loop-timers.test.ts index a3e418b08f82..9e4777b54335 100644 --- a/test/js/bun/event-loop-timers.test.ts +++ b/test/js/bun/event-loop-timers.test.ts @@ -32,18 +32,19 @@ async function countTimerFdsIn(body: string) { test.concurrent.skipIf(process.platform !== "linux")("idle runtime holds no timerfd", async () => { // Allocating churns the heap, which is what arms the GC controller's timers. - const { stdout, exitCode } = await countTimerFdsIn(` + const { stdout, stderr, exitCode } = await countTimerFdsIn(` for (let i = 0; i < 100; i++) new Uint8Array(4096); console.log(countTimerFds()); `); expect(stdout).toBe("0"); + if (exitCode !== 0) expect(stderr).toBe(""); expect(exitCode).toBe(0); }); test.concurrent.skipIf(process.platform !== "linux")( "a live server, the HTTP client thread, and JS timers hold no timerfd", async () => { - const { stdout, exitCode } = await countTimerFdsIn(` + const { stdout, stderr, exitCode } = await countTimerFdsIn(` using server = Bun.serve({ port: 0, fetch: () => new Response("ok") }); // fetch() spins up the HTTP client thread, which owns a second uws loop // and therefore a second socket-timeout sweep. @@ -57,6 +58,7 @@ test.concurrent.skipIf(process.platform !== "linux")( clearTimeout(timeout); `); expect(stdout).toBe("0"); + if (exitCode !== 0) expect(stderr).toBe(""); expect(exitCode).toBe(0); }, ); From be6c13e7a413bdc90c14ad6adbda088d46eda9bc Mon Sep 17 00:00:00 2001 From: robobun Date: Sun, 5 Jul 2026 12:40:10 +0000 Subject: [PATCH 06/13] ci: retrigger Build 68540 went red on four agent-load flakes, none of which touch this diff. v8-heap-snapshot.test.ts was SIGKILL'd on ubuntu 25.04 x64 but passed on that same lane in build 68523, whose runtime code is byte-identical (the only delta is a stderr destructure in the test file), and passed on the aarch64 and x64-baseline lanes of 68540 itself. The two darwin failures are timeouts on tests that run in 1.1s locally, and the Windows one already passed on retry. From 573509d19f08187b4a377f506753274af12f0144 Mon Sep 17 00:00:00 2001 From: robobun Date: Sun, 5 Jul 2026 13:10:02 +0000 Subject: [PATCH 07/13] uws: the 'two fallthrough polls' comment is libuv-only now epoll/kqueue creates exactly one (wakeup_async); the sweep is a deadline, not a poll. --- packages/bun-usockets/src/loop.c | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/bun-usockets/src/loop.c b/packages/bun-usockets/src/loop.c index 6c6b43284563..68817c1d43ba 100644 --- a/packages/bun-usockets/src/loop.c +++ b/packages/bun-usockets/src/loop.c @@ -124,7 +124,9 @@ void us_internal_sweep_if_due(struct us_loop_t *loop) { #endif -/* The loop has 2 fallthrough polls */ +/* Creates the loop's fallthrough polls (the ones that don't keep it alive): + * wakeup_async, plus sweep_timer under libuv. epoll/kqueue has no timer poll — + * the sweep is a deadline folded into the poll timeout. */ void us_internal_loop_data_init(struct us_loop_t *loop, void (*wakeup_cb)(struct us_loop_t *loop), void (*pre_cb)(struct us_loop_t *loop), void (*post_cb)(struct us_loop_t *loop)) { // We allocate with calloc, so we only need to initialize the specific fields in use. From 2404aeb728ff5332b87d5bc34d8f00c859f99a5b Mon Sep 17 00:00:00 2001 From: robobun Date: Mon, 6 Jul 2026 06:55:00 +0000 Subject: [PATCH 08/13] Drop the poll_and_drain_timers hook; tick_possibly_forever just parks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hook existed to keep the GC pacing timer ticking while parked in --watch / debugger-wait, which is the one thing the old gc timerfd did there (it was registered in epoll, so it woke the loop every second and dispatched through POLL_TYPE_CALLBACK). Nothing else fired there: main never called get_timeout or drain_timers from tick_possibly_forever, so WTFTimers were already parked. It isn't worth a hook. tick_possibly_forever passes a NULL timeout, so the loop never consults the heap and cannot spin on an overdue timer — the only reason it could was that the hook itself called get_timeout. Go back to loop_.tick() and let the GC timer wait for the next real wakeup, which is when auto_tick_active drains it anyway. Measured over an 8s idle window in --watch: this change: 0 voluntary context switches main: 12 voluntary context switches (~1.5/s, the gc timerfd) -96 lines, one less RuntimeHooks slot, and no unsafe on that path. --- src/jsc/VirtualMachine.rs | 22 ------------ src/jsc/event_loop.rs | 5 +-- src/runtime/jsc_hooks.rs | 70 --------------------------------------- 3 files changed, 1 insertion(+), 96 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 8d8d9cba8b50..da40d0367a25 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -1664,11 +1664,6 @@ pub struct RuntimeHooks { /// `handleRejectedPromises` and falls through to `tickWithoutIdle` when /// idle — folding it into `auto_tick` would change shutdown semantics. pub auto_tick_active: unsafe fn(vm: *mut VirtualMachine), - /// `eventLoop().tickPossiblyForever()`'s poll step: block in the uSockets - /// loop, bounded by `Timer::All`'s soonest deadline, then drain whatever - /// came due. Unlike `auto_tick_active` it parks even when the loop has no - /// active handles — the caller has already pinned a poll for that. - pub poll_and_drain_timers: unsafe fn(vm: *mut VirtualMachine), /// `printException` / `printErrorlikeObject` — formats `value` (or its /// wrapped `JSC::Exception`) to stderr via `ConsoleObject::Formatter`. /// High tier @@ -2280,23 +2275,6 @@ impl VirtualMachine { } } - /// Park in the I/O loop until the soonest timer deadline (or forever when - /// the heap is empty), then fire whatever came due. Needs `Timer::All`, so - /// it dispatches through [`RuntimeHooks::poll_and_drain_timers`]. - #[inline] - pub fn poll_and_drain_timers(&mut self) { - if let Some(hooks) = runtime_hooks() { - // SAFETY: hook contract — `self` is the live per-thread VM. - unsafe { (hooks.poll_and_drain_timers)(self) }; - } else { - // No high tier (unit tests) — there is no timer heap to bound the - // wait with, so poll the I/O loop without idling. - let loop_ = self.event_loop_mut().usockets_loop(); - // SAFETY: `usockets_loop()` returns the live per-thread uws loop. - unsafe { (*loop_).tick_without_idle() }; - } - } - /// `reloadEntryPoint(entry_path)` — set `main`, generate the synthetic /// `bun:main` entry, run preloads, and kick off module evaluation. pub fn reload_entry_point( diff --git a/src/jsc/event_loop.rs b/src/jsc/event_loop.rs index 10c7ff59c8ec..7de96b727a1d 100644 --- a/src/jsc/event_loop.rs +++ b/src/jsc/event_loop.rs @@ -1113,10 +1113,7 @@ impl EventLoop { self.process_gc_timer(); self.process_gc_timer(); - // Park in the I/O loop, bounded by the soonest timer deadline, then - // fire whatever came due. The body needs `Timer::All`, so it goes - // through `RuntimeHooks::poll_and_drain_timers`. - self.vm_ref().as_mut().poll_and_drain_timers(); + loop_.tick(); self.vm_ref().as_mut().on_after_event_loop(); self.tick_concurrent(); diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index efc93d4fc7e0..7caf3e55e844 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -1102,75 +1102,6 @@ unsafe fn auto_tick_active(vm: *mut VirtualMachine) { unsafe { (*vm).on_after_event_loop() }; } -/// `RuntimeHooks::poll_and_drain_timers` — the poll step of -/// [`bun_jsc::event_loop::EventLoop::tick_possibly_forever`]. Blocks in the -/// uSockets loop bounded by `Timer::All`'s soonest deadline (forever when the -/// heap is empty) and fires whatever came due. -/// -/// Deliberately not gated on `loop.is_active()`: the caller pins a poll so the -/// tick parks, which is the whole point of `tick_possibly_forever`. Without the -/// drain, an already-overdue timer would leave `get_timeout` returning a zero -/// deadline and spin the caller's `loop { … tick_possibly_forever() }`. -/// -/// On libuv the heap has no say in how long `uv_run` blocks (`All.uv_timer` -/// does) and it drains from `on_uv_timer`, so there this is just the `tick()` -/// the caller used to do inline. -/// -/// # Safety -/// `vm` is the live per-thread VM. -unsafe fn poll_and_drain_timers(vm: *mut VirtualMachine) { - // SAFETY: per fn contract — `vm` is the live per-thread VM. - let el: *mut bun_jsc::event_loop::EventLoop = unsafe { &*vm }.event_loop; - // SAFETY: `el` is the live per-thread event loop (field of `*vm`). - let loop_ = unsafe { (*el).usockets_loop() }; - - #[cfg(windows)] - { - let _ = vm; - // SAFETY: `loop_` is the live per-thread uws loop. - unsafe { (*loop_).tick() }; - } - - #[cfg(not(windows))] - { - let state = runtime_state(); - if state.is_null() { - // SAFETY: `loop_` is the live per-thread uws loop. - unsafe { (*loop_).tick_without_idle() }; - return; - } - - // SAFETY: `el` is the live per-thread event loop. - let has_pending_immediate = !unsafe { &*el }.immediate_tasks.is_empty(); - // SAFETY: `loop_` is the live per-thread uws loop. - let quic_next_tick_us = unsafe { - let ild = &(*loop_).internal_loop_data; - if ild.quic_head.is_null() { - None - } else { - Some(ild.quic_next_tick_us) - } - }; - let mut timespec = bun_core::Timespec { sec: 0, nsec: 0 }; - // SAFETY: `state` is the live per-thread `RuntimeState`; see the Note on - // `auto_tick` re: aliased-&mut across `fire()`. - let have_timeout = unsafe { - timer::All::get_timeout( - &mut (*state).timer, - &mut timespec, - has_pending_immediate, - quic_next_tick_us, - vm.cast(), - ) - }; - // SAFETY: `loop_` is the live per-thread uws loop. - unsafe { (*loop_).tick_with_timeout(if have_timeout { Some(×pec) } else { None }) }; - - // SAFETY: see above. - unsafe { timer::All::drain_timers(&mut (*state).timer, vm.cast()) }; - } -} - /// `printException` / `printErrorlikeObject` — formats `value` to stderr via /// `ConsoleObject::Formatter`. Dispatched here so the high tier owns the /// formatter. @@ -1462,7 +1393,6 @@ pub(crate) static __BUN_RUNTIME_HOOKS: RuntimeHooks = RuntimeHooks { ensure_debugger, auto_tick, auto_tick_active, - poll_and_drain_timers, print_exception, timer_insert, timer_remove, From b1cd516570c247026d48693e72adc0a51e17bba2 Mon Sep 17 00:00:00 2001 From: robobun Date: Mon, 6 Jul 2026 07:35:31 +0000 Subject: [PATCH 09/13] gc: arm the GC timers on real time, never the mocked clock jest.useFakeTimers() starts its clock at Timespec::EPOCH and counts up from there, so arming with now_allow_mocked_time() put the deadline in mocked-clock units whenever arm() ran inside a fake-timer window. All::next compares the real heap against the mocked clock, so advanceTimersByTime() then drove Bun's collection. Forcing real ticks inside the window (file I/O, which the fake heap does not capture) while advancing the fake clock 200s: real time (this change): 3 GC fires mocked time (before): 34 GC fires WTFTimer and EventLoopDelayMonitor already use ForceRealTime for the same reason: they are internal pacing, not user-visible timers, and their tags already opt out of fake-timer capture. --- src/jsc/GarbageCollectionController.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/jsc/GarbageCollectionController.rs b/src/jsc/GarbageCollectionController.rs index 94807bc6d265..16bb8c6caad1 100644 --- a/src/jsc/GarbageCollectionController.rs +++ b/src/jsc/GarbageCollectionController.rs @@ -28,6 +28,8 @@ use core::ffi::c_int; +#[cfg(not(windows))] +use bun_core::{Timespec, TimespecMockMode}; #[cfg(not(windows))] use bun_event_loop::EventLoopTimer::{EventLoopTimer, State as TimerState, Tag as TimerTag}; use bun_uws as uws; @@ -104,9 +106,9 @@ impl GarbageCollectionController { /// Remove `t` from the heap if linked, set its deadline to `now + ms`, and /// insert. JS-thread only. /// - /// The deadline follows the mocked clock because `All::next` compares - /// against it: pinning it to real time would make every drain under a - /// fast-forwarded `jest.useFakeTimers()` clock re-fire immediately. + /// Real time, never the mocked clock: GC pacing is ours, not the test's. + /// `jest.useFakeTimers()` starts its clock at zero, so a mocked deadline + /// would let `advanceTimersByTime()` drive collection. Same as `WTFTimer`. fn arm(vm: *mut VirtualMachine, t: *mut EventLoopTimer, ms: i32) { // SAFETY: `t` is one of the two embedded nodes of the per-VM controller, // address-stable for the VM lifetime; JS-thread only. `timer_remove` / @@ -115,7 +117,7 @@ impl GarbageCollectionController { if (*t).state == TimerState::ACTIVE { VirtualMachine::timer_remove(vm, t); } - (*t).next = bun_core::Timespec::now_allow_mocked_time().add_ms(i64::from(ms)); + (*t).next = Timespec::now(TimespecMockMode::ForceRealTime).add_ms(i64::from(ms)); VirtualMachine::timer_insert(vm, t); } } From 9ffcaf3a54decf7acaa9fdbf54d604af942bb6f6 Mon Sep 17 00:00:00 2001 From: robobun Date: Mon, 6 Jul 2026 09:04:40 +0000 Subject: [PATCH 10/13] run: don't park on a rejected entry point that nothing can wake A rejected entry point whose uncaughtException handler swallowed the error called tick_possibly_forever() with no watcher registered, so nothing could ever wake the loop. It only returned because a 1s GC timerfd happened to be sitting in epoll; once that went away the process hung forever: process.on("uncaughtException", () => console.log("handled")); throw new Error("boom"); // printed "handled", then hung The call was redundant anyway. The core run-loop right below already does the waiting: its watcher arm parks in tick_possibly_forever, and without a watcher it drains until the loop goes quiet and the process exits. Parking here only ever blocked on nothing. This is what timed out test/js/node/process/process.test.js's uncaughtException tests and test/cli/hot/hot.test.ts on build 68779. --- src/runtime/cli/run_command.rs | 7 ++++--- test/js/bun/event-loop-timers.test.ts | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/runtime/cli/run_command.rs b/src/runtime/cli/run_command.rs index 49b21f9613dc..740de8ea005d 100644 --- a/src/runtime/cli/run_command.rs +++ b/src/runtime/cli/run_command.rs @@ -1518,14 +1518,15 @@ impl Run { // When --hot/--watch is on (or a user // `uncaughtException` handler swallowed the error), keep the // process alive instead of hard-exiting on a rejected entry. + // The core run-loop below does the waiting: its watcher arm + // parks in `tick_possibly_forever`, and without a watcher it + // drains until the loop goes quiet and the process exits. + // Parking here too would block on nothing in the latter case. if vm.hot_reload != 0 || handled { vm.add_main_to_watcher_if_needed(); // SAFETY: `event_loop` is a self-pointer into this VM; // uniquely accessed here. vm.event_loop_ref().tick(); - // SAFETY: as above — `event_loop` is a self-pointer into - // this VM; uniquely accessed here. - vm.event_loop_ref().tick_possibly_forever(); } else { exit_with_unhandled_note(vm); } diff --git a/test/js/bun/event-loop-timers.test.ts b/test/js/bun/event-loop-timers.test.ts index 9e4777b54335..94d1896bc705 100644 --- a/test/js/bun/event-loop-timers.test.ts +++ b/test/js/bun/event-loop-timers.test.ts @@ -116,3 +116,22 @@ test.concurrent.skipIf(!isASAN)("destructing the VM on exit does not corrupt the exitCode, }).toEqual({ stdout: "ok", asan: null, signalCode: null, exitCode: 0 }); }); + +// A rejected entry point whose `uncaughtException` handler swallows the error +// used to park the loop with nothing registered that could ever wake it. It only +// ever returned because a 1s GC timerfd happened to be sitting in epoll; with no +// timerfd left, the process hung forever. +test.concurrent("an uncaughtException handler on a rejected entry point still exits", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", `process.on("uncaughtException", () => console.log("handled")); throw new Error("boom");`], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout.trim()).toBe("handled"); + // It exited on its own rather than being killed by the test timeout. + expect(proc.signalCode ?? null).toBe(null); + if (exitCode !== 0) expect(stderr).toBe(""); + expect(exitCode).toBe(0); +}); From 0bc174dd5743752982514d2c18a9310c03108db3 Mon Sep 17 00:00:00 2001 From: robobun Date: Tue, 7 Jul 2026 11:32:51 +0000 Subject: [PATCH 11/13] event_loop: bound tick_possibly_forever's park at 1s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tick_possibly_forever's trailing tick() can start work whose only wake source is a cross-thread wakeup() — a --hot reload kicks off a transpile on a worker thread and waits for the result. After a throwing reload, unhandled_error_counter > 0 makes is_event_loop_alive() permanently false and the watcher loop degenerates to tick_possibly_forever on repeat; every step of every future reload must then land a wakeup() against a loop parked with no timeout. On a loaded CI box, one of the ~3 wakeups per cycle does not arrive and test/cli/hot/hot.test.ts stalls (6-8 of 10 Linux lanes on first attempt, 0/20 locally). main never actually parked here unbounded: a 1s GC timerfd woke the loop periodically. This PR set out to remove the file descriptor, not change the parking semantics — so keep the bound, lose the fd. The wakeup reliability question can be pursued separately without blocking this. Not a spin: loop_.tick() was measured to block in epoll_pwait2 for 14µs-26ms per call, ~3 calls per reload cycle, each properly woken; and a kernel-level test confirms eventfd+EPOLLET fires on every write even when never drained. --watch idle over 8s: ~1 wakeup/sec (main: ~2.25/sec). Still no timerfd. --- src/jsc/event_loop.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/jsc/event_loop.rs b/src/jsc/event_loop.rs index 7de96b727a1d..386bdc9c4ee7 100644 --- a/src/jsc/event_loop.rs +++ b/src/jsc/event_loop.rs @@ -1113,7 +1113,13 @@ impl EventLoop { self.process_gc_timer(); self.process_gc_timer(); - loop_.tick(); + // The trailing `tick()` can start work — a `--hot` reload kicks off a + // transpile on a worker thread and waits for the result — whose only + // wake source is a cross-thread `wakeup()`. Parking forever on that one + // source is brittle; `main` never did: a 1s GC `timerfd` woke this loop + // periodically. Keep the bound, lose the file descriptor. libuv never + // blocked here (`tick_with_timeout` ignores its argument). + loop_.tick_with_timeout(Some(&bun_core::Timespec { sec: 1, nsec: 0 })); self.vm_ref().as_mut().on_after_event_loop(); self.tick_concurrent(); From a211ba9cc27c359af21494ce5a0bba42ff187abb Mon Sep 17 00:00:00 2001 From: robobun Date: Fri, 10 Jul 2026 00:21:03 +0000 Subject: [PATCH 12/13] Cut the cfg-split: fix ensure_uv_timer instead of working around it The GarbageCollectionController cfg-split was a 200-line workaround for a 4-line bug in All::ensure_uv_timer: it restarts the uv_timer on every insert, and restarting an already-overdue handle shifts its wakeup out by 1ms each time. An insert-heavy path (the GC controller re-arming on every tick) starved the already-due callback forever, which is what broke test-timers-immediate-queue on Windows. Skip the restart when the handle is already armed and due sooner-or-equal. With that, the GC timers can sit on the heap on all platforms (same as #32447), and the per-platform scheduling backend goes away. Trim the comments that were narrating decisions instead of stating invariants. src+packages: +683/-283 -> +311/-369 (net -58 code/comments/blank). The remaining +79 total is the 137-line test file. --- .../bun-usockets/src/eventing/epoll_kqueue.c | 13 +- packages/bun-usockets/src/internal/internal.h | 4 - .../bun-usockets/src/internal/loop_data.h | 5 +- packages/bun-usockets/src/libusockets.h | 6 +- packages/bun-usockets/src/loop.c | 14 +- src/jsc/GarbageCollectionController.rs | 346 ++++-------------- src/jsc/VirtualMachine.rs | 4 +- src/jsc/event_loop.rs | 22 +- src/jsc/web_worker.rs | 4 +- src/runtime/cli/run_command.rs | 5 +- src/runtime/dispatch.rs | 18 +- src/runtime/timer/mod.rs | 8 +- src/uws_sys/InternalLoopData.rs | 5 - src/uws_sys/Timer.rs | 51 +-- src/uws_sys/lib.rs | 4 +- 15 files changed, 94 insertions(+), 415 deletions(-) diff --git a/packages/bun-usockets/src/eventing/epoll_kqueue.c b/packages/bun-usockets/src/eventing/epoll_kqueue.c index 5a64fee1d609..1f29cadbac8a 100644 --- a/packages/bun-usockets/src/eventing/epoll_kqueue.c +++ b/packages/bun-usockets/src/eventing/epoll_kqueue.c @@ -308,16 +308,12 @@ static void us_internal_drain_ready_polls(struct us_loop_t *loop) { } } -/* The socket-timeout sweep has no timerfd/EVFILT_TIMER behind it: bound the - * poll by its deadline when it is sooner than `timeout` (NULL == forever). - * `storage` is the caller's stack slot for the clamped value. */ +/* Bound `timeout` by the socket-timeout sweep deadline (NULL == forever). */ static const struct timespec *us_internal_clamp_to_sweep(struct us_loop_t *loop, const struct timespec *timeout, struct timespec *storage) { long long ns = us_internal_sweep_timeout_ns(loop); if (ns < 0) { return timeout; } - /* Field-wise, not widened to nanoseconds: tv_sec is a 64-bit second count - * and a far-future timeout would overflow the multiply. */ long long sweep_sec = ns / 1000000000LL; long long sweep_nsec = ns % 1000000000LL; if (timeout && (timeout->tv_sec < sweep_sec || @@ -384,8 +380,6 @@ void us_loop_run_bun_tick(struct us_loop_t *loop, const struct timespec* timeout } } - /* Same story for the socket-timeout sweep: Bun's timer heap doesn't know - * about it (the HTTP thread has no heap at all), so bound the poll here. */ struct timespec sweep_ts; timeout = us_internal_clamp_to_sweep(loop, timeout, &sweep_ts); @@ -609,11 +603,6 @@ size_t us_internal_accept_poll_event(struct us_poll_t *p) { #endif } -/* There is no us_timer_t here: it cost a timerfd (one fd each) or an - * EVFILT_TIMER registration (several syscalls per arm). Callers schedule on - * bun.JSC.EventLoopTimer instead, and the socket-timeout sweep is a deadline in - * us_internal_loop_data_t folded into the poll timeout. */ - /* Async (internal helper for loop's wakeup feature) */ #ifdef LIBUS_USE_EPOLL struct us_internal_async *us_internal_create_async(struct us_loop_t *loop, int fallthrough, unsigned int ext_size) { diff --git a/packages/bun-usockets/src/internal/internal.h b/packages/bun-usockets/src/internal/internal.h index cffef302bec9..ad52a99f6f37 100644 --- a/packages/bun-usockets/src/internal/internal.h +++ b/packages/bun-usockets/src/internal/internal.h @@ -150,8 +150,6 @@ void us_internal_timer_sweep(us_loop_r loop); void us_internal_enable_sweep_timer(struct us_loop_t *loop); void us_internal_disable_sweep_timer(struct us_loop_t *loop); #ifndef LIBUS_USE_LIBUV -/* POSIX sweep scheduling: no us_timer_t, just a deadline folded into the - * epoll/kqueue timeout. Defined in loop.c, driven from epoll_kqueue.c. */ long long us_internal_sweep_timeout_ns(struct us_loop_t *loop); void us_internal_sweep_if_due(struct us_loop_t *loop); #endif @@ -380,8 +378,6 @@ struct us_internal_callback_t { int leave_poll_ready; void (*cb)(struct us_internal_callback_t *cb); #ifdef LIBUS_USE_LIBUV - /* us_timer_set's one-shot guard for the sweep timer. POSIX has no - * us_timer_t at all (see loop_data.h sweep_next_tick_ns). */ unsigned has_added_timer_to_event_loop; #endif }; diff --git a/packages/bun-usockets/src/internal/loop_data.h b/packages/bun-usockets/src/internal/loop_data.h index 5454bd16b2ef..8e369f11df0b 100644 --- a/packages/bun-usockets/src/internal/loop_data.h +++ b/packages/bun-usockets/src/internal/loop_data.h @@ -39,9 +39,8 @@ struct us_internal_loop_data_t { #ifdef LIBUS_USE_LIBUV struct us_timer_t *sweep_timer; #else - /* Absolute CLOCK_MONOTONIC nanoseconds of the next socket-timeout sweep, - * or -1 when no sockets are linked. Folded into the epoll/kqueue timeout - * and checked after the poll — no timerfd, no EVFILT_TIMER. */ + /* Absolute monotonic ns of the next sweep, or -1. Folded into the poll + * timeout — no timerfd, no EVFILT_TIMER. */ long long sweep_next_tick_ns; #endif int sweep_timer_count; diff --git a/packages/bun-usockets/src/libusockets.h b/packages/bun-usockets/src/libusockets.h index 515d13ecd5b9..89c208b7f747 100644 --- a/packages/bun-usockets/src/libusockets.h +++ b/packages/bun-usockets/src/libusockets.h @@ -203,10 +203,8 @@ void *us_udp_socket_user(struct us_udp_socket_t *s); /* Binds the UDP socket to an interface and port */ int us_udp_socket_bind(struct us_udp_socket_t *s, const char *hostname, unsigned int port); -/* Public interfaces for timers. libuv (Windows) only: on epoll/kqueue a - * us_timer_t cost a file descriptor (timerfd) or a pair of kevent64 syscalls - * per arm, so it no longer exists — schedule on bun.JSC.EventLoopTimer. Gated - * on the platform because the backend is selected further down this header. */ +/* Public interfaces for timers. libuv (Windows) only — epoll/kqueue schedules + * on bun.JSC.EventLoopTimer, no file descriptor or syscall. */ #ifdef _WIN32 /* Create a new high precision, low performance timer. May fail and return null */ diff --git a/packages/bun-usockets/src/loop.c b/packages/bun-usockets/src/loop.c index 68817c1d43ba..b216a5633578 100644 --- a/packages/bun-usockets/src/loop.c +++ b/packages/bun-usockets/src/loop.c @@ -70,10 +70,6 @@ void us_internal_disable_sweep_timer(struct us_loop_t *loop) { #else -/* POSIX has no us_timer_t: the sweep is a plain deadline folded into the - * epoll/kqueue timeout (us_internal_sweep_timeout_ns) and dispatched from the - * same tick (us_internal_sweep_if_due). */ - #define LIBUS_TIMEOUT_GRANULARITY_NS ((long long) LIBUS_TIMEOUT_GRANULARITY * 1000000000LL) static long long us_internal_monotonic_ns(void) { @@ -97,8 +93,6 @@ void us_internal_disable_sweep_timer(struct us_loop_t *loop) { } } -/* Nanoseconds until the next sweep, or -1 when disarmed. Clamped at 0 for an - * already-overdue deadline so the caller polls without blocking. */ long long us_internal_sweep_timeout_ns(struct us_loop_t *loop) { if (loop->data.sweep_next_tick_ns < 0) { return -1; @@ -115,18 +109,14 @@ void us_internal_sweep_if_due(struct us_loop_t *loop) { if (now < loop->data.sweep_next_tick_ns) { return; } - /* Re-arm before dispatching: a timeout handler may unlink the last socket - * and us_internal_disable_sweep_timer would then disarm us — writing the - * next deadline afterwards would resurrect a dead timer. */ + /* Re-arm first: a timeout handler may unlink the last socket and disarm. */ loop->data.sweep_next_tick_ns = now + LIBUS_TIMEOUT_GRANULARITY_NS; us_internal_timer_sweep(loop); } #endif -/* Creates the loop's fallthrough polls (the ones that don't keep it alive): - * wakeup_async, plus sweep_timer under libuv. epoll/kqueue has no timer poll — - * the sweep is a deadline folded into the poll timeout. */ + void us_internal_loop_data_init(struct us_loop_t *loop, void (*wakeup_cb)(struct us_loop_t *loop), void (*pre_cb)(struct us_loop_t *loop), void (*post_cb)(struct us_loop_t *loop)) { // We allocate with calloc, so we only need to initialize the specific fields in use. diff --git a/src/jsc/GarbageCollectionController.rs b/src/jsc/GarbageCollectionController.rs index 16bb8c6caad1..931d559fc9d6 100644 --- a/src/jsc/GarbageCollectionController.rs +++ b/src/jsc/GarbageCollectionController.rs @@ -17,45 +17,21 @@ //! //! Thread Safety: This type must be unique per JavaScript thread and is not //! thread-safe. Each VirtualMachine instance should have its own controller. -//! -//! The two timers are scheduled differently per platform. On epoll/kqueue they -//! are intrusive nodes on the per-VM timer heap, because a `us_timer_t` there -//! costs a file descriptor (timerfd) or a pair of kevent64 syscalls per arm. On -//! libuv a `us_timer_t` is just a `uv_timer_t` — neither — and putting them on -//! the heap instead routes every GC arm through `All::ensure_uv_timer`, which -//! restarts the event loop's single shared `uv_timer` and starves JS timers -//! that are already due (`test-timers-immediate-queue`). use core::ffi::c_int; -#[cfg(not(windows))] use bun_core::{Timespec, TimespecMockMode}; -#[cfg(not(windows))] use bun_event_loop::EventLoopTimer::{EventLoopTimer, State as TimerState, Tag as TimerTag}; use bun_uws as uws; use crate::VM; use crate::virtual_machine::VirtualMachine; -/// Interval of the repeating timer once the heap has been stable for 30 ticks. const SLOW_REPEAT_INTERVAL_MS: i32 = 30_000; -/// Delay of the one-shot "collect on the next tick" nudge. -const ONE_SHOT_INTERVAL_MS: i32 = 16; pub struct GarbageCollectionController { - /// One-shot: when it fires, the next `process_gc_timer()` will - /// `collect_async()`. - #[cfg(not(windows))] pub gc_timer: EventLoopTimer, - /// Repeating: drives `perform_gc()` and the fast↔slow backoff. - #[cfg(not(windows))] pub gc_repeating_timer: EventLoopTimer, - // Raw FFI handles created by `uws::Timer::create_fallthrough` in `init`, - // freed in Drop. Stored as `Option>` (None = uninit). - #[cfg(windows)] - pub gc_timer: Option>, - #[cfg(windows)] - pub gc_repeating_timer: Option>, pub gc_last_heap_size: usize, pub gc_last_heap_size_on_repeating_timer: usize, pub heap_size_didnt_change_for_repeating_timer_ticks_count: u8, @@ -65,7 +41,6 @@ pub struct GarbageCollectionController { pub disabled: bool, } -#[cfg(not(windows))] bun_event_loop::impl_timer_owner!( GarbageCollectionController; from_gc_timer_ptr => gc_timer, @@ -75,14 +50,8 @@ bun_event_loop::impl_timer_owner!( impl Default for GarbageCollectionController { fn default() -> Self { Self { - #[cfg(not(windows))] gc_timer: EventLoopTimer::init_paused(TimerTag::GcOneShot), - #[cfg(not(windows))] gc_repeating_timer: EventLoopTimer::init_paused(TimerTag::GcRepeating), - #[cfg(windows)] - gc_timer: None, - #[cfg(windows)] - gc_repeating_timer: None, gc_last_heap_size: 0, gc_last_heap_size_on_repeating_timer: 0, heap_size_didnt_change_for_repeating_timer_ticks_count: 0, @@ -100,19 +69,13 @@ pub enum GcRepeatSetting { Slow, } -// ── scheduling backend: epoll/kqueue (the per-VM timer heap) ───────────────── -#[cfg(not(windows))] impl GarbageCollectionController { /// Remove `t` from the heap if linked, set its deadline to `now + ms`, and - /// insert. JS-thread only. - /// - /// Real time, never the mocked clock: GC pacing is ours, not the test's. - /// `jest.useFakeTimers()` starts its clock at zero, so a mocked deadline - /// would let `advanceTimersByTime()` drive collection. Same as `WTFTimer`. + /// insert. JS-thread only. Real time, not the mocked clock: GC pacing is + /// Bun's, not the test's. fn arm(vm: *mut VirtualMachine, t: *mut EventLoopTimer, ms: i32) { // SAFETY: `t` is one of the two embedded nodes of the per-VM controller, - // address-stable for the VM lifetime; JS-thread only. `timer_remove` / - // `timer_insert` re-deref `t` per-field, so no `&mut *t` is held here. + // address-stable for the VM lifetime; JS-thread only. unsafe { if (*t).state == TimerState::ACTIVE { VirtualMachine::timer_remove(vm, t); @@ -122,200 +85,6 @@ impl GarbageCollectionController { } } - /// Nothing to allocate: both nodes are embedded fields. - fn create_timers(&mut self) {} - - fn arm_one_shot(&mut self) { - Self::arm( - VirtualMachine::get_mut_ptr(), - &raw mut self.gc_timer, - ONE_SHOT_INTERVAL_MS, - ); - } - - /// Re-arm the repeating timer at a new interval. A no-op when called from - /// inside `on_gc_repeating_timer` — the node has just been popped (state - /// `FIRED`) and the callback's tail re-inserts it at the new interval. - fn rearm_repeating(&mut self, ms: i32) { - if self.gc_repeating_timer.state == TimerState::ACTIVE { - Self::arm( - VirtualMachine::get_mut_ptr(), - &raw mut self.gc_repeating_timer, - ms, - ); - } - } - - /// Arm the repeating timer on the first event-loop tick rather than in - /// `init()`, so the heap is never touched before the event loop is wired. - fn ensure_repeating_armed(&mut self) { - if self.gc_repeating_timer.state == TimerState::PENDING { - let interval = self.repeat_interval(); - Self::arm( - VirtualMachine::get_mut_ptr(), - &raw mut self.gc_repeating_timer, - interval, - ); - } - } - - /// Unlink both nodes from the per-VM heap. - /// - /// Must run while that heap is still intact, i.e. BEFORE JSC teardown: - /// `~RunLoop::Timer` unlinks and frees the `WTFTimer` nodes sharing it, so - /// an unlink afterwards walks freed siblings. Both callers (`global_exit`, - /// `web_worker`) do it next to `cancel_all_timers`. - fn unschedule(&mut self) { - // A `Drop` that runs after the VM left its thread-local slot has no heap - // left to unlink from — and the nodes die with the VM anyway. - let Some(vm) = VirtualMachine::get_or_null() else { - return; - }; - for t in [&raw mut self.gc_timer, &raw mut self.gc_repeating_timer] { - // SAFETY: JS-thread; nodes are linked iff state == ACTIVE, and - // `timer_remove` leaves them CANCELLED so a second call is a no-op. - unsafe { - if (*t).state == TimerState::ACTIVE { - VirtualMachine::timer_remove(vm, t); - } - } - } - } - - /// `Tag::GcOneShot` fire body. - /// - /// # Safety - /// `this` is the live per-VM controller; JS-thread only. - pub unsafe fn on_gc_timer(this: *mut Self) { - // SAFETY: per fn contract. - let this = unsafe { &mut *this }; - this.gc_timer.state = TimerState::FIRED; - this.on_one_shot_fired(); - } - - /// `Tag::GcRepeating` fire body. - /// - /// # Safety - /// `this` is the live per-VM controller; `vm` is the per-thread VM. - pub unsafe fn on_gc_repeating_timer(this: *mut Self, vm: *mut VirtualMachine) { - { - // SAFETY: per fn contract — `this` is live; this borrow ends before - // the re-entrant `arm()` below. - let me = unsafe { &mut *this }; - me.gc_repeating_timer.state = TimerState::FIRED; - if me.disabled { - return; - } - me.on_repeating_fired(); - } - // `rearm_repeating` only fires across a Fast↔Slow transition and skips - // the popped node anyway, so the steady-state tick re-arms here. - // SAFETY: per fn contract. - unsafe { - let interval = (*this).repeat_interval(); - Self::arm(vm, &raw mut (*this).gc_repeating_timer, interval); - } - } -} - -// ── scheduling backend: libuv (a us_timer_t is a uv_timer_t) ───────────────── -#[cfg(windows)] -impl GarbageCollectionController { - /// Recover `&mut Self` from a uws timer's ext slot. Single audited deref - /// for the two `extern "C"` callbacks below so they stay safe-bodied. - /// - /// `timer` is the live uws timer whose ext data was set to - /// `*mut GarbageCollectionController` in [`Self::init`]; the controller is - /// a BACKREF that strictly outlives the timer (`deinit()` closes the timer - /// before `self` is dropped). - #[inline] - fn from_timer_ext<'a>(timer: *mut uws::Timer) -> &'a mut Self { - let ptr = uws::Timer::opaque_mut(timer).as_::<*mut Self>(); - // SAFETY: BACKREF — see doc comment above. - unsafe { &mut *ptr } - } - - /// Accessor for the init-once `gc_timer` handle. - #[inline] - fn gc_timer_mut(&mut self) -> &mut uws::Timer { - // SAFETY: `gc_timer` is set in `init()` (via `Timer::create_fallthrough`) - // before any code path reaches a deref site, and remains a live FFI - // handle until `deinit()` closes it. The Timer lives on the uws heap, - // not inside `self`, so the returned `&mut` cannot alias `self`. - unsafe { &mut *self.gc_timer.expect("gc_timer set in init()").as_ptr() } - } - - /// Accessor for the init-once `gc_repeating_timer` handle. - #[inline] - fn gc_repeating_timer_mut(&mut self) -> &mut uws::Timer { - // SAFETY: same invariant as `gc_timer_mut`. - unsafe { - &mut *self - .gc_repeating_timer - .expect("gc_repeating_timer set in init()") - .as_ptr() - } - } - - fn create_timers(&mut self) { - // SAFETY: uws::Loop::get() returns the live process-global loop. - let actual = unsafe { &mut *uws::Loop::get() }; - self.gc_timer = Some(uws::Timer::create_fallthrough( - actual, - std::ptr::from_mut::(self), - )); - self.gc_repeating_timer = Some(uws::Timer::create_fallthrough( - actual, - std::ptr::from_mut::(self), - )); - } - - fn arm_one_shot(&mut self) { - let ext = std::ptr::from_mut::(self); - self.gc_timer_mut() - .set(ext, Some(on_gc_timer), ONE_SHOT_INTERVAL_MS, 0); - } - - fn rearm_repeating(&mut self, ms: i32) { - let ext = std::ptr::from_mut::(self); - self.gc_repeating_timer_mut() - .set(ext, Some(on_gc_repeating_timer), ms, ms); - } - - /// The uv_timer repeats on its own; `init()` arms it once. - fn ensure_repeating_armed(&mut self) {} - - fn unschedule(&mut self) { - // SAFETY: timers were created via uws::Timer::create_fallthrough; close:: - // frees the fallthrough timer. `take()` ensures we close at most once. - unsafe { - if let Some(t) = self.gc_timer.take() { - uws::Timer::close::(t.as_ptr()); - } - if let Some(t) = self.gc_repeating_timer.take() { - uws::Timer::close::(t.as_ptr()); - } - } - } -} - -#[cfg(windows)] -pub(crate) extern "C" fn on_gc_timer(timer: *mut uws::Timer) { - GarbageCollectionController::from_timer_ext(timer).on_one_shot_fired(); -} - -#[cfg(windows)] -pub(crate) extern "C" fn on_gc_repeating_timer(timer: *mut uws::Timer) { - let this = GarbageCollectionController::from_timer_ext(timer); - if this.disabled { - return; - } - this.on_repeating_fired(); -} - -// ── platform-independent policy ───────────────────────────────────────────── -impl GarbageCollectionController { - /// The interval the repeating timer currently runs at. #[inline] fn repeat_interval(&self) -> i32 { if self.gc_repeating_timer_fast { @@ -330,15 +99,6 @@ impl GarbageCollectionController { let actual = unsafe { &mut *uws::Loop::get() }; actual.internal_loop_data.jsc_vm = vm.jsc_vm.cast(); - self.create_timers(); - - // `Transpiler::init` is deferred to the high-tier - // `init_runtime_state` hook (which runs *after* `ensure_waker` → - // this `init`), so `vm.transpiler.env` is still the zeroed null ptr - // here on the main boot path. Fall back to defaults when null — these are debug/tuning - // knobs (BUN_GC_TIMER_INTERVAL / BUN_GC_TIMER_DISABLE / - // BUN_GC_RUNS_UNTIL_SKIP_RELEASE_ACCESS) and the dot_env loader would - // just be reading process env anyway. let env = vm.env_loader_opt(); let mut gc_timer_interval: i32 = 1000; @@ -361,36 +121,33 @@ impl GarbageCollectionController { } self.disabled = env.is_some_and(|e| e.has(b"BUN_GC_TIMER_DISABLE")); - - // libuv arms here (the uv_timer repeats itself); the heap backend waits - // for the first tick, see `ensure_repeating_armed`. - #[cfg(windows)] - if !self.disabled { - self.rearm_repeating(gc_timer_interval); - } } pub fn schedule_gc_timer(&mut self) { self.gc_timer_state = GCTimerState::Scheduled; - self.arm_one_shot(); + Self::arm(VirtualMachine::get_mut_ptr(), &raw mut self.gc_timer, 16); } pub fn bun_vm(&mut self) -> &mut VirtualMachine { - // S017: dropped `container_of` recovery — provenance of `&mut self` - // (which only covers `vm.gc_controller`) cannot soundly widen to the - // whole `VirtualMachine` under Stacked Borrows. Route through the - // per-thread singleton instead (same pointer, full-allocation - // provenance via `VirtualMachine::get_mut_ptr`). VirtualMachine::get().as_mut() } - /// Explicit teardown. Idempotent — `Drop` forwards here. Callers - /// (web_worker, the VM exit path) must run it before JSC teardown; see - /// `unschedule`. + /// Idempotent. Must run before JSC teardown: `~RunLoop::Timer` frees the + /// `WTFTimer` nodes sharing the heap, so an unlink afterwards walks freed + /// siblings. pub fn deinit(&mut self) { - // Terminal: nothing may re-arm the timers after they are torn down. self.disabled = true; - self.unschedule(); + let Some(vm) = VirtualMachine::get_or_null() else { + return; + }; + for t in [&raw mut self.gc_timer, &raw mut self.gc_repeating_timer] { + // SAFETY: JS-thread; nodes are linked iff state == ACTIVE. + unsafe { + if (*t).state == TimerState::ACTIVE { + VirtualMachine::timer_remove(vm, t); + } + } + } } // We want to always run GC once in awhile @@ -412,8 +169,14 @@ impl GarbageCollectionController { }; self.gc_repeating_timer_fast = want_fast; self.heap_size_didnt_change_for_repeating_timer_ticks_count = 0; - let interval = self.repeat_interval(); - self.rearm_repeating(interval); + if self.gc_repeating_timer.state == TimerState::ACTIVE { + let interval = self.repeat_interval(); + Self::arm( + VirtualMachine::get_mut_ptr(), + &raw mut self.gc_repeating_timer, + interval, + ); + } } #[inline] @@ -421,7 +184,14 @@ impl GarbageCollectionController { if self.disabled { return; } - self.ensure_repeating_armed(); + if self.gc_repeating_timer.state == TimerState::PENDING { + let interval = self.repeat_interval(); + Self::arm( + VirtualMachine::get_mut_ptr(), + &raw mut self.gc_repeating_timer, + interval, + ); + } let vm = VirtualMachine::get().jsc_vm(); self.process_gc_timer_with_heap_size(vm, vm.block_bytes_allocated()); } @@ -470,31 +240,47 @@ impl GarbageCollectionController { self.gc_last_heap_size = vm.block_bytes_allocated(); } - /// Shared body of the one-shot timer's callback. - fn on_one_shot_fired(&mut self) { - if self.disabled { + /// `Tag::GcOneShot` fire body. + /// + /// # Safety + /// `this` is the live per-VM controller; JS-thread only. + pub unsafe fn on_gc_timer(this: *mut Self) { + // SAFETY: per fn contract. + let this = unsafe { &mut *this }; + this.gc_timer.state = TimerState::FIRED; + if this.disabled { return; } - self.gc_timer_state = GCTimerState::RunOnNextTick; + this.gc_timer_state = GCTimerState::RunOnNextTick; } - /// Shared body of the repeating timer's callback. - fn on_repeating_fired(&mut self) { - let prev_heap_size = self.gc_last_heap_size_on_repeating_timer; - self.perform_gc(); - self.gc_last_heap_size_on_repeating_timer = self.gc_last_heap_size; - if prev_heap_size == self.gc_last_heap_size_on_repeating_timer { - self.heap_size_didnt_change_for_repeating_timer_ticks_count = self + /// `Tag::GcRepeating` fire body. + /// + /// # Safety + /// `this` is the live per-VM controller; `vm` is the per-thread VM. + pub unsafe fn on_gc_repeating_timer(this: *mut Self, vm: *mut VirtualMachine) { + // SAFETY: per fn contract. + let this = unsafe { &mut *this }; + this.gc_repeating_timer.state = TimerState::FIRED; + if this.disabled { + return; + } + let prev_heap_size = this.gc_last_heap_size_on_repeating_timer; + this.perform_gc(); + this.gc_last_heap_size_on_repeating_timer = this.gc_last_heap_size; + if prev_heap_size == this.gc_last_heap_size_on_repeating_timer { + this.heap_size_didnt_change_for_repeating_timer_ticks_count = this .heap_size_didnt_change_for_repeating_timer_ticks_count .saturating_add(1); - if self.heap_size_didnt_change_for_repeating_timer_ticks_count >= 30 { - // make the timer interval longer - self.update_gc_repeat_timer(GcRepeatSetting::Slow); + if this.heap_size_didnt_change_for_repeating_timer_ticks_count >= 30 { + this.update_gc_repeat_timer(GcRepeatSetting::Slow); } } else { - self.heap_size_didnt_change_for_repeating_timer_ticks_count = 0; - self.update_gc_repeat_timer(GcRepeatSetting::Fast); + this.heap_size_didnt_change_for_repeating_timer_ticks_count = 0; + this.update_gc_repeat_timer(GcRepeatSetting::Fast); } + let interval = this.repeat_interval(); + Self::arm(vm, &raw mut this.gc_repeating_timer, interval); } } diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index da40d0367a25..b06bdf1744e4 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -1537,9 +1537,7 @@ impl VirtualMachine { // `destroy()`, well after `global_exit`). unsafe { (hooks.cancel_all_timers)(core::ptr::from_mut(self)) }; } - // Same window, same reason: the GC timers are heap nodes too, and - // `~RunLoop::Timer` below frees the `WTFTimer` nodes they share the - // heap with. + // Same reason: the GC timers are heap nodes too. self.gc_controller.deinit(); // Detached worker threads may still be in startVM()/spin() using // the process-global resolver BSSMap singletons. transpiler.deinit() diff --git a/src/jsc/event_loop.rs b/src/jsc/event_loop.rs index 386bdc9c4ee7..5bc9a66c36db 100644 --- a/src/jsc/event_loop.rs +++ b/src/jsc/event_loop.rs @@ -75,10 +75,7 @@ pub struct EventLoop { // BACKREF — owning `*VirtualMachine` (EventLoop is a value field of it). pub virtual_machine: Option>, pub waker: Option, - /// `tick_possibly_forever()` has to park the loop even when nothing is - /// registered with it. libuv needs a live ref'd handle for that; epoll and - /// kqueue just need `num_polls != 0`, which is all this (no-op) timer ever - /// did for them. + // see `hold_forever_poll` #[cfg(windows)] pub forever_timer: Option>, #[cfg(not(windows))] @@ -1060,15 +1057,11 @@ impl EventLoop { Ok(result) } - /// Keep one poll registered with the loop so the upcoming - /// `us_loop_run_bun_tick` actually parks instead of returning immediately - /// (it bails out on `num_polls == 0`). Idempotent. + /// Keep one poll registered with the loop so `us_loop_run_bun_tick` parks + /// instead of returning immediately on `num_polls == 0`. #[cfg(not(windows))] fn hold_forever_poll(&mut self, loop_: &mut uws::Loop) { if !self.holds_forever_poll { - // Mirrors the non-fallthrough `us_create_timer` this replaced: - // `num_polls += 1`, never released (the timer was only closed at - // `global_exit`). loop_.inc(); self.holds_forever_poll = true; } @@ -1113,12 +1106,9 @@ impl EventLoop { self.process_gc_timer(); self.process_gc_timer(); - // The trailing `tick()` can start work — a `--hot` reload kicks off a - // transpile on a worker thread and waits for the result — whose only - // wake source is a cross-thread `wakeup()`. Parking forever on that one - // source is brittle; `main` never did: a 1s GC `timerfd` woke this loop - // periodically. Keep the bound, lose the file descriptor. libuv never - // blocked here (`tick_with_timeout` ignores its argument). + // `tick()` below can start work (e.g. a --hot reload) whose only wake + // source is a cross-thread `wakeup()`; bound the park, same as the GC + // timerfd used to. libuv's `tick_with_timeout` ignores the argument. loop_.tick_with_timeout(Some(&bun_core::Timespec { sec: 1, nsec: 0 })); self.vm_ref().as_mut().on_after_event_loop(); diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index 80ad06e8e4e8..ed93376cbaaf 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -1237,9 +1237,7 @@ impl WebWorker { // worker thread is still installed (torn down in `destroy()`). unsafe { (hooks.cancel_all_timers)(vm_ptr) }; } - // Same window, same reason: the GC timers are heap nodes too, and - // `WebWorker__teardownJSCVM` below frees the `WTFTimer` nodes they - // share the heap with. + // Same reason: the GC timers are heap nodes too. vm.gc_controller.deinit(); // Embedded socket groups must drain while JSC is still alive — // closeAll() fires on_close → JS callbacks. RareData.deinit() runs diff --git a/src/runtime/cli/run_command.rs b/src/runtime/cli/run_command.rs index 740de8ea005d..53bf18368aeb 100644 --- a/src/runtime/cli/run_command.rs +++ b/src/runtime/cli/run_command.rs @@ -1518,10 +1518,7 @@ impl Run { // When --hot/--watch is on (or a user // `uncaughtException` handler swallowed the error), keep the // process alive instead of hard-exiting on a rejected entry. - // The core run-loop below does the waiting: its watcher arm - // parks in `tick_possibly_forever`, and without a watcher it - // drains until the loop goes quiet and the process exits. - // Parking here too would block on nothing in the latter case. + // The core run-loop below does the actual waiting. if vm.hot_reload != 0 || handled { vm.add_main_to_watcher_if_needed(); // SAFETY: `event_loop` is a self-pointer into this VM; diff --git a/src/runtime/dispatch.rs b/src/runtime/dispatch.rs index 1d00d6703744..22049be0b6ab 100644 --- a/src/runtime/dispatch.rs +++ b/src/runtime/dispatch.rs @@ -164,8 +164,6 @@ use bun_sql_jsc::postgres::PostgresSQLConnection; use crate::test_runner::bun_test::{BunTest, BunTestPtr}; use crate::timer::{DateHeaderTimer, EventLoopDelayMonitor}; use bun_jsc::abort_signal::Timeout as AbortSignalTimeout; -// libuv keeps the GC timers as `us_timer_t`s, so the heap never sees them there. -#[cfg(not(windows))] use bun_jsc::garbage_collection_controller::GarbageCollectionController; #[cfg(not(windows))] @@ -978,29 +976,17 @@ pub unsafe fn __bun_fire_timer(t: *mut EventLoopTimer, now: *const ElTimespec, v AbortSignalTimeout::run(c, vm) }) } - // libuv keeps the GC timers as `us_timer_t`s, so these tags never reach - // the heap there. EventLoopTimerTag::GcOneShot => { - #[cfg(not(windows))] timer_arm!(GarbageCollectionController, gc_timer, |c, _now, _vm| { GarbageCollectionController::on_gc_timer(c) - }); - #[cfg(windows)] - if cfg!(debug_assertions) { - unreachable!("GcOneShot timer on Windows"); - } + }) } EventLoopTimerTag::GcRepeating => { - #[cfg(not(windows))] timer_arm!( GarbageCollectionController, gc_repeating_timer, |c, _now, vm| GarbageCollectionController::on_gc_repeating_timer(c, vm) - ); - #[cfg(windows)] - if cfg!(debug_assertions) { - unreachable!("GcRepeating timer on Windows"); - } + ) } EventLoopTimerTag::DateHeaderTimer => { timer_arm!(DateHeaderTimer, event_loop_timer, |c, _now, vm| (*c) diff --git a/src/runtime/timer/mod.rs b/src/runtime/timer/mod.rs index d86f406f3168..9309549ec06f 100644 --- a/src/runtime/timer/mod.rs +++ b/src/runtime/timer/mod.rs @@ -744,7 +744,13 @@ impl All { // https://github.com/nodejs/node/blob/f552c86fecd6c2ba9e832ea129b731dd63abdbe2/src/env.cc#L1512 let wait_ms = core::cmp::max(1, wait.ms_unsigned()); - self.uv_timer.start(wait_ms, 0, Some(Self::on_uv_timer)); + // SAFETY: `uv_timer_init` ran above; the handle is live. + let due_in = unsafe { uv::uv_timer_get_due_in(&self.uv_timer) }; + // Restarting an overdue handle shifts the wakeup out by 1ms. Done + // on every insert, the already-due callback never runs. + if !(self.uv_timer.is_active() && due_in <= wait_ms) { + self.uv_timer.start(wait_ms, 0, Some(Self::on_uv_timer)); + } if self.active_timer_count > 0 { self.uv_timer.ref_(); diff --git a/src/uws_sys/InternalLoopData.rs b/src/uws_sys/InternalLoopData.rs index d71c2ea4076d..5a38af507434 100644 --- a/src/uws_sys/InternalLoopData.rs +++ b/src/uws_sys/InternalLoopData.rs @@ -24,12 +24,8 @@ bun_opaque::opaque_ffi! { #[repr(C)] pub struct InternalLoopData { - /// libuv only: the `us_timer_t` driving the 4s socket-timeout sweep. #[cfg(windows)] pub sweep_timer: *mut Timer, - /// Absolute `CLOCK_MONOTONIC` nanoseconds of the next socket-timeout - /// sweep, or `-1` when no sockets are linked. epoll/kqueue has no - /// `us_timer_t`: C folds this straight into the poll timeout. #[cfg(not(windows))] pub sweep_next_tick_ns: i64, pub sweep_timer_count: i32, @@ -37,7 +33,6 @@ pub struct InternalLoopData { pub head: *mut SocketGroup, pub quic_head: *mut c_void, pub quic_next_tick_us: i64, - /// libuv only: see `quic_next_tick_us`. #[cfg(windows)] pub quic_timer: *mut Timer, pub iterator: *mut SocketGroup, diff --git a/src/uws_sys/Timer.rs b/src/uws_sys/Timer.rs index 9df36bd485c0..c6c8aed41875 100644 --- a/src/uws_sys/Timer.rs +++ b/src/uws_sys/Timer.rs @@ -6,21 +6,11 @@ use crate::Loop; bun_core::declare_scope!(uws, visible); -// **DEPRECATED** -// **DO NOT USE IN NEW CODE!** -// -// Use `JSC.EventLoopTimer` instead. -// -// Windows (libuv) only. On epoll/kqueue this type no longer exists: it held an -// entire file descriptor per timer on Linux, and cost several system calls per -// arm on macOS. +// Windows (libuv) only. Use `JSC.EventLoopTimer` everywhere else. bun_opaque::opaque_ffi! { pub struct Timer; } impl Timer { pub fn create(loop_: &mut Loop, _ptr: T) -> NonNull { - // never fallthrough poll - // the problem is uSockets hardcodes it on the other end - // so we can never free non-fallthrough polls // SAFETY: `loop_` is a valid loop pointer. let t = unsafe { us_create_timer( @@ -37,23 +27,6 @@ impl Timer { }) } - pub fn create_fallthrough(loop_: &mut Loop, _ptr: T) -> NonNull { - // SAFETY: `loop_` is a valid loop pointer. - let t = unsafe { - us_create_timer( - loop_, - 1, - c_uint::try_from(size_of::()).expect("int cast"), - ) - }; - NonNull::new(t).unwrap_or_else(|| { - panic!( - "us_create_timer: returned null: {}", - std::io::Error::last_os_error().raw_os_error().unwrap_or(0) - ) - }) - } - pub fn set( &mut self, ptr: T, @@ -70,34 +43,14 @@ impl Timer { } } - // Named `as_` because `as` is a Rust keyword. - pub fn as_(&mut self) -> T { - unsafe { - // SAFETY: the ext slot was allocated with `size_of::()` and - // written via [`set`] as a bare `T`, so read it as `T` directly. - // Wrapping in `Option` here would over-read and misinterpret - // the bytes (`Option<*mut T>` has no niche, so it is two words - // while the slot is one). Callers pass pointer-ish `T` and - // tolerate a (debug-asserted) null read. - let slot: *mut T = us_timer_ext(self).cast(); - slot.read() - } - } - - // Not `impl Drop` — FFI opaque handle with a const-generic param; - // destruction is an explicit C call and Drop cannot take parameters. Per PORTING.md - // FFI-handle exception, expose `unsafe fn close(*mut Self)` instead of `deinit(&mut self)`. pub unsafe fn close(this: *mut Self) { bun_core::scoped_log!(uws, "Timer.deinit()"); - // SAFETY: `this` is a live timer handle; us_timer_close frees it (caller must not - // use `this` afterward). + // SAFETY: `this` is a live timer handle; us_timer_close frees it. unsafe { us_timer_close(this, FALLTHROUGH as i32) }; } } unsafe extern "C" { - // `Loop` is a sized `#[repr(C)]` mirror (not an opaque ZST) — keep raw `*mut` - // so the FFI boundary does not annotate `noalias` over real loop fields. pub(crate) fn us_create_timer( loop_: *mut Loop, fallthrough: i32, diff --git a/src/uws_sys/lib.rs b/src/uws_sys/lib.rs index ad2b00a6f7ed..bd16222a618e 100644 --- a/src/uws_sys/lib.rs +++ b/src/uws_sys/lib.rs @@ -379,9 +379,7 @@ pub mod socket_group; pub mod socket_kind; #[path = "thunk.rs"] pub mod thunk; -// `us_timer_t` only exists on the libuv backend: on epoll/kqueue it cost an -// entire file descriptor (timerfd) or a pair of kevent64 syscalls per arm, so -// it is gone. Schedule on `bun_event_loop::EventLoopTimer` instead. +// libuv only — use `bun_event_loop::EventLoopTimer` elsewhere. #[cfg(windows)] #[path = "Timer.rs"] pub mod timer; From bcb8da2a86d6ce651b5156d57c093a7133259db3 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 9 Jul 2026 20:40:04 -0700 Subject: [PATCH 13/13] Delete pointless test --- test/js/bun/event-loop-timers.test.ts | 137 -------------------------- 1 file changed, 137 deletions(-) delete mode 100644 test/js/bun/event-loop-timers.test.ts diff --git a/test/js/bun/event-loop-timers.test.ts b/test/js/bun/event-loop-timers.test.ts deleted file mode 100644 index 94d1896bc705..000000000000 --- a/test/js/bun/event-loop-timers.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -// uSockets' us_timer_t no longer exists on epoll/kqueue: everything that used -// to need one now schedules on Bun's own event-loop timer heap. On Linux that -// means the process must not hold a single timerfd, no matter how much of the -// runtime is spun up. It used to hold four: the JS thread's socket-timeout -// sweep plus its two GC timers, and one more sweep on the HTTP client thread. -import { expect, test } from "bun:test"; -import { bunEnv, bunExe, isASAN } from "harness"; - -const COUNT_TIMERFDS = /* js */ ` - function countTimerFds() { - const { readdirSync, readlinkSync } = require("fs"); - let n = 0; - for (const fd of readdirSync("/proc/self/fd")) { - let link; - try { link = readlinkSync("/proc/self/fd/" + fd); } catch { continue; } - if (link.startsWith("anon_inode:[timerfd]")) n++; - } - return n; - } -`; - -async function countTimerFdsIn(body: string) { - await using proc = Bun.spawn({ - cmd: [bunExe(), "-e", COUNT_TIMERFDS + body], - env: bunEnv, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - return { stdout: stdout.trim(), stderr, exitCode }; -} - -test.concurrent.skipIf(process.platform !== "linux")("idle runtime holds no timerfd", async () => { - // Allocating churns the heap, which is what arms the GC controller's timers. - const { stdout, stderr, exitCode } = await countTimerFdsIn(` - for (let i = 0; i < 100; i++) new Uint8Array(4096); - console.log(countTimerFds()); - `); - expect(stdout).toBe("0"); - if (exitCode !== 0) expect(stderr).toBe(""); - expect(exitCode).toBe(0); -}); - -test.concurrent.skipIf(process.platform !== "linux")( - "a live server, the HTTP client thread, and JS timers hold no timerfd", - async () => { - const { stdout, stderr, exitCode } = await countTimerFdsIn(` - using server = Bun.serve({ port: 0, fetch: () => new Response("ok") }); - // fetch() spins up the HTTP client thread, which owns a second uws loop - // and therefore a second socket-timeout sweep. - const res = await fetch(server.url); - if ((await res.text()) !== "ok") throw new Error("bad response"); - const interval = setInterval(() => {}, 10); - const timeout = setTimeout(() => {}, 60_000); - await Bun.sleep(1); - console.log(countTimerFds()); - clearInterval(interval); - clearTimeout(timeout); - `); - expect(stdout).toBe("0"); - if (exitCode !== 0) expect(stderr).toBe(""); - expect(exitCode).toBe(0); - }, -); - -// The sweep that expires idle sockets used to ride on that timerfd. It is now a -// deadline folded into the epoll/kqueue wait, so prove it still fires. uSockets' -// sweep granularity is 4 seconds (LIBUS_TIMEOUT_GRANULARITY), which is the floor -// on how fast this can be observed — hence the explicit budget, matching the -// idleTimeout tests in test/js/bun/http/serve.test.ts. -test.concurrent( - "Bun.serve idleTimeout still expires an idle connection", - async () => { - using server = Bun.serve({ - port: 0, - idleTimeout: 1, - fetch: () => new Response("ok"), - }); - - const { promise, resolve, reject } = Promise.withResolvers(); - await Bun.connect({ - hostname: server.hostname, - port: server.port, - socket: { - // An incomplete request line: the server never replies, so the sweep is - // the only thing that can close this connection. - open: socket => void socket.write("GET / HTT"), - close: () => resolve("closed"), - error: (_socket, err) => reject(err), - connectError: (_socket, err) => reject(err), - data: () => reject(new Error("server should not have responded")), - }, - }); - - expect(await promise).toBe("closed"); - }, - 30_000, -); - -// The GC controller's timers live on the same heap as the `WTFTimer` nodes that -// `~RunLoop::Timer` frees during JSC teardown, so they have to be unlinked -// before it runs. Under `BUN_DESTRUCT_VM_ON_EXIT` that teardown actually -// happens, and getting the order wrong is a use-after-free in the pairing heap. -test.concurrent.skipIf(!isASAN)("destructing the VM on exit does not corrupt the timer heap", async () => { - await using proc = Bun.spawn({ - cmd: [bunExe(), "-e", `setTimeout(() => {}, 1); await Bun.sleep(5); console.log("ok");`], - env: { ...bunEnv, BUN_DESTRUCT_VM_ON_EXIT: "1" }, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect({ - stdout: stdout.trim(), - asan: stderr.includes("AddressSanitizer") ? stderr.slice(0, 400) : null, - signalCode: proc.signalCode ?? null, - exitCode, - }).toEqual({ stdout: "ok", asan: null, signalCode: null, exitCode: 0 }); -}); - -// A rejected entry point whose `uncaughtException` handler swallows the error -// used to park the loop with nothing registered that could ever wake it. It only -// ever returned because a 1s GC timerfd happened to be sitting in epoll; with no -// timerfd left, the process hung forever. -test.concurrent("an uncaughtException handler on a rejected entry point still exits", async () => { - await using proc = Bun.spawn({ - cmd: [bunExe(), "-e", `process.on("uncaughtException", () => console.log("handled")); throw new Error("boom");`], - env: bunEnv, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stdout.trim()).toBe("handled"); - // It exited on its own rather than being killed by the test timeout. - expect(proc.signalCode ?? null).toBe(null); - if (exitCode !== 0) expect(stderr).toBe(""); - expect(exitCode).toBe(0); -});