Skip to content

uws: drop us_timer_t on epoll/kqueue in favor of bun's timer heap - #33359

Merged
Jarred-Sumner merged 13 commits into
mainfrom
farm/ffa10012/posix-timer-heap
Jul 10, 2026
Merged

uws: drop us_timer_t on epoll/kqueue in favor of bun's timer heap#33359
Jarred-Sumner merged 13 commits into
mainfrom
farm/ffa10012/posix-timer-heap

Conversation

@robobun

@robobun robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator

us_timer_t is expensive on POSIX: each one holds a file descriptor on Linux (timerfd_create + timerfd_settime) and costs a pair of kevent64 syscalls per arm on macOS/FreeBSD. Bun already has a pairing-heap EventLoopTimer that costs nothing per timer.

A plain bun run with one Bun.serve + one fetch holds four timerfds: the socket-timeout sweep plus the two GC-controller timers on the JS thread, and a second sweep on the HTTP client thread.

Sweep — becomes an absolute CLOCK_MONOTONIC deadline in us_internal_loop_data_t, folded into the epoll_pwait2/kevent64 timeout and dispatched from the same tick. Same mechanism quic_next_tick_us already uses; works on loops that have no timer heap (the HTTP client thread, the CLI mini loops).

GC timers — become EventLoopTimer nodes embedded in the controller. Their tags opt out of jest.useFakeTimers() and they arm on real time (GC pacing is Bun's, not the test's). Doing this hit a latent 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, so the GC controller re-arming on every tick starved the already-due callback forever (test-timers-immediate-queue, hit=930 instead of 10). Fixed by skipping the restart when the handle is already armed and due sooner-or-equal.

tick_possibly_forever() — stays bounded at ~1s, just without the fd. Its trailing tick() can start work (a --hot reload on a worker thread) whose only wake source is a cross-thread wakeup(), and after a throwing reload the watcher loop degenerates to tick_possibly_forever on repeat. main never parked here unbounded anyway: the GC timerfd woke it every second. The forever_timer becomes a num_polls bump on epoll/kqueue.

Teardowngc_controller.deinit() now removes heap nodes, so it moves next to cancel_all_timers (before JSC teardown, where ~RunLoop::Timer frees the WTFTimer nodes sharing the heap). Also fixed: a rejected entry point whose uncaughtException handler swallowed the error parked with nothing able to wake it — the core run-loop already does the waiting.

us_create_timer/us_timer_set/us_timer_close and the uws::Timer wrapper are libuv-only now. timerfd/EVFILT_TIMER are gone from epoll_kqueue.c. src/ + packages/: net −58.

Tests

test/js/bun/event-loop-timers.test.ts:

  • /proc/self/fd holds zero anon_inode:[timerfd] entries idle and with a server + the HTTP thread + JS timers live (fails on main: 3 and 4).
  • Bun.serve idleTimeout still expires an idle connection.
  • BUN_DESTRUCT_VM_ON_EXIT=1 teardown is ASAN-clean.
  • An uncaughtException handler on a rejected entry point still exits.

--watch idle: ~1 wakeup/sec (main: ~2.25/sec).


no test proof · iteration 11 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/event-loop-timers.test.ts

@coderabbitai

coderabbitai Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 17 minutes

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

How can I continue?

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

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

How do review limits work?

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

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

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: d22ba2ce-a6ff-477d-a44b-ae704aad09de

📥 Commits

Reviewing files that changed from the base of the PR and between 4c56e53 and bcb8da2.

📒 Files selected for processing (18)
  • packages/bun-usockets/src/eventing/epoll_kqueue.c
  • packages/bun-usockets/src/internal/eventing/epoll_kqueue.h
  • packages/bun-usockets/src/internal/internal.h
  • packages/bun-usockets/src/internal/loop_data.h
  • packages/bun-usockets/src/libusockets.h
  • packages/bun-usockets/src/loop.c
  • src/event_loop/EventLoopTimer.rs
  • src/jsc/GarbageCollectionController.rs
  • src/jsc/VirtualMachine.rs
  • src/jsc/event_loop.rs
  • src/jsc/web_worker.rs
  • src/runtime/cli/run_command.rs
  • src/runtime/dispatch.rs
  • src/runtime/timer/mod.rs
  • src/uws/lib.rs
  • src/uws_sys/InternalLoopData.rs
  • src/uws_sys/Timer.rs
  • src/uws_sys/lib.rs

Walkthrough

This PR replaces epoll/kqueue socket-timeout sweeps with deadline-clamped polling, gates timer APIs and storage by platform, migrates GC timers and idle-loop keepalive behavior, adjusts shutdown ordering, and adds regression tests for timerfd usage and teardown behavior.

Changes

Timer and event-loop wiring changes

Layer / File(s) Summary
Platform-gated timer contracts
packages/bun-usockets/src/libusockets.h, packages/bun-usockets/src/internal/internal.h, packages/bun-usockets/src/internal/loop_data.h, packages/bun-usockets/src/internal/eventing/epoll_kqueue.h, src/uws/lib.rs, src/uws_sys/InternalLoopData.rs, src/uws_sys/Timer.rs, src/uws_sys/lib.rs
Public us_timer_* declarations, sweep and QUIC timer fields, and Rust timer bindings/re-exports are now platform-gated; non-libuv builds use sweep_next_tick_ns instead of a sweep timer handle.
Non-libuv sweep deadline implementation
packages/bun-usockets/src/loop.c
Adds the monotonic deadline helpers for sweep timeout calculation and due-sweep execution, initializes the alternate sweep state, and conditions sweep timer callback code on libuv builds.
Poll timeout clamping in epoll/kqueue
packages/bun-usockets/src/eventing/epoll_kqueue.c
Removes timer helper accessors, clamps poll timeouts against the sweep deadline, runs due sweeps after dispatch, updates Bun tick handling, and adjusts kqueue readable classification and accept-event comments.
GC timer migration to EventLoopTimer
src/event_loop/EventLoopTimer.rs, src/jsc/GarbageCollectionController.rs, src/runtime/dispatch.rs
GC pacing tags are excluded from fake timers; GarbageCollectionController splits timer storage and scheduling by platform, adds arm/rearm/unschedule helpers, and __bun_fire_timer dispatches the GC timer tags.
Forever-poll idle wiring
src/jsc/event_loop.rs, src/runtime/cli/run_command.rs
EventLoop replaces the always-present idle timer handle with a platform-specific idle state, adds a helper for keeping the loop alive while idle, and changes rejected-entrypoint parking to a single tick when watch or hot-reload is active.
GC teardown ordering
src/jsc/VirtualMachine.rs, src/jsc/web_worker.rs
Moves gc_controller.deinit() earlier in shutdown, removes the later duplicate call, and restricts forever_timer cleanup to Windows builds.
Timerfd and teardown regression tests
test/js/bun/event-loop-timers.test.ts
Adds Linux tests asserting zero timerfd usage, a Bun.serve idleTimeout sweep test, an ASAN-gated VM teardown test, and a rejected-entrypoint exit test.

Possibly related PRs

  • oven-sh/bun#32490: Modifies the epoll_pwait2 syscall path used by the same event-loop poll/timeout scheduling this PR changes.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title is concise and accurately summarizes the main POSIX timer-heap change.
Description check ✅ Passed The description is detailed and covers both the change rationale and verification, though it doesn't use the template's exact headings.

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

@robobun

robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 10:05 PM PT - Jul 9th, 2026

@Jarred-Sumner, your commit bcb8da2 is building: #71309

@github-actions github-actions Bot added the claude label Jul 5, 2026
@github-actions

github-actions Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Found 5 issues this PR may fix:

  1. Bun uses 2-10% idle CPU with large module registry + timers (Node.js uses ~0%) #27365 - Idle CPU overhead (2-10%, 35x context switches vs Node) directly caused by timerfd-based polling wakeups that this PR eliminates
  2. Small non-zero CPU usage while idling #21081 - Small non-zero CPU usage while idling matches timerfd/kevent-timer registrations waking the event loop when nothing is due
  3. Event loop busy-spins at 100% CPU when spawning concurrent bun processes using Ink + fetch (macOS ARM64) #27766 - Event loop busy-spins at 100% CPU on macOS ARM64 with kevent64 zero-timeout, consistent with EVFILT_TIMER entries this PR removes
  4. Bun.sql pegs a CPU while doing approximately nothing #22040 - Bun.sql pegs CPU while idle, maps to socket sweep timer and GC controller timers continuously firing via timerfd
  5. Node http.createServer can 100% CPU after day uptime in Bun 1.3.13 and 1.4.0 #32600 - HTTP server hits 100% CPU after day uptime, plausibly caused by GC/sweep timers misfiring over time via timerfd

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #27365
Fixes #21081
Fixes #27766
Fixes #22040
Fixes #32600

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. GarbageCollectionController: use EventLoopTimer instead of raw uws::Timer #32447 - Also rewrites GarbageCollectionController to use EventLoopTimer instead of raw uws::Timer; PR uws: drop us_timer_t on epoll/kqueue in favor of bun's timer heap #33359 supersedes it by additionally removing the broader us_timer_t abstraction across the epoll/kqueue backend

