From d4e578a786838045e45df6e9f49afec65b63528f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 24 Jul 2026 04:07:46 +0000 Subject: [PATCH 1/2] jsc: move close_all_socket_groups to VirtualMachine; hot_reloader scopeguard via BackRef Stacked on #35373, which retargets the RareData *_group accessors to take *mut uws::Loop. This PR finishes the rare_data/field-alias borrowck cleanup cluster (three sites #35373 doesn't cover). close_all_socket_groups was a &mut self method on RareData that didn't use self (let _ = self;): it walks the per-VM uSockets loop's linked group list, not RareData's embedded fields. Move it to VirtualMachine as a &self method so global_exit and WebWorker::shutdown call it directly instead of re-deriving &VirtualMachine through core::ptr::from_ref(self) while holding &mut rare_data. hot_reloader::on_file_update held the Watcher as a *mut so self could be reborrowed in the loop body and the flush_evictions scopeguard didn't pin a &mut across it. The reloader already stores a BackRef (Copy); have the scopeguard capture that and reach the Watcher on drop, and reborrow via self.get_context() at the two remove_at_index sites. Same BACKREF unsafe obligation, one audited site instead of three. --- src/jsc/VirtualMachine.rs | 46 ++++++++++++++++++++++++++++++--------- src/jsc/hot_reloader.rs | 26 +++++++++++----------- src/jsc/rare_data.rs | 37 ------------------------------- src/jsc/web_worker.rs | 11 ++-------- 4 files changed, 51 insertions(+), 69 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index ed4cbfb877ad..810eba163703 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -966,6 +966,41 @@ impl VirtualMachine { self.rare_data.as_mut().unwrap() } + /// Drain every socket group linked into the per-VM uSockets loop. Must run + /// BEFORE JSC teardown: `close_all_groups` fires `on_close` → JS callbacks → + /// needs a live VM. `RareData`'s `Drop` runs after `WebWorker__teardownJSCVM` + /// and only `deinit()`s (asserts empty in debug). + /// + /// Takes `&self` because it only touches the uSockets loop (a separate heap + /// allocation reached via `uws_loop_mut`), not any `VirtualMachine` field. + pub fn close_all_socket_groups(&self) { + // closeAll() dispatches on_close into JS while the VM is still alive, so a + // handler can call Bun.connect/postgres/etc. and re-populate a group we + // just drained. Loop until every group is observed empty in the same pass + // (bounded: each retry only happens if a JS callback opened a new socket, + // and the cap stops a deliberately-spinning on_close from wedging + // teardown; the post-close force-drain in close_all handles whatever's + // left after the cap). + // Walk the loop's linked-group list rather than RareData's 14 embedded + // fields: Listener/uWS-App groups own their own SocketGroup, and accepted + // sockets land *there*, not in RareData. Iterating only the embedded + // fields missed those, leaking one 88-byte us_socket_t per still-open + // accepted connection at process.exit() (the LSAN cluster on #29932 + // build 49245). + let mut rounds: u8 = 0; + while rounds < 8 { + if !self.uws_loop_mut().close_all_groups() { + break; + } + rounds += 1; + } + // us_socket_close pushes to loop->data.closed_head; loop_post() normally + // frees it on the next tick. We're past the last tick, so drain it now or + // every us_socket_t (libc-allocated) becomes an LSAN leak once we + // unregister the RareData root region. + self.uws_loop_mut().drain_closed_sockets(); + } + pub fn is_main_thread(&self) -> bool { self.worker.is_none() } @@ -1560,16 +1595,7 @@ impl VirtualMachine { // alive (closeAll() fires on_close → JS). After JSC teardown, // RareData's Drop only deinit()s the groups (asserts empty). if self.rare_data.is_some() { - // Note: reshaped for borrowck — `close_all_socket_groups` - // walks the loop's group list via `vm.uws_loop()` and never - // touches `vm.rare_data`, so the disjoint reborrow is sound. - // SAFETY: `self` is the live per-thread VM; the shared borrow - // only reads `event_loop_handle` (no overlap with `rare_data`). - let vm_ref = unsafe { &*core::ptr::from_ref(self) }; - self.rare_data - .as_deref_mut() - .unwrap() - .close_all_socket_groups(vm_ref); + self.close_all_socket_groups(); } // Destroy the per-VM c-ares channel while JSC / `RareData.file_polls` // / `runtime_state` are all still live — `ares_destroy()` re-enters diff --git a/src/jsc/hot_reloader.rs b/src/jsc/hot_reloader.rs index 678cfdc6e936..9dc3a35f04d1 100644 --- a/src/jsc/hot_reloader.rs +++ b/src/jsc/hot_reloader.rs @@ -860,11 +860,6 @@ where let hashes = slice.items_hash(); let parents = slice.items_parent_hash(); let file_descriptors = slice.items_fd(); - // Note: reshaped for borrowck — `ctx` is held as a raw pointer so - // `self` can be reborrowed inside the loop body for tombstone access, - // and so the deferred `flush_evictions` doesn't hold `&mut Watcher` - // across the loop. - let ctx: *mut Watcher = std::ptr::from_mut(self.get_context()); // Wrap the Task itself in a guard so any exit path (including future // early-returns) flushes the buffered hashes via `enqueue()`. // Dereferenced as `&mut *current_task` for the loop body below. @@ -881,14 +876,18 @@ where Task::::init_empty(self), |mut t| t.enqueue(), ); - // See the note above for why this drops *before* `current_task`. - let _flush = scopeguard::guard(ctx, |ctx| { + // See the note above for why this drops *before* `current_task`. The + // guard captures the `BackRef` (Copy) so the Watcher is reached on + // drop without holding a `&mut` across the loop body. + let _flush = scopeguard::guard(self.ctx, |mut ctx_ref| { Output::flush(); - // SAFETY: the Watcher outlives this call (it owns the Reloader that calls us). - unsafe { (*ctx).flush_evictions() }; + // SAFETY: BACKREF invariant — `ctx` outlives the reloader; at guard + // drop no other `&mut Ctx` borrow is live (the loop body's short + // `self.get_context()` reborrows have all retired). + unsafe { ctx_ref.get_mut() } + .bun_watcher_mut() + .flush_evictions(); }); - // SAFETY: the Watcher outlives this call (it owns the Reloader that calls us). - let ctx = unsafe { &mut *ctx }; let fs: &mut FileSystem = FileSystem::instance(); let rfs: &mut Fs::file_system::RealFS = &mut fs.fs; @@ -916,7 +915,8 @@ where if event.op.contains(WatchOp::DELETE) || (event.op.contains(WatchOp::RENAME) && IS_KQUEUE) { - ctx.remove_at_index(bun_watcher::Kind::File, event.index, 0, &[]); + self.get_context() + .remove_at_index(bun_watcher::Kind::File, event.index, 0, &[]); } if self.verbose { @@ -1202,7 +1202,7 @@ where ) )); } - ctx.remove_at_index( + self.get_context().remove_at_index( bun_watcher::Kind::File, entry_id as u16, 0, diff --git a/src/jsc/rare_data.rs b/src/jsc/rare_data.rs index 8f215ac18387..29a3e532b993 100644 --- a/src/jsc/rare_data.rs +++ b/src/jsc/rare_data.rs @@ -830,43 +830,6 @@ impl RareData { loop_, ) } - - // ── close_all_socket_groups ─────────────────────────────────────────── - /// Drain every embedded socket group. Must run BEFORE JSC teardown — closeAll - /// fires on_close → JS callbacks → needs a live VM. RareData.deinit() runs - /// after `WebWorker__teardownJSCVM`, so doing the closeAll - /// there would dispatch into freed JSC heap. - pub fn close_all_socket_groups(&mut self, vm: &VirtualMachine) { - // closeAll() dispatches on_close into JS while the VM is still alive, so a - // handler can call Bun.connect/postgres/etc. and re-populate a group we - // just drained. Loop until every group is observed empty in the same pass - // (bounded — each retry only happens if a JS callback opened a *new* - // socket, and the cap stops a deliberately-spinning on_close from wedging - // teardown; the post-close force-drain in close_all handles whatever's - // left after the cap). - // Walk the loop's linked-group list rather than just our 14 embedded - // fields: Listener/uWS-App groups own their own SocketGroup, and accepted - // sockets land *there*, not in RareData. Iterating only the embedded - // fields missed those, leaking one 88-byte us_socket_t per still-open - // accepted connection at process.exit() (the LSAN cluster on #29932 - // build 49245). - let _ = self; - let mut rounds: u8 = 0; - while rounds < 8 { - // `uws_loop_mut()` is the centralised BACKREF accessor for the - // per-VM uSockets loop (live for the VM lifetime). - if !vm.uws_loop_mut().close_all_groups() { - break; - } - rounds += 1; - } - // us_socket_close pushes to loop->data.closed_head; loop_post() normally - // frees it on the next tick. We're past the last tick, so drain it now — - // every us_socket_t is libc-allocated and otherwise becomes an LSAN leak - // (the only pointer into it lives in mimalloc-backed RareData, which LSAN - // can't trace once we unregister the root region). - vm.uws_loop_mut().drain_closed_sockets(); - } } // ────────────────────────────────────────────────────────────────────────── diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index 482c696fb37e..5bf793f40c70 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -1265,15 +1265,8 @@ impl WebWorker { // Embedded socket groups must drain while JSC is still alive — // closeAll() fires on_close → JS callbacks. RareData.deinit() runs // after teardownJSCVM and only deinit()s (asserts empty in debug). - if let Some(rare) = vm.rare_data.as_deref_mut() { - // reshaped for borrowck — `close_all_socket_groups` - // wants `&VirtualMachine` while `rare` is `&mut` borrowed from - // `vm`. Re-derive `vm` through the raw ptr (sole owner). - - // SAFETY: `vm_ptr` was unpublished under `vm_lock` above, so this - // thread is the sole owner; the JSC VM is still alive (teardown - // is step 3 below). - rare.close_all_socket_groups(unsafe { &*vm_ptr }); + if vm.rare_data.is_some() { + vm.close_all_socket_groups(); } // Destroy the per-VM c-ares channel now: `ares_destroy()` fires // every pending query callback with `ARES_EDESTRUCTION` and then From 542a3c17c7f3c4e82177f95ef912d2c6473b9635 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:15:50 +0000 Subject: [PATCH 2/2] [autofix.ci] apply automated fixes --- src/jsc/hot_reloader.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/jsc/hot_reloader.rs b/src/jsc/hot_reloader.rs index 9dc3a35f04d1..10dcecedab6b 100644 --- a/src/jsc/hot_reloader.rs +++ b/src/jsc/hot_reloader.rs @@ -915,8 +915,12 @@ where if event.op.contains(WatchOp::DELETE) || (event.op.contains(WatchOp::RENAME) && IS_KQUEUE) { - self.get_context() - .remove_at_index(bun_watcher::Kind::File, event.index, 0, &[]); + self.get_context().remove_at_index( + bun_watcher::Kind::File, + event.index, + 0, + &[], + ); } if self.verbose {