Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .cirrus.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ env:
freebsd_task:
name: test ($TARGET)
freebsd_instance:
image_family: freebsd-13-4
image_family: freebsd-14-3
matrix:
- env:
TARGET: x86_64-unknown-freebsd
Expand Down
18 changes: 12 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ jobs:
- run: cargo minimal-versions build --all --all-features
- name: Clone async-io
run: git clone https://github.com/smol-rs/async-io.git
# Remove this line after polling makes a new release.
- run: cd async-io && git fetch origin notgull/safe && git checkout notgull/safe
# The async-io Cargo.toml already has a patch section at the bottom, so we
# can just add this.
- name: Patch polling
Expand Down Expand Up @@ -134,15 +136,19 @@ jobs:
run: |
rustup target add x86_64-unknown-redox
cargo check --target x86_64-unknown-redox
- name: HermitOS
if: startsWith(matrix.rust, 'nightly') && startsWith(matrix.os, 'ubuntu')
run: cargo check -Z build-std --target x86_64-unknown-hermit
# Bring this back once Hermit stdlib bugs are fixed
# See: https://github.com/rust-lang/rust/issues/150294
#- name: HermitOS
# if: startsWith(matrix.rust, 'nightly') && startsWith(matrix.os, 'ubuntu')
# run: cargo check -Z build-std --target x86_64-unknown-hermit
- name: Check haiku
if: startsWith(matrix.rust, 'nightly') && startsWith(matrix.os, 'ubuntu')
run: cargo check -Z build-std --target x86_64-unknown-haiku
- name: Check vita
if: startsWith(matrix.rust, 'nightly') && startsWith(matrix.os, 'ubuntu')
run: cargo check -Z build-std --target armv7-sony-vita-newlibeabihf
# Bring this back once Vita stdlib bugs are fixed
# See: https://github.com/rust-lang/rust/pull/150297
#- name: Check vita
# if: startsWith(matrix.rust, 'nightly') && startsWith(matrix.os, 'ubuntu')
# run: cargo check -Z build-std --target armv7-sony-vita-newlibeabihf
- name: Check ESP-IDF
if: startsWith(matrix.rust, 'nightly') && startsWith(matrix.os, 'ubuntu')
run: cargo check -Z build-std --target riscv32imc-esp-espidf
Expand Down
4 changes: 1 addition & 3 deletions examples/tcp_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,7 @@ use socket2::Type;
fn main() -> io::Result<()> {
let socket = socket2::Socket::new(socket2::Domain::IPV4, Type::STREAM, None)?;
let poller = polling::Poller::new()?;
unsafe {
poller.add(&socket, Event::new(0, true, true))?;
}
poller.add(&socket, Event::new(0, true, true))?;
let addr = net::SocketAddr::new(net::Ipv4Addr::LOCALHOST.into(), 8080);
socket.set_nonblocking(true)?;
let _ = socket.connect(&addr.into());
Expand Down
6 changes: 2 additions & 4 deletions examples/two-listeners.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,8 @@ fn main() -> io::Result<()> {
l2.set_nonblocking(true)?;

let poller = Poller::new()?;
unsafe {
poller.add(&l1, Event::readable(1))?;
poller.add(&l2, Event::readable(2))?;
}
poller.add(&l1, Event::readable(1))?;
poller.add(&l2, Event::readable(2))?;

println!("You can connect to the server using `nc`:");
println!(" $ nc 127.0.0.1 8001");
Expand Down
7 changes: 1 addition & 6 deletions src/epoll.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,12 +97,7 @@ impl Poller {
}

/// Adds a new file descriptor.
///
/// # Safety
///
/// The `fd` must be a valid file descriptor. The usual condition of remaining registered in
/// the `Poller` doesn't apply to `epoll`.
pub unsafe fn add(&self, fd: RawFd, ev: Event, mode: PollMode) -> io::Result<()> {
pub fn add(&self, fd: RawFd, ev: Event, mode: PollMode) -> io::Result<()> {
#[cfg(feature = "tracing")]
let span = tracing::trace_span!(
"add",
Expand Down
11 changes: 1 addition & 10 deletions src/iocp/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,16 +165,7 @@ impl Poller {
}

/// Add a new source to the poller.
///
/// # Safety
///
/// The socket must be a valid socket and must last until it is deleted.
pub(super) unsafe fn add(
&self,
socket: RawSocket,
interest: Event,
mode: PollMode,
) -> io::Result<()> {
pub(super) fn add(&self, socket: RawSocket, interest: Event, mode: PollMode) -> io::Result<()> {
#[cfg(feature = "tracing")]
let span = tracing::trace_span!(
"add",
Expand Down
10 changes: 4 additions & 6 deletions src/kqueue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,15 +82,13 @@ impl Poller {
}

/// Adds a new file descriptor.
///
/// # Safety
///
/// The file descriptor must be valid and it must last until it is deleted.
pub unsafe fn add(&self, fd: RawFd, ev: Event, mode: PollMode) -> io::Result<()> {
pub fn add(&self, fd: RawFd, ev: Event, mode: PollMode) -> io::Result<()> {
self.add_source(SourceId::Fd(fd))?;

// File descriptors don't need to be added explicitly, so just modify the interest.
self.modify(BorrowedFd::borrow_raw(fd), ev, mode)
// SAFETY: Move to BorrowedFd at next breaking change.
// See also: https://github.com/smol-rs/polling/issues/255
self.modify(unsafe { BorrowedFd::borrow_raw(fd) }, ev, mode)
}

/// Modifies an existing file descriptor.
Expand Down
44 changes: 12 additions & 32 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,7 @@
//!
//! // Create a poller and register interest in readability on the socket.
//! let poller = Poller::new()?;
//! unsafe {
//! poller.add(&socket, Event::readable(key))?;
//! }
//! poller.add(&socket, Event::readable(key))?;
//!
//! // The event loop.
//! let mut events = Events::new();
Expand Down Expand Up @@ -343,16 +341,13 @@ impl Event {
///
/// ```
/// use std::{io, net};
/// // Assuming polling and socket2 are included as dependencies in Cargo.toml
/// use polling::Event;
/// use socket2::Type;
///
/// fn main() -> io::Result<()> {
/// let socket = socket2::Socket::new(socket2::Domain::IPV4, Type::STREAM, None)?;
/// let poller = polling::Poller::new()?;
/// unsafe {
/// poller.add(&socket, Event::new(0, true, true))?;
/// }
/// poller.add(&socket, Event::new(0, true, true))?;
/// let addr = net::SocketAddr::new(net::Ipv4Addr::LOCALHOST.into(), 8080);
/// socket.set_nonblocking(true)?;
/// let _ = socket.connect(&addr.into());
Expand Down Expand Up @@ -495,12 +490,6 @@ impl Poller {
/// will act as if the source was registered with another [`Poller`], with the same caveats
/// as above.
///
/// # Safety
///
/// The source must be [`delete()`]d from this `Poller` before it is dropped.
///
/// [`delete()`]: Poller::delete
///
/// # Errors
///
/// This method returns an error in the following situations:
Expand All @@ -520,13 +509,12 @@ impl Poller {
/// let key = 7;
///
/// let poller = Poller::new()?;
/// unsafe {
/// poller.add(&source, Event::all(key))?;
/// }
/// poller.add(&source, Event::all(key))?;
/// poller.delete(&source)?;
/// # std::io::Result::Ok(())
/// ```
pub unsafe fn add(&self, source: impl AsRawSource, interest: Event) -> io::Result<()> {
// TODO: At the next breaking change, change `AsRawSource` to `AsSource`
pub fn add(&self, source: impl AsRawSource, interest: Event) -> io::Result<()> {
Copy link

@hanna-kruppe hanna-kruppe Jan 3, 2026

Choose a reason for hiding this comment

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

I'm not familiar with the polling crate, but I don't think this is signature is sound w.r.t. I/O safety, even outside the "backend may still poll the fd after it's closed" aspect discussed in #255. Standard library documentation says explicitly:

To uphold I/O safety, it is crucial that no code acts on file descriptors it does not own or borrow, and no code closes file descriptors it does not own. In other words, a safe function that takes a regular integer, treats it as a file descriptor, and acts on it, is unsound.

... and this API does exactly that, by taking impl AsRawSource which includes RawFd = c_int. For example, poller.add(666, ev) acts on the regular integer 666 as if was an fd.

Note that it's not enough that there happens to be an fd with this numeric value at the time of call. Whoever "owns" the fd must consent to giving up exclusive ownership for as long as the poller may still use it, by either passing ownership of the fd (unlikely since they'd still want to act on it when getting events) or borrowing the fd to the poller. Conversely, they must promise to not close the fd while the poller is using it, otherwise something else could reuse the fd for something that's supposed to be owned). For example, a function like this would violate I/O safety because a caller who has an OwnedFd would be allowed to close the fd after calling this function:

fn add_to_poller(fd: BorrowedFd<'_>, poller: &Poller, interest: Event) -> io::Result<()> {
    // SAFETY: `fd` is a valid file descriptor
    unsafe {
        poller.add(fd.as_raw_fd(), interest)
    }
}

Edit: also, due to lifetime aspect (how long the fd is borrowed), I don't think changing the bound to AsSource will suffice to make this function safe. Nothing about this API bounds how long the fd is borrowed, so you could write something like:

let fd: OwnedFd = /* ... */;
poller.add(fd.as_fd(), interest);
drop(fd);

... and then the poller will act on an fd that may have been reused for something else entirely, whose owner relies on I/O safety granting them exclusive ownership of that fd.

Copy link
Member Author

Choose a reason for hiding this comment

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

As discussed in #255, the worst thing that can happen at the polling boundary is that the wrong event gets delivered. This is an acceptable compromise to ensure public API safety, especially since (as discussed up-thread) I/O safety is more of a suggestion.

Choose a reason for hiding this comment

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

As I said, I'm not very familiar with the internals of polling nor with all its backends. If there's really no way that spurious polling of a random integer that happens to match the numeric value of someone's OwnedFd can negatively impact what the owner is doing with their fd, fair enough. But this doesn't really seem obvious to me from the discussion in #255 and from what I know about e.g. epoll.

Past discussion here and here was about what happens if you have an fd, add it to an epoll instance, and then close the fd. I understand why that is fine. However, it's not clear to me that it's a non-issue for other backends where the OS won't automatically remove the fd on close. Even with epoll, it seems surprising and potentially problematic if an OwnedFd you just created and never shared with anyone was already added (causing you to get EEXIST when trying to add it) or modified/deleted (e.g., causing you to miss events you'd otherwise be guaranteed to get). There's also at least one epoll flag (EPOLLEXCLUSIVE) that has spooky action at a distance between different epoll instances that all contain the same fd, though I think this one's harmless as long as polling doesn't use it itself (but will that also be true for the next epoll flag Linux adds in the future?).

I don't think it's necessarily unreasonable to say these kinds of problems aren't safety concerns and ergonomics win. But that does means that polling is potentially incompatible with other libraries (existing or yet-to-be-written) that try to do fancy things with polling based on I/O safety. If you say the worst thing that can happen is delivering wrong events, I can see where you're coming from, but it seems a bit too simplistic when considering composability of separately-developed safe abstractions.

especially since (as discussed up-thread) I/O safety is more of a suggestion.

I don't see any discussion on this PR (or in #255 for that matter) that I would summarize as "I/O safety is more of a suggestion". Perhaps you're referring to @RalfJung explaining that I/O safety is library invariants and thus can't be checked by miri, and that there's currently no satisfying way to account for fds passed by env variables. But taking that to mean "I/O safety is just a suggestion" seems like a big leap to me.

Copy link

@RalfJung RalfJung Jan 4, 2026

Choose a reason for hiding this comment

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

Regarding the question of whether it is safe to poll on file descriptors you don't know -- I would say no. When one uses edge triggers, it is crucial to control all the polling to an FD, or else one may miss some events and fail to wake up.

But in the end this is something the libs-api team has to define, they "own" the "protocol" of what exactly is and is not allowed under I/O safety. I would suggest opening an issue in the Rust repo.

Choose a reason for hiding this comment

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

I'm not actually sure if edge triggered polling is relevant in this case. At least with the epoll backend, my understanding is that epoll instances are independent w.r.t. event delivery (other than EPOLLEXCLUSIVE) and level/edge choice, i.e., edge-triggered polling of an fd in your own epoll instance still gets all the events even if the same fd was added to another epoll instance via polling. I don't think polling itself makes any API guarantees that would make it sound to build any safety invariants on reasoning like "I added it fd that I own and didn't share with anyone to this Poller, thus [...]" -- e.g., if a bug in polling causes it to sometimes not poll an fd that was registered, this probably wouldn't be treated as a soundness bug in the library.

I don't have encyclopedic knowledge of different polling APIs (and even my epoll knowledge is limited) so maybe I'm wrong or there are similar problems with other polling backends. But as long as there's no side effects on other uses of the same fd, then it's probably harmless w.r.t. I/O safety. However, that's a rather open-ended question, and the answer may change as operating systems add new features and APIs (cf. EPOLLEXCLUSIVE added in Linux 4.5). Asking libs-api seems like a good idea in any case.

Copy link

Choose a reason for hiding this comment

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

Ah, right, the edge state is per epoll instance. That part should be fine then. But as you say there may be other interactions.

Copy link
Member

Choose a reason for hiding this comment

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

@notgull Reading through the arguments here, I'm much less certain now that this is a good idea. Sorry!

self.add_with_mode(source, interest, PollMode::Oneshot)
}

Expand All @@ -535,17 +523,11 @@ impl Poller {
/// This is identical to the `add()` function, but allows specifying the
/// polling mode to use for this socket.
///
/// # Safety
///
/// The source must be [`delete()`]d from this `Poller` before it is dropped.
///
/// [`delete()`]: Poller::delete
///
/// # Errors
///
/// If the operating system does not support the specified mode, this function
/// will return an error.
pub unsafe fn add_with_mode(
pub fn add_with_mode(
&self,
source: impl AsRawSource,
interest: Event,
Expand Down Expand Up @@ -588,7 +570,7 @@ impl Poller {
/// # let source = std::net::TcpListener::bind("127.0.0.1:0")?;
/// # let key = 7;
/// # let poller = Poller::new()?;
/// # unsafe { poller.add(&source, Event::none(key))?; }
/// # poller.add(&source, Event::none(key))?;
/// poller.modify(&source, Event::all(key))?;
/// # std::io::Result::Ok(())
/// ```
Expand All @@ -600,7 +582,7 @@ impl Poller {
/// # let source = std::net::TcpListener::bind("127.0.0.1:0")?;
/// # let key = 7;
/// # let poller = Poller::new()?;
/// # unsafe { poller.add(&source, Event::none(key))?; }
/// # poller.add(&source, Event::none(key))?;
/// poller.modify(&source, Event::readable(key))?;
/// # poller.delete(&source)?;
/// # std::io::Result::Ok(())
Expand All @@ -613,7 +595,7 @@ impl Poller {
/// # let poller = Poller::new()?;
/// # let key = 7;
/// # let source = std::net::TcpListener::bind("127.0.0.1:0")?;
/// # unsafe { poller.add(&source, Event::none(key))? };
/// # poller.add(&source, Event::none(key))?;
/// poller.modify(&source, Event::writable(key))?;
/// # poller.delete(&source)?;
/// # std::io::Result::Ok(())
Expand All @@ -626,7 +608,7 @@ impl Poller {
/// # let source = std::net::TcpListener::bind("127.0.0.1:0")?;
/// # let key = 7;
/// # let poller = Poller::new()?;
/// # unsafe { poller.add(&source, Event::none(key))?; }
/// # poller.add(&source, Event::none(key))?;
/// poller.modify(&source, Event::none(key))?;
/// # poller.delete(&source)?;
/// # std::io::Result::Ok(())
Expand Down Expand Up @@ -681,7 +663,7 @@ impl Poller {
/// let key = 7;
///
/// let poller = Poller::new()?;
/// unsafe { poller.add(&socket, Event::all(key))?; }
/// poller.add(&socket, Event::all(key))?;
/// poller.delete(&socket)?;
/// # std::io::Result::Ok(())
/// ```
Expand Down Expand Up @@ -719,9 +701,7 @@ impl Poller {
/// let key = 7;
///
/// let poller = Poller::new()?;
/// unsafe {
/// poller.add(&socket, Event::all(key))?;
/// }
/// poller.add(&socket, Event::all(key))?;
///
/// let mut events = Events::new();
/// let n = poller.wait(&mut events, Some(Duration::from_secs(1)))?;
Expand Down
8 changes: 2 additions & 6 deletions src/os/iocp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,6 @@ pub trait PollerIocpExt: PollerSealed {
///
/// Once the object has been signalled, the poller will emit the `interest` event.
///
/// # Safety
///
/// The added handle must not be dropped before it is deleted.
///
/// # Examples
///
/// ```no_run
Expand Down Expand Up @@ -98,7 +94,7 @@ pub trait PollerIocpExt: PollerSealed {
/// assert_eq!(events.len(), 1);
/// assert_eq!(events.iter().next().unwrap(), Event::all(0));
/// ```
unsafe fn add_waitable(
fn add_waitable(
&self,
handle: impl AsRawWaitable,
interest: Event,
Expand Down Expand Up @@ -198,7 +194,7 @@ impl PollerIocpExt for Poller {
self.poller.post(packet)
}

unsafe fn add_waitable(
fn add_waitable(
&self,
handle: impl AsRawWaitable,
event: Event,
Expand Down
8 changes: 2 additions & 6 deletions src/port.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,9 @@ impl Poller {
}

/// Adds a file descriptor.
///
/// # Safety
///
/// The `fd` must be a valid file descriptor and it must last until it is deleted.
pub unsafe fn add(&self, fd: RawFd, ev: Event, mode: PollMode) -> io::Result<()> {
pub fn add(&self, fd: RawFd, ev: Event, mode: PollMode) -> io::Result<()> {
// File descriptors don't need to be added explicitly, so just modify the interest.
self.modify(BorrowedFd::borrow_raw(fd), ev, mode)
self.modify(unsafe { BorrowedFd::borrow_raw(fd) }, ev, mode)
}

/// Modifies an existing file descriptor.
Expand Down
12 changes: 3 additions & 9 deletions tests/concurrent_modification.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,7 @@ fn concurrent_add() -> io::Result<()> {
})
.add(|| {
thread::sleep(Duration::from_millis(100));
unsafe {
poller.add(&reader, Event::readable(0))?;
}
poller.add(&reader, Event::readable(0))?;
writer.write_all(&[1])?;
Ok(())
})
Expand All @@ -46,9 +44,7 @@ fn concurrent_add() -> io::Result<()> {
fn concurrent_modify() -> io::Result<()> {
let (reader, mut writer) = tcp_pair()?;
let poller = Poller::new()?;
unsafe {
poller.add(&reader, Event::none(0))?;
}
poller.add(&reader, Event::none(0))?;

let mut events = Events::new();

Expand Down Expand Up @@ -84,9 +80,7 @@ fn concurrent_interruption() -> io::Result<()> {

let (reader, _writer) = tcp_pair()?;
let poller = Poller::new()?;
unsafe {
poller.add(&reader, Event::none(0))?;
}
poller.add(&reader, Event::none(0))?;

let mut events = Events::new();
let events_borrow = &mut events;
Expand Down
23 changes: 10 additions & 13 deletions tests/io.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,7 @@ use std::time::Duration;
fn basic_io() {
let poller = Poller::new().unwrap();
let (read, mut write) = tcp_pair().unwrap();
unsafe {
poller.add(&read, Event::readable(1)).unwrap();
}
poller.add(&read, Event::readable(1)).unwrap();

// Nothing should be available at first.
let mut events = Events::new();
Expand Down Expand Up @@ -50,7 +48,8 @@ fn insert_twice() {
let read = Arc::new(read);

let poller = Poller::new().unwrap();
unsafe {

{
#[cfg(unix)]
let read = read.as_raw_fd();
#[cfg(windows)]
Expand Down Expand Up @@ -101,15 +100,13 @@ fn append_events() {

// Add the sockets to the poller.
let poller = Poller::new().unwrap();
unsafe {
for (read, _write) in &pairs {
#[cfg(unix)]
let read = read.as_raw_fd();
#[cfg(windows)]
let read = read.as_raw_socket();

poller.add(read, Event::readable(1)).unwrap();
}
for (read, _write) in &pairs {
#[cfg(unix)]
let read = read.as_raw_fd();
#[cfg(windows)]
let read = read.as_raw_socket();

poller.add(read, Event::readable(1)).unwrap();
}

// Trigger read events on the sockets and reuse the event list to test
Expand Down
4 changes: 1 addition & 3 deletions tests/many_connections.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,7 @@ fn many_connections() {
let poller = polling::Poller::new().unwrap();

for (i, reader, _) in connections.iter() {
unsafe {
poller.add(reader, polling::Event::readable(*i)).unwrap();
}
poller.add(reader, polling::Event::readable(*i)).unwrap();
}

let mut events = Events::new();
Expand Down
Loading