🤖 Generated with Claude Code

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't find any bugs, but this rearchitects timer scheduling across the C event loop, the Rust FFI struct mirrors, GC controller lifetime, and the tick_possibly_forever park path on all POSIX targets — worth a human sign-off.

Extended reasoning...

Overview

This PR removes us_timer_t on epoll/kqueue backends and replaces its three remaining users with cheaper mechanisms: the two GarbageCollectionController timers become intrusive EventLoopTimer nodes on the per-VM pairing heap, EventLoop::forever_timer becomes a bare num_polls bump plus a new poll_and_drain_timers runtime hook, and the uSockets socket-timeout sweep becomes an absolute CLOCK_MONOTONIC deadline folded into the epoll_pwait2/kevent64 timeout. It touches 17 files spanning packages/bun-usockets/ (C), src/uws_sys/ (Rust FFI struct mirrors), src/jsc/ and src/runtime/ (event loop, GC controller, timer dispatch), and adds a new test file.

Security risks

None identified. The change is internal scheduling plumbing; no untrusted input parsing, auth, or permission surfaces are touched.

Level of scrutiny

High. This is core event-loop scheduling that every request, socket timeout, GC nudge, and --watch/debugger-wait park runs through, with divergent per-platform code paths (Windows/libuv keeps us_timer_t; Linux/macOS/FreeBSD drop it entirely). It changes a #[repr(C)] struct layout that must stay byte-identical between C and Rust (us_internal_loop_data_tInternalLoopData), rewires GarbageCollectionController teardown to depend on runtime_state still being installed, and adds a new RuntimeHooks slot. The us_loop_run change also removes the us_loop_integrate call, which is now a no-op but is a semantic change to the public loop entry point.

Other factors

The PR description is thorough, the new test verifies zero timerfds and that idleTimeout still fires, and the author cross-checked the major socket/fetch/worker suites against a main baseline. The bug-hunting system found nothing. The implementation looks careful (e.g., re-arming the sweep before dispatching so a handler that unlinks the last socket doesn't resurrect a dead deadline; field-wise timespec compare to avoid overflow; schedule() handling ACTIVE→remove→insert). Still, the combination of intrusive-heap lifetime management in unsafe Rust, a C/Rust struct-layout change, and altered park semantics on the tick_possibly_forever path is exactly the kind of change a maintainer should read end-to-end.

@robobun

robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

Answering the three bot comments above.

#32447 is a real overlap, and this PR now matches it

#32447 is @Jarred-Sumner's own open PR doing the GarbageCollectionController half of this (src/event_loop/EventLoopTimer.rs, src/jsc/GarbageCollectionController.rs, src/runtime/dispatch.rs). I hadn't seen it when I opened this.

I've pushed b218e18 to converge on its shape exactly, so whichever of the two goes in first, the other rebases without conflict:

  • Tag::GcOneShot / Tag::GcRepeating (was GCTimer / GCRepeatingTimer)
  • arm(vm, t, ms) instead of my schedule(...)
  • the repeating timer arms lazily on the first process_gc_timer() tick rather than in init() — that's strictly better than what I had, since it keeps the timer heap untouched until the event loop is wired (Windows' ensure_uv_timer), and it drops a runtime_state.is_null() guard I needed
  • update_gc_repeat_timer skips the re-arm when called from inside the fire callback, letting the callback's tail re-insert

If you'd rather land #32447 on its own first, this PR rebases down to the uSockets + forever_timer work. Happy either way.

Re-verified the arming path after the rework, with a temporary eprintln! in each fire arm:

lazy-arm fires exactly once
repeating, 3.2s idle @ 1s 3 fires
repeating, 35s idle 31 fires — 30 fast ticks, then the slow-mode transition
--watch park (tick_possibly_forever) 3 fires in 4s

Not adding the Fixes block

I don't think this closes those five idle-CPU issues, and I'd rather not claim it. The change removes file descriptors and per-arm syscalls, but it does not change how often the loop wakes: the GC repeating timer still bounds the poll timeout at 1s (30s once the heap settles), and the socket sweep still wakes every 4s while sockets are linked. That is the same cadence the timerfds produced. What goes away is one fd per timer, the read(timerfd, 8) per expiry on Linux, and the one-to-two kevent64 calls per re-arm on macOS. Real, but not the 2-10% those issues describe.

One thing I did notice while testing, unrelated to this PR and not fixed here: BUN_GC_TIMER_DISABLE and BUN_GC_TIMER_INTERVAL have never worked on the main boot path. GarbageCollectionController::init() reads them through vm.env_loader_opt(), which is still None at that point (the comment right above it says as much), so disabled is always false and the interval is always the 1000ms default. On main:

$ BUN_GC_TIMER_DISABLE=1 bun -e '<count timerfds>'
timerfds: 3     # should be 1

Worth a separate fix.

Re: human sign-off

Agreed with @claude — the struct-layout mirror (us_internal_loop_data_tInternalLoopData) and the tick_possibly_forever park semantics are the two places I'd want a second pair of eyes. For what it's worth, bun run rust:check-all is green across all 10 targets, so the Windows/macOS cfg branches at least compile; I have no way to run the Windows suite from here.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't find any bugs, but this reworks the core event loop (epoll/kqueue poll timeout, GC pacing, tick_possibly_forever, C↔Rust InternalLoopData layout) across three platforms with a lot of unsafe/FFI surface — worth a human pass before merging.

Extended reasoning...

Overview

This PR removes us_timer_t on epoll/kqueue and replaces its three POSIX users with in-process mechanisms: the GC controller's two timers become intrusive EventLoopTimer nodes on the per-VM heap, the socket-timeout sweep becomes a CLOCK_MONOTONIC deadline folded into the epoll_pwait2/kevent64 timeout, and forever_timer becomes a bare num_polls bump plus a new poll_and_drain_timers runtime hook. It touches 17 files spanning the uSockets C backend (epoll_kqueue.c, loop.c, loop_data.h, libusockets.h), the Rust event loop / VM / GC controller, the tag-dispatch table in dispatch.rs, and the #[repr(C)] InternalLoopData mirror.

Security risks

None identified. No user input parsing, auth, or crypto is touched. The change is internal event-loop plumbing.

Level of scrutiny

High. This is production-critical hot-path code with several dimensions that each deserve careful eyes:

  • Cross-language struct layout: us_internal_loop_data_t gained/lost fields under #ifdef LIBUS_USE_LIBUV, and the Rust InternalLoopData mirror must match exactly on every platform or every field after the divergence is misread.
  • Event-loop parking semantics: tick_possibly_forever now goes through a new poll_and_drain_timers hook instead of loop_.tick(), and us_loop_run dropped its us_loop_integrate call. Getting this wrong means hangs (--watch, debugger wait) or busy-spins.
  • GC pacing: the repeating timer moved from kernel-driven to lazy-armed on first process_gc_timer(), with re-arm ordering that interacts with update_gc_repeat_timer being called from inside its own fire body. A regression here is silent (memory growth or excess CPU over hours/days).
  • Memory safety: two new intrusive EventLoopTimer nodes embedded in GarbageCollectionController with container_of recovery, raw-pointer arm()/deinit() paths, and a new Drop that reaches for the thread-local VM.
  • Three-way platform split: Linux epoll, macOS/FreeBSD kqueue, and Windows libuv now diverge more than before; the Windows path is meant to be unchanged but that's only true if every #[cfg]/#ifdef lines up.

Other factors

The PR description is thorough and the added test does exercise the observable contract (zero timerfds, idle-timeout still fires). The author's manual verification against baseline test suites is a good signal. But this is a ~600-line architectural change to the event loop that also supersedes another open PR (#32447), and it's exactly the kind of change where a maintainer should sanity-check the design choices (e.g., hold_forever_poll never releasing its num_polls bump, poll_and_drain_timers only calling drain_timers on unix, the sweep re-arm ordering in us_internal_sweep_if_due).

@robobun

robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

The ~2000 asan failures in build 68483 — root cause and fix (e4b2fbe)

Real bug, mine. It only fires under BUN_DESTRUCT_VM_ON_EXIT, which the x64-asan lane sets and my local bun bd runs did not — so it slipped through.

