Skip to content

event loop: park on the next timer deadline when nothing refs the loop - #39107

Closed
robobun wants to merge 1 commit into
mainfrom
farm/b4790877/auto-tick-park-on-timer-deadline
Closed

event loop: park on the next timer deadline when nothing refs the loop#39107
robobun wants to merge 1 commit into
mainfrom
farm/b4790877/auto-tick-park-on-timer-deadline

Conversation

@robobun

@robobun robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • bun test spins at 100% CPU while it waits for a test that nothing will settle: time bun test e.test.ts --timeout 1000 on test("t", async () => { await new Promise(() => {}); }) reports user 1.00s for real 1.02s. The same spin happens for a test awaiting an unref'd setTimeout, for expect(p).resolves while p waits on one, and for bun run of a file whose top-level await waits on one (all measured at ~1000 ms of CPU per 1000 ms of waiting on the release binary).
  • Cause: auto_tick (src/runtime/jsc_hooks.rs, the if (*loop_).is_active() branch) only sleeps in the poll while the uws loop has active handles; otherwise it calls tick_without_idle(), which returns immediately. Timers never ref the loop: the bun:test timeout (BunTest::update_min_timeout -> timer::All::insert), an unref'd TimeoutObject, the idle GC timer. So every wait loop built on tick(); auto_tick(); (test_command.rs while phase != Done, EventLoop::wait_for_promise used by expect().resolves / .rejects and load_entry_point, AnyEventLoop::tick_raw) polls non-stop until the deadline arrives.

