diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 2dc03fbd4246..ba5bc33ac7a1 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -2433,6 +2433,10 @@ impl VirtualMachine { if self.is_watcher_enabled() { // accessed here (no overlapping `&mut EventLoop`). self.event_loop_mut().perform_gc(); + // See `EventLoop::ref_loop_scoped` — this drive loop keeps + // ticking until the module promise settles, so ref the loop so + // `auto_tick` parks on timer deadlines instead of spinning. + let _loop_ref = self.event_loop_shared().ref_loop_scoped(); loop { let Some(p) = self.pending_internal_promise else { break; @@ -3615,31 +3619,6 @@ impl VirtualMachine { self.event_loop_mut().enqueue_task_concurrent(task); } - /// `cond` is `&Cell` (not `&mut bool`): the re-entrant - /// `tick()/auto_tick()` calls run JS that flips the flag through an - /// independently-captured handle, so the read must not be `noalias`. - /// `Cell` is `!Freeze`, which suppresses the LLVM `noalias`/`readonly` - /// attributes and forces a real reload on every `.get()` — no raw-pointer - /// laundering needed for the condition. - pub fn wait_for(&mut self, cond: &core::cell::Cell) { - // R-2 noalias mitigation (PORT_NOTES_PLAN R-2; precedent - // `b818e70e1c57` NodeHTTPResponse::cork): `&mut self` is - // LLVM-`noalias`, but `tick()/auto_tick()` re-enter JS which reaches - // `self` again via `VirtualMachine::get()`. Launder `self` so each - // access goes through an opaque address. - let this: *mut Self = core::hint::black_box(core::ptr::from_mut(self)); - while !cond.get() { - // SAFETY: `this` is the unique live VM; each deref is a momentary - // access only (no borrow held across the re-entrant call). - unsafe { (*this).event_loop_mut().tick() }; - if !cond.get() { - // SAFETY: as above — momentary deref of the unique live VM, - // no borrow held across the re-entrant call. - unsafe { (*this).auto_tick() }; - } - } - } - /// Ticks the event loop until no tasks keep it alive. pub fn wait_for_tasks(&mut self) { while self.is_event_loop_alive() { @@ -4620,6 +4599,9 @@ impl VirtualMachine { // pending_internal_promise can change if hot module reloading is enabled if self.is_watcher_enabled() { self.event_loop_mut().perform_gc(); + // See `EventLoop::ref_loop_scoped` — park instead of spin while + // waiting on the module promise. + let _loop_ref = self.event_loop_shared().ref_loop_scoped(); loop { let Some(p) = self.pending_internal_promise else { break; diff --git a/src/jsc/event_loop.rs b/src/jsc/event_loop.rs index daae5d2a5284..264a2935b691 100644 --- a/src/jsc/event_loop.rs +++ b/src/jsc/event_loop.rs @@ -234,6 +234,20 @@ impl Drop for EventLoopEnterGuard { } } +/// Keeps the uSockets loop ref'd for the guard's lifetime. Construct via +/// [`EventLoop::ref_loop_scoped`], whose docs carry the full contract. +#[must_use = "dropping immediately releases the loop ref, reintroducing the busy-spin"] +pub struct LoopRefGuard(*mut uws::Loop); + +impl Drop for LoopRefGuard { + #[inline] + fn drop(&mut self) { + // SAFETY: the loop is the per-thread singleton, live for the VM + // lifetime; balances the `ref_()` in `ref_loop_scoped`. + unsafe { (*self.0).unref() }; + } +} + impl EventLoop { /// Before your code enters JavaScript at the top of the event loop, call /// `loop.enter()`. If running a single callback, prefer `runCallback` instead. @@ -953,13 +967,65 @@ impl EventLoop { self.vm_ref().as_mut().auto_tick_active(); } + /// Hold a uSockets-loop ref for the lifetime of the returned guard. + /// + /// Condition-gated drivers (`wait_for_promise`, the `bun:test` drive + /// loop, the entry-point/preload loaders) tick `auto_tick` until a + /// JS-visible condition is satisfied, independently of whether JS has + /// anything refing the loop. Without this ref `auto_tick` takes its + /// `!is_active()` branch — a non-blocking pump that busy-spins the driver + /// on POSIX and on Windows never runs due timers (`uv_run` skips them + /// when the loop has no ref'd handles). With the ref, `auto_tick`'s + /// active branch parks in epoll/kqueue (`uv_run(UV_RUN_ONCE)` on Windows) + /// until the next timer-heap deadline — or **indefinitely** when the heap + /// is empty, which is the normal state while awaiting an external event. + /// + /// # Contract + /// While the guard is held, every exit condition of the guarded loop must + /// be observable through something that wakes the uws loop: a task + /// enqueued on this `EventLoop`, a heap timer, an I/O event, or an + /// explicit [`Self::wakeup`] paired with the flag store. A bare + /// cross-thread or signal-handler flag flip is NOT sufficient — before + /// this ref existed the non-blocking pump observed such flips within + /// microseconds (see `sigint_handler` in `cli/repl.rs`, which wakes the + /// loop after setting `execution_forbidden`). + /// + /// The ref also makes [`VirtualMachine::is_event_loop_alive`] and + /// `is_event_loop_alive_excluding_immediates` report true for the guard's + /// scope. That is intentional and has one JS-visible consequence: an + /// unref'd `setImmediate` is only cleared-without-running when the loop + /// looks dead, so one scheduled inside a guarded driver (a `bun:test` + /// test body, a preload, a synchronously-required ESM module) now runs. + /// This matches Node, whose `node:test` runner refs the loop for the same + /// reason. + /// + /// Liveness-gated drivers (the main run loop via `auto_tick_active`, + /// `wait_for_tasks`) must NOT use this — their exit condition is + /// precisely that nothing refs the loop. A driver that checks liveness + /// inside the loop but is otherwise condition-gated (the Worker entry + /// loader, `wait_for_promise_with_termination`) must scope the guard to + /// `auto_tick` only, after the liveness check, so that check reads the + /// real ref state. + pub fn ref_loop_scoped(&self) -> LoopRefGuard { + let loop_ = self.usockets_loop(); + // SAFETY: `usockets_loop()` returns the live per-thread uws loop; + // `ref_()` bumps `num_polls` + `active` (POSIX) / `active_handles` + // (Windows). + unsafe { (*loop_).ref_() }; + LoopRefGuard(loop_) + } + /// `eventLoop().waitForPromise(promise)` — spin tick/auto_tick until /// `promise` settles or execution is forbidden. + /// + /// Holds a loop ref, so `execution_forbidden` must be paired with a loop + /// wakeup to be observed — see [`Self::ref_loop_scoped`]'s contract. pub fn wait_for_promise(&mut self, promise: jsc::AnyPromise) { let jsc_vm = self.vm_ref().jsc_vm(); if promise.status() != PromiseStatus::Pending { return; } + let _loop_ref = self.ref_loop_scoped(); while promise.status() == PromiseStatus::Pending { if jsc_vm.execution_forbidden() { break; @@ -1172,6 +1238,9 @@ impl EventLoop { if !self.vm_ref().is_event_loop_alive() { break; } + // Scoped to auto_tick only, so the check above reads the + // real ref state — see ref_loop_scoped's contract. + let _loop_ref = self.ref_loop_scoped(); self.auto_tick(); } } diff --git a/src/runtime/cli/repl.rs b/src/runtime/cli/repl.rs index dcce6191a6f9..d31160a060dc 100644 --- a/src/runtime/cli/repl.rs +++ b/src/runtime/cli/repl.rs @@ -967,6 +967,11 @@ impl<'a> Repl<'a> { // Cleared in disable_signals_during_wait; Release pairs with the // Acquire load in `sigint_handler`. SIGINT_VM.store(vm.jsc_vm, core::sync::atomic::Ordering::Release); + // `wait_for_promise` refs the loop (see `EventLoop::ref_loop_scoped`), + // so `auto_tick` parks in epoll/kqueue instead of polling. Setting + // `execution_forbidden` from the handler is therefore not observable + // on its own — the handler must also wake the loop. + SIGINT_LOOP.store(vm.uws_loop(), core::sync::atomic::Ordering::Release); } #[cfg(unix)] @@ -990,6 +995,7 @@ impl<'a> Repl<'a> { /// Restore raw terminal mode after promise wait fn disable_signals_during_wait(&mut self) { SIGINT_VM.store(core::ptr::null_mut(), core::sync::atomic::Ordering::Release); + SIGINT_LOOP.store(core::ptr::null_mut(), core::sync::atomic::Ordering::Release); #[cfg(unix)] { @@ -2436,6 +2442,11 @@ impl<'a> Drop for Repl<'a> { static SIGINT_VM: core::sync::atomic::AtomicPtr = core::sync::atomic::AtomicPtr::new(core::ptr::null_mut()); +/// The uws loop the interrupted `wait_for_promise` is parked in. See +/// `enable_signals_during_wait`. +static SIGINT_LOOP: core::sync::atomic::AtomicPtr = + core::sync::atomic::AtomicPtr::new(core::ptr::null_mut()); + #[cfg(unix)] extern "C" fn sigint_handler(_: c_int) { let vm = SIGINT_VM.load(core::sync::atomic::Ordering::Acquire); @@ -2444,6 +2455,16 @@ extern "C" fn sigint_handler(_: c_int) { // blocked in wait while the handler runs, so it stays valid). jsc::VM::opaque_ref(vm).set_execution_forbidden(true); } + let loop_ = SIGINT_LOOP.load(core::sync::atomic::Ordering::Acquire); + if !loop_.is_null() { + // Wake the parked `auto_tick` so `wait_for_promise` re-checks + // `execution_forbidden`. `wakeup_raw` takes a raw pointer (the parked + // thread holds `&mut Loop`) and bottoms out in an atomic increment + // plus a `write()` to the wakeup eventfd — async-signal-safe. + // SAFETY: `loop_` is the VM's loop, live while the JS thread is + // parked in the wait this handler interrupts. + unsafe { bun_uws::Loop::wakeup_raw(loop_) }; + } } fn is_incomplete_code(code: &[u8]) -> bool { diff --git a/src/runtime/cli/test_command.rs b/src/runtime/cli/test_command.rs index 4827edb650e7..3af46da23b11 100644 --- a/src/runtime/cli/test_command.rs +++ b/src/runtime/cli/test_command.rs @@ -3228,6 +3228,12 @@ impl TestCommand { // Process event loop while bun_test tests are running vm.event_loop_ref().tick(); + // This drive loop keeps ticking until `Phase::Done` (a + // JS-visible condition) regardless of whether JS has anything + // refing the loop. Hold a loop ref for the duration so + // `auto_tick` parks on the next timer-heap deadline rather + // than busy-spinning when a test awaits an unref'd timer. + let _loop_ref = vm.event_loop_shared().ref_loop_scoped(); let mut prev_unhandled_count = vm.unhandled_error_counter; while buntest.phase != bun_test::Phase::Done { if buntest.wants_wakeup { diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index 5adc48000d94..96ff3549fdfe 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -791,6 +791,12 @@ unsafe fn load_preloads(vm: *mut VirtualMachine) -> bun_jsc::CrateResult<*mut JS let el = unsafe { &*vm }.event_loop(); // SAFETY: `el` is the live per-thread event loop. unsafe { (*el).perform_gc() }; + // See `EventLoop::ref_loop_scoped` — this drive loop keeps + // ticking until the preload's module promise settles, so + // ref the loop so `auto_tick` parks on timer deadlines + // instead of spinning (mirrors the entry-point loaders). + // SAFETY: `el` is the live per-thread event loop. + let _loop_ref = unsafe { &*el }.ref_loop_scoped(); loop { // SAFETY: `pending_internal_promise` was set just above (or // swapped by HMR to another live cell); `status()` is a @@ -983,23 +989,21 @@ unsafe fn auto_tick(vm: *mut VirtualMachine) { // SAFETY: `el` is the live per-thread event loop. unsafe { (*el).process_gc_timer() }; // Note (§Forbidden aliased-&mut): `get_timeout` may fire a - // `WTFTimer` JS callback. - // A re-entrant `setTimeout`/`clearTimeout` reaches - // `timer::All::insert`/`remove` via `runtime_state()` and would - // mint a second `&mut timer` if we held `&mut (*state).timer` - // across the call. Pass the raw `*mut Self` instead; - // `timer::All::get_timeout` forms short-lived `&mut` only around - // heap ops that cannot re-enter JS, releasing the borrow before - // invoking `fire()`. + // `WTFTimer` JS callback. A re-entrant `setTimeout`/ + // `clearTimeout` reaches `timer::All::insert`/`remove` via + // `runtime_state()` and would mint a second `&mut timer` if a + // `&mut (*state).timer` were live across the call, so the + // receiver is a raw `*mut All`. // `get_timeout` reads CLOCK_MONOTONIC to compare against the timer heap; hand that // same reading to the tick for the park hook's idle-sweep rate limit. It is lazy, // and so is the hook: NOW_NS_UNKNOWN means it took none. let mut now: Option = None; - // SAFETY: `state` is the live per-thread `RuntimeState`; the - // `timer` field address is stable for the VM lifetime. + // SAFETY: `state` is the live per-thread `RuntimeState` (JS + // thread, no outstanding `&mut All`); the `timer` field address + // is stable for the VM lifetime. let have_timeout = unsafe { timer::All::get_timeout( - &mut (*state).timer, + core::ptr::addr_of_mut!((*state).timer), &mut timespec, has_pending_immediate, quic_next_tick_us, @@ -1023,12 +1027,13 @@ unsafe fn auto_tick(vm: *mut VirtualMachine) { { // Note (§Forbidden aliased-&mut): `drain_timers` fires user // `setTimeout` callbacks which may re-enter `timer::All::insert`/ - // `remove` via `runtime_state()`. Pass raw `*mut Self` so no - // long-lived `&mut (*state).timer` is held across `fire()`; - // `drain_timers` forms short-lived `&mut` only around heap pop/peek. - // SAFETY: `state` is the live per-thread `RuntimeState`; the `timer` - // field address is stable for the VM lifetime. - unsafe { timer::All::drain_timers(&mut (*state).timer, vm.cast()) }; + // `remove` via `runtime_state()`. The receiver is a raw `*mut All` so + // no `&mut (*state).timer` is held across `fire()`; `drain_timers` + // forms short-lived `&mut` only around heap pop/peek. + // SAFETY: `state` is the live per-thread `RuntimeState` (JS thread, + // no outstanding `&mut All`); the `timer` field address is stable for + // the VM lifetime. + unsafe { timer::All::drain_timers(core::ptr::addr_of_mut!((*state).timer), vm.cast()) }; } #[cfg(not(unix))] let _ = state; @@ -1119,11 +1124,12 @@ unsafe fn auto_tick_active(vm: *mut VirtualMachine) { // same reading to the tick for the park hook's idle-sweep rate limit. It is lazy, // and so is the hook: NOW_NS_UNKNOWN means it took none. let mut now: Option = None; - // SAFETY: `state` is the live per-thread `RuntimeState`; see - // Note on `auto_tick` re: aliased-&mut across `fire()`. + // SAFETY: `state` is the live per-thread `RuntimeState` (JS + // thread, no outstanding `&mut All`); see Note on `auto_tick` + // re: aliased-&mut across `fire()`. let have_timeout = unsafe { timer::All::get_timeout( - &mut (*state).timer, + core::ptr::addr_of_mut!((*state).timer), &mut timespec, has_pending_immediate, quic_next_tick_us, @@ -1145,9 +1151,9 @@ unsafe fn auto_tick_active(vm: *mut VirtualMachine) { #[cfg(unix)] { - // SAFETY: `state` is the live per-thread `RuntimeState`; see Note - // on `auto_tick` re: aliased-&mut across `fire()`. - unsafe { timer::All::drain_timers(&mut (*state).timer, vm.cast()) }; + // SAFETY: `state` is the live per-thread `RuntimeState` (JS thread, + // no outstanding `&mut All`); see Note on `auto_tick`. + unsafe { timer::All::drain_timers(core::ptr::addr_of_mut!((*state).timer), vm.cast()) }; } #[cfg(not(unix))] let _ = state; diff --git a/src/runtime/timer/Timer.rs b/src/runtime/timer/Timer.rs index 27a99009c83c..a030e3e18a6a 100644 --- a/src/runtime/timer/Timer.rs +++ b/src/runtime/timer/Timer.rs @@ -505,9 +505,9 @@ pub fn drain_timers_export(vm: *mut VirtualMachine) { if all.is_null() { return; } - // SAFETY: `all` is the live per-thread `All`; `vm` is the erased VM pointer - // (mod.rs::All::drain_timers takes `*mut ()`). - unsafe { (*all).drain_timers(vm.cast::<()>()) }; + // SAFETY: `all` is the live per-thread `All` with no outstanding `&mut`; + // `vm` is the erased VM pointer (mod.rs::All::drain_timers takes `*mut ()`). + unsafe { All::drain_timers(all, vm.cast::<()>()) }; } // `generate-host-exports.ts` diff --git a/src/runtime/timer/mod.rs b/src/runtime/timer/mod.rs index 8a05dfdba08f..741af5465bbc 100644 --- a/src/runtime/timer/mod.rs +++ b/src/runtime/timer/mod.rs @@ -771,7 +771,7 @@ impl All { // thread); `all` is live for the VM lifetime. `drain_timers` may // re-enter `(*runtime_state()).timer` — it forms only short-lived // `&mut All` around heap pop/peek, so the raw-ptr deref here is sound. - unsafe { (*all).drain_timers(vm) }; + unsafe { All::drain_timers(all, vm) }; // SAFETY: see above; re-arm for the next-soonest deadline (if any). unsafe { (*all).ensure_uv_timer() }; } @@ -935,13 +935,13 @@ impl All { /// passed in pre-computed until the cycle is broken. /// /// # Safety - /// `vm` is the erased `*mut VirtualMachine` for the calling JS thread and - /// must remain live across any `EventLoopTimer::fire` re-entry. - // Forwards `vm` to `__bun_fire_timer` without dereferencing it; - // not_unsafe_ptr_arg_deref is a false positive on opaque-token forwarding. - #[allow(clippy::not_unsafe_ptr_arg_deref)] - pub fn get_timeout( - &mut self, + /// `this` must point to the calling JS thread's live `All` with no + /// outstanding `&mut All` — the WTFTimer arm fires callbacks that may + /// re-enter `(*runtime_state()).timer`. `vm` is the erased + /// `*mut VirtualMachine` for the calling JS thread and must remain live + /// across any `EventLoopTimer::fire` re-entry. + pub unsafe fn get_timeout( + this: *mut Self, spec: &mut Timespec, has_pending_immediate: bool, quic_next_tick_us: Option, @@ -956,7 +956,6 @@ impl All { #[cfg(not(unix))] let _ = has_pending_immediate; - let this: *mut Self = self; let maybe_now: &mut Option = now_out; // SAFETY: `this` is the live per-thread `All`; `vm` per fn contract. @@ -1036,29 +1035,17 @@ impl All { } /// # Safety - /// `vm` is the erased `*mut VirtualMachine` for the calling JS thread and - /// must remain live across any `EventLoopTimer::fire` re-entry. - // Forwards `vm` to `__bun_fire_timer` without dereferencing it; - // not_unsafe_ptr_arg_deref is a false positive on opaque-token forwarding. - #[allow(clippy::not_unsafe_ptr_arg_deref)] - pub fn drain_timers(&mut self, vm: *mut () /* erased *mut VirtualMachine */) { - // Note (§Forbidden aliased-&mut): fired handlers re-enter `vm.timer` - // (e.g. setInterval reschedule → `vm.timer.update(...)`, `cancel()` → - // `vm.timer.remove(...)`). In Rust those re-entrant calls resolve to - // `(*runtime_state()).timer.{update,remove}()`, minting a fresh - // `&mut All` to this same allocation while the outer `&mut self` is - // live → UB under Stacked Borrows. Convert `self` to a raw pointer - // up-front and form a *short-lived* `&mut` only around `next()`, - // dropping it before `fire()` so no `&mut All` is held across the - // re-entrant call (mirroring the raw-ptr pattern in - // `TimerObjectInternals::run_immediate_task`). - // - // TODO: the call-site auto-ref at jsc_hooks.rs (`(*state).timer - // .drain_timers(...)`) still creates a `&mut All` for the call frame - // itself; switch it to `All::drain_timers(core::ptr::addr_of_mut!( - // (*state).timer), vm)` and change this signature to `this: *mut Self`. - let this: *mut Self = self; - + /// `this` must point to the calling JS thread's live `All` with no + /// outstanding `&mut All` — fired handlers may re-enter + /// `(*runtime_state()).timer`. `vm` is the erased `*mut VirtualMachine` + /// for the calling JS thread and must remain live across any + /// `EventLoopTimer::fire` re-entry. + pub unsafe fn drain_timers(this: *mut Self, vm: *mut () /* erased *mut VirtualMachine */) { + // Note (§Forbidden aliased-&mut): fired handlers re-enter + // `(*runtime_state()).timer.{update,remove}()` (setInterval + // reschedule, `cancel()`). Per `# Safety` the receiver is a raw + // `*mut Self`; form a *short-lived* `&mut` only around `next()`, + // never across `fire()`. let mut wtf_now: Option = None; // SAFETY: `this` is the live per-thread `All`; `vm` per fn contract. let _ = unsafe { Self::drain_due_wtf_timers(this, &mut wtf_now, vm) }; @@ -1066,8 +1053,9 @@ impl All { let mut now = Timespec { sec: 0, nsec: 0 }; let mut has_set_now = false; loop { - // SAFETY: `this` derived from `&mut self`; short-lived exclusive - // borrow scoped to this `next()` call only — dropped before fire(). + // SAFETY: `this` is the live per-thread `All` (fn contract); + // short-lived exclusive borrow scoped to this `next()` call only — + // dropped before fire(). let Some(t) = (unsafe { &mut *this }).next(&mut has_set_now, &mut now) else { break; }; diff --git a/src/uws_sys/Loop.rs b/src/uws_sys/Loop.rs index 144d86379b31..db4862c59c2f 100644 --- a/src/uws_sys/Loop.rs +++ b/src/uws_sys/Loop.rs @@ -243,6 +243,22 @@ impl PosixLoop { unsafe { c::us_wakeup_loop(self) }; } + /// [`Self::wakeup`] from a signal handler or a thread that may already + /// hold a `&mut Loop`. + /// + /// Takes `*mut Self` so a signal handler interrupting a thread parked + /// inside `tick_with_timeout` (which holds `&mut Loop` across the FFI + /// call) does not mint a second, aliasing `&mut`. `us_wakeup_loop` is an + /// atomic increment plus a `write()` to the wakeup eventfd, so it is + /// async-signal-safe. + /// + /// # Safety + /// `this` must be a live loop pointer for the duration of the call. + pub unsafe fn wakeup_raw(this: *mut Self) { + // SAFETY: per fn contract. + unsafe { c::us_wakeup_loop(this) }; + } + #[inline] pub fn wake(&mut self) { self.wakeup(); @@ -472,6 +488,15 @@ impl WindowsLoop { unsafe { c::us_wakeup_loop(self) }; } + /// See [`PosixLoop::wakeup_raw`]. + /// + /// # Safety + /// `this` must be a live loop pointer for the duration of the call. + pub unsafe fn wakeup_raw(this: *mut Self) { + // SAFETY: per fn contract. + unsafe { c::us_wakeup_loop(this) }; + } + #[inline] pub fn wake(&mut self) { self.wakeup(); diff --git a/test/js/bun/repl/repl.test.ts b/test/js/bun/repl/repl.test.ts index 0d8e2ce79af9..e6e948b074fa 100644 --- a/test/js/bun/repl/repl.test.ts +++ b/test/js/bun/repl/repl.test.ts @@ -985,6 +985,25 @@ describe.todoIf(isWindows)("Bun REPL (Terminal)", () => { }); }); + test.skipIf(isWindows)("SIGINT interrupts an await that never settles", async () => { + // `wait_for_promise` refs the event loop, so `auto_tick` parks in + // epoll/kqueue rather than polling. SIGINT only sets `execution_forbidden`, + // so the handler must also wake the loop or the REPL never notices. + await withTerminalRepl(async ({ send, waitFor, proc }) => { + // The timer only fires from inside the parked wait, so its output proves + // the REPL reached the state this test is about before we interrupt. + // The sentinel is assembled at runtime so waitFor matches the timer's + // output rather than the terminal's echo of this line. + send("setTimeout(() => console.log('par' + 'ked'), 1); await new Promise(() => {})\n"); + await waitFor("\nparked"); + proc.kill("SIGINT"); + await waitFor(/\u276f|> /); + // The prompt is usable again + send("1 + 1\n"); + await waitFor("2"); + }); + }); + test("Ctrl+D exits on empty line", async () => { await withTerminalRepl(async ({ terminal, proc }) => { terminal.write("\x04"); // Ctrl+D diff --git a/test/js/web/timers/setTimeout-clear-in-callback-leak-fixture.js b/test/js/web/timers/setTimeout-clear-in-callback-leak-fixture.js index f3f7d0ae67df..d15daf2ff388 100644 --- a/test/js/web/timers/setTimeout-clear-in-callback-leak-fixture.js +++ b/test/js/web/timers/setTimeout-clear-in-callback-leak-fixture.js @@ -10,8 +10,17 @@ if (mode !== "clear" && mode !== "refresh" && mode !== "repeat") { } // ASAN's quarantine retains freed allocations (default 256 MB) so RSS deltas -// run far higher under bun-asan; widen the threshold to avoid false positives. -const isASAN = process.execPath.includes("bun-asan"); +// run far higher under it; widen the threshold to avoid false positives. Probe +// the runtime, not the binary name: `bun bd` is ASAN too, but named bun-debug. +const isASAN = detectASAN(); + +function detectASAN() { + try { + const { isASANEnabled } = require("bun:internal-for-testing"); + if (typeof isASANEnabled === "function") return isASANEnabled(); + } catch {} + return process.execPath.includes("bun-asan"); +} const BATCH = 2_000; diff --git a/test/js/web/timers/timers-unref-idle-loop.test.ts b/test/js/web/timers/timers-unref-idle-loop.test.ts new file mode 100644 index 000000000000..f5f0bf65c000 --- /dev/null +++ b/test/js/web/timers/timers-unref-idle-loop.test.ts @@ -0,0 +1,197 @@ +// When only unref'd timers remain but a driver is still spinning the event +// loop waiting on a JS-visible condition (bun:test awaiting a test body, +// wait_for_promise, top-level await in the entrypoint, the --hot/--watch +// loaders), those drivers hold a uSockets-loop ref for their duration so +// auto_tick takes its active branch and parks on the next timer-heap +// deadline. Without that ref the idle branch is a non-blocking pump that +// busy-spins the driver on POSIX and on Windows never runs due timers +// (uv_run skips them when the loop has no ref'd handles). +// +// Every test here spawns a child: the behavior under test is the *child's* +// drive loop, and a regression is a child that hangs (Windows) or burns CPU +// (POSIX), neither of which an in-process test could report — bun:test's own +// per-test timeout lives in the same timer heap that stops draining. +import { expect, it } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; + +// A child that reports the CPU it burned across an await of an unref'd timer. +// Unfixed, the driver busy-spins for the whole wait (CPU tracks wall time); +// fixed, it parks and CPU stays near zero. Measuring CPU (not wall time) keeps +// the assertion meaningful on both POSIX (spin) and Windows (hang). +const CPU_PROBE = (body: string) => `const cpu0 = process.cpuUsage(); +${body} +const cpu = process.cpuUsage(cpu0); +console.log(JSON.stringify({ fired, cpuMs: Math.round((cpu.user + cpu.system) / 1000) }));`; + +const AWAIT_UNREFD_TIMER = `const fired = await new Promise(resolve => { + setTimeout(() => resolve(true), 2000).unref(); +});`; + +async function run(cmd: string[], cwd?: string) { + await using proc = Bun.spawn({ cmd, env: bunEnv, cwd, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode, signalCode: proc.signalCode }; +} + +// Parses the JSON line the CPU probe printed and asserts the timer fired +// without the driver spinning. A hung child (the Windows regression) produces +// no such line and a non-null signalCode once the parent tears it down. +function expectParkedNotSpun({ stdout, exitCode, signalCode }: Awaited>) { + expect({ signalCode, exitCode }).toEqual({ signalCode: null, exitCode: 0 }); + const line = stdout.trim().split("\n").at(-1) ?? ""; + const { fired, cpuMs } = JSON.parse(line || "null") ?? {}; + expect(fired).toBe(true); + expect(cpuMs).toBeLessThan(1000); +} + +// `bun test` children print their banner on stdout and their report on stderr. +function expectTestRunnerPassed({ stderr }: Awaited>) { + expect({ pass: stderr.includes("1 pass"), fail: stderr.includes("0 fail") }).toEqual({ pass: true, fail: true }); +} + +it.concurrent("bun:test drive loop: unref'd setTimeout fires without spinning", async () => { + using dir = tempDir("unref-timer-buntest", { + "x.test.ts": `import { test, expect } from "bun:test"; + test("unref'd setTimeout", async () => { + ${CPU_PROBE(AWAIT_UNREFD_TIMER)} + expect(fired).toBe(true); + });`, + }); + const res = await run([bunExe(), "test", "x.test.ts"], String(dir)); + expectTestRunnerPassed(res); + expectParkedNotSpun(res); +}); + +it.concurrent("bun:test drive loop: unref'd setInterval fires without spinning", async () => { + using dir = tempDir("unref-interval-buntest", { + "x.test.ts": `import { test, expect } from "bun:test"; + test("unref'd setInterval", async () => { + ${CPU_PROBE(`const fired = await new Promise(resolve => { + const t = setInterval(() => { clearInterval(t); resolve(true); }, 2000); + t.unref(); + });`)} + expect(fired).toBe(true); + });`, + }); + const res = await run([bunExe(), "test", "x.test.ts"], String(dir)); + expectTestRunnerPassed(res); + expectParkedNotSpun(res); +}); + +it.concurrent("wait_for_promise: unref'd setTimeout fires under top-level await", async () => { + // Divergence from Node >= 22, tracked in https://github.com/oven-sh/bun/issues/33283: + // Node prints "Detected unsettled top-level await" and exits 13 instead of waiting. + // Flip the stderr/exitCode assertions below when Bun implements that detection. + const res = await run([bunExe(), "-e", CPU_PROBE(AWAIT_UNREFD_TIMER)]); + expect(res.stderr).toBe(""); + expectParkedNotSpun(res); +}); + +it.concurrent("--hot entry loader: unref'd setTimeout fires without spinning", async () => { + // The watcher branch of `load_entry_point` is its own inlined drive loop. + // `bun test --watch` uses a byte-identical loop, so this covers both. + using dir = tempDir("unref-timer-hot", { + "entry.ts": `${CPU_PROBE(AWAIT_UNREFD_TIMER)} + process.exit(0);`, + }); + const res = await run([bunExe(), "--hot", "entry.ts"], String(dir)); + expect(res.stderr).toBe(""); + expectParkedNotSpun(res); +}); + +it.concurrent("--preload loader: unref'd setTimeout fires without spinning", async () => { + // `load_preloads`' watcher branch is a third copy of the same drive loop. + using dir = tempDir("unref-timer-preload", { + "p.ts": CPU_PROBE(AWAIT_UNREFD_TIMER), + "main.ts": `process.exit(0);`, + }); + const res = await run([bunExe(), "--hot", "--preload", "./p.ts", "main.ts"], String(dir)); + expect(res.stderr).toBe(""); + expectParkedNotSpun(res); +}); + +it.concurrent("Worker: the loop ref does not defeat unsettled-TLA exit", async () => { + // wait_for_promise_with_termination breaks on !is_event_loop_alive(), which + // reads the same counter ref_loop_scoped bumps; the guard is scoped to + // auto_tick so that check reads the real ref state. A Worker whose module + // promise never settles must still exit promptly, not park indefinitely. + // (Node 26.3 exits 13 here; see worker-top-level-await.test.ts.) + using dir = tempDir("unref-timer-worker", { + "worker.ts": `setTimeout(() => {}, 60_000).unref(); + await new Promise(() => {});`, + "main.ts": `const t0 = performance.now(); + const w = new Worker(new URL("./worker.ts", import.meta.url).href); + w.addEventListener("close", () => { + console.log(JSON.stringify({ ms: Math.round(performance.now() - t0) })); + process.exit(0); + });`, + }); + const res = await run([bunExe(), "main.ts"], String(dir)); + expect({ signalCode: res.signalCode, exitCode: res.exitCode }).toEqual({ signalCode: null, exitCode: 0 }); + const { ms } = JSON.parse(res.stdout.trim().split("\n").at(-1) ?? "null") ?? {}; + expect(ms).toBeLessThan(5000); +}); + +// The loop ref also makes is_event_loop_alive*() true for the driver's scope. +// That is JS-visible: an unref'd setImmediate is dropped without running only +// when the loop looks dead, so one scheduled inside a guarded driver now runs. +// Node's node:test runner refs the loop and behaves the same way. +it.concurrent("unref'd setImmediate runs inside a bun:test test body", async () => { + using dir = tempDir("unref-immediate-buntest", { + "x.test.ts": `import { test, expect } from "bun:test"; + test("unref'd setImmediate", async () => { + const ran = await new Promise(resolve => { + setImmediate(() => resolve(true)).unref(); + // unref'd so it cannot itself keep the loop alive and mask the gate + setTimeout(() => resolve(false), 1000).unref(); + }); + expect(ran).toBe(true); + });`, + }); + const res = await run([bunExe(), "test", "x.test.ts"], String(dir)); + expectTestRunnerPassed(res); + expect(res.exitCode).toBe(0); +}); + +it.concurrent("unref'd setImmediate runs inside a preload", async () => { + using dir = tempDir("unref-immediate-preload", { + "p.ts": `const ran = await new Promise(resolve => { + setImmediate(() => resolve(true)).unref(); + // unref'd so it cannot itself keep the loop alive and mask the gate + setTimeout(() => resolve(false), 1000).unref(); + }); + console.log(ran ? "ran" : "dropped");`, + "main.ts": `process.exit(0);`, + }); + const { stdout, stderr, exitCode } = await run([bunExe(), "--preload", "./p.ts", "main.ts"], String(dir)); + expect(stderr).toBe(""); + expect({ stdout: stdout.trim(), exitCode }).toEqual({ stdout: "ran", exitCode: 0 }); +}); + +it.concurrent("awaiting setImmediate exits promptly with a long unref'd timer pending", async () => { + // The driver's ref keeps the loop active after the setImmediate drops its + // own ref, so the wait ends on the immediate rather than parking until the + // unrelated 60s deadline. Matches Node. + const { stdout, stderr, exitCode, signalCode } = await run([ + bunExe(), + "-e", + `setTimeout(() => {}, 60000).unref(); + await new Promise(resolve => setImmediate(resolve)); + console.log("done");`, + ]); + expect(stderr).toBe(""); + expect({ stdout: stdout.trim(), exitCode, signalCode }).toEqual({ stdout: "done", exitCode: 0, signalCode: null }); +}); + +it.concurrent("unref'd timers still do not keep the process alive", async () => { + // The guard is scoped to the driver, so exit semantics are unchanged. + const { stdout, stderr, exitCode } = await run([ + bunExe(), + "-e", + `process.on("beforeExit", () => console.log("beforeExit")); + setTimeout(() => console.log("BAD: unref'd timer kept the loop alive"), 1000000).unref(); + setInterval(() => console.log("BAD: unref'd interval kept the loop alive"), 1000000).unref();`, + ]); + expect(stderr).toBe(""); + expect({ stdout: stdout.trim(), exitCode }).toEqual({ stdout: "beforeExit", exitCode: 0 }); +});