Once the GC controller's timers became heap nodes, gc_controller.deinit() became a timer-heap operation. Both teardown paths ran it after JSC teardown:

  • global_exit: after Zig__GlobalObject__destructOnExit
  • web_worker: after WebWorker__teardownJSCVM

That's precisely where ~RunLoop::Timer frees the WTFTimer nodes sharing that heap. And WTFTimer::cancel gates its unlink on script_execution_context_id.valid(), which is already false by then — so those nodes get freed while still linked. Unlinking a GC node afterwards walks into the freed siblings:

==12833==ERROR: AddressSanitizer: heap-use-after-free
WRITE of size 8
    #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<WTFTimer>::drop
    #10 WTFTimer::deinit
    #12 WTF::RunLoop::TimerBase::~TimerBase()

Nothing ever touched the heap that late before, because the GC timers were uSockets timers — Intrusive has no Drop, so deinit_runtime_state just drops the root pointer and never walks.

Fix: move gc_controller.deinit() into the window the codebase already reserves for exactly this — right next to cancel_all_timers, before JSC teardown — in both paths, and make deinit() terminal (disabled = true) so nothing can re-arm a node after it leaves the heap.

A hardening attempt I backed out, because it was wrong

My first instinct was to also fix the "freed while linked" half at its owning layer: have WTFTimer::deinit unlink unconditionally rather than letting cancel()'s valid() gate decide. That immediately UAF'd on the worker path:

READ of size 8
    #0 bun_runtime::jsc_hooks::runtime_state_of   src/runtime/jsc_hooks.rs:211
    #1 WTFTimer::deinit
    #3 WTF::RunLoop::TimerBase::~TimerBase()

The script_execution_context_id.valid() check is not about the heap node at all — it's the only thing guarding the vm backref deref inside cancel(), and on worker teardown the VirtualMachine is already gone by the time ~RunLoop::Timer runs. So the gate is load-bearing, and the dangling node it leaves behind is harmless precisely because the heap it dangles in is about to be dropped untouched. Reverted; WTFTimer.rs is unchanged in this PR.

Verification

Reproduced and confirmed fixed locally with the lane's flag:

BUN_DESTRUCT_VM_ON_EXIT=1 bun-debug -e 'console.log("ok")'                  # was: UAF
BUN_DESTRUCT_VM_ON_EXIT=1 bun-debug -e 'setTimeout(()=>{},1); await Bun.sleep(5)'
BUN_DESTRUCT_VM_ON_EXIT=1 bun-debug worker.js                               # worker teardown path
BUN_DESTRUCT_VM_ON_EXIT=1 bun-debug -e 'Bun.serve(...) + fetch'

All clean. Three of the suites the lane failed on (bundler_allow_unresolved, css/view-transition-23600, transpiler-stack-overflow) pass under that env, as do all 344 test/js/web/workers/ tests. bun run rust:check-all: 10/10.

Added a regression test for it, gated on isASAN since the corruption is only observable there.

@robobun
robobun force-pushed the farm/ffa10012/posix-timer-heap branch from e4b2fbe to bdc7d16 Compare July 5, 2026 10:01
@robobun

robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

Windows regression, and a scope correction (bdc7d16)

The asan lane is clean now (zero AddressSanitizer annotations in build 68506, 18 of 20 asan jobs green — the 2 reds were color.test.ts and tls-syscall-fault, both already fixed on main). Rebased onto main to pick those up.

That left one real failure I'd missed under the asan noise: test/js/node/test/parallel/test-timers-immediate-queue.js has been failing on all three Windows lanes since the first commit of this branch (the annotation list caps at 30 and the UAFs crowded it out — I had to grep the job logs to find it).

AssertionError: Expected values to be strictly equal:
930 !== 10

The test queues 10 setImmediates, arms a setTimeout(…, 1), blocks 20ms, and asserts the timer fires before the recursively-queued immediates get another turn. hit = 930 means the timer was starved for ~16ms of spinning.

Cause: on libuv, All.uv_timer is the only thing that wakes uv_run for JS timers, and All::insertensure_uv_timer() restarts it for the soonest deadline on every insert (uv_timer.start(max(1, wait_ms), …)). Putting the GC timers on the heap made the GC controller an insert source, so an already-due JS timer keeps having its wakeup pushed back out.

Fix: don't. There was never a reason to move them on libuv — a us_timer_t there is a uv_timer_t: no file descriptor, no syscall per arm. Only epoll/kqueue pay timerfd / EVFILT_TIMER, which is the whole point of this branch, and the task was scoped to POSIX. So the scheduling backend is now per-platform behind four small helpers (arm_one_shot / rearm_repeating / ensure_repeating_armed / unschedule); the state machine and the fast↔slow backoff stay shared, and libuv keeps byte-for-byte the code it had on main. poll_and_drain_timers likewise collapses to the tick() the caller used to do inline, since the heap doesn't bound uv_run anyway.

@Jarred-Sumner — this is worth knowing for #32447, which moves the GC timers to EventLoopTimer on all platforms. I'd expect it to hit the same Windows starvation. Fixing it properly probably means making ensure_uv_timer idempotent (skip the uv_timer_start when it's already armed for the same deadline) rather than restarting on every insert, but I didn't want to guess at the libuv interaction without a Windows box to test on.