Fix

  • auto_tick now computes the poll deadline (timer::All::get_timeout) whether or not the loop is active. When it is idle but a timer is armed and no task or immediate is queued, it parks in the poll until that deadline, bracketing the tick with inc()/dec() so us_loop_run_bun_tick does not early-return on num_polls == 0 (on Windows: so uv_run sees a live handle). This is the shape MiniEventLoop::tick_once already uses to park.
  • Why this is correct: with nothing ref'd, the only events that can move what the caller is waiting on are a timer firing, I/O on a registered fd, or another thread posting work. The poll wakes for all three: the deadline is the poll timeout, fds stay in the set regardless of ref state, and every cross-thread post (VmHandle::deliver, add_keep_alive, request_termination, JSC deferred work via JSCTaskScheduler) already calls wakeup(), which the active branch has always relied on. Same-thread work queued before the poll makes get_timeout return a zero timeout, which is excluded from parking. With no armed timer the idle branch still returns at once, so a wait that nothing will ever wake keeps its old behavior rather than parking forever.
  • auto_tick_active (the bun run / on_before_exit run-to-completion loops) is unchanged; those must return as soon as the loop is idle. get_timeout running for an idle loop only fires due WTFTimers before the poll instead of in the timer drain right after it.
  • Test: test/js/bun/test/test-timers.test.ts, four fixtures (per-test timeout, unref'd timer in a test, expect().resolves on one, top-level await on one in bun run) each wait 1 s and report process.cpuUsage() over the wait; assert under 500 ms. Unfixed debug build of main: ~975-1000 ms each (all four fail). Fixed debug (ASAN) build: 26-40 ms each.
  • bun bd test test/cli/test/ test/js/bun/test/ test/js/web/timers/ test/js/bun/test/expect/ test/bundler/bundler_plugin*.test.ts test/js/bun/repl/repl.test.ts test/cli/run/run-eval.test.ts and a few more: the remaining failures (parallel/randomize timeouts, RSS leak fixtures, spawn_waiter_thread) fail identically on a debug build of unmodified main in this container.
  • Wake-up latency is unchanged: with the loop idle, import(), Bun.file().text(), WebAssembly.instantiate, zlib, subtle crypto, Bun.spawn exit, workers and immediates complete in the same time as with the loop held active (details below).
  • Domain runs: one loop step for everything; spawnSync waits on the real loop admitting only its own consequences #38378 reworks these drivers but keeps the idle branch as tick_without_idle(), so it would still spin; this change is the else branch of the same poll and carries over.

Background

  • uws loop "active" count: Bun-side handles that should keep the process alive (KeepAlive, ref'd timers via increment_timer_ref, sockets, subprocesses) ref() the uws loop. is_active() is what bun run uses to decide the program is done, which is why unref'd and internal timers deliberately do not count. num_polls is a second counter (ref'd handles plus registered fds); us_loop_run_bun_tick returns without polling when it is 0, so parking needs it non-zero, hence inc()/dec().
  • auto_tick vs auto_tick_active: both do one poll of the I/O loop. auto_tick serves loops waiting for a condition (promise settled, test file done); auto_tick_active serves loops that exit when the loop goes idle.
  • timer::All::get_timeout returns the time until the soonest entry of the JS timer heap and the JSC (WTFTimer) heap, zero if work is already queued, and false when both heaps are empty.
  • On Windows tick_with_timeout ignores the timespec and runs uv_run(UV_RUN_ONCE); libuv derives the sleep from its own timer, which timer::All::insert arms for the heap's soonest deadline (ensure_uv_timer), and inc() is the virtual handle that lets uv_run block at all. That is why the idle branch also checks for pending work explicitly instead of relying on the zero timespec.
Probe numbers (debug ASAN build with the fix, same binary, loop idle vs held active by a ref'd timer)
inactive: import():           wall 14.7ms/op, cpu 11.0ms/op
active:   import():           wall 18.5ms/op, cpu  9.6ms/op
inactive: Bun.file().text():  wall  1.8ms/op, cpu  1.8ms/op
active:   Bun.file().text():  wall  0.8ms/op, cpu  1.1ms/op
unref'd setTimeout(50):       wall 52.7ms/op, cpu  5.5ms/op   (parks, wakes on the deadline)
expect().resolves on unref'd setTimeout(5): wall 6.7ms/op
WebAssembly.instantiate at top level: 6.6-14.3ms (unmodified main debug build: 13.4-16.6ms)
zlib.gzip / Bun.build / Worker round trip: wall ~= cpu (compute bound, no parking)

Release binary before the fix, CPU burned during a 1000 ms wait, per fixture in the new test: 1000.2 / 998.1 / 998.5 / 1001.6 ms. Fixed debug build: 26.5 / 40.3 / 35.3 / 32.0 ms.

auto_tick only slept in the poll while the uws loop had active handles.
Timers do not ref the loop (unref'd setTimeout, the bun:test per-test
timeout, the idle GC timer), so a wait loop built on tick(); auto_tick();
(the test runner, wait_for_promise behind expect().resolves and the entry
point's top-level await) polled without blocking and spun at 100% CPU
until the deadline.

Compute the poll deadline for the idle case as well and, when a timer is
armed and no work is queued, park until it, bracketing the tick with
inc()/dec() so it does not early-return on an empty poll set (the same
shape MiniEventLoop::tick_once uses). Without a deadline the idle branch
still returns at once, so a wait nothing will ever wake does not park
forever. auto_tick_active is unchanged: the run-to-completion loops must
not sleep once the loop is idle.
@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 2 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: 35e376d2-88e4-4d19-aefc-79ea8948106e

📥 Commits

Reviewing files that changed from the base of the PR and between 88a6398 and 86dca51.

📒 Files selected for processing (3)
  • src/jsc/event_loop.rs
  • src/runtime/jsc_hooks.rs
  • test/js/bun/test/test-timers.test.ts

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

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Status: closed as a duplicate of #32014.

  • Reproduced on the release binary and on a debug build of main: bun test on a test awaiting a never-settling promise with --timeout 1000 burns ~1.0 s of CPU per 1.0 s of wall time; same for a test awaiting an unref'd timer, expect().resolves on one, and a bun run top-level await on one.
  • Have condition-gated drive loops ref the event loop so unref'd timers fire without spinning #32014 fixes the same spin with the mechanism its review asked for (per-driver loop refs, plus the REPL SIGINT wakeup that parking requires); it needs a rebase. This PR's idle-branch variant is superseded by it.

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Have condition-gated drive loops ref the event loop so unref'd timers fire without spinning #32014 - Fixes the same busy-spin (auto_tick's idle branch calls non-blocking tick_without_idle(), so bun:test's drive loop, wait_for_promise, and the entry-point loaders burn 100% CPU when only a non-ref'ing timer can settle them) with the opposite implementation — it refs the loop from each driver via ref_loop_scoped()/LoopRefGuard so the existing active branch parks, instead of adding an idle-branch park inside auto_tick.

🤖 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 reviewed this PR and didn't find any bugs. Because it changes when the core event loop parks vs. returns immediately — and removes a comment that previously said get_timeout must stay guarded by is_active() — a maintainer look would still be worthwhile.

What was reviewed:

  • get_timeout now runs unconditionally; verified due WTFTimers would fire right after the poll in drain_timers anyway, so this only moves them earlier.
  • inc()/dec() bracketing matches the existing MiniEventLoop::tick_once pattern; timespec != EPOCH and !has_pending_immediate guards keep the Windows path (which ignores the timespec) from parking with work queued.
  • process_gc_timer() now runs before get_timeout on the idle path too, so the GC timer's deadline is included in the park.
  • Test fixtures cover each affected wait loop (test runner, expect().resolves, top-level await) and assert CPU time, not wall time.
Extended reasoning...

Overview

This PR changes auto_tick in src/runtime/jsc_hooks.rs so that when the uws loop has no active handles but a timer (unref'd setTimeout, the bun:test per-test timeout, or the idle GC timer) is armed, the loop parks in the poll until that timer's deadline instead of returning immediately via tick_without_idle(). The other two files are a doc-comment update in src/jsc/event_loop.rs and four new subprocess fixtures in test/js/bun/test/test-timers.test.ts that measure process.cpuUsage() over a 1s idle wait.

Security risks

None. This is event-loop scheduling; no untrusted input, auth, or crypto is involved.

Level of scrutiny

High. auto_tick is the poll step of every tick(); auto_tick(); wait loop in the runtime — the test runner's per-file loop, wait_for_promise (backing expect().resolves/.rejects and entry-point loading), and AnyEventLoop::tick_raw. A mistake here manifests as either a hang (parking when nothing will wake the poll) or a busy-spin regression. The diff also deletes a comment that explicitly said get_timeout must stay inside the is_active() guard because it has side effects; the PR description argues convincingly that firing due WTFTimers a moment earlier is harmless (drain_timers right after the poll would fire them anyway), but that is exactly the kind of invariant a maintainer who wrote the original guard should confirm.

Other factors

The reasoning is unusually thorough: it enumerates every wake source that must end the park (timer deadline as the poll timeout, registered fds, cross-thread wakeup() from VmHandle::deliver/add_keep_alive/request_termination/JSCTaskScheduler), explains why inc()/dec() are needed on both POSIX (num_polls == 0 early-return) and Windows (libuv needs a live handle), and cites MiniEventLoop::tick_once as precedent for the same shape. The Windows path is subtle — tick_with_timeout ignores the timespec there and relies on timer::All::insert having armed a real libuv timer via ensure_uv_timer — and auto_tick_active is deliberately left unchanged so bun run's run-to-completion semantics are preserved. The tests are well-designed (concurrent, hermetic, assert CPU not wall time, cover all four affected wait loops, verified to fail on unfixed main). I did not find a case where the new branch would park without a wake source, but given the blast radius and the removed guard comment, a human should sign off.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Closing in favor of #32014, which fixes the same spin (reported again via a separate trace of bun test burning a core until the per-test timeout) and has already been through review.

#32014 currently conflicts with main and needs a rebase; noted there.

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.

1 participant