Skip to content

Have condition-gated drive loops ref the event loop so unref'd timers fire without spinning - #32014

Open
robobun wants to merge 3 commits into
mainfrom
farm/1f2e3214/unref-timers-idle-loop
Open

Have condition-gated drive loops ref the event loop so unref'd timers fire without spinning#32014
robobun wants to merge 3 commits into
mainfrom
farm/1f2e3214/unref-timers-idle-loop

Conversation

@robobun

@robobun robobun commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Problem

Unref'd timers never fire on Windows when nothing else refs the event loop but a driver is still spinning it waiting on a condition, and on POSIX the same state busy-spins a core until the deadline.

import { expect, test } from "bun:test";

test("repro", async () => {
  const fired = await new Promise<boolean>(resolve => {
    setTimeout(() => resolve(true), 20).unref();
  });
  expect(fired).toBe(true); // parks forever on win32; busy-spins 20ms on posix
});

A 2s unref'd-timer wait measures ~2000ms of CPU time (pure spin) on POSIX.

Cause

wait_for_promise, wait_for_promise_with_termination, the entry-point/preload loader loops, and bun:test's drive loop all tick auto_tick until a JS-visible condition is satisfied (promise settled, test phase done) regardless of whether JS has anything refing the loop. In that state auto_tick's !is_active() branch is a non-blocking tick_without_idle(): the driver busy-spins on POSIX (the subsequent drain_timers eventually fires the timer), and on Windows uv_run(UV_RUN_ONCE) returns immediately without running any timers when uv__loop_alive() is false.

Fix