State

  • bun run rust:check-all: 10/10 targets, including both Windows ones.
  • timerfd count with a live server + fetch + JS timers: still 0 (was 4).
  • BUN_DESTRUCT_VM_ON_EXIT=1: clean on the plain-exit, timers, worker, and server paths.
  • test-timers-immediate-queue.js, test/js/web/workers/ (344), test/js/bun/test/fake-timers/ (61), the new event-loop-timers.test.ts (4), Bun.serve idleTimeout, --watch park: all pass.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/bun/event-loop-timers.test.ts`:
- Around line 39-41: The `countTimerFdsIn` subprocess checks in
`event-loop-timers.test.ts` are only asserting `stdout` and `exitCode`, so any
child-process failure will hide useful diagnostics. Update the affected call
sites to also use `stderr`, and insert the house-style guard `if (exitCode !==
0) { expect(stderr).toBe(""); }` immediately before each `exitCode` assertion.
Apply this to both `countTimerFdsIn` usages so failures surface stderr
consistently.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6f83cab7-4be3-4d30-a0c1-2bad13cb9162

📥 Commits

Reviewing files that changed from the base of the PR and between fb50cce and bdc7d16.

📒 Files selected for processing (18)
  • packages/bun-usockets/src/eventing/epoll_kqueue.c
  • packages/bun-usockets/src/internal/eventing/epoll_kqueue.h
  • packages/bun-usockets/src/internal/internal.h
  • packages/bun-usockets/src/internal/loop_data.h
  • packages/bun-usockets/src/libusockets.h
  • packages/bun-usockets/src/loop.c
  • src/event_loop/EventLoopTimer.rs
  • src/jsc/GarbageCollectionController.rs
  • src/jsc/VirtualMachine.rs
  • src/jsc/event_loop.rs
  • src/jsc/web_worker.rs
  • src/runtime/dispatch.rs
  • src/runtime/jsc_hooks.rs
  • src/uws/lib.rs
  • src/uws_sys/InternalLoopData.rs
  • src/uws_sys/Timer.rs
  • src/uws_sys/lib.rs
  • test/js/bun/event-loop-timers.test.ts
💤 Files with no reviewable changes (1)
  • packages/bun-usockets/src/internal/eventing/epoll_kqueue.h

Comment thread test/js/bun/event-loop-timers.test.ts Outdated
@robobun

robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

Build 68540's reds are all flakes (re-rolled)

Four failures, none in this diff's blast radius:

failure lane why it's flake
v8-heap-snapshot.test.ts — SIGKILL ubuntu 25.04 x64 Passed on this exact lane in build 68523, whose runtime code is byte-identical (bdc7d16a9b3b32 only destructures stderr in a test file). Also passed on the aarch64 and x64-baseline lanes of 68540 itself.
bun-serve-file.test.ts — timeout darwin 26 aarch64 67 pass / 0 fail locally
fetch-file-upload.test.tssendfile() timed out at 10002ms darwin 26 aarch64 passes locally in 1.1s
bake/dev-and-prod.test.ts windows 2019 x64-baseline already a warning — passed on retry

Build 68523, with this diff's runtime code, was 284 passed / 0 failed across the whole matrix. Re-rolled as 8f571af; Buildkite's read-only token can't retry the single job.

Where this stands

Ready for review. Summary of what changed and why is in the PR body; the three things worth a maintainer's eyes:

  1. us_internal_loop_data_tInternalLoopData — the C and Rust mirrors diverge by #ifdef LIBUS_USE_LIBUV / #[cfg(windows)] now, so they have to stay in lockstep. bun run rust:check-all is 10/10 and the layout asserts in Loop.rs still hold, but it's the kind of thing that rots.
  2. The sweep deadline (us_internal_sweep_if_due re-arms before dispatching, so a handler that unlinks the last socket can't resurrect a dead deadline).
  3. GarbageCollectionController: use EventLoopTimer instead of raw uws::Timer #32447 overlaps on the GC-controller half. I matched its naming/shape so either can land first, but it moves the GC timers to EventLoopTimer on all platforms and I'd expect it to hit the Windows ensure_uv_timer starvation described in the body.

Comment thread packages/bun-usockets/src/loop.c
@robobun

robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

Final status: the diff is green; CI is red on broken agents

Build 68557 (90318a7): 282 passed, 0 test failures. The two red jobs never ran a single test — both are agent infrastructure:

:darwin: 26 aarch64 - test-bun
  Error: buildkite-agent artifact download timed out after 120s for step
  'darwin-aarch64-build-bun'. Refusing to continue with a partial download.

:darwin: 14 x64 - test-bun
  SystemError [ERR_SYSTEM_ERROR]: uv_os_get_passwd returned ENOENT
      at userInfo (node:os:305:11)
      at spawnBun (scripts/runner.node.mjs:1306:33)

The only test annotation is test/cli/hot/hot.test.ts on Windows 11 aarch64, a warning that passed on retry.

Taken together with build 68523284 passed / 0 failed across the whole matrix, on runtime code identical to what's here — this branch is green. I've spent my one re-roll (8f571af) and won't push another; the remaining reds are an artifact-download timeout and a macOS agent with no passwd entry, neither of which another run fixes reliably.

Ready for review

The PR body has the full story. Three things worth a maintainer's eyes:

  1. us_internal_loop_data_tInternalLoopData now diverge by #ifdef LIBUS_USE_LIBUV / #[cfg(windows)], so the C and Rust mirrors have to stay in lockstep. rust:check-all is 10/10 and the Loop.rs layout asserts still hold, but it's the kind of thing that rots.
  2. us_internal_sweep_if_due re-arms before dispatching, so a timeout handler that unlinks the last socket can't resurrect a dead deadline.
  3. GarbageCollectionController: use EventLoopTimer instead of raw uws::Timer #32447 overlaps on the GC-controller half — I matched its naming and shape so either can land first, but it moves the GC timers on all platforms and should hit the Windows ensure_uv_timer starvation documented in the body.

Headline: a bun run with one Bun.serve and one fetch went from 4 timerfds to 0, and macOS stops paying a kevent64 pair per timer arm.

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This poll_and_drain_timers call seems strange? Don't we already drain timers? Why are we doing it twice? And why is there any unsafe usage?

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

You're right on all three. Deleted it in f69e395 — 96 lines out, one less RuntimeHooks slot, no unsafe on that path.

Why it was there: tick_possibly_forever() is the --watch / debugger-wait park. On main, the one thing that fired there was the GC pacing timer, and only because its timerfd was registered in epoll — so it woke the loop every second and dispatched through POLL_TYPE_CALLBACK. Once the GC timers moved onto the heap, nothing woke for them, so I added a hook to compute the heap deadline, pass it as the poll timeout, and drain.

"Don't we already drain timers?" Not twice in one pass — auto_tick_active and tick_possibly_forever are mutually exclusive (loop { while alive { tick(); auto_tick_active() } ; tick_possibly_forever() }). But it was a third copy of the same get_timeouttick_with_timeoutdrain_timers body, which is the smell you're picking up on.

And my own justification was circular. I wrote that without the drain, an overdue timer would make get_timeout return a zero deadline and spin the caller's loop. That's only true because the hook called get_timeout. loop_.tick() passes a NULL timeout and never consults the heap, so it just blocks. Remove the call, remove the hazard.

So tick_possibly_forever goes back to loop_.tick(). The GC pacing timer simply waits for the next real wakeup, where auto_tick_active drains it anyway. For what it's worth main already parked WTFTimers there (it never called get_timeout/drain_timers), so GC pacing was the only thing losing a tick — and not ticking it is the better answer. Measured over an 8s idle window in --watch:

this branch:   0 voluntary context switches
main:         12 voluntary context switches  (~1.5/s — the gc timerfd)

--watch now actually parks instead of waking every second to collect a heap that isn't growing.

"Why is there any unsafe usage?" The hook body was raw-pointer-per-field (unsafe { (*vm).event_loop }, unsafe { (*loop_).tick_with_timeout(..) }) because that's what auto_tick does — it has to, since get_timeout/drain_timers fire JS callbacks that re-enter runtime_state(), so you can't hold a &mut All or &mut VirtualMachine across them. Copying that shape into a third site was the wrong call; deleting the site is the right one. Nothing in tick_possibly_forever is unsafe now beyond the &mut *loop_ptr that was already there on main.

Re-verified after the deletion: timerfd count still 0, test-timers-immediate-queue.js passes, fake-timers 61/0, BUN_DESTRUCT_VM_ON_EXIT=1 teardown clean, --watch still reloads on change, rust:check-all 10/10.

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Done in 2b05dd7 — and you were right for a reason I had backwards.

I'd used now_allow_mocked_time() because All::next compares the real heap against the mocked clock, and I reasoned a real-time deadline would look perpetually overdue under a fast-forwarded fake clock. That's the wrong direction: jest.useFakeTimers() seeds the clock at Timespec::EPOCH and counts up from zero, while now_real() is CLOCK_MONOTONIC (machine uptime). So a real-time deadline sits far ahead of the mocked clock and simply never fires while fake timers are installed — it pauses, then resumes on useRealTimers(). No spin.

The mocked version, meanwhile, put the deadline in mocked-clock units whenever arm() ran inside a fake-timer window, which means advanceTimersByTime() was driving Bun's garbage collection.

It does reproduce, just not from a synchronous test body — you need real event-loop ticks inside the window so process_gc_timer()arm() actually runs. File I/O works, since the fake heap doesn't capture it:

jest.useFakeTimers();
for (let i = 0; i < 40; i++) {
  jest.advanceTimersByTime(5_000);          // 200s of mocked time
  await Bun.file("/etc/hostname").text();   // a real tick
}
jest.useRealTimers();
GC fires
ForceRealTime (now) 3 — pure real-time pacing, the mocked advance is inert
AllowMockedTime (before) 34

Deterministic across runs (3/3, 34/35). That also puts the GC timers in line with WTFTimer and EventLoopDelayMonitor, which already force real time for the same reason — internal pacing, and their tags already opt out of fake-timer capture.

Verified after: timerfd count still 0, test/js/bun/test/fake-timers/ 61/0, the new event-loop-timers.test.ts 4/0, BUN_DESTRUCT_VM_ON_EXIT=1 teardown clean, rust:check-all 10/10.

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Dropping the hook exposed a real hang — fixed in 4c56e533

Build 68779 (the poll_and_drain_timers removal) failed test/js/node/process/process.test.js's uncaughtException tests on a 90s timeout, and test/cli/hot/hot.test.ts. Reproduced it down to two lines:

process.on("uncaughtException", () => console.log("handled"));
throw new Error("boom");
main prints handled, exits 0
that commit prints handled, then hangs forever

Cause. run_command.rs did this on a rejected entry point:

if vm.hot_reload != 0 || handled {
    vm.add_main_to_watcher_if_needed();
    vm.event_loop_ref().tick();
    vm.event_loop_ref().tick_possibly_forever();   // ← parks on nothing
}

With handled == true and no --watch, nothing registered with that loop can ever wake it. It only ever came back because a 1s GC timerfd happened to be sitting in epoll. So this was a latent "park on nothing" bug that main papers over with a timer it doesn't even want — and the accident survived my first pass only because poll_and_drain_timers' get_timeout also returned that 1s GC deadline. Delete the hook, delete the accident, hang.

Fix. Drop the call. It was redundant: the core run-loop immediately 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 there only ever blocked on nothing.

Added a regression test; it times out against the broken code and passes against the fix.

Verified

  • process.test.js — was a 90s hang, now runs clean (103 pass / 1 fail, and that one is process.env.USER missing in this container; released bun fails it too, 102/2).
  • hot.test.ts — 12/12.
  • --watch still parks and reloads: 0 voluntary context switches over a 6s idle window, then picks up the change.
  • timerfd count still 0; event-loop-timers.test.ts 5/0; fake-timers 61/0; test-timers-immediate-queue.js passes; BUN_DESTRUCT_VM_ON_EXIT=1 teardown clean; rust:check-all 10/10.

Worth flagging: I'd have missed this if I hadn't gone looking at the superseded build 68779 — it was canceled by my next push, so its failures never surfaced as a CI notification.

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Status after 4c56e533, and one question for you

Removing poll_and_drain_timers turned out to expose two latent "the loop parks and nothing wakes it" problems, because main has a 1s GC timerfd sitting in epoll that quietly un-parks tick_possibly_forever() every second.

First one — fixed, deterministic. A rejected entry point whose uncaughtException handler swallowed the error parked with no watcher at all registered. Two-line repro, main exits 0, that commit hung forever. run_command.rs was calling tick_possibly_forever() where the core run-loop right below already does the waiting. Dropped the call, added a regression test that times out against the broken code. process.test.js went from a 90s hang to clean, and failed lanes went 15 → 1.

Second one — real, and I'd like your call. test/cli/hot/hot.test.tsshould recover from errors now fails on about one Linux lane per build (a different one each time: ubuntu x64-baseline, then ubuntu x64, then debian aarch64). It's clean on main, and I cannot reproduce it locally — 0 failures in 20 runs.

The --hot reloader enqueues a ConcurrentTask and calls wakeup(), which should make epoll return. But it only needs to miss or lag once to stall, and on main a missed wake costs at most one GC-timer tick before the loop spins again and drains the task. Take that away and the stall is permanent. So I think this is a pre-existing reliability gap in the watcher wake-up path that the GC timerfd has been masking — my change didn't introduce it, it just stopped hiding it.

Two ways forward, and I don't want to pick for you:

  1. Fix the wake path. Correct, but it's a separate investigation into the watcher/concurrent-task handoff, and it's not something I want to guess at blind.
  2. Bound the park. tick_possibly_forever() stops meaning forever and takes an upper bound. Honest about what main already does, but it is a safety net over a bug rather than a fix, and it gives back some of the idle win (--watch currently sits at 0 voluntary context switches over an 8s idle window, vs 12 on main).

I'd lean (1), but it belongs to someone who knows that code. Happy to do either.

Everything else

2b05dd7 also took your note on mocked time — GC timers now arm on ForceRealTime, matching WTFTimer/EventLoopDelayMonitor. That one was a real bug: with the mocked clock, advanceTimersByTime() was driving collection (34 GC fires vs 3 across 200s of mocked advance).

Green otherwise: timerfd count 0, event-loop-timers.test.ts 5/0, fake-timers 61/0, test-timers-immediate-queue.js passes, BUN_DESTRUCT_VM_ON_EXIT=1 teardown clean, --watch parks and reloads, rust:check-all 10/10.

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Addendum on the hot.test.ts failure — I tried to pin it down further and ruled out the easy explanations, so the question above stands.

The correlation is clean:

hot.test.ts on Linux
builds with poll_and_drain_timers (68474, 68483, 68523, 68557) 0 / 4 failed
builds without it (68779, 68791, 68803) 3 / 3 failed

What it isn't. I replicated the test's exact good → error → good → error cycle locally against this branch:

  • Not a spin/starvation: CPU stays at 0.5–1.5% through every phase, including while parked after a reload that threw.
  • Not a hang: all reloads fire.
  • Not a latency regression: 4 reload cycles take 494ms on this debug build vs 208ms on the released (release) binary — normal debug overhead, not a stall.
  • Not locally flaky: 0 failures in 20 runs of should recover from errors.

What I think it is. A rare lost wakeup somewhere in the watcher → enqueue_task_concurrentwakeup() handoff. On main a lost wake costs at most one GC-timer tick before the loop spins again and drains the task, so it's invisible. With the timerfd gone the loop parks indefinitely on that single wake source, and one lost event strands it until the 10s test budget expires. Rare, load-dependent, and exactly the shape of "fails on one lane per build, a different one each time, never locally".

So I'd still rather not guess. Fixing the wake path is the right answer, but it's your code and I don't want to paper over it by quietly reinstating the bound you just asked me to remove. If you'd rather I bound the park as a stopgap, say the word and it's a one-liner — a 2s bound would still leave --watch at ~0.5 wakeups/sec against main's ~1.5, so most of the idle win survives.

Everything else on the branch is green.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun could it be that the it's spin-looping instead of blocking in epoll/kqueue?

@robobun

robobun commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

could it be that the it's spin-looping instead of blocking in epoll/kqueue?

Not that I can see. I instrumented loop_.tick() inside tick_possibly_forever() with a monotonic timer around it and ran the exact hot.test.ts error→good→error→good sequence:

--- write error 1          (no tick_possibly_forever calls yet — still in auto_tick_active)
--- write good 1
  call #1 blocked 23834µs  np=1 active=1   ← parked, woken by the watcher's eventfd write
  call #2 blocked  3402µs  np=2 active=2
  call #3 blocked  1293µs  np=2 active=2   ← Reloaded: 2 printed after this
--- write error 2
  call #4 blocked 26557µs  np=1 active=1
--- write good 2
  call #5 blocked    14µs  np=2 active=2
  call #6 blocked  2526µs  np=2 active=2
  call #7 blocked  1182µs  np=2 active=2
--- (idle, no further calls)

So it's ~3 calls per reload cycle, each one actually blocks in epoll_pwait2 and is woken. Whole-process CPU stays at 0.5–1.5% throughout. I also falsified a lost-edge theory directly — eventfd + EPOLLET generates a fresh event on every write() even when never drained (kernel test: 4/4).

What I did find

First, I was wrong about the scale. Pulling the raw job logs instead of the annotations: it hangs on 6–8 of the 10 Linux lanes on the first attempt, and the runner's retry hides most of them. The 1–2 that show as failed are the ones where every retry hung. A different subset each build, so it's not lane-specific.

Second — and this is the interesting bit — the fixture has setTimeout(() => {}, 9999999), so I'd assumed the process lives in auto_tick_active. It doesn't: is_event_loop_alive()'s precondition is unhandled_error_counter == 0, and report_exception_in_hot_reloaded_module_if_needed() bumps that counter on every throwing reload. Once it throws once, is_event_loop_alive() is permanently false and the watcher loop degenerates to

loop { on_before_exit(); report_exception...(); tick_possibly_forever(); }

auto_tick_active is never reached again. That means after the first throw, nothing ever fires heap timers in this loop: no get_timeout (with its WTFTimer side-effect), no drain_timers. On main the GC timerfd still woke loop_.tick() every second, but it never drained the heap either, so that can't be the difference.

So the stall is a reload whose completion needs one more wake that doesn't arrive on a loaded CI box — and on main the 1s GC tick covered for it. Locally each cycle gets exactly the 2–3 wakeups it needs and finishes in ~55ms; I can't catch the missing one here (0/20 test runs, 0/4 cycles across the instrumented trace).

What I'd like to try next

Push instrumentation to CI to see which loop_.tick() call is the last before the 10s timeout, and what num_polls/active/pending_wakeups were. That would tell us whether the watcher's wakeup() actually fired, and if it did, why epoll didn't return. I can put that together if you're OK with a throwaway instrumented push.

Or if you'd rather just bound the park while we root-cause it separately — say the word.

robobun added 7 commits July 7, 2026 09:03
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.
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).
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<WTFTimer>::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.
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.
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.
epoll/kqueue creates exactly one (wakeup_async); the sweep is a deadline,
not a poll.
@robobun

robobun commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator Author

Fair. The bloat was mine, and it was mostly one bad decision.

before now
total +683/−283 (net +400) +448/−369 (net +79)
src+packages only net +263 net −58
GarbageCollectionController.rs 316→513 (+197) 316→299 (−17)

The +79 that remains is the 137-line test file.

What was wasteful. When putting the GC timers on the heap broke test-timers-immediate-queue on Windows, I cfg-split GarbageCollectionController — keeping us_timer_t on Windows and adding the heap backend on POSIX, with helper stubs on both sides. That's +200 lines of working around a 4-line bug in All::ensure_uv_timer.

The actual fix (a211ba9): ensure_uv_timer restarts the uv_timer on every insert, and restarting an already-overdue handle shifts its wakeup out by 1ms. An insert-heavy path — the GC controller re-arming on every tick — starved the already-due callback forever. Skip the restart when the handle is already armed and due sooner-or-equal. With that, the GC timers sit on the heap on all platforms (same as #32447), and the per-platform split goes away.

Also deleted uws::Timer::create_fallthrough/as_/ext (no callers left) and stripped comments that narrated decisions rather than stating invariants — those belong in commit messages.

All key suites still green locally; rust:check-all 10/10. I can't run Windows CI from here, so the ensure_uv_timer change is the thing to watch on the next build.

@robobun

robobun commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator Author

Build 71240: 284 passed / 0 failed.

  • Windows: all 24 test-bun shards passed — the ensure_uv_timer fix holds, so the GC timers live on the heap on every platform and the 200-line cfg-split is gone for real. test-timers-immediate-queue.js is clean on all three Windows lanes.
  • Linux hot.test.ts: 0/79 shards hung.
  • Only annotation is update_interactive_install.test.ts on Windows, a retried-and-passed install flake.

net +79 (src+packages −58, the rest is the test file).

@Jarred-Sumner
Jarred-Sumner merged commit 6bb5135 into main Jul 10, 2026
5 of 8 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/ffa10012/posix-timer-heap branch July 10, 2026 03:40
Comment on lines +76 to 87
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.
unsafe {
if (*t).state == TimerState::ACTIVE {
VirtualMachine::timer_remove(vm, t);
}
(*t).next = Timespec::now(TimespecMockMode::ForceRealTime).add_ms(i64::from(ms));
VirtualMachine::timer_insert(vm, t);
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 arm() computes the re-arm deadline from Timespec::now(ForceRealTime), but All::drain_timers compares against a cached Timespec::now(AllowMockedTime). Under jest.useFakeTimers() (which seeds the mocked monotonic clock at 0), if advanceTimersByTime(N) pushes mocked time past the machine's CLOCK_MONOTONIC uptime and the loop then reaches drain_timers, on_gc_repeating_timer re-inserts at real-uptime+1s (still < cached mocked now) on every fire and the drain loop never terminates. Re-arm from the passed-in now like EventLoopDelayMonitor::on_fire does (mod.rs:528-539), or compute next as max(real_now, passed_now).add_ms(ms).

Extended reasoning...

What the bug is. GarbageCollectionController::arm() sets the timer's next deadline to Timespec::now(TimespecMockMode::ForceRealTime).add_ms(ms) — i.e. CLOCK_MONOTONIC uptime + interval. on_gc_repeating_timer unconditionally tail-calls Self::arm(vm, &raw mut this.gc_repeating_timer, interval) to re-schedule itself. But All::drain_timers (src/runtime/timer/mod.rs:1030-1049) drives the real timer heap in a loop against a single cached now read once via next() (mod.rs:985-987) as Timespec::now(AllowMockedTime). Under jest.useFakeTimers(), that mocked monotonic clock is seeded at Timespec::EPOCH (0 ns) and advanced only by advanceTimersByTime. When the mocked clock exceeds real CLOCK_MONOTONIC uptime + 1s, every fire re-inserts the node at a deadline still less than the cached now, so drain_timers pops it again immediately and never returns.

Code path. (1) GcRepeating has allow_fake_timers() == false (EventLoopTimer.rs:217), so insert_lock_held puts it into self.timers — the real heap that next() peeks at mod.rs:984 — not the fake heap. (2) FakeTimers::activate() seeds mocked monotonic time at Timespec::EPOCH via mock_time::set(0). (3) drain_timers sets has_set_now = true after the first next() call and never re-reads the clock for the rest of the loop. (4) on_gc_repeating_timer sets state = FIRED, then unconditionally calls Self::arm(...), which re-reads real CLOCK_MONOTONIC, adds interval (1000ms), and re-inserts synchronously — inside the drain loop. (5) The dispatch arm in dispatch.rs discards _now for GcRepeating, so the fire body never sees the drain loop's cached clock and cannot re-arm relative to it.

Why nothing prevents it. The PR author's own analysis (comment 2026-07-06T07:36:10Z, commit b1cd516) reasoned that a ForceRealTime deadline "sits far ahead of the mocked clock and simply never fires" because useFakeTimers() "seeds the clock at Timespec::EPOCH and counts up from zero". That covers only the mocked < real direction. The mocked > real direction — reached the moment advanceTimersByTime crosses machine uptime — was not considered; their 200s repro passed only because their machine had > 200s uptime. On a fresh CI container (uptime seconds to minutes), even modest advances like advanceTimersByTime(10 * 60 * 1000) cross the threshold.

Step-by-step proof. On a machine with 120s of CLOCK_MONOTONIC uptime:

  1. Test calls jest.useFakeTimers() → mocked monotonic = 0 ns.
  2. Test calls jest.advanceTimersByTime(7 * 24 * 3600 * 1000) → mocked monotonic = 604800000 ms.
  3. Test does await Bun.file("/etc/hostname").text() → real event-loop tick → auto_tickdrain_timers.
  4. next() caches now = 604800.000s (mocked). has_set_now = true.
  5. The GcRepeating node (armed by an earlier process_gc_timer() at real-uptime + 1s ≈ 121s) has next ≈ 121.000s < 604800s → popped → fired.
  6. on_gc_repeating_timer runs, tail-calls arm(), which sets next = CLOCK_MONOTONIC (≈120s) + 1s = 121s and re-inserts into self.timers.
  7. Loop iterates: next() peeks the same node, has_set_now is already true so now stays 604800s, 121s < 604800s → popped → fired → step 6. Infinite loop.

Impact. A hard hang (100% CPU in drain_timers) reachable from any test file that combines fake timers with a large advanceTimersByTime and then awaits real I/O — a common pattern for JWT/session-expiry/cache-TTL tests. Triggers deterministically on fresh CI VMs whose CLOCK_MONOTONIC uptime is less than the advanced interval. This is new to this PR: before, the GC timers were us_timer_t timerfds and were never on the heap drain_timers walks. (The bug classForceRealTime deadlines compared against a cached AllowMockedTime — is latent for WTFTimer::update on main, but WTFTimers don't unconditionally re-arm on every fire; GcRepeating does, on every VM, once process_gc_timer() has run once.)

Fix. Match EventLoopDelayMonitor::on_fire (mod.rs:528-539) and DateHeaderTimer: re-arm from the now argument that drain_timers passes into the fire callback, so the re-inserted deadline is always ≥ the cached loop clock. Concretely, plumb now through the GcRepeating dispatch arm (currently |c, _now, vm|) and have arm() compute next as max(Timespec::now(ForceRealTime), passed_now).add_ms(ms) — that keeps real-time pacing when fake timers aren't installed (the property b1cd516 added) while guaranteeing forward progress when they are.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in #33896. Turned out to be broader than arm(): the spin reproduces with BUN_GC_TIMER_DISABLE=1 too (WTFTimer hits the same re-arm loop), so the fix moves the comparison in drain_timers/get_timeout to ForceRealTime rather than flooring the re-arm deadline. StatWatcherScheduler gets the matching arm-side change so its deadlines stay in the same units as the heap.

Comment on lines +1521 to 1526
// 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;
// uniquely accessed here.
vm.event_loop_ref().tick();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Commit bcb8da2 ("Delete pointless test") removed the entire 137-line test/js/bun/event-loop-timers.test.ts — all 5 tests, not one — leaving the PR with zero automated coverage while the description still lists them. Was that intended to be the whole file? At minimum, the uncaughtException-handler-still-exits test (guards this run_command.rs change; hung forever on the broken commit) and the BUN_DESTRUCT_VM_ON_EXIT ASAN test (guards the gc_controller.deinit() reordering that broke ~2000 tests in build 68483) look worth keeping — those are the crash/UAF repros CLAUDE.md asks for, and they're cross-platform unlike the /proc/self/fd counts.

Extended reasoning...

Commit bcb8da2a (HEAD, authored by Jarred) deleted test/js/bun/event-loop-timers.test.ts in its entirety — 137 lines, 5 tests. git show --stat confirms it's the only change in that commit; the file no longer exists on disk; and the PR's changed-files list (18 files) now contains no test file at all. The PR description's Tests section still lists all four bullet points and the robobun evidence footer still names the file, so the description is now stale.

The commit message says "Delete pointless test" (singular). Given that only one of the five tests could plausibly read as "pointless" — and given that the description wasn't updated and the previous robobun status comment (2h earlier) still counts "the 137-line test file" toward the net diff — this looks like it may have been meant to delete one test rather than the whole file.

Three of the five deleted tests are regression guards for bugs this PR itself introduced and then fixed during development:

  • "an uncaughtException handler on a rejected entry point still exits" guards the run_command.rs change (commit 9ffcaf3a, this file at line 1521). Per the timeline, process.on('uncaughtException', ()=>{}); throw new Error() printed handled and then hung forever on the broken commit, causing the 90s timeout in process.test.js on build 68779. This is exactly the "crash fixes need the crashing input as a spawned fixture" test CLAUDE.md requires, and the "hang-guard tests assert the process exited on its own (signalCode === null)" pattern it describes.
  • "destructing the VM on exit does not corrupt the timer heap" guards the gc_controller.deinit() reordering in VirtualMachine.rs / web_worker.rs (commit e8925fd1). Per the timeline, getting this wrong was a heap-use-after-free in Intrusive::combine_siblings that broke ~2000 tests on the x64-asan lane in build 68483. CLAUDE.md: "UAF/leak fixes need an ASan repro on the unfixed build."
  • "Bun.serve idleTimeout still expires an idle connection" verifies the new sweep_next_tick_ns deadline mechanism in loop.c actually fires socket timeouts — the direct behavioral test that the sweep-timer replacement works.

The remaining two are the Linux-only /proc/self/fd timerfd-count assertions. Those are the only tests that fail on main and prove the headline change (4 → 0 timerfds), but they're also the only ones one might reasonably call brittle/platform-specific — plausibly the "pointless test" the commit message meant.

Step-by-step proof: (1) git log --oneline shows bcb8da2a is HEAD. (2) git show --stat bcb8da2a shows exactly one hunk: test/js/bun/event-loop-timers.test.ts | 137 --------. (3) ls test/js/bun/event-loop-timers.test.ts → No such file. (4) git show bcb8da2a^:test/js/bun/event-loop-timers.test.ts shows 5 test.concurrent(...) blocks, three of which are cross-platform. (5) The PR diff (18 files changed) touches only src/ and packages/ — zero files under test/. (6) The PR description's ## Tests section still names the file and all four items.

CLAUDE.md is explicit on both counts: "Every behavioral change ships an automated test in the same PR. 'Verified manually' … don't count, even for one-liners" and "Never silently weaken, skip, or delete an existing test or safety net. Every deletion needs a stated reason or replacement." If the deletion was intentional, the description should be updated and the hang / UAF regression guards restored (they're cross-platform, cheap, and each protects a specific commit in this PR). If it was a mistake — git revert bcb8da2a restores the file.

Jarred-Sumner pushed a commit that referenced this pull request Jul 10, 2026
)

Follow-up to #33359
([review](#33359 (comment))).

## Repro

```ts
jest.useFakeTimers();
for (let i = 0; i < 100; i++) jest.advanceTimersByTime(40 * 24 * 3600 * 1000);
await Bun.file(process.execPath).stat();   // spins at 100% CPU
```

Any `jest.advanceTimersByTime` that pushes the mocked monotonic clock
past the machine's `CLOCK_MONOTONIC` uptime, followed by a real I/O
await, spins `All::drain_timers` forever. Deterministic on a fresh CI
container; on a developer machine it needs an advance larger than
uptime.

## Cause

`All::drain_timers` and `All::get_timeout` compared `self.timers`
against `Timespec::now(AllowMockedTime)`. That heap holds only
`allow_fake_timers()==false` nodes (GC controller, `WTFTimer`,
`bun:test` timeouts, `StatWatcherScheduler`) plus anything armed before
`useFakeTimers()`, and every one of those arms its deadline with
`ForceRealTime`. Once the mocked clock exceeds real uptime, every node
looks overdue; any that re-arm on fire (`GcRepeating` unconditionally,
`WTFTimer` when JSC's GC scheduler has more work) are re-inserted at
`real_uptime + interval`, still less than the cached mocked `now`, and
the drain loop never returns.

The `GcRepeating` case is new to #33359 (the GC timers were kernel
timerfds before and fired on real time regardless of fake timers). The
`WTFTimer` case was latent before that. Reproduces with
`BUN_GC_TIMER_DISABLE=1` too, so the fix has to be at the comparison
layer, not in `GarbageCollectionController::arm()`.

## Fix

`drain_timers::next()` and `get_timeout` read `ForceRealTime` for
`self.timers`. The fake heap is already walked separately by
`advanceTimersByTime`, so the two heaps are now ticked against their own
clocks. No behavior change when fake timers are not installed
(`AllowMockedTime == ForceRealTime` then).

`StatWatcherScheduler::set_timer` was the one
`allow_fake_timers()==false` tag that still armed with
`AllowMockedTime`; flipped to `ForceRealTime` so its deadlines are in
the same units as the heap they live in. The Windows path
(`ensure_uv_timer`) already used `ForceRealTime`.

## Verification

`test/js/bun/test/test-timers.test.ts` spawns a `bun test` child that
advances mocked time by ~11 years and then awaits file I/O inside the
fake-timer window. Without this change the child spins and is killed by
the 20s spawn timeout; with it the child exits in ~35ms. Also ran
`test/js/bun/test/fake-timers/` (61 pass), `test/js/node/timers/` (20
pass), `test/js/node/watch/fs.watchFile.test.ts` (9 pass), and
`rust:check-all` (10/10).

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
robobun added a commit that referenced this pull request Jul 10, 2026
Main's #33359 gated the timer-based sweep path behind LIBUS_USE_LIBUV
(vs the new folded-timeout path on epoll/kqueue). This branch removed
libuv entirely, so LIBUS_USE_LIBUV is never defined and the guards
silently took the POSIX #else branch on Windows, dropping sweep_timer
and has_added_timer_to_event_loop from the structs and breaking the
static_asserts in bun_iocp.h and the Rust mirrors.

The IOCP backend uses the same timer-based sweep (us_create_timer /
us_timer_set) that libuv did, so the guards become LIBUS_USE_BUN_IOCP.
Also re-guards us_timer_close(sweep_timer) in loop_data_free, which
the earlier merge had left unconditional (compile error on POSIX).
@claude claude Bot mentioned this pull request Jul 10, 2026
Jarred-Sumner pushed a commit that referenced this pull request Jul 16, 2026
…#33131)

`WTF::RunLoop::TimerBase` (JSC's GC scheduler timers, and the timer
behind an `Atomics.waitAsync` timeout) is started and stopped from
threads other than the one that owns its run loop: `Atomics.notify` on
thread B re-arms thread A's `JSRunLoopTimer` and cancels thread A's
`waitAsync` timeout, and `~ArrayBufferContents` does the same from
whichever thread drops the last `SharedArrayBuffer` reference.

Those timers shared one pairing heap (`All.timers`) with `setTimeout`
and every other same-thread timer. `All.lock` existed only because of
them, and `get_timeout`'s pre-poll pass peeked and popped that shared
heap without taking it, racing the locked `Intrusive::remove` from the
other threads. In a debug build that trips

```
assertion failed: self.root == v    (src/io/heap.rs:165, Intrusive::remove)
```

and in a release build the check compiles out, so `remove` walks the
corrupted links silently.

### Fix

Rather than locking the shared heap, take the one cross-thread client
out of it:

- `All.wtf_timers` (a `Guarded<TimerHeap>`) holds only `WTFTimer` nodes,
reached through `All::wtf_arm` and `All::wtf_disarm` from any thread.
- `All.lock` is deleted. `insert`, `remove`, `update`, the regular heap,
the fake timers, and the epoch are single threaded again, asserted with
a `debug_assert!` on the owning thread id, so `setTimeout` no longer
takes a mutex per call. The fake-timer lock helper and its
`assert_locked` machinery existed only because of that shared lock, so
they are deleted too.
- `get_timeout` no longer pops and fires inline. It drains the due
`WTFTimer`s through their own mutex (dropping the guard across each
`fire`, which can synchronously re-enter it), then returns `min(wtf,
regular, quic)`.
- `drain_timers` drains the `WTFTimer` heap first, preserving both
existing firing paths: the pre-poll one only runs when the uws loop is
active, and on Windows `drain_timers` runs from `on_uv_timer`.
- `WTFTimer.lock` (the per-instance mutex) is deleted; `wtf_timers` owns
everything it guarded, and `update` never took it anyway.
- `ensure_uv_timer` folds both heaps into the libuv deadline and is now
only reachable from the owning thread, which also removes the
cross-thread TLS hazard it had.

`WTFTimer` never enters the fake-timer heap. Its tag already returned
`false` from `allow_fake_timers()`, but it can no longer reach that
branch at all.

### Reproducer

`test/js/web/timers/timer-heap-race.test.ts` with
`timer-heap-atomics-fixture.ts`: four threads each arm batches of short
`Atomics.waitAsync` timeouts while the others `Atomics.notify` them,
alongside `setTimeout` churn. Under a debug build of `main` this aborts
within a few seconds with the assertion above, or with an ASan
`heap-use-after-free` in `Intrusive::remove` at `src/io/heap.rs:178`.
With this change it runs to completion.

A second fixture drives `Bun.gc(true)` in a `setTimeout` loop so the
per-VM `JSRunLoopTimer` is re-armed and popped repeatedly on the owning
thread.

### Rebased onto #33359, #33623, #33896, #34009

Squashed to one commit and rebased; the earlier commits in the branch
history were the two rejected approaches (locking `get_timeout`, then
`Guarded<Heaps>`).

One conflict had semantic content: #33896 switched the regular heap's
clock reads in `get_timeout` and `next` from `AllowMockedTime` to
`ForceRealTime` because every node that lands there is armed in
real-time units. `WTFTimer` is one of those tags, and its new separate
heap is the same case, so the `ForceRealTime` change is also applied in
`drain_due_wtf_timers`. The #33359 hunk (skip `uv_timer.start` when
already due sooner) and the #33623 hunks (`set_wall_ms`, `clear_wall`,
`Bun__FakeTimers__setSystemTime`) applied without semantic interaction.

#34009 added a `now_out: &mut Option<Timespec>` out-parameter to
`get_timeout` so its caller can reuse the monotonic clock read; the lazy
`maybe_now` in the rewritten body becomes a reborrow of that
out-parameter, and `drain_due_wtf_timers` fills it the same way the old
loop did.

### Verification

```
bun bd test test/js/web/timers/timer-heap-race.test.ts    # 2 pass
bun bd test test/js/bun/test/test-timers.test.ts          # #33896's suite, all pass
bun bd test test/js/bun/test/fake-timers/fake-timers.test.ts   # #33623's suite, all pass
```

Three `setTimeout doesn't leak when X is called inside its own callback`
tests in `test/js/web/timers/` fail under debug + ASan on this machine,
and did so identically with an unmodified `src/` at the previous merge
base: their fixtures widen the RSS threshold only when
`process.execPath.includes("bun-asan")`, which is never true for the
`bun-debug` binary name.

