fix(io): deregister a stopped actor's fd wait on the unwind path

A stopped actor unwinding out of wait_fd's park leaked its waiters entry
and the kernel-side EPOLLONESHOT registration; the stale entry then failed
every future wait_*() on that fd with AlreadyExists (the defensive bare DEL
in epoll_register sits behind the contains_key check, so it never ran).

Fix where the invariant breaks: a drop guard in wait_fd, armed after a
successful register and forgotten on the normal wake (where FdReady already
removed + DEL'd). On unwind it cleans up iff the entry is still this wait's
(pid, epoch) — an entry consumed by a racing FdReady means the fd may carry
another actor's fresh registration, which must be left alone. Closes the
v0.2 fd-hygiene TODO.
This commit is contained in:
Claude
2026-06-10 15:07:35 +00:00
parent 24b95c99ae
commit f6969e538b
3 changed files with 86 additions and 9 deletions
+11 -9
View File
@@ -44,11 +44,13 @@
//!
//! Fd hygiene
//! ==========
//! If an actor dies while waiting on an fd, the registration is leaked
//! (the fd stays in the epollfd, armed). EPOLLONESHOT bounds the damage:
//! at most one stale wakeup, after which the kernel disarms. The stale
//! wakeup hits a dead pid in `waiters` and is dropped. Acceptable for v0.2;
//! a future pass should DEL on actor death.
//! An actor stopped while waiting on an fd unwinds out of `wait_fd`'s park;
//! a drop guard there (armed after a successful register, forgotten on a
//! normal wake) removes the `waiters` entry iff it is still that wait's
//! `(pid, epoch)` and only then `EPOLL_CTL_DEL`s the fd — an entry already
//! consumed by a racing `FdReady` means the fd may carry someone else's
//! fresh registration, which must be left alone. `epoll_register` keeps a
//! defensive bare DEL before ADD as belt-and-braces.
//!
//! Buffers used with `read`/`write` should be on fds opened with
//! `O_NONBLOCK`. If they aren't, the syscall may block the scheduler
@@ -282,10 +284,10 @@ impl IoThread {
));
}
// Defensive cleanup: if a previous actor died while waiting on this
// fd, the kernel-side registration was leaked (we don't walk all
// waiters on actor death). A bare DEL is harmless if the fd isn't
// registered (ENOENT), and removes any leak.
// Belt-and-braces: the unwind guard in `wait_fd` is responsible for
// cleaning up a stopped waiter's registration, but a bare DEL is
// harmless if the fd isn't registered (ENOENT) and removes any leak
// a path we haven't thought of might leave behind.
unsafe {
libc::epoll_ctl(self.epollfd, libc::EPOLL_CTL_DEL, fd, std::ptr::null_mut());
}
+32
View File
@@ -396,7 +396,39 @@ fn wait_fd(fd: std::os::fd::RawFd, readable: bool, writable: bool) -> std::io::R
let mut io = inner.io.lock().unwrap();
io.as_mut().expect("io thread not started").epoll_register(fd, me, epoch, readable, writable)
})?;
// If a terminal stop unwinds us out of the park below, the registration
// must not outlive us: a stale `waiters` entry fails every future
// `wait_*` on this fd with AlreadyExists, and the kernel-side ADD leaks
// until the fd happens to be reused. Clean up iff the entry is still
// ours — a `FdReady` racing the stop may have consumed it already (it
// removes + DELs under the io lock), after which the fd may even carry
// ANOTHER actor's fresh registration; in that case touch nothing.
struct Dereg {
fd: std::os::fd::RawFd,
me: Pid,
epoch: u32,
}
impl Drop for Dereg {
fn drop(&mut self) {
with_runtime(|inner| {
let mut io = inner.io.lock().unwrap();
if let Some(io) = io.as_mut() {
if io.waiters.get(&self.fd) == Some(&(self.me, self.epoch)) {
io.waiters.remove(&self.fd);
io.epoll_deregister(self.fd);
}
}
});
}
}
let guard = Dereg { fd, me, epoch };
park_current();
// Normal wake: the FdReady path removed the entry and DEL'd the fd
// before the unpark, so the guard's check would be a guaranteed no-op —
// skip the io lock on the hot path. (Dereg owns nothing; forget leaks
// no resource.)
std::mem::forget(guard);
Ok(())
}