Each condition-gated driver holds a scoped uSockets-loop ref for its duration via the new EventLoop::ref_loop_scoped() (a named #[must_use] LoopRefGuard). With that ref held, auto_tick takes its existing active branch and parks on the next timer-heap deadline on both POSIX and Windows — auto_tick itself is unchanged. Liveness-gated drivers (the main run loop via auto_tick_active, wait_for_tasks) deliberately do not take the ref, so exit semantics are unchanged.

The guarded drivers: wait_for_promise, the --hot/--watch entry loader, the bun test --watch loader, load_preloads' watcher branch, and bun:test's drive loop. wait_for_promise_with_termination (Workers) is a hybrid: since #31216 it breaks on !is_event_loop_alive() inside the loop, which reads the same counter the guard bumps, so there the guard is scoped to auto_tick only, taken after the liveness check — see the Rebase note below.

Two consequences the ref brings with it, both handled here:

  1. Every exit condition of a guarded loop now needs a paired loop wakeup. The loop parks in epoll_pwait2/kevent64 (both of which retry on EINTR) rather than polling, so a bare flag store is no longer observable. The REPL's SIGINT handler only set execution_forbidden, so Ctrl+C on a hung await was observed only when JSC happened to wake the loop for its own reasons — measured 12ms–600ms depending on build and background GC activity, with park deadlines as long as 201 seconds when it didn't. The handler now wakes the loop after setting the flag (uws::Loop::wakeup_raw: a raw-pointer entry point so a handler interrupting a thread holding &mut Loop can't mint an aliasing &mut; it bottoms out in an atomic add plus a write() to the wakeup eventfd, so it is async-signal-safe). The contract is documented on ref_loop_scoped, wait_for_promise, and exercised by a pty REPL test.
  2. The ref makes is_event_loop_alive*() true for the guarded scope. That is JS-visible: an unref'd setImmediate is cleared-without-running only when the loop looks dead, so one scheduled inside a bun:test test body, a preload, or a sync-required ESM module now runs instead of being dropped. This matches Node, and is covered by two dedicated tests.

Also converts All::get_timeout and All::drain_timers to raw-pointer receivers so the call-site &mut All auto-ref no longer aliases the re-entrant &mut All that fired WTFTimer/timer callbacks mint via runtime_state() (this was the explicit TODO above get_timeout on main; the doc moves to a # Safety section). VirtualMachine::wait_for had no callers and is deleted.

Rebase notes (three conflicts with main since the original review, all in get_timeout/drain_timers or the Worker driver):

  • get_timeout gained a now_out: &mut Option<Timespec> out-parameter. Kept the raw-pointer receiver, dropped the now-dead let this = self + the stale receiver TODO, bound maybe_now to now_out. The jsc_hooks.rs conflicts were comment-only.
  • node:worker_threads: +48 Node.js tests passing — MessagePort, stdio, SHARE_ENV, exit codes, transfer semantics, postMessageToThread + inspector #31216 added an !is_event_loop_alive() break inside wait_for_promise_with_termination's loop (Node's Worker exit-13 semantics). That check reads the same counter ref_loop_scoped bumps, so holding the guard across the whole loop made the break dead code and a Worker whose module never settles parked indefinitely. The guard there is now scoped to auto_tick only, taken after the liveness check, so the check reads the real ref state. The Worker test is rewritten to guard that (it previously asserted an unref'd timer fires inside a Worker's entry-module TLA with nothing else ref'd, which Node 26.3 does not do; the Worker exits 13 without waiting).
  • Move WTFTimer out of the shared timer heap to fix a cross-thread race #33131 factored WTFTimer draining into drain_due_wtf_timers(this: *mut Self, ...) and rewrote both get_timeout and drain_timers bodies around it. Main's new bodies already use a raw this internally; the resolution keeps the raw-pointer receiver and the new body, and drops the let this = self + stale TODO + not_unsafe_ptr_arg_deref allow (no longer applies on an unsafe fn).

Node 26.3 cross-check

Scenario Node 26.3 Bun (this PR)
node --test awaiting an unref'd timer waits, timer fires, test passes (node:test deliberately refs the loop for exactly this reason) same
node --test with an unref'd setImmediate runs same
TLA await new Promise(r=>setImmediate(r)) + long unref'd timer setImmediate fires, exits promptly same
TLA awaiting only an unref'd timer, nothing else ref'd Warning: Detected unsettled top-level await, exit 13 timer fires, exit 0
Worker entry-module TLA awaiting only an unref'd timer Warning: Detected unsettled top-level await, exit 13 same

Row 4 is a deliberate, tracked divergence: wait_for_promise refs the loop so the timer fires rather than Bun busy-spinning at 100% CPU. Node's early-exit requires unsettled-TLA detection, tracked in #33283 (split out of #14951, which was closed for the CPU-spin half). The test asserting today's behavior fences the two divergent assertions (stderr and exitCode) behind a comment pointing at #33283, so the parity PR is a one-line flip rather than an argument with a green test.

How did you verify your code works?

test/js/web/timers/timers-unref-idle-loop.test.ts — ten subprocess tests, one per guarded driver plus the invariants. Each driver test measures the child's process.cpuUsage() across an await of an unref'd timer, so a missing ref fails on POSIX (spin ≈ 2000ms CPU) as well as Windows (hang), rather than hanging the parent's test file for the CI timeout. With ref_loop_scoped neutered, 8 of the 10 fail; with the fix, all 10 pass (cpuMs ≈ 12).

test/js/bun/repl/repl.test.ts — pty test: evaluate a never-settling await, send SIGINT, assert the prompt returns and the REPL is usable.

Also verified: setTimeout.test.js + test/js/node/timers/ (50 pass, 0 fail) and the full REPL suite (118 pass).

One unrelated-looking test change, for an honest local run: setTimeout-clear-in-callback-leak-fixture.js widened its RSS threshold only when the binary is named bun-asan, so a bun bd debug build (ASAN-instrumented, named bun-debug) measured ASAN's ~136 MB of quarantine retention against the 10 MB release threshold and reported a leak in all three modes. It now probes ASAN_ENABLED the way test/harness.ts already does, keeping the name check as a fallback. With the quarantine disabled the delta is 0.0 MB, so there is no leak to begin with; the release threshold is unchanged.

Related issues


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

@robobun

robobun commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 10:36 PM PT - Jul 15th, 2026

@robobun, your commit 9db2a20 has 4 failures in Build #73604 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32014

That installs a local version of the PR into your bun-32014 executable, so you can run:

bun-32014 --bun

@github-actions github-actions Bot added the claude label Jun 9, 2026
@coderabbitai

coderabbitai Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Integrates timer-deadline parking into the idle event loop, converts timer entry points to unsafe raw-pointer receivers to avoid aliased borrows during re-entrant timer fires, updates Windows libuv handling, and adds tests ensuring unref() timers fire without busy-spinning.

Changes

Unref'd timer event loop handling

Layer / File(s) Summary
Timer API raw-pointer get_timeout and drain_timers
src/runtime/timer/mod.rs, src/runtime/timer/Timer.rs
All::get_timeout and All::drain_timers changed to unsafe raw-pointer receivers; internal borrow scopes reworked so heap peek/delete_min borrows are short-lived before calling EventLoopTimer::fire. drain_timers_export now calls All::drain_timers(all, vm.cast::<()>()).
Timer internals and Windows parking
src/runtime/timer/mod.rs
ensure_uv_timer now returns a bool indicating whether a deadline was armed; libuv callback re-arming adjusted; added All::tick_uv_loop_with_timer_deadline to (re)arm/ref_/unref_ uv_timer around a single uws_loop run and return whether a deadline was armed.
auto_tick idle-path timer parking integration
src/runtime/jsc_hooks.rs
auto_tick and auto_tick_active updated to use raw-pointer get_timeout/drain_timers: on Unix compute timespec and call tick_with_timeout (or tick_without_idle()), wrapping timeout ticks with temporary ref_()/scoped unref() when num_polls == 0; on Windows attempt tick_uv_loop_with_timer_deadline and fall back to tick_without_idle().
Unref'd timer and event-loop tests
test/js/web/timers/timers-unref-idle-loop.test.ts
Adds tests asserting unref() timers fire while the process is kept alive, a subprocess top-level-await test that prints fired, and a CPU-usage regression test measuring process.cpuUsage() to detect busy-spin regressions.
🚥 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 accurately summarizes the main change: refing drive loops so unref'd timers fire without spinning.
Description check ✅ Passed The description includes the required purpose and verification sections and provides detailed implementation and tests.

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

@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: 4

🤖 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 `@src/runtime/jsc_hooks.rs`:
- Around line 1003-1013: The manual pairing of ref_()/unref() around
tick_with_timeout should be replaced with an RAII guard to ensure unref() always
runs on scope exit; create a small guard type (e.g., LoopRefGuard) whose
constructor calls unsafe { (*loop_).ref_() } and whose Drop implementation calls
unsafe { (*loop_).unref() }, then in the branch where needs_virtual_poll is true
instantiate the guard (let _guard = LoopRefGuard::new(loop_);) before calling
unsafe { (*loop_).tick_with_timeout(Some(&timespec)) }; remove the explicit
unref() call and any manual ref_()/unref() pairing so the release is tied to the
acquisition site even if the branch later gains early returns.

In `@test/js/web/timers/timers-unref-idle-loop.test.ts`:
- Around line 45-68: In the "waiting on an unref'd timer parks the event loop
instead of spinning" test, add an unconditional assertion that inspects `stderr`
(the `stderr` variable returned from Promise.all on `proc`) before asserting
`exitCode` so test diagnostics include subprocess error output; locate the
assertion block that currently checks `{ fired, exitCode }` and `cpuMs` and
insert a check such as expecting `stderr` to be empty or matching the allowed
pattern immediately after parsing `stdout` and before the `exitCode` equality
assertion.
- Around line 9-68: Wrap these four independent tests in concurrency by either
replacing each it(...) with it.concurrent(...) (targets: the four test functions
currently using setTimeout/setInterval and Bun.spawn) or grouping them inside a
describe.concurrent(...) block; ensure test semantics unchanged (keep awaits,
promises, Bun.spawn usage and expects) and run the suite to verify no flakiness
from concurrent execution.
- Around line 27-43: Update the test "unref'd setTimeout fires while top-level
await keeps the process alive" to assert the subprocess stderr before asserting
exitCode: after awaiting proc.stdout.text(), proc.stderr.text(), and
proc.exited, first check that stderr is the expected value (or at least empty
string) using the existing stderr variable, then assert stdout and exitCode;
locate the test by the it(...) block or variables proc/ stdout/ stderr/ exitCode
and reorder/add the stderr assertion so diagnostics are printed when the
subprocess fails.
🪄 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: fac42a7f-325c-4214-9064-2529b85d935d

📥 Commits

Reviewing files that changed from the base of the PR and between a988615 and 1cd64d0.

📒 Files selected for processing (3)
  • src/runtime/jsc_hooks.rs
  • src/runtime/timer/mod.rs
  • test/js/web/timers/timers-unref-idle-loop.test.ts

Comment thread src/runtime/jsc_hooks.rs Outdated
Comment thread test/js/web/timers/timers-unref-idle-loop.test.ts Outdated
Comment thread test/js/web/timers/timers-unref-idle-loop.test.ts Outdated
Comment thread test/js/web/timers/timers-unref-idle-loop.test.ts Outdated
@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. Event loop busy-spins at 100% CPU when spawning concurrent bun processes using Ink + fetch (macOS ARM64) #27766 - Stack traces show kevent64 zero-timeout polling (the exact POSIX busy-spin in auto_tick's not-active branch); parking with a timeout directly fixes this
  2. awaiting top level no-op promise that never resolves is missing an error/warn, causing 100% cpu usage #14951 - await new Promise(r => {}) with nothing ref'ing the loop enters the not-active branch and busy-spins at 100% CPU; the PR would make it park at 0% CPU instead

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

Fixes #27766
Fixes #14951

🤖 Generated with Claude Code

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/runtime/jsc_hooks.rs (2)

925-928: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Update the get_timeout invariant comment.

This comment still says get_timeout must only run inside loop.isActive(), but the idle branch below now intentionally calls it to discover the next unref'd timer deadline. Leaving that invariant in place makes the new behavior look accidental.

As per coding guidelines, "Sweep the same PR for everything describing the old state" and "A comment contradicting the code is a correctness bug."

🤖 Prompt for 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.

In `@src/runtime/jsc_hooks.rs` around lines 925 - 928, Update the invariant
comment around ctx.timer.getTimeout/get_timeout to reflect the new intentional
behavior: note that while get_timeout has side effects (pops + fires due to
WTFTimer heap entries) it is normally guarded by loop.isActive(), but it may
also be invoked in the idle branch to discover the next unref'd timer deadline;
remove the absolute prohibition and clearly document the exceptional idle-case,
why it is safe there, and any expectations (e.g., that callers handle the side
effects or only rely on deadline discovery) so the comment matches get_timeout,
loop.isActive(), and the idle-branch semantics.

Source: Coding guidelines


988-995: ⚠️ Potential issue | 🟠 Major

Fix aliased-&mut UB in timer::All::get_timeout call sites (and update stale comment).

  • src/runtime/jsc_hooks.rs calls timer::All::get_timeout(&mut (*state).timer, ...) from multiple auto_tick branches (active-path and the parked/unix path). src/runtime/timer/mod.rs already notes that get_timeout(&mut self, ...) can re-enter JS via WTFTimer::fire while the outer &mut All is still live (aliased-&mut UB), and calls out a TODO to fix this by switching the receiver to a raw pointer (this: *mut Self) and updating the jsc_hooks.rs call sites together. Change the signature and update all sibling call sites in the same PR.
  • The comment near the idle parking logic states get_timeout must only run inside if (loop.isActive()), but the unix parked branch still invokes timer::All::get_timeout when loop.is_active() is false. Update the comment to reflect the actual control flow (or adjust the guard to match the stated intent).
🤖 Prompt for 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.

In `@src/runtime/jsc_hooks.rs` around lines 988 - 995, Change
timer::All::get_timeout to take a raw receiver (this: *mut Self) instead of &mut
self to avoid aliased-&mut UB from re-entrancy via WTFTimer::fire, then update
all call sites that pass &mut (*state).timer (notably the calls in
src/runtime/jsc_hooks.rs inside the auto_tick active and parked/unix branches)
to pass a raw pointer (e.g., &mut (*state).timer as *mut _ or
state.timer.as_mut_ptr) and adjust any unsafe blocks accordingly; also update
the idle-parking comment near the unix parked branch to reflect that get_timeout
may be invoked even when loop.is_active() is false (or alternatively tighten the
guard to only call when loop.is_active()), ensuring the comment and control flow
are consistent.

Source: Coding guidelines

♻️ Duplicate comments (2)
test/js/web/timers/timers-unref-idle-loop.test.ts (2)

9-82: 🧹 Nitpick | 🔵 Trivial | 💤 Low value

Consider making tests concurrent (duplicate of previous review).

The past review suggestion to make these tests concurrent remains valid. All four tests are independent (no shared state or ordering dependencies), and per guidelines, tests that spawn processes should prefer test.concurrent or describe.concurrent to improve suite execution time.

🤖 Prompt for 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.

In `@test/js/web/timers/timers-unref-idle-loop.test.ts` around lines 9 - 82, These
four independent tests (the "unref'd ..." test cases in
timers-unref-idle-loop.test.ts) should be run concurrently to speed the suite;
change each it(...) to test.concurrent(...) (or wrap the group in
describe.concurrent and keep the individual it blocks) so the tests that spawn
processes (the blocks creating Bun.spawn and the top-level setTimeout/Interval
promises) run in parallel without changing test logic or assertions.

71-81: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Same stderr pattern issue as the previous test.

This test has the same stderr: expect.any(String) pattern that contradicts the documented behavior of bunEnv (see comment on lines 42-48). The conventional pattern would assert expect(stderr).toBe("") before the output assertions.

🤖 Prompt for 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.

In `@test/js/web/timers/timers-unref-idle-loop.test.ts` around lines 71 - 81, The
test currently asserts stderr with expect.any(String) which conflicts with the
documented bunEnv behavior; change the test to assert expect(stderr).toBe("")
first (before parsing stdout), and then update the subsequent equality check to
expect stderr to be "" (or remove stderr from the expected object) so the check
uses the explicit empty-stderr expectation; refer to the variables stdout,
stderr, output and the existing expect({ output, exitCode, stderr }) block when
making the change.

Source: Learnings

🤖 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/web/timers/timers-unref-idle-loop.test.ts`:
- Around line 42-48: Update the two subprocess assertions in
timers-unref-idle-loop.test.ts to assert stderr is empty instead of using
expect.any(String): replace the object expectation that contains stderr:
expect.any(String) with stderr: "" (or filter known benign noise before
asserting) so bunEnv uses strict stderr checks; also mark the two independent
subprocess tests as test.concurrent to allow them to run in parallel. Ensure you
update the expectation object in the block containing expect({ stdout:
stdout.trim(), exitCode, stderr }).toEqual(...) and adjust the test declarations
for the two subprocess tests to test.concurrent(...).

---

Outside diff comments:
In `@src/runtime/jsc_hooks.rs`:
- Around line 925-928: Update the invariant comment around
ctx.timer.getTimeout/get_timeout to reflect the new intentional behavior: note
that while get_timeout has side effects (pops + fires due to WTFTimer heap
entries) it is normally guarded by loop.isActive(), but it may also be invoked
in the idle branch to discover the next unref'd timer deadline; remove the
absolute prohibition and clearly document the exceptional idle-case, why it is
safe there, and any expectations (e.g., that callers handle the side effects or
only rely on deadline discovery) so the comment matches get_timeout,
loop.isActive(), and the idle-branch semantics.
- Around line 988-995: Change timer::All::get_timeout to take a raw receiver
(this: *mut Self) instead of &mut self to avoid aliased-&mut UB from re-entrancy
via WTFTimer::fire, then update all call sites that pass &mut (*state).timer
(notably the calls in src/runtime/jsc_hooks.rs inside the auto_tick active and
parked/unix branches) to pass a raw pointer (e.g., &mut (*state).timer as *mut _
or state.timer.as_mut_ptr) and adjust any unsafe blocks accordingly; also update
the idle-parking comment near the unix parked branch to reflect that get_timeout
may be invoked even when loop.is_active() is false (or alternatively tighten the
guard to only call when loop.is_active()), ensuring the comment and control flow
are consistent.

---

Duplicate comments:
In `@test/js/web/timers/timers-unref-idle-loop.test.ts`:
- Around line 9-82: These four independent tests (the "unref'd ..." test cases
in timers-unref-idle-loop.test.ts) should be run concurrently to speed the
suite; change each it(...) to test.concurrent(...) (or wrap the group in
describe.concurrent and keep the individual it blocks) so the tests that spawn
processes (the blocks creating Bun.spawn and the top-level setTimeout/Interval
promises) run in parallel without changing test logic or assertions.
- Around line 71-81: The test currently asserts stderr with expect.any(String)
which conflicts with the documented bunEnv behavior; change the test to assert
expect(stderr).toBe("") first (before parsing stdout), and then update the
subsequent equality check to expect stderr to be "" (or remove stderr from the
expected object) so the check uses the explicit empty-stderr expectation; refer
to the variables stdout, stderr, output and the existing expect({ output,
exitCode, stderr }) block when making the change.
🪄 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: e60f6e18-615b-4060-9ae3-24c99c4ecef3

📥 Commits

Reviewing files that changed from the base of the PR and between 1cd64d0 and 448ef90.

📒 Files selected for processing (2)
  • src/runtime/jsc_hooks.rs
  • test/js/web/timers/timers-unref-idle-loop.test.ts

Comment thread test/js/web/timers/timers-unref-idle-loop.test.ts Outdated

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/runtime/timer/mod.rs (2)

923-999: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Finish the raw-pointer re-entrancy fix in drain_timers too.

This change removes the call-frame &mut All from get_timeout, but drain_timers still documents the identical aliasing hazard at Lines 1061-1076 and still reaches EventLoopTimer::fire(...) with the caller-side &mut self alive. A fired timer can re-enter update/remove and mint a second &mut All, so the same UB class remains on the main timer-drain path. Please switch drain_timers and its call site to the same this: *mut Self pattern in this PR. As per coding guidelines, "Fix the whole class in the same PR."

🤖 Prompt for 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.

In `@src/runtime/timer/mod.rs` around lines 923 - 999, The drain_timers path still
holds a caller-side &mut self across EventLoopTimer::fire, leaving the same
aliased-&mut UB; change drain_timers to accept and operate on a raw this: *mut
All (mirror get_timeout) and update its caller(s) to pass the erased pointer
(instead of &mut self) so you short-livedly borrow &mut *this only for
peek()/delete_min() and drop before calling unsafe { EventLoopTimer::fire(min,
&el_now, vm) }; update function signature(s) named drain_timers and its callers,
and adjust local borrows and safety comments to match the raw-pointer
re-entrancy pattern used in get_timeout.

Source: Coding guidelines


809-835: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Re-check that a timer was actually armed before forcing run().

timers.peek() and ensure_uv_timer() are separated by an unlocked window, and this type already allows cross-thread heap mutation under self.lock. If the last timer disappears in that gap, ensure_uv_timer() can return without starting the current minimum, but this code still ref_()s uv_timer and blocks in run(). That can park on stale timer state instead of falling back to the non-blocking pump. Have ensure_uv_timer() report whether it armed a live deadline, or re-check/stop the handle here before the ref_()/run() pair.

🤖 Prompt for 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.

In `@src/runtime/timer/mod.rs` around lines 809 - 835, The code may call
uv_timer.ref_() and uws_loop.run() even if no timer was actually armed between
timers.peek() and ensure_uv_timer(); update tick_uv_loop_with_timer_deadline to
verify a live deadline before blocking: either change ensure_uv_timer() to
return a bool indicating it armed a deadline and use that result to skip
ref_/run when false, or after calling ensure_uv_timer() re-check the timers
(e.g., peek() or an uv_timer.is_active()/is_started()-style query) and only call
(*this).uv_timer.ref_() and (*uws_loop).run() if a live timer exists; still keep
the existing unref() logic using active_timer_count as before.
🤖 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.

Outside diff comments:
In `@src/runtime/timer/mod.rs`:
- Around line 923-999: The drain_timers path still holds a caller-side &mut self
across EventLoopTimer::fire, leaving the same aliased-&mut UB; change
drain_timers to accept and operate on a raw this: *mut All (mirror get_timeout)
and update its caller(s) to pass the erased pointer (instead of &mut self) so
you short-livedly borrow &mut *this only for peek()/delete_min() and drop before
calling unsafe { EventLoopTimer::fire(min, &el_now, vm) }; update function
signature(s) named drain_timers and its callers, and adjust local borrows and
safety comments to match the raw-pointer re-entrancy pattern used in
get_timeout.
- Around line 809-835: The code may call uv_timer.ref_() and uws_loop.run() even
if no timer was actually armed between timers.peek() and ensure_uv_timer();
update tick_uv_loop_with_timer_deadline to verify a live deadline before
blocking: either change ensure_uv_timer() to return a bool indicating it armed a
deadline and use that result to skip ref_/run when false, or after calling
ensure_uv_timer() re-check the timers (e.g., peek() or an
uv_timer.is_active()/is_started()-style query) and only call
(*this).uv_timer.ref_() and (*uws_loop).run() if a live timer exists; still keep
the existing unref() logic using active_timer_count as before.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2f324e00-0e52-4104-8078-c035d0176c9e

📥 Commits

Reviewing files that changed from the base of the PR and between e5db654 and 63138b2.

📒 Files selected for processing (3)
  • src/runtime/jsc_hooks.rs
  • src/runtime/timer/mod.rs
  • test/js/web/timers/timers-unref-idle-loop.test.ts

@ig-ant
ig-ant force-pushed the farm/1f2e3214/unref-timers-idle-loop branch 3 times, most recently from 80367ec to 7bf3a2c Compare June 10, 2026 18:16
@ig-ant ig-ant self-assigned this Jun 10, 2026
@robobun
robobun force-pushed the farm/1f2e3214/unref-timers-idle-loop branch from 7bf3a2c to 85157df Compare June 15, 2026 19:11
Comment thread src/runtime/jsc_hooks.rs Outdated

@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.

Thanks for addressing the setImmediate-park regression with the immediate_fired guard and the new test — that resolves my earlier comment. I didn't find any further issues, but since this changes blocking semantics in auto_tick (the core event-loop tick path) on both POSIX and Windows, it's worth a human pass before merge.

Extended reasoning...

Overview

This PR modifies auto_tick in src/runtime/jsc_hooks.rs so the !is_active() branch parks on the soonest timer-heap deadline instead of doing a non-blocking pump (fixing a busy-spin on POSIX and a never-fires on Windows for unref'd timers when only the runtime is driving the loop). It also converts All::get_timeout/All::drain_timers to raw-pointer receivers to close an aliased-&mut re-entrancy hole, adds a Windows tick_uv_loop_with_timer_deadline helper that ref's the uv_timer for one UV_RUN_ONCE, and adds a new test file with five regression tests including a CPU-usage check.

Since my previous review the author pushed a fix (commit c68e55b) for the issue I flagged: immediate_fired is now snapshotted before tick_immediate_tasks, and an else if immediate_fired branch falls through to the non-blocking tick_without_idle() so a just-fired setImmediate that satisfied the driver's wait condition doesn't get parked on an unrelated unref'd-timer deadline. A dedicated test ("awaiting setImmediate exits promptly when an unref'd timer is pending") now covers exactly that repro.

Security risks

None identified. No auth, crypto, permissions, or untrusted-input handling is involved. The unsafe Rust is bookkeeping around already-unsafe FFI (uws/libuv loop pointers, the per-thread All), and the &mut self → *mut Self conversions tighten the existing aliasing story rather than loosen it.

Level of scrutiny

High. auto_tick is the heart of Bun's event-loop scheduling — it decides whether the runtime blocks, spins, or returns to the driver on every tick. A subtle mistake here can hang processes, busy-spin CPUs, or change exit semantics across every Bun program. The fix touches blocking behavior on two distinct backends (uSockets/epoll/kqueue and libuv), and the Windows path in particular adds new ref/unref bracketing around uv_run(UV_RUN_ONCE). This is well outside the "simple/mechanical" bar for bot approval.

Other factors

All CodeRabbit threads are resolved, the PR description is thorough, CI was previously green on all relevant lanes per the author's notes, and the bug-hunting system found nothing on the latest revision. The new immediate_fired guard plus its regression test directly implement the fix I suggested. I'm deferring purely on blast-radius grounds, not because of any open concern with the current diff.

@robobun
robobun force-pushed the farm/1f2e3214/unref-timers-idle-loop branch from c68e55b to 4fd2f80 Compare June 18, 2026 22:53
@robobun robobun changed the title Fire unref'd timers when nothing refs the event loop, without busy-spinning Have condition-gated drive loops ref the event loop so unref'd timers fire without spinning Jun 18, 2026
Comment thread src/jsc/VirtualMachine.rs
Comment thread src/jsc/event_loop.rs

@alii alii left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The mechanism here is right, and I want to say that clearly up front because I went looking for reasons it wasn't: a scoped ref over the existing uws::Loop::ref_() counter is the correct shape (KeepAlive would be strictly worse — it's a stored-state field with manual unref(), no RAII, bottoming out on the same primitive), putting the fix inside auto_tick's idle branch instead would be worse (is_active() is not a single-consumer bit), and I could not find a condition-gated drive loop you missed (e.g. --inspect-wait already goes through the liveness-gated path). The get_timeout/drain_timers raw-receiver conversion is also a real soundness fix that closes a documented TODO. Nice work.

Requesting changes for one behavior regression plus test/API-shape issues — none of which needs the approach rethought:

  1. (must fix) Every guarded loop went from non-blocking poll of its exit flag to park in the OS until woken, so every exit condition now needs a paired wakeup — and the REPL's SIGINT doesn't have one. With the ref held and an empty timer heap (the normal state while awaiting in bun repl), auto_tick parks in kevent64/epoll_pwait2 with a NULL timeout (get_timeout returns false), both backends retry on EINTR, and the REPL's SIGINT handler only stores execution_forbidden — a bare flag flip with no loop wakeup. Pre-PR the non-blocking pump observed that flag within microseconds; post-PR, Ctrl+C on a hung await in the REPL parks forever. I audited the other exit-condition setters reachable from the refed loops; this is the only broken one, but it's an interactive one. Inline comment has the fix (wake the loop from the handler — async-signal-safe — plus the doc contract).
  2. Tests 1–2 (the headline in-process ones) can't produce an attributable failure on any platform: on Windows a regression becomes the whole file hanging for the 3-minute CI timeout (bun:test's own per-test timeout lives in the same timer heap that stops draining), and on POSIX they pass on an unfixed build. Make them subprocess tests.
  3. 4 of the 6 live refed sites (--hot entry, bun test --watch, --hot --preload — that one added by this PR's own last commit with no test — and the Worker wait) have no coverage at all, and a 5th refed site, VirtualMachine::wait_for, is dead code with zero callers. Delete it or say why it stays.
  4. ref_loop_scoped() should return a named #[must_use] guard (the repo's EnteredEventLoop idiom), not the codebase's only -> impl Drop: rustc emits no unused warning for a bare ev.ref_loop_scoped();, which silently reintroduces the exact bug at a site with (per 3) no test to catch it.
  5. The ref makes is_event_loop_alive*() true for the guarded scope, with one JS-observable consequence: an unref'd setImmediate inside a test / preload / sync-required ESM that used to be dropped now runs. Probably the right behavior — but it's an unlabeled, untested behavior change. Name it and test it.
  6. Both the PR body and the shipped test comment point the deliberate Node-TLA divergence at #14951, which is closed — so the divergence ships with no live tracker and a green test the future parity PR must knowingly break. (Also, "node --test does the same" is a Node >= 24 behavior produced by node:test deliberately ref'ing the loop — which actually strengthens the bun:test half of this change; worth stating accurately.)

With 1 fixed and the tests real, I'd approve this.

Comment thread src/jsc/event_loop.rs
Comment thread test/js/web/timers/timers-unref-idle-loop.test.ts Outdated
Comment thread src/jsc/VirtualMachine.rs Outdated
Comment thread src/jsc/event_loop.rs Outdated
Comment thread src/jsc/event_loop.rs
Comment thread test/js/web/timers/timers-unref-idle-loop.test.ts Outdated
@alii

alii commented Jul 2, 2026

Copy link
Copy Markdown
Member

@robobun please address the review feedback above (#32014 (review)).

Suggested order: the REPL SIGINT wakeup first (it's the one behavior regression) plus the ref_loop_scoped doc contract ('every exit condition of a guarded loop needs a paired loop wakeup'); then convert the two in-process tests to subprocess tests so a regression is an attributable failure rather than a 3-minute file hang on Windows; then the three missing site tests (--hot, --hot --preload, Worker — using the cpuUsage technique so they fail on POSIX too) and delete the dead VirtualMachine::wait_for; then the named #[must_use] LoopRefGuard; then the unref'd-setImmediate tests + doc; then re-point the Node-TLA divergence at a live tracker and TODO-fence its assertions. The core mechanism is right — please don't rework the approach, and don't raise any test timeouts.

@robobun

robobun commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator Author

@alii thanks, that review found one real regression and three gaps. All six items are addressed in 61d2d58, in the order you suggested; every thread has a reply with the details and is resolved. The mechanism is unchanged and no test timeout was raised.

1. REPL SIGINT wakeup (the behavior regression). Real, and worse than "latency": with the loop ref'd and no wakeup, Ctrl+C on a hung await was observed only when JSC happened to wake the loop for its own reasons. Instrumenting the park caught it sitting on have_timeout=true ts=201.045846191 — a 201-second deadline. Measured SIGINT → output:

latency
before the loop ref (non-blocking pump) 0.2–0.3ms
with the ref, no wakeup 12–17ms, up to ~600ms; worst case the 201s park
with the wakeup (this PR) 0.3–3ms

sigint_handler now wakes the loop after setting execution_forbidden, through a new uws::Loop::wakeup_raw(this: *mut Self) so a handler interrupting a thread that holds &mut Loop can't mint an aliasing &mut. It bottoms out in an atomic add plus a write() to the wakeup eventfd, so it stays async-signal-safe. Covered by a pty test in repl.test.ts. The general rule — every exit condition of a guarded loop needs a paired loop wakeup — is now a # Contract section on ref_loop_scoped, and wait_for_promise's doc points at it.

2. Subprocess tests. timers-unref-idle-loop.test.ts is rewritten: 10 it.concurrent tests, each spawning a child, each asserting on the child's process.cpuUsage() across the await. A missing ref now fails on POSIX too (spin ≈ 2000ms CPU) instead of only hanging on Windows, so a regression is an attributable failed assertion rather than a 3-minute file hang. With ref_loop_scoped neutered, 8 of the 10 fail (cpuMs: 2009); with the fix all 10 pass (cpuMs: 12).

3. Untested sites + dead code. Added --hot (byte-identical to the bun test --watch loop), --hot --preload, and Worker tests, all using the cpuUsage probe. VirtualMachine::wait_for had zero callers and is deleted.

4. Named guard. ref_loop_scoped() -> LoopRefGuard, #[must_use = "dropping immediately releases the loop ref, reintroducing the busy-spin"]. I reproduced your rustc -W unused_must_use result first: the fn() -> impl Drop call site is silent, the named one warns. Confirmed, and fixed.

5. Unref'd setImmediate liveness. Two tests (inside a bun:test body; inside a preload, which takes load_preloads' non-watcher wait_for_promise arm — the same driver a sync-required ESM module uses). Their fallback timers had to be .unref()'d: with a ref'd fallback both tests passed even with the guard neutered, because that timer kept the loop alive and satisfied the liveness gate on its own. The consequence is now a bullet in the PR description and a paragraph on ref_loop_scoped.

6. Node-TLA divergence. Filed #33283 for the unsettled-TLA detection (#14951's close was for the CPU-spin half, which this PR fixes, so a fresh issue rather than a reopen). The divergent assertions are fenced with TODO(#33283); the load-bearing ones (timer fires, no hang, no spin) are not. PR body now says node:test refs the loop deliberately, rather than attributing it to Node generally.

@robobun
robobun force-pushed the farm/1f2e3214/unref-timers-idle-loop branch from 61d2d58 to c8ae33e Compare July 2, 2026 23:24
@robobun

robobun commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

The approach changed substantially since your last pass (which reviewed a design that parked inside auto_tick itself); the current diff leaves auto_tick unchanged and instead has the condition-gated drivers hold a scoped loop ref. The walkthrough above is stale as a result.

@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

@robobun, understood — since the approach shifted from parking inside auto_tick to scoped loop refs held by the condition-gated drivers, I'll disregard the earlier walkthrough and re-review the current diff in full.

✅ Action performed

Full review finished.

@robobun
robobun force-pushed the farm/1f2e3214/unref-timers-idle-loop branch 2 times, most recently from 520557a to 5f883e5 Compare July 3, 2026 00:10
@robobun

robobun commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator Author

CI status, for whoever picks this up: the diff is green, and the two red builds are unrelated lanes.

Build #68115 (5f883e5c): test/js/bun/util/v8-heap-snapshot.test.ts SIGKILLed on 🐧 25.04 x64. The identical signature (same file, same lane, main process killed by SIGKILL but no core file found) also hit #68114 on farm/ae076f78/ws-maxpayload at 0f7c121cb, which does not contain this change.

Build #68108 (520557a0): test/js/bun/s3/s3.test.ts on 🪟 2019 x64-baseline, two R2 large file upload timeouts (20s and 100s), no assertion failures. Same R2 large-file timeout class showed up on another branch's build the same afternoon.

(The earlier node-tls-connect OCSP failure is gone: it was the live bun.sh cert dropping its OCSP URI, fixed on main in #32789, which this branch picked up in the rebase.)

I did not take the heap-snapshot one on faith, since the bun:test drive loop is inside this diff's blast radius. Measured locally, neutering ref_loop_scoped as the control:

  • the drive loop runs 0 iterations for that file (phase is already Done when it is reached), so the guard there is never exercised
  • auto_tick's active branch runs fewer than 25 times in the whole process; total time parked is ~175ms, worst single park 93ms. Two of those parks had far-future deadlines (49s, 196s) and still returned in under 30ms, which is the wants_wakeup pairing doing its job
  • process_gc_timer() (which only runs in the newly-taken is_active() branch) never exceeds 2ms
  • wait_for_promise costs the same in both arms: 1328ms vs 1318ms

Interleaved file timings, alternating arms to avoid confounding with machine drift:

pair with ref neutered
1 34.92s 35.60s
2 34.47s 50.68s
3 48.36s 48.23s
4 35.89s 34.43s

The within-arm spread (34s to 48s) swamps any between-arm difference, and the neutered mean is higher. That file is simply very heavy under debug+ASAN, and which of its tests cross the 5s default timeout shuffles between runs. So the SIGKILL is that file being slow on that agent, not this change.

Local verification on the pushed tree: timers-unref-idle-loop.test.ts 10/10, repl.test.ts 118/118, setTimeout.test.js + test/js/node/timers/ 50/50.

Happy to push again if you want a fresh build, but the token here is read-only so I cannot retry a single job, and I would rather not stack empty commits on the branch. Ready to merge or re-run from my side.

@robobun
robobun force-pushed the farm/1f2e3214/unref-timers-idle-loop branch from 5f883e5 to 171bd9a Compare July 14, 2026 22:38
Comment thread src/jsc/event_loop.rs Outdated
@robobun
robobun force-pushed the farm/1f2e3214/unref-timers-idle-loop branch from 600e0a4 to 589c94a Compare July 14, 2026 23:09

@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.

Additional findings (outside current diff — PR may have been updated during review):

  • 🔴 ait_for_promise,+30p:1-10 — This file is an accidentally-committed shell artifact — the filename ait_for_promise,+30p is a fragment of a sed -n '/wait_for_promise/,+30p' address expression, and the content is a stale 1554-line snapshot of src/jsc/event_loop.rs (its wait_for_promise_with_termination still has the pre-600e0a4f _loop_ref placement). It has no .rs suffix and no build-system references; please git rm it before merging.

    Extended reasoning...

    What the bug is

    The PR adds a new file at the repository root named ait_for_promise,+30p (new file mode 100644, 1554 lines). This is not a source file — it's a shell-mishap artifact. The filename is the tail of a sed address expression: something like sed -n /wait_for_promise/,+30p src/jsc/event_loop.rs where an unquoted argument was word-split or mis-redirected, leaving ait_for_promise,+30p as an output filename. The file's content is a full snapshot of src/jsc/event_loop.rs at some point during this PR's development.

    Why it's clearly accidental

    Several independent signals confirm this is not intentional:

    1. The filename is not a valid identifier or path in any language — it contains a comma and +, and matches the sed addr1,+N range syntax with the leading /w chopped off.
    2. The content is stale relative to the PR's own HEAD. Its wait_for_promise_with_termination (around line 1207 in the artifact) has let _loop_ref = self.ref_loop_scoped(); taken before the while loop, above the if !self.vm_ref().is_event_loop_alive() { break; } check. Commit 600e0a4 (this PR's tip) moved that guard inside the loop, after the liveness check, precisely because the outer placement made the check dead code. So the artifact captures a superseded intermediate state.
    3. No .rs extension and no build-system references. Cargo won't compile it; nothing in the diff or the tree references it.
    4. It's at the repo root, not under src/ or test/.

    Step-by-step proof

    1. The diff shows: diff --git a/ait_for_promise,+30p b/ait_for_promise,+30p / new file mode 100644 / @@ -0,0 +1,1554 @@.
    2. The file opens with the exact module doc-comment of src/jsc/event_loop.rs: //! \jsc.EventLoop` — the JS-thread event loop.`
    3. Search the artifact for wait_for_promise_with_termination: the PromiseStatus::Pending => arm reads let _loop_ref = self.ref_loop_scoped(); immediately, then enters the while loop, then reaches if !self.vm_ref().is_event_loop_alive() { break; }. Compare to the real src/jsc/event_loop.rs hunk in this same diff, which places let _loop_ref = self.ref_loop_scoped(); after that check with the comment "Scoped to auto_tick only, so the check above reads the real ref state". The artifact predates that fix.
    4. rg 'ait_for_promise' --files-with-matches on the PR tree finds only the file itself — no build script, no Cargo.toml, no include.

    Impact

    There's no runtime impact (the file isn't compiled), but merging it would land 1554 lines of stale duplicated source at the repo root. It would pollute grep/rg results for anyone searching event_loop symbols (every hit doubled, half of them pointing at buggy superseded code), confuse future readers, and clutter git history. That's a concrete enough consequence to block merge.

    Fix

    git rm 'ait_for_promise,+30p' and amend/push. No other change needed.

@robobun

robobun commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator Author

Re: the ait_for_promise,+30p artifact — already removed in 589c94a (amend of 600e0a4). It was sed output swept in by a git add -A during the Worker-guard fix; the diff hygiene gate caught it and I git rm'd + force-pushed before this review landed, which is why it shows as "outside current diff". The PR head tree no longer contains it.

@robobun

robobun commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator Author

CI on 589c94a9 (build #73029, finished): the diff is green, the two error annotations are main/infra breaks being handled elsewhere.

  • test/js/node/test/parallel/test-net-connect-memleak.js on 🏔️ alpine: the weak-ref collected assertion raced GC. Hits four other branches' builds today (73028, 73026, 73023, 73017). Passes 5/5 locally on this branch; uses a ref'd setImmediate with a live server socket, so the event loop is already is_active() regardless of this PR's guard, and nothing here touches GC or FinalizationRegistry.
  • test/js/third_party/grpc-js/test-tonic.test.ts on 🍎 14 aarch64: the agent's rustup has no default toolchain (rustup could not choose a version of cargo to run), so cargo build for the tonic server never starts, startServer() times out, and server.kill() throws on undefined. Agent config, not the test and not this diff.

timers-unref-idle-loop.test.ts, worker-top-level-await.test.ts, and repl.test.ts are not mentioned in any annotation (all lanes passed them). Ready for a maintainer to merge or re-run.

robobun added 3 commits July 16, 2026 03:31
…mers fire without spinning

`wait_for_promise`, `wait_for_promise_with_termination`, the entry-point and
preload loader loops, and bun:test's drive loop all tick `auto_tick` until a
JS-visible condition is satisfied, whether or not JS refs the loop. In that
state `auto_tick`'s `!is_active()` branch is a non-blocking pump: the driver
busy-spins a core on POSIX, and on Windows `uv_run(UV_RUN_ONCE)` returns
without running due timers because `uv__loop_alive()` is false, so an unref'd
timer never fires.

Each of those drivers now holds a scoped uSockets-loop ref via
`EventLoop::ref_loop_scoped()`, a named `#[must_use] LoopRefGuard`. With the
ref held `auto_tick` takes its existing active branch and parks on the next
timer-heap deadline on both platforms; `auto_tick` itself is unchanged.
Liveness-gated drivers (`auto_tick_active`, `wait_for_tasks`) deliberately do
not take it, so exit semantics are unchanged.

A guarded loop parks in epoll/kqueue rather than polling, so every exit
condition now needs a paired loop wakeup. The REPL's SIGINT handler only set
`execution_forbidden`, which was observed only when JSC happened to wake the
loop for its own reasons; it now wakes the loop through
`uws::Loop::wakeup_raw`, a raw-pointer entry point so a handler interrupting a
thread that holds `&mut Loop` cannot mint an aliasing `&mut`. The contract is
documented on `ref_loop_scoped`.

The ref also makes `is_event_loop_alive*()` true for the guarded scope, so an
unref'd `setImmediate` scheduled inside a driver now runs instead of being
cleared. This matches Node, whose `node:test` runner refs the loop for the same
reason.

Also converts `All::get_timeout` and `All::drain_timers` to raw-pointer
receivers so the call-site `&mut All` no longer aliases the re-entrant
`&mut All` that fired timer callbacks mint via `runtime_state()`, and deletes
`VirtualMachine::wait_for`, which had no callers.
The fixture widened its RSS threshold only when the binary is named
bun-asan, so a `bun bd` debug build (ASAN-instrumented, named bun-debug)
measured ASAN's ~136 MB of quarantine retention against the 10 MB release
threshold and reported a leak in all three modes. Probe ASAN_ENABLED the
way test/harness.ts does, keeping the binary-name check as a fallback.
…ill fires

The rebase onto #31216 picked up an is_event_loop_alive() break inside
wait_for_promise_with_termination's loop. ref_loop_scoped bumps the same
counter that check reads, so taking the guard before the loop made the break
dead code and a Worker whose entry module never settles parked indefinitely
instead of exiting (Node exits 13 here).

Scope the guard to auto_tick only, after the liveness check, so the check
reads the real ref state. Replaces the Worker test in
timers-unref-idle-loop.test.ts with one that guards this: it previously
asserted an unref'd timer fires inside a Worker's entry-module TLA with
nothing else ref'd, which Node 26.3 does not do (the Worker exits 13 without
waiting). Extends ref_loop_scoped's contract to cover the hybrid case.
@robobun
robobun force-pushed the farm/1f2e3214/unref-timers-idle-loop branch from 589c94a to 9db2a20 Compare July 16, 2026 03:41
@robobun

robobun commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator Author

CI on 9db2a204 (build #73604, finished): the diff is green. timers-unref-idle-loop.test.ts, worker-top-level-await.test.ts, and repl.test.ts passed every lane. The four red tests are all fleet-wide and being handled separately:

  • test/js/web/timers/timer-heap-race.test.ts on debian asan: LeakSanitizer report of ConcurrentTask allocations during WebWorker VM::~VM() teardown (JSC DeferredWorkTimerBun__queueJSCDeferredWorkTaskConcurrently). Also on builds 73605 and 73602. Passes 3/3 locally with this branch's debug+ASAN build. The leak is in the Worker-teardown path; this PR's conflict resolution with Move WTFTimer out of the shared timer heap to fix a cross-thread race #33131 touched only the get_timeout/drain_timers function prologues.
  • test/js/node/test/parallel/test-worker-message-port-transfer-terminate.js on debian asan: the existing JSC exception-scope assert; hits 4+ other branches. Passes 5/5 locally including under BUN_JSC_validateExceptionChecks=1.
  • test/js/node/test/parallel/test-net-connect-memleak.js on alpine: GC weak-ref timing; hits 4+ other branches, passes 5/5 locally.
  • test/cli/run/require-cache.test.ts on darwin aarch64: RSS 100 MB vs the 64 MB threshold; also on 73641, 73640, 73639. This PR does not touch require.cache or module resolution.

Ready for a maintainer to merge or re-run; happy to rebase again if main moves first.

@robobun

robobun commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

Heads up: #34478 is a narrow us_loop_pump-level fix for the subprocess side of this (spawn.test.ts was timing out the whole file on Windows lanes). It's compatible with the LoopRefGuard approach here; once this lands the us_loop_pump bump is just a safety net.

@robobun

robobun commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator Author

Another symptom this fixes, verified on Windows x64 against both head commits:

bun test's own per-test timeout never fires on Windows when a test body awaits a never-resolving promise and nothing JS-side refs the loop. The BunTest timeout is an EventLoopTimer inserted via vm_timer().insert() (src/runtime/test_runner/bun_test.rs:1012), which arms the shared uv_timer_t but leaves it unref'd (active_timer_count stays 0). Without the drive-loop ref, uv__loop_alive() is 0 so uv_run never reaches uv__run_timers; the test_command while-loop busy-spins at 100% CPU until the outer CI file timeout kills the process.

// hang.test.ts
import { test } from "bun:test";
test("never resolves", async () => { await new Promise(() => {}); });

bun test hang.test.ts --timeout 2000:

main (98f664962) this PR (9db2a204)
bare await new Promise(()=>{}) still running at 15s, CPU 15016ms timed out after 2000ms, exit 1 in 2.06s
with Bun.spawn({terminal:...}) in body still running at 15s, CPU 14969ms timed out after 2000ms, exit 1 in 2.06s

This is what turned the Win2019 ConPTY race in test/js/bun/terminal/terminal-spawn.test.ts into 180s file timeouts (builds 75638, 75516): once a test is left awaiting a dead promise, the per-test timeout can't rescue it. #34695 works around the trigger; this PR fixes the timeout mechanism itself (and the existing bun:test drive loop tests here already cover the same code path, since the internal timeout goes through the same uv_timer_t).

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

This spin was traced again independently (a bun test file whose test awaits a never-settling promise burns a full core until the per-test timeout; same for an unref'd timer in a test body, expect().resolves on one, and a top-level await on one in bun run, all ~1000 ms of CPU per second of waiting on current main, 88a6398836). The resulting PR, #39107, took the idle-branch shape the review here ruled out and has been closed in favor of this one; its four process.cpuUsage() fixtures are a subset of timers-unref-idle-loop.test.ts.

This branch no longer merges cleanly against main (mergeable_state: dirty as of today), so it needs a rebase before it can land. The related narrow Windows fix (us_loop_pump bumping active_handles for one UV_RUN_NOWAIT iteration) is on main now, so on Windows the timers fire but the drivers still spin exactly as on POSIX; the per-driver ref here is still what stops the spin on both.

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.

3 participants