<!-- robobun:evidence:begin -->

---

**[review]** gate passed · iteration 11 · 10 files touched

<details><summary>fails on main (without fix)</summary>

```console
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/timers/timer-heap-race.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (f0fa434)

test/js/web/timers/timer-heap-race.test.ts:
19 | 
20 |   return { stdout, stderr, signal: proc.signalCode, exitCode };
21 | }
22 | 
23 | it("timer heap survives cross-thread Atomics.waitAsync timeout cancellation", async () => {
24 |   expect(await runFixture("timer-heap-atomics-fixture.ts")).toEqual({
                                                                 ^
error: expect(received).toEqual(expected)

  {
-   "exitCode": 0,
-   "signal": null,
-   "stderr": Any<String>,
+   "exitCode": 134,
+   "signal": "SIGABRT",
+   "stderr": 
+ "============================================================
+ Bun Debug v1.4.0 (f0fa434) Linux x64
+ Linux Kernel v6.17.0 | glibc v2.41
+ CPU: sse42 popcnt avx avx2 avx51
... (truncated)

release without fix: 1 skipped
bun test v1.4.0-canary.1 (1498d7b)

test/js/web/timers/timer-heap-race.test.ts:
(pass) timer heap survives cross-thread Atomics.waitAsync timeout cancellation [3036.31ms]
(skip) timer heap stays consistent while GC re-arms the RunLoop timer

 1 pass
 1 skip
 0 fail
 1 expect() calls
Ran 2 tests across 1 file. [3.21s]
__F:0:S:1
```

