From 5338b87f5b9c41d3a828ab89f579a8ef86c6f42c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 17:57:25 +0000 Subject: [PATCH 1/8] sys: `linux::epoll_ctl` is an `unsafe fn` again, for the value it registers `epoll_ctl` gives the kernel `event.u64`, and `epoll_wait` gives it back to whoever waits on `epfd`. Both loops here use it as a pointer. uSockets reads a `us_poll_t` through it (`GET_READY_POLL`, eventing/epoll_kqueue.c) or passes a tagged one to `Bun__internal_dispatch_ready_poll`, and the thread of `bun_io` turns it into a `Pollable`. An epoll descriptor is a plain integer, which `Loop::fd()` returns. So as a safe fn, `epoll_ctl` let safe code register any value with either loop. #47338 made `linux::epoll_ctl` and `linux_syscall::epoll_ctl` safe when it typed their `event` pointer as `Option<&epoll_event>`. The reference carries what their contract named until then, that the pointer is null or valid. It does not carry this. Both are `unsafe fn`s again, with the registered value as their contract, and they keep the reference. `FilePoll::register_with_fd_impl` and `unregister_with_fd_impl` call it in an `unsafe` block each, as they did before #47338. The compiled code is the same. --- src/io/posix_event_loop.rs | 7 +++++-- src/sys/lib.rs | 14 ++++++++++++-- src/sys/linux_syscall.rs | 12 ++++++++++-- 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/io/posix_event_loop.rs b/src/io/posix_event_loop.rs index f556b07da09b..4576f26353b3 100644 --- a/src/io/posix_event_loop.rs +++ b/src/io/posix_event_loop.rs @@ -626,7 +626,9 @@ impl FilePoll { EPOLL::CTL_ADD }; - let ctl = linux::epoll_ctl(watcher_fd, op, fd.native(), Some(&event)); + // SAFETY: `watcher_fd` is the epoll instance of `loop_`, whose dispatch decodes a + // `Pollable`. `deinit` unregisters `self` before its slot is reused. + let ctl = unsafe { linux::epoll_ctl(watcher_fd, op, fd.native(), Some(&event)) }; self.flags.insert(Flags::WasEverRegistered); if let Some(errno) = errno_sys(ctl, sys::Tag::epoll_ctl) { self.deactivate(loop_); @@ -940,7 +942,8 @@ impl FilePoll { { use bun_sys::linux::{self, EPOLL}; // CTL_DEL keys on fd alone, so both directions are removed together. - let ctl = linux::epoll_ctl(watcher_fd, EPOLL::CTL_DEL, fd.native(), None); + // SAFETY: CTL_DEL registers nothing. + let ctl = unsafe { linux::epoll_ctl(watcher_fd, EPOLL::CTL_DEL, fd.native(), None) }; match sys::get_errno(ctl) { sys::E::SUCCESS => {} diff --git a/src/sys/lib.rs b/src/sys/lib.rs index 88887633ad53..df4c5968ee2b 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -5590,9 +5590,19 @@ pub mod linux { unsafe { libc::ppoll(fds.as_mut_ptr(), fds.len() as _, timeout, sigmask) } } /// `EPOLL_CTL_DEL` takes no `event`. + /// + /// # Safety + /// Whoever waits on `epfd` gets `event.u64` back and may dereference it. It must be what they + /// expect for as long as `fd` is registered. #[inline] - pub fn epoll_ctl(epfd: c_int, op: c_int, fd: c_int, event: Option<&epoll_event>) -> c_int { - super::linux_syscall::epoll_ctl(epfd, op, fd, event) + pub unsafe fn epoll_ctl( + epfd: c_int, + op: c_int, + fd: c_int, + event: Option<&epoll_event>, + ) -> c_int { + // SAFETY: the caller's contract. + unsafe { super::linux_syscall::epoll_ctl(epfd, op, fd, event) } } // ── raw syscall thunks ── diff --git a/src/sys/linux_syscall.rs b/src/sys/linux_syscall.rs index 9c3449f3857b..73cce239b318 100644 --- a/src/sys/linux_syscall.rs +++ b/src/sys/linux_syscall.rs @@ -420,11 +420,19 @@ pub(crate) fn read_raw(fd: i32, buf: &mut [u8]) -> isize { /// (c) synthesizing EINVAL for unknown `op` values ourselves rather than /// letting the kernel reject them. /// Passing the raw quad straight through avoids all three. +/// +/// # Safety +/// As for `linux::epoll_ctl`. #[inline] -pub(crate) fn epoll_ctl(epfd: i32, op: i32, fd: i32, event: Option<&libc::epoll_event>) -> i32 { +pub(crate) unsafe fn epoll_ctl( + epfd: i32, + op: i32, + fd: i32, + event: Option<&libc::epoll_event>, +) -> i32 { let event = event.map_or(core::ptr::null(), core::ptr::from_ref); // SAFETY: the kernel validates `epfd`/`op`/`fd`; `event` is live, or null, which is - // EFAULT for every `op` but CTL_DEL. + // EFAULT for every `op` but CTL_DEL. The caller answers for `event.u64`. unsafe { libc::syscall(libc::SYS_epoll_ctl, epfd, op, fd, event) as i32 } } From 64cde30561814a38f43f5f147843cc94b0b60183 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 17:58:19 +0000 Subject: [PATCH 2/8] uws: six loop imports are unsafe again, and their callers say what they rely on #47231 put the fields of `Loop` in `Cell`s, gave the loop imports `&Loop`, and marked six of them `safe fn` on the ground that a live loop on its own thread is all that C asks for. C asks for more: - `us_loop_run_bun_tick`, and `us_loop_run` and `us_loop_pump` on Windows, dereference `loop->data.jsc_vm`. uSockets provides for a tick inside a handler (`tick_depth`, `poll_cb_depth`), but the inner tick receives into the same `recv_buf` that the outer handler was lent and can dispatch the same socket again. On Windows it is a `uv_run` inside a libuv callback, which libuv's documentation rules out. - `us_quic_loop_flush_if_pending` stays out of lsquic's callbacks by testing `processing`, which `us_quic_socket_make_stream` does not set around the `on_stream_open` that it makes. - `us_loop_close_all_groups` reads `next->linked` after the close handlers of the current group ran, so none of them may free that group. - `us_nq_loop_drain` takes `d->next` before `Bun__nodeQuic__drainEndpoint` runs JS. The six are plain imports again and keep `&Loop`. Their twelve calls, in `tick`, `tick_without_idle`, `tick_with_timeout`, `run`, `drain_quic_if_necessary` and `close_all_groups` on both platforms, are in `unsafe` blocks again, as before #47231, with what each relies on in its `SAFETY` comment. Those methods were safe before #47231 and still are, so what safe code can reach is the same. The compiled code is the same. --- src/uws_sys/Loop.rs | 64 ++++++++++++++++++++++++++++++++------------- 1 file changed, 46 insertions(+), 18 deletions(-) diff --git a/src/uws_sys/Loop.rs b/src/uws_sys/Loop.rs index c428570afdd1..d6f4c3d29d4c 100644 --- a/src/uws_sys/Loop.rs +++ b/src/uws_sys/Loop.rs @@ -239,12 +239,17 @@ impl PosixLoop { pub fn drain_quic_if_necessary(&self) { if !self.internal_loop_data.nq_head.get().is_null() { // Full pass with close dispatch deferred to the next loop point. - c::us_nq_loop_drain(self); + // SAFETY: `self` is this thread's live loop. The walk holds its successor while JS + // runs, and relies on an endpoint staying rooted until its close is delivered. + unsafe { c::us_nq_loop_drain(self) }; } if !self.internal_loop_data.has_quic() { return; } - c::us_quic_loop_flush_if_pending(self); + // SAFETY: `self` is this thread's live loop. `processing` keeps this out of every lsquic + // callback but the `on_stream_open` that `us_quic_socket_make_stream` makes, and that one + // does not flush. + unsafe { c::us_quic_loop_flush_if_pending(self) }; } /// `None` if epoll/kqueue cannot be created (EMFILE). @@ -262,18 +267,25 @@ impl PosixLoop { } pub fn tick(&self) { - c::us_loop_run_bun_tick(self, None, NOW_NS_UNKNOWN); + // SAFETY: as in `tick_with_timeout`. + unsafe { c::us_loop_run_bun_tick(self, None, NOW_NS_UNKNOWN) }; } pub fn tick_without_idle(&self) { - c::us_loop_run_bun_tick(self, Some(&Timespec { sec: 0, nsec: 0 }), NOW_NS_UNKNOWN); + // SAFETY: as in `tick_with_timeout`. + unsafe { + c::us_loop_run_bun_tick(self, Some(&Timespec { sec: 0, nsec: 0 }), NOW_NS_UNKNOWN) + }; } /// `now_ns` is the CLOCK_MONOTONIC reading the caller took to pick `timespec` (see /// `timer::All::get_timeout`), reused by the JS park hook's idle-sweep rate limit rather /// than read again. `NOW_NS_UNKNOWN` if the caller has none to share. pub fn tick_with_timeout(&self, timespec: Option<&Timespec>, now_ns: u64) { - c::us_loop_run_bun_tick(self, timespec, now_ns); + // SAFETY: `self` is this thread's live loop, and `jsc_vm` is null or its live VM. A handler + // may tick (`tick_depth`), but that refills the receive buffer and can dispatch the same + // socket again: such a handler is done with what the dispatch lent it. + unsafe { c::us_loop_run_bun_tick(self, timespec, now_ns) }; } /// Free everything queued on `loop->data.closed_head` / @@ -298,7 +310,10 @@ impl PosixLoop { /// loop — covers Listener/App-owned groups that `RareData`'s static field /// list doesn't enumerate. Returns whether any group was linked. pub fn close_all_groups(&self) -> bool { - c::us_loop_close_all_groups(self) != 0 + // SAFETY: `self` is this thread's live loop. The walk reads its successor after the close + // handlers ran, so none may free another group. The one caller runs at teardown, with the + // owners of the groups stopped and script forbidden. + unsafe { c::us_loop_close_all_groups(self) != 0 } } pub fn should_enable_date_header_timer(&self) -> bool { @@ -393,22 +408,29 @@ impl WindowsLoop { /// Windows the park hook is driven from `us_loop_run` (libuv.c), which reads libuv's /// already-refreshed clock via `uv_now` rather than taking one of its own. pub fn tick_with_timeout(&self, _: Option<&Timespec>, _now_ns: u64) { - c::us_loop_run(self); + // SAFETY: as in `run`. + unsafe { c::us_loop_run(self) }; } pub fn tick_without_idle(&self) { - c::us_loop_pump(self); + // SAFETY: as in `run`. + unsafe { c::us_loop_pump(self) }; } pub fn drain_quic_if_necessary(&self) { if !self.internal_loop_data.nq_head.get().is_null() { // Full pass with close dispatch deferred to the next loop point. - c::us_nq_loop_drain(self); + // SAFETY: `self` is this thread's live loop. The walk holds its successor while JS + // runs, and relies on an endpoint staying rooted until its close is delivered. + unsafe { c::us_nq_loop_drain(self) }; } if !self.internal_loop_data.has_quic() { return; } - c::us_quic_loop_flush_if_pending(self); + // SAFETY: `self` is this thread's live loop. `processing` keeps this out of every lsquic + // callback but the `on_stream_open` that `us_quic_socket_make_stream` makes, and that one + // does not flush. + unsafe { c::us_quic_loop_flush_if_pending(self) }; } /// `None` if `uv_loop_init` fails (handle exhaustion). @@ -421,7 +443,10 @@ impl WindowsLoop { } pub fn run(&self) { - c::us_loop_run(self); + // SAFETY: `self` is this thread's live loop, and `jsc_vm` is null or its live VM. A handler + // may tick (`tick_depth`, `poll_cb_depth`), though libuv does not provide for `uv_run` in a + // callback: such a handler is done with what the dispatch lent it, its handle included. + unsafe { c::us_loop_run(self) }; } // TODO: remove these two aliases @@ -464,7 +489,10 @@ impl WindowsLoop { } pub fn close_all_groups(&self) -> bool { - c::us_loop_close_all_groups(self) != 0 + // SAFETY: `self` is this thread's live loop. The walk reads its successor after the close + // handlers ran, so none may free another group. The one caller runs at teardown, with the + // owners of the groups stopped and script forbidden. + unsafe { c::us_loop_close_all_groups(self) != 0 } } pub fn update_date(&self) { @@ -508,21 +536,21 @@ mod c { ext_size: c_uint, ) -> *mut Loop; pub(super) fn us_loop_free(loop_: *mut Loop); - pub(super) safe fn us_quic_loop_flush_if_pending(loop_: &Loop); - pub(super) safe fn us_nq_loop_drain(loop_: &Loop); + pub(super) fn us_quic_loop_flush_if_pending(loop_: &Loop); + pub(super) fn us_nq_loop_drain(loop_: &Loop); #[cfg(windows)] - pub(super) safe fn us_loop_run(loop_: &Loop); + pub(super) fn us_loop_run(loop_: &Loop); #[cfg(windows)] - pub(super) safe fn us_loop_pump(loop_: &Loop); + pub(super) fn us_loop_pump(loop_: &Loop); pub fn us_wakeup_loop(loop_: *const Loop); #[cfg(not(windows))] - pub(super) safe fn us_loop_run_bun_tick( + pub(super) fn us_loop_run_bun_tick( loop_: &Loop, timeout_ms: Option<&Timespec>, now_ns: u64, ); pub(super) fn us_internal_free_closed_sockets(loop_: &Loop); - pub(super) safe fn us_loop_close_all_groups(loop_: &Loop) -> c_int; + pub(super) fn us_loop_close_all_groups(loop_: &Loop) -> c_int; #[cfg(not(windows))] pub(super) safe fn uws_get_loop() -> *mut Loop; #[cfg(windows)] From 5a30ebe27a1146f327e0a9c9c2dfe80024f095e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 17:58:43 +0000 Subject: [PATCH 3/8] ffi: `Bun__FFI__closeFunctions` is an unsafe import again #47291 marked it a `safe fn` because C++ type-checks `library` before it uses it. C++ also takes the `functionsValue` internal field of the library, checks only that it is a `JSArray`, and reads it with `getIndexQuickly(i)` for every `i` below its length (JSFFIFunction.cpp). That is in bounds for the array that `cc` stores, which it fills from 0 to its length and which JS never sees. It is not for a sparse array, and the generated `functions_value_set_cached` is a safe fn that stores any value. The import, which is local to `FFI::close`, is a plain one again and keeps `&JSGlobalObject`. Its one call is in an `unsafe` block again, which names the array. The compiled code is the same. --- src/runtime/ffi/ffi_body.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/runtime/ffi/ffi_body.rs b/src/runtime/ffi/ffi_body.rs index 1e9dea696cd4..392e523586e7 100644 --- a/src/runtime/ffi/ffi_body.rs +++ b/src/runtime/ffi/ffi_body.rs @@ -1361,10 +1361,12 @@ impl FFI { pub(crate) fn close<'a>(&self, this_value: JsWrapper<'a, Self>, cx: &mut JsScope<'a>) { unsafe extern "C" { /// Type-checks `library` (`dynamicDowncast`) before use. - safe fn Bun__FFI__closeFunctions(global: &JSGlobalObject, library: JSValue) -> bool; + fn Bun__FFI__closeFunctions(global: &JSGlobalObject, library: JSValue) -> bool; } jsc::mark_binding(); - let is_running = Bun__FFI__closeFunctions(cx.global(), this_value.to_js()); + // SAFETY: C++ indexes `functionsValue` unchecked up to its length. It is empty or the array + // that `cc` filled from 0 to its length, which JS never sees. + let is_running = unsafe { Bun__FFI__closeFunctions(cx.global(), this_value.to_js()) }; if is_running { // The running call returns into the compiled code, so leak it instead of letting `do_close` free it. self.shared_state.set(None); From 9f31e0b2f8a2681aaeb49b3247848b87937f9414 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 18:08:49 +0000 Subject: [PATCH 4/8] TextDecoderStream: the two results of `TextDecoder__createForStream` are `&mut MaybeUninit` #47895 typed the two out-parameters `&mut bool` and `&mut EncodingLabel`, where they had been raw pointers that the body only wrote through. A `&mut` says that a valid value is there already. That holds for the one caller, which passes the addresses of `bool utf8FastPath = false` and `uint8_t encoding = 0` (JSTextDecoderStream.cpp), but only because of those two initialisers and because `EncodingLabel::Utf8` is 0. `&mut MaybeUninit` and `&mut MaybeUninit` ask for nothing but room, and the body stores with `write`. The compiled code is the same. --- src/runtime/webcore/TextDecoder.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/runtime/webcore/TextDecoder.rs b/src/runtime/webcore/TextDecoder.rs index f2ffcb11533d..2cbaa37df8bd 100644 --- a/src/runtime/webcore/TextDecoder.rs +++ b/src/runtime/webcore/TextDecoder.rs @@ -4,6 +4,7 @@ use crate::webcore::jsc::{self as jsc, JSGlobalObject, JSString, JSValue}; use bun_core::strings; use bun_jsc::HostReturn as _; use bun_jsc::bindgen::{self, JsHeap, JsLocal, JsScope}; +use core::mem::MaybeUninit; use std::collections::TryReserveError; use jsc::StringJsc as _; @@ -496,10 +497,10 @@ pub(crate) extern "C" fn TextDecoder__createForStream( label: JSValue, fatal: bool, ignore_bom: bool, - out_utf8_fast_path: &mut bool, - out_encoding: &mut EncodingLabel, + out_utf8_fast_path: &mut MaybeUninit, + out_encoding: &mut MaybeUninit, ) -> *mut TextDecoder { - *out_utf8_fast_path = false; + out_utf8_fast_path.write(false); let encoding = if label.is_undefined() { EncodingLabel::Utf8 } else { @@ -524,9 +525,9 @@ pub(crate) extern "C" fn TextDecoder__createForStream( } } }; - *out_encoding = encoding; + out_encoding.write(encoding); if matches!(encoding, EncodingLabel::Utf8) && !fatal { - *out_utf8_fast_path = true; + out_utf8_fast_path.write(true); return core::ptr::null_mut(); } bun_core::heap::into_raw(Box::new(TextDecoder { From 92bfa9af320251478e54e9a09c4b6d27c33ad013 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 08:54:44 +0000 Subject: [PATCH 5/8] alloc: `Zone::init` asserts that `malloc_create_zone` gave it a zone `malloc_create_zone` returns null on an error, says its man page. `init` named the zone and turned it into a `&'static Zone` without looking, and it is a safe fn since #47901. It asserts first that the pointer is not null. This is compiled for macOS only and runs only where `heap_breakdown::ENABLED` is true: in debug builds without ASAN, once per zone. --- src/bun_alloc/heap_breakdown.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/bun_alloc/heap_breakdown.rs b/src/bun_alloc/heap_breakdown.rs index 08250c628b2b..c15d1c727ea7 100644 --- a/src/bun_alloc/heap_breakdown.rs +++ b/src/bun_alloc/heap_breakdown.rs @@ -30,11 +30,11 @@ impl Zone { /// `'static`: `malloc_set_zone_name` may keep the pointer instead of copying. #[cfg(target_os = "macos")] pub fn init(name: &'static CStr) -> &'static Zone { - // SAFETY: malloc_create_zone is safe to call with (0, 0); returns a - // process-lifetime zone pointer. `name` is NUL-terminated and outlives the - // process. + let zone = malloc_create_zone(0, 0); + assert!(!zone.is_null(), "malloc_create_zone failed"); + // SAFETY: the zone is not null and nothing destroys it. `name` is NUL-terminated and + // outlives the process. unsafe { - let zone = malloc_create_zone(0, 0); malloc_set_zone_name(zone, name.as_ptr()); &*zone } From 8258e3e1f82926f22a6361d9ca05858cdf7c6599 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 19:42:11 +0000 Subject: [PATCH 6/8] io: the comment on `FilePoll`'s `epoll_ctl` says what `deinit` really leaves behind The `SAFETY` comment said that `deinit` unregisters the poll before its slot is reused. `deinit` passes `force_unregister = false`, and for a one-shot poll that has fired (`NeedsRearm`) `unregister_with_fd_impl` then returns before its `EPOLL_CTL_DEL`. The entry stays in the kernel with the slot's address in it. What keeps that address from coming back is `EPOLLONESHOT`: the kernel disarmed the entry when it reported it, and reports it again only after an `EPOLL_CTL_MOD`. The comment says so. Only the comment changes. --- src/io/posix_event_loop.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/io/posix_event_loop.rs b/src/io/posix_event_loop.rs index 4576f26353b3..ae453e92d73f 100644 --- a/src/io/posix_event_loop.rs +++ b/src/io/posix_event_loop.rs @@ -627,7 +627,8 @@ impl FilePoll { }; // SAFETY: `watcher_fd` is the epoll instance of `loop_`, whose dispatch decodes a - // `Pollable`. `deinit` unregisters `self` before its slot is reused. + // `Pollable`. Before the slot of `self` is reused, `deinit` removes the entry. Or it + // leaves one that `EPOLLONESHOT` disarmed when it fired, which is not reported again. let ctl = unsafe { linux::epoll_ctl(watcher_fd, op, fd.native(), Some(&event)) }; self.flags.insert(Flags::WasEverRegistered); if let Some(errno) = errno_sys(ctl, sys::Tag::epoll_ctl) { From 44382b4b5950c1c657677f1dc7c6c9631049b535 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 19:42:32 +0000 Subject: [PATCH 7/8] sys: `kevent` is an `unsafe fn`, for the `udata` it registers `bun_sys::kevent` is to kqueue what `linux::epoll_ctl` is to epoll. A change carries `udata`, and whoever waits on the kqueue gets it back. On macOS and FreeBSD uSockets reads a `us_poll_t` through it (`GET_READY_POLL`, eventing/epoll_kqueue.c) or passes a tagged one to `Bun__internal_dispatch_ready_poll`, and `Loop::fd()` returns the descriptor. As a safe `pub fn` over `&[libc::kevent]`, it let safe code register any `udata` with the loop. No caller did. `darwin::kevent64` and the raw `kevent` re-export were `unsafe fn`s already. It is an `unsafe fn` with the registered `udata` as its contract. Of its five calls, two register changes, each with a kqueue of its own whose readers take `udata` as a number or ignore it: `wait_mac_kqueue` in spawn/process.rs and `add_file_descriptor_to_kqueue_without_checks` in the watcher. The other three pass an empty changelist and only wait. All of this is compiled only for macOS and FreeBSD. The same calls are made with the same arguments. --- src/spawn/process.rs | 20 +++++++++++++------- src/sys/lib.rs | 8 ++++++-- src/watcher/KEventWatcher.rs | 6 ++++-- src/watcher/Watcher.rs | 3 ++- 4 files changed, 25 insertions(+), 12 deletions(-) diff --git a/src/spawn/process.rs b/src/spawn/process.rs index 6e5d5a949f27..0cd38f2ea294 100644 --- a/src/spawn/process.rs +++ b/src/spawn/process.rs @@ -3603,12 +3603,17 @@ mod spawn_process_body { } let mut receipts: [libc::kevent; 5] = bun_core::ffi::zeroed(); - match bun_sys::kevent( - kq_fd, - &changes_buf[..changes_len], - &mut receipts[..changes_len], - None, - ) { + // SAFETY: the loop below reads `udata` as a number. The one other reader of `kq_fd`, + // `NoOrphansTracker::killTracked`, throws the events away. + let registered = unsafe { + bun_sys::kevent( + kq_fd, + &changes_buf[..changes_len], + &mut receipts[..changes_len], + None, + ) + }; + match registered { Err(err) => return Some(Err(err)), Ok(_) => {} } @@ -3652,7 +3657,8 @@ mod spawn_process_body { let mut child_exited = false; let mut child_status: Option = None; loop { - let got = match bun_sys::kevent(kq_fd, &[], &mut events[..], None) { + // SAFETY: an empty changelist registers nothing. + let got = match unsafe { bun_sys::kevent(kq_fd, &[], &mut events[..], None) } { Err(err) => return Some(Err(err)), Ok(c) => c, }; diff --git a/src/sys/lib.rs b/src/sys/lib.rs index df4c5968ee2b..5c124a70b1da 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -7528,8 +7528,12 @@ pub fn kqueue() -> Maybe { /// `kevent()` — slice-wrapped Maybe form. Retries on EINTR. /// Returns the number of events written into `eventlist`. +/// +/// # Safety +/// Whoever waits on `fd` gets the `udata` of each change back and may dereference it. It must be +/// what they expect for as long as the change is registered. #[cfg(any(target_os = "macos", target_os = "freebsd"))] -pub fn kevent( +pub unsafe fn kevent( fd: Fd, changelist: &[libc::kevent], eventlist: &mut [libc::kevent], @@ -7537,7 +7541,7 @@ pub fn kevent( ) -> Maybe { loop { // SAFETY: fd is a valid kqueue; slices give exact (ptr,len); timeout - // is either null or a valid timespec. + // is either null or a valid timespec. The caller answers for each `udata`. let rc = unsafe { libc::kevent( fd.native(), diff --git a/src/watcher/KEventWatcher.rs b/src/watcher/KEventWatcher.rs index c784b8636584..a8ce212b23f8 100644 --- a/src/watcher/KEventWatcher.rs +++ b/src/watcher/KEventWatcher.rs @@ -56,7 +56,8 @@ pub(crate) fn watch_loop_cycle(this: &mut Watcher) -> bun_sys::Result<()> { let mut changelist: [libc::kevent; CHANGELIST_COUNT] = bun_core::ffi::zeroed(); - let mut count = bun_sys::kevent(fd, &[], &mut changelist, None)?; + // SAFETY: an empty changelist registers nothing. + let mut count = unsafe { bun_sys::kevent(fd, &[], &mut changelist, None) }?; // Give the events more time to coalesce if count < CHANGELIST_COUNT / 2 { @@ -64,7 +65,8 @@ pub(crate) fn watch_loop_cycle(this: &mut Watcher) -> bun_sys::Result<()> { tv_sec: 0, tv_nsec: 100_000, }; // 0.0001 seconds - count += bun_sys::kevent(fd, &[], &mut changelist[count..], Some(&ts))?; + // SAFETY: as above. + count += unsafe { bun_sys::kevent(fd, &[], &mut changelist[count..], Some(&ts)) }?; } let changes = &changelist[..count]; diff --git a/src/watcher/Watcher.rs b/src/watcher/Watcher.rs index ce05b8b677d8..41ac25504180 100644 --- a/src/watcher/Watcher.rs +++ b/src/watcher/Watcher.rs @@ -517,7 +517,8 @@ impl Watcher { // Basically: // - We register the event here. // our while(true) loop above receives notification of changes to any of the events created here. - let _ = bun_sys::kevent(self.platform.fd, &[event], &mut [], None); + // SAFETY: only `watch_loop_cycle` waits on this kqueue, and it reads `udata` as an index. + let _ = unsafe { bun_sys::kevent(self.platform.fd, &[event], &mut [], None) }; } fn append_file_assume_capacity( From 8ad7cf9549c4b8771e6669aaafd4fb05f15e6f04 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 19:59:28 +0000 Subject: [PATCH 8/8] sys, io: the contracts of `epoll_ctl` and `kevent` cover the events already fetched Both said that the registered value must be what the waiter expects for as long as the registration lasts. That is not enough. `EPOLL_CTL_DEL`, `EV_DELETE` or `EV_ONESHOT` can end a registration while the waiter still holds an event for it that an earlier `epoll_wait` or `kevent` returned. The value has to stay good until the waiter is done with those, and the two `# Safety` sections say so. `FilePoll` keeps to that, and the `SAFETY` comment on its `epoll_ctl` names how: `Store::put` marks a slot that was ever registered `IgnoreUpdates`, which the dispatch tests, and frees it only in the callback that each driver of the loop runs when a tick has returned. By then the batch that the tick fetched has been dispatched, and a tick inside a handler has overwritten the outer one's. Only comments change. --- src/io/posix_event_loop.rs | 5 +++-- src/sys/lib.rs | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/io/posix_event_loop.rs b/src/io/posix_event_loop.rs index ae453e92d73f..3bfe5f47ea45 100644 --- a/src/io/posix_event_loop.rs +++ b/src/io/posix_event_loop.rs @@ -627,8 +627,9 @@ impl FilePoll { }; // SAFETY: `watcher_fd` is the epoll instance of `loop_`, whose dispatch decodes a - // `Pollable`. Before the slot of `self` is reused, `deinit` removes the entry. Or it - // leaves one that `EPOLLONESHOT` disarmed when it fired, which is not reported again. + // `Pollable`. `deinit` removes the entry, or leaves one that `EPOLLONESHOT` disarmed + // when it fired, which is not reported again. `Store::put` then holds the slot back, + // marked `IgnoreUpdates`, until a tick returns, when no event fetched for it is left. let ctl = unsafe { linux::epoll_ctl(watcher_fd, op, fd.native(), Some(&event)) }; self.flags.insert(Flags::WasEverRegistered); if let Some(errno) = errno_sys(ctl, sys::Tag::epoll_ctl) { diff --git a/src/sys/lib.rs b/src/sys/lib.rs index 5c124a70b1da..5ea991abacd0 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -5593,7 +5593,8 @@ pub mod linux { /// /// # Safety /// Whoever waits on `epfd` gets `event.u64` back and may dereference it. It must be what they - /// expect for as long as `fd` is registered. + /// expect for as long as `fd` is registered, and until they are done with the events that + /// they had fetched by then. #[inline] pub unsafe fn epoll_ctl( epfd: c_int, @@ -7531,7 +7532,8 @@ pub fn kqueue() -> Maybe { /// /// # Safety /// Whoever waits on `fd` gets the `udata` of each change back and may dereference it. It must be -/// what they expect for as long as the change is registered. +/// what they expect for as long as the change is registered, and until they are done with the +/// events that they had fetched by then. #[cfg(any(target_os = "macos", target_os = "freebsd"))] pub unsafe fn kevent( fd: Fd,