</details>

<details><summary>passes on PR (with fix)</summary>

```console
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/timers/timer-heap-race.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (f0fa434)

test/js/web/timers/timer-heap-race.test.ts:
(pass) timer heap survives cross-thread Atomics.waitAsync timeout cancellation [3787.70ms]
(pass) timer heap stays consistent while GC re-arms the RunLoop timer [2478.37ms]

 2 pass
 0 fail
 2 expect() calls
Ran 2 tests across 1 file. [8.23s]
__F:0:S:0

release with fix: 1 skipped
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
error: could not download file from 'https://static.rust-lang.org/dist/2026-05-06/channel-rust-nightly.toml' to '/root/.rustup/tmp/pg_7uy9hcvyywudd_file.toml': error downloading file: error sending request for url (https://static.rust-lang.org/dist/2026-05-06/channel-rust-nightly.toml): client error (Connect): tls handshake eof
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     f0fa434
  features     (none)

22 deps, 106 codegen, 1168 objects in 667ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1231] install /workspace/bun
bun install v1.4.0-canary.1 (1498d7b)

Checked 124 installs across 170 packages (no changes) [10.00ms]
[2/1231] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (1498d7b)

Checked 1 install across 2 packages (no changes) [1.00ms]
[3/1231] gen bindgenv2
[4/1231] install /workspace/bun/src/node-fallbacks
bun install v1.4.0-canary.1 (1498d7b)

Checked 129 installs across 147 packages (no changes) [
... (truncated)
```

</details>

<details><summary>diff hotspot</summary>

```
src/runtime/hw_exports.rs                        |   4 +-
 src/runtime/jsc_hooks.rs                         |  12 +-
 src/runtime/test_runner/timers/FakeTimers.rs     | 183 ++--------
 src/runtime/timer/Timer.rs                       |   6 +-
 src/runtime/timer/WTFTimer.rs                    |  58 +---
 src/runtime/timer/mod.rs                         | 414 ++++++++++++-----------
 src/runtime/timer/timer_object_internals.rs      |   8 +-
 test/js/web/timers/timer-heap-atomics-fixture.ts |  68 ++++
 test/js/web/timers/timer-heap-gc-fixture.ts      |  17 +
 test/js/web/timers/timer-heap-race.test.ts       |  43 +++
 10 files changed, 411 insertions(+), 402 deletions(-)
```

</details>

**gate history** · 3 passed · 0 rejected · iteration 11

<details><summary>evidence per changed file</summary>

```
file                                              reads  edits  tests
src/runtime/hw_exports.rs                             2      3      0
src/runtime/jsc_hooks.rs                              2      2      0
src/runtime/test_runner/timers/FakeTimers.rs          9     16      0
src/runtime/timer/Timer.rs                            1      1      0
src/runtime/timer/WTFTimer.rs                         3     13      0
src/runtime/timer/mod.rs                             24     59      0
src/runtime/timer/timer_object_internals.rs           4      3      0
test/js/web/timers/timer-heap-atomics-fixture.ts      2      4      0
test/js/web/timers/timer-heap-gc-fixture.ts           2      3      0
test/js/web/timers/timer-heap-race.test.ts            3      6      0
```

</details>

<!-- robobun:evidence:end -->
@robobun robobun mentioned this pull request Jul 16, 2026
liooil pushed a commit to liooil/poly that referenced this pull request Aug 7, 2026
…896)

Follow-up to #33359
([review](oven-sh/bun#33359 (comment))).

## Repro

```ts
jest.useFakeTimers();
for (let i = 0; i < 100; i++) jest.advanceTimersByTime(40 * 24 * 3600 * 1000);
await Bun.file(process.execPath).stat();   // spins at 100% CPU
```

Any `jest.advanceTimersByTime` that pushes the mocked monotonic clock
past the machine's `CLOCK_MONOTONIC` uptime, followed by a real I/O
await, spins `All::drain_timers` forever. Deterministic on a fresh CI
container; on a developer machine it needs an advance larger than
uptime.

## Cause

`All::drain_timers` and `All::get_timeout` compared `self.timers`
against `Timespec::now(AllowMockedTime)`. That heap holds only
`allow_fake_timers()==false` nodes (GC controller, `WTFTimer`,
`bun:test` timeouts, `StatWatcherScheduler`) plus anything armed before
`useFakeTimers()`, and every one of those arms its deadline with
`ForceRealTime`. Once the mocked clock exceeds real uptime, every node
looks overdue; any that re-arm on fire (`GcRepeating` unconditionally,
`WTFTimer` when JSC's GC scheduler has more work) are re-inserted at
`real_uptime + interval`, still less than the cached mocked `now`, and
the drain loop never returns.

The `GcRepeating` case is new to #33359 (the GC timers were kernel
timerfds before and fired on real time regardless of fake timers). The
`WTFTimer` case was latent before that. Reproduces with
`BUN_GC_TIMER_DISABLE=1` too, so the fix has to be at the comparison
layer, not in `GarbageCollectionController::arm()`.

## Fix

`drain_timers::next()` and `get_timeout` read `ForceRealTime` for
`self.timers`. The fake heap is already walked separately by
`advanceTimersByTime`, so the two heaps are now ticked against their own
clocks. No behavior change when fake timers are not installed
(`AllowMockedTime == ForceRealTime` then).

`StatWatcherScheduler::set_timer` was the one
`allow_fake_timers()==false` tag that still armed with
`AllowMockedTime`; flipped to `ForceRealTime` so its deadlines are in
the same units as the heap they live in. The Windows path
(`ensure_uv_timer`) already used `ForceRealTime`.

## Verification

`test/js/bun/test/test-timers.test.ts` spawns a `bun test` child that
advances mocked time by ~11 years and then awaits file I/O inside the
fake-timer window. Without this change the child spins and is killed by
the 20s spawn timeout; with it the child exits in ~35ms. Also ran
`test/js/bun/test/fake-timers/` (61 pass), `test/js/node/timers/` (20
pass), `test/js/node/watch/fs.watchFile.test.ts` (9 pass), and
`rust:check-all` (10/10).

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants