Rust in Everything
基于 mio 1.2.4

register 报 Invalid argument,事件照样来

主线位置:注册主线在 kqueue 上的最后一步。一次 register 是一次带两项 changelist 的 kevent,两项各自成败;Err 只说明「至少有一项失败」,不说明「什么也没注册」。

flowchart TB
  regnode["register R|W"] --> chg["changelist: WRITE, READ"]
  chg --> wr["WRITE 回执 EINVAL"]
  chg --> rd["READ 回执 0"]
  wr --> errnode["返回 Err"]
  rd --> left["READ 留在 kqueue"]
  left --> ev["之后照样来事件"]
  errnode --> fix["调用方 deregister"]
  fix --> clean["两项都删掉"]

为什么一项失败不会撤销另一项,在 kqueue 的「一项失败,另一项照样生效」里已经从 kevent_register 和 check_errors 讲清楚了,那里的 kqfd.rs 也跑过 register 的三种情况。这一页回答四个用的时候才会碰到的问题:哪些 fd 会触发它;reregister、deregister 是不是也只生效一半;拿到 Err 之后该做什么;tokio 怎么处理。

本页实验在 M2 Max、macOS、rustc 1.99.0、--release 下运行(tokio 那一节另用 nightly 开 ASan),依赖:

mio = { version = "=1.2.4", features = ["os-poll", "net", "os-ext"] }
libc = "=0.2.191"

现象

把一个 Poll 的 fd(在 macOS 上就是一个 kqueue fd)注册进另一个 Poll,关注 READABLE | WRITABLE,然后让里面那个 kqueue 有一个待取事件,看外面的 poll。src/bin/nested.rs:

// Register one Poll's fd in another Poll, with R, W and R|W; then make the inner one readable.
use std::os::fd::AsRawFd;
use std::time::Duration;

use mio::unix::SourceFd;
use mio::{Events, Interest, Poll, Token, Waker};

fn main() {
  for (name, interest) in [
    ("READABLE", Interest::READABLE),
    ("WRITABLE", Interest::WRITABLE),
    ("READABLE|WRITABLE", Interest::READABLE.add(Interest::WRITABLE)),
  ] {
    let mut outer = Poll::new().unwrap();
    let inner = Poll::new().unwrap();
    let r = outer.registry().register(&mut SourceFd(&inner.as_raw_fd()), Token(1), interest);
    let waker = Waker::new(inner.registry(), Token(5)).unwrap();
    waker.wake().unwrap();
    let mut events = Events::with_capacity(8);
    outer.poll(&mut events, Some(Duration::from_millis(100))).unwrap();
    let evs: Vec<String> = events.iter()
      .map(|e| format!("token={} readable={} writable={}", e.token().0, e.is_readable(), e.is_writable()))
      .collect();
    println!("{name:<18} register {r:?}");
    println!("{:<18} then poll: {} event(s) {evs:?}", "", events.iter().count());
  }
}

跑 3 次,输出相同:

READABLE           register Ok(())
                   then poll: 1 event(s) ["token=1 readable=true writable=false"]
WRITABLE           register Err(Os { code: 22, kind: InvalidInput, message: "Invalid argument" })
                   then poll: 0 event(s) []
READABLE|WRITABLE  register Err(Os { code: 22, kind: InvalidInput, message: "Invalid argument" })
                   then poll: 1 event(s) ["token=1 readable=true writable=false"]

第三行就是这个坑:register 返回 Err,Display 出来是 Invalid argument (os error 22),可是 Token(1) 的可读事件照样来了。kqueue fd 支持 EVFILT_READ(里面有待取事件就可读),不支持 EVFILT_WRITE;changelist 是 [WRITE, READ],WRITE 失败,READ 成功并留在外层 kqueue 里。

❗IMPORTANT
动脑筋:同一个 nested.rs 在 Linux 上,WRITABLE 那一行 register 返回什么?

Linux 6.12.28(Apple container 里的 Alpine,aarch64 musl 静态链接)、rustc 1.99.0。那里的 Poll fd 是 epoll fd。

哪些 fd 会触发

部分成功需要一种 fd:EVFILT_READ 能加、EVFILT_WRITE 加不上(或者反过来)。两项都失败或都成功的 fd 只会得到干脆的结果。src/bin/probe.rs 和 probe2.rs 对一组常见的 fd 分别用 R、W、R|W 各注册一次(每次之后 deregister),最后再注册一次 R|W 看 poll:

// Which fd types accept EVFILT_READ / EVFILT_WRITE? mio register(R|W) on each.
use std::fs::{File, OpenOptions};
use std::os::fd::{AsRawFd, RawFd};
use std::os::unix::fs::OpenOptionsExt;
use std::time::Duration;

use mio::unix::SourceFd;
use mio::{Events, Interest, Poll, Token};

const RW: Interest = Interest::READABLE.add(Interest::WRITABLE);

fn main() {
  let dir = std::env::args().nth(1).expect("dir");
  let file_path = format!("{dir}/regular");
  std::fs::write(&file_path, b"abc").unwrap();
  let fifo = format!("{dir}/fifo");
  let _ = std::fs::remove_file(&fifo);
  let c = std::ffi::CString::new(fifo.clone()).unwrap();
  assert_eq!(unsafe { libc::mkfifo(c.as_ptr(), 0o600) }, 0);
  let fifo_r = OpenOptions::new().read(true).custom_flags(libc::O_NONBLOCK).open(&fifo).unwrap();
  let fifo_w = OpenOptions::new().write(true).custom_flags(libc::O_NONBLOCK).open(&fifo).unwrap();
  let (pr, pw) = std::io::pipe().unwrap();
  let inner = Poll::new().unwrap();
  let fds: Vec<(&str, Box<dyn AsRawFd>)> = vec![
    ("regular file", Box::new(File::open(&file_path).unwrap())),
    ("directory", Box::new(File::open(&dir).unwrap())),
    ("fifo read end", Box::new(fifo_r)),
    ("fifo write end", Box::new(fifo_w)),
    ("pipe read end", Box::new(pr)),
    ("pipe write end", Box::new(pw)),
    ("/dev/null", Box::new(File::open("/dev/null").unwrap())),
    ("/dev/zero", Box::new(File::open("/dev/zero").unwrap())),
    ("/dev/urandom", Box::new(File::open("/dev/urandom").unwrap())),
    ("kqueue fd", Box::new(inner)),
  ];
  for (name, f) in &fds {
    let fd: RawFd = f.as_raw_fd();
    let mut poll = Poll::new().unwrap();
    let mut results = vec![];
    for (iname, i) in [("R", Interest::READABLE), ("W", Interest::WRITABLE), ("R|W", RW)] {
      let r = poll.registry().register(&mut SourceFd(&fd), Token(1), i);
      results.push(format!("{iname}: {}", match r { Ok(()) => "Ok".to_string(), Err(e) => format!("Err({})", e.raw_os_error().unwrap()) }));
      let _ = poll.registry().deregister(&mut SourceFd(&fd));
    }
    // after a failing R|W register, what is left?
    let r = poll.registry().register(&mut SourceFd(&fd), Token(1), RW);
    let mut events = Events::with_capacity(8);
    poll.poll(&mut events, Some(Duration::from_millis(50))).unwrap();
    let ev: Vec<String> = events.iter().map(|e| format!("{}{}", if e.is_readable() {"r"} else {""}, if e.is_writable() {"w"} else {""})).collect();
    println!("{name:<15} {}  | R|W then poll: ok={} events={:?}", results.join("  "), r.is_ok(), ev);
  }
}

probe2.rs 是同样的循环(不做最后那次 poll),fd 换成 socket 和伪终端:std::net::TcpListener、UdpSocket、一个未连接的 socket(AF_INET, SOCK_STREAM)、posix_openpt 得到的主端和从端、UnixStream::pair 的一端。两个程序各跑 3 次,输出相同:

regular file    R: Ok  W: Ok  R|W: Ok  | R|W then poll: ok=true events=["w", "r"]
directory       R: Err(22)  W: Err(22)  R|W: Err(22)  | R|W then poll: ok=false events=[]
fifo read end   R: Ok  W: Ok  R|W: Ok  | R|W then poll: ok=true events=["w"]
fifo write end  R: Ok  W: Ok  R|W: Ok  | R|W then poll: ok=true events=["w"]
pipe read end   R: Ok  W: Ok  R|W: Ok  | R|W then poll: ok=true events=[]
pipe write end  R: Ok  W: Ok  R|W: Ok  | R|W then poll: ok=true events=["w"]
/dev/null       R: Err(22)  W: Err(22)  R|W: Err(22)  | R|W then poll: ok=false events=[]
/dev/zero       R: Err(22)  W: Err(22)  R|W: Err(22)  | R|W then poll: ok=false events=[]
/dev/urandom    R: Err(22)  W: Err(22)  R|W: Err(22)  | R|W then poll: ok=false events=[]
kqueue fd       R: Ok  W: Err(22)  R|W: Err(22)  | R|W then poll: ok=false events=[]
tcp listener    R: Ok  W: Ok  R|W: Ok
udp socket      R: Ok  W: Ok  R|W: Ok
tcp unconnected R: Ok  W: Ok  R|W: Ok
pty master      R: Ok  W: Ok  R|W: Ok
pty slave       R: Ok  W: Ok  R|W: Ok
unix stream     R: Ok  W: Ok  R|W: Ok

在这台 macOS 上,16 种 fd 里只有 kqueue fd 是「读能加、写加不上」。其余的要么两个过滤器都接受(普通文件、FIFO 两端、pipe 两端、各种 socket、伪终端),要么两个都拒绝(目录、/dev/null 这类字符设备;从 /dev/null 重定向来的 stdin 也一样,见下文 stdin_cleanup.rs),R|W 的结果和单项一致。注意 FIFO 读端、pipe 读端也接受 EVFILT_WRITE,所以「只读的 fd 加不上写过滤器」这个直觉在 macOS 上不成立;源码里 does_not_register_rw 这个测试也只拿 kqueue fd 举例(「Registering kqueue fd will fail if write is requested (On anything but some versions of macOS).」)。

也就是说,现实里踩到它的路线是「把另一个事件循环的 fd 交给自己的事件循环」:mio 的 Poll 实现了 AsRawFd,很多库也会暴露自己内部的 kqueue fd 让上层嵌套;拿它用默认的读写兴趣注册,就是上面第三行。

reregister 和 deregister

reregister 总是提交两项:要的方向 EV_ADD,不要的方向 EV_DELETE,同样逐项生效。deregister 两项都是 EV_DELETE,忽略 ENOENT。src/bin/partial.rs 在同一个 kqueue fd 上走一遍,每步之后让内层 kqueue 可读,看外层收到什么,再把内层的事件取掉:

// kqueue fd in another Poll: register / reregister / deregister after an Err
use std::os::fd::AsRawFd;
use std::time::Duration;

use exp_mio_pit3::raw;
use mio::unix::SourceFd;
use mio::{Events, Interest, Poll, Token, Waker};

const RW: Interest = Interest::READABLE.add(Interest::WRITABLE);

// make the inner kqueue readable, then see what the outer poll reports
fn check(outer: &mut Poll, waker: &Waker, step: &str) {
  waker.wake().unwrap();
  let mut events = Events::with_capacity(8);
  outer.poll(&mut events, Some(Duration::from_millis(100))).unwrap();
  println!("{step}");
  println!("    then poll: {} event(s)", events.iter().count());
  for e in &events {
    println!("      token={} {}", e.token().0, raw(e));
  }
}

fn main() {
  let mut outer = Poll::new().unwrap();
  let mut inner = Poll::new().unwrap();
  let waker = Waker::new(inner.registry(), Token(99)).unwrap();
  let fd = inner.as_raw_fd();
  let reg = outer.registry().try_clone().unwrap();
  let mut drain = Events::with_capacity(8);

  let r = reg.register(&mut SourceFd(&fd), Token(1), RW);
  check(&mut outer, &waker, &format!("1. register(Token(1), R|W)        -> {r:?}"));
  inner.poll(&mut drain, Some(Duration::ZERO)).unwrap();

  let r = reg.reregister(&mut SourceFd(&fd), Token(2), RW);
  check(&mut outer, &waker, &format!("2. reregister(Token(2), R|W)      -> {r:?}"));
  inner.poll(&mut drain, Some(Duration::ZERO)).unwrap();

  let r = reg.reregister(&mut SourceFd(&fd), Token(3), Interest::WRITABLE);
  check(&mut outer, &waker, &format!("3. reregister(Token(3), WRITABLE) -> {r:?}"));
  inner.poll(&mut drain, Some(Duration::ZERO)).unwrap();

  let r = reg.register(&mut SourceFd(&fd), Token(4), RW);
  check(&mut outer, &waker, &format!("4. register(Token(4), R|W)        -> {r:?}"));
  inner.poll(&mut drain, Some(Duration::ZERO)).unwrap();

  let r = reg.deregister(&mut SourceFd(&fd));
  check(&mut outer, &waker, &format!("5. deregister                     -> {r:?}"));
}

raw(e) 把 &Event 当成 &libc::kevent 打印字段(Event 是 #[repr(transparent)] 包着 kevent 的,这依赖私有布局,只用于观察;和 kqueue 一页的同名函数相同)。跑 3 次,输出相同:

1. register(Token(1), R|W)        -> Err(Os { code: 22, kind: InvalidInput, message: "Invalid argument" })
    then poll: 1 event(s)
      token=1 ident=4 filter=READ flags=ADD|CLEAR|RECEIPT fflags=0 data=1 udata=1
2. reregister(Token(2), R|W)      -> Err(Os { code: 22, kind: InvalidInput, message: "Invalid argument" })
    then poll: 1 event(s)
      token=2 ident=4 filter=READ flags=ADD|CLEAR|RECEIPT fflags=0 data=1 udata=2
3. reregister(Token(3), WRITABLE) -> Err(Os { code: 22, kind: InvalidInput, message: "Invalid argument" })
    then poll: 0 event(s)
4. register(Token(4), R|W)        -> Err(Os { code: 22, kind: InvalidInput, message: "Invalid argument" })
    then poll: 1 event(s)
      token=4 ident=4 filter=READ flags=ADD|CLEAR|RECEIPT fflags=0 data=1 udata=4
5. deregister                     -> Ok(())
    then poll: 0 event(s)
步提交的两项回执结果实际状态
1WRITE 加、READ 加EINVAL、0Err读方向以 Token(1) 注册
2WRITE 加、READ 加EINVAL、0Err读方向的 udata 改成了 2
3WRITE 加、READ 删EINVAL、0Err什么都没注册了
4同第 1 步EINVAL、0Err读方向以 Token(4) 注册
5两项都删ENOENT(忽略)、0Ok干净
  • reregister 也只生效一半。 第 2 步报错,token 却已经换成了 2;第 3 步报错,读方向却已经被删了。调用方如果按「Err 等于什么都没改」来维护自己的状态表,这两步之后表和内核就对不上了。
  • deregister 不会部分失败。 两项都是删除,不存在的那一项回 ENOENT,正好在忽略表里。所以第 5 步对这个「一半注册」的 fd 返回 Ok,之后再也没有事件。

为什么 mio 不回滚

register 看到 WRITE 失败后,要撤销 READ 只能再交一次 EV_DELETE。mio 不这样做,有一个比「省一次系统调用」更硬的原因:它不知道 READ 这一项在这次 register 之前存不存在。EV_ADD 遇到已有的项就修改(所以 macOS 上同一个 fd register 两次会成功),而 kqueue 上的 IoSourceState 是空结构,mio 不记每个 fd 注册了什么。如果 READ 本来就在(比如用户先 register(READABLE) 成功,再错误地 register(R|W)),回滚会把用户原来的注册一起删掉。kevent_register 只能如实报告第一张失败的回执(check_errors),状态留给调用方收拾。

Linux 没有这个问题。epoll 的 register 是一次 epoll_ctl,一个 fd 只有一项、带一个事件掩码,要么整项加上,要么什么也没加。

📝NOTE
没有蠢问题

问: 既然 kqueue fd 不支持写,register 为什么不先判断一下 fd 类型,干脆只注册读?

答: 判断不了,也不该判断。mio 拿到的只是一个 RawFd,内核支持哪些过滤器只有内核知道,唯一可靠的办法就是提交了看回执。即使能判断,悄悄丢掉用户要的 WRITABLE 也会把错误藏起来:用户等一个永远不会来的可写事件。报错是对的,问题只在于报错时已经生效了一半。

怎么修

  1. 只要你确实需要的方向。 嵌套的事件循环 fd 只会「可读」,用 Interest::READABLE 注册,就不会有失败的那一项。上面 nested.rs 第一行就是这样。
  2. register / reregister 返回 Err 时,把这个 fd 当成「状态未知」,马上 deregister,并忽略它的返回值。 在 kqueue 上它会把两项都删掉、返回 Ok(上面第 5 步);在 epoll 上,失败的 register 什么也没留下,deregister 多半也报错。之后要么用正确的兴趣重新注册,要么关掉 fd。macOS 上关闭 fd 时 kqueue 也会删掉它的项(没 deregister 就 drop)。
  3. 别在 Err 之后立刻把这个 token 分给别的 source。 第 1 步之后不收拾,Token(1) 会一直以读事件的身份出现,直到 fd 关闭。

src/bin/stdin_cleanup.rs 对 stdin 依次用三种兴趣 register,每次紧接着 deregister:

// (1) register stdin with R / W / R|W; (2) after a failed register, what does deregister return?
use mio::unix::SourceFd;
use mio::{Interest, Poll, Token};

fn main() {
  let poll = Poll::new().unwrap();
  for (name, i) in [("R", Interest::READABLE), ("W", Interest::WRITABLE), ("R|W", Interest::READABLE | Interest::WRITABLE)] {
    let r = poll.registry().register(&mut SourceFd(&0), Token(1), i);
    let d = poll.registry().deregister(&mut SourceFd(&0));
    println!("{} stdin {name:<3}: register {:?}, then deregister {:?}", std::env::consts::OS,
      r.map_err(|e| e.raw_os_error()), d.map_err(|e| e.raw_os_error()));
  }
}

< /dev/null 运行,macOS 和 Linux(容器)各 3 次,输出相同:

macos stdin R  : register Err(Some(22)), then deregister Ok(())
macos stdin W  : register Err(Some(22)), then deregister Ok(())
macos stdin R|W: register Err(Some(22)), then deregister Ok(())
linux stdin R  : register Err(Some(1)), then deregister Err(Some(1))
linux stdin W  : register Err(Some(1)), then deregister Err(Some(1))
linux stdin R|W: register Err(Some(1)), then deregister Err(Some(1))

macOS 上 /dev/null 两个过滤器都不接受(EINVAL),deregister 删两个不存在的项,ENOENT 被忽略,返回 Ok。Linux 上 epoll 不接受这种 fd(EPERM),deregister 的 EPOLL_CTL_DEL 同样 EPERM。所以清理用的 deregister 不能 ?,要吞掉错误。

tokio:释放了内核还在用的 token

tokio 的 token 就是 ScheduledIo 的地址,事件回来时直接解引用(tokio 卷的反应器「token 就是指针」一节)。它的安全前提是:内核还可能带着这个地址回来时,ScheduledIo 不能被释放。Handle::add_source 在注册失败时是这样处理的:

        let scheduled_io = self.registrations.allocate(&mut self.synced.lock())?;
        let token = scheduled_io.token();

        // we should remove the `scheduled_io` from the `registrations` set if registering
        // the `source` with the OS fails. Otherwise it will leak the `scheduled_io`.
        if let Err(e) = self.registry.register(source, token, interest.to_mio()) {
            // safety: `scheduled_io` is part of the `registrations` set.
            unsafe {
                self.registrations
                    .remove(&mut self.synced.lock(), &scheduled_io)
            };

            return Err(e);
        }

register 失败就把 ScheduledIo 从链表里摘掉,函数返回时最后一份 Arc 被 drop,内存释放;没有调 deregister。这在 epoll 上是对的(失败的 epoll_ctl 什么也没留下),在 kqueue 的部分成功上不对:READ 那一项还在 tokio 的 kqueue 里,udata 是刚释放的地址。而 AsyncFd::register(以及已弃用的 AsyncFd::new)默认用的正是 READABLE | WRITABLE。

exp-mio-pit3-tokio/src/bin/asyncfd.rs(tokio = { version = "=1.54.1", features = ["rt", "net", "time"] }):

// tokio AsyncFd::register on a kqueue fd (macOS): register(R|W) fails,
// but READ stays in tokio's kqueue with the token of a freed ScheduledIo.
use std::os::fd::AsRawFd;
use std::time::Duration;

use mio::{Poll, Token, Waker};
use tokio::io::unix::AsyncFd;

struct Fd(i32);
impl AsRawFd for Fd {
  fn as_raw_fd(&self) -> i32 { self.0 }
}

fn main() {
  let rt = tokio::runtime::Builder::new_current_thread().enable_io().enable_time().build().unwrap();
  rt.block_on(async {
    let inner = Poll::new().unwrap();
    let waker = Waker::new(inner.registry(), Token(0)).unwrap();
    // SAFETY: `inner` outlives this block and its fd is not closed.
    let r = unsafe { AsyncFd::register(Fd(inner.as_raw_fd())) };
    println!("AsyncFd::register -> {:?}", r.as_ref().map(|_| ()).map_err(|e| e.to_string()));
    drop(r);
    // the inner kqueue becomes readable; tokio's driver gets a READ event
    waker.wake().unwrap();
    tokio::time::sleep(Duration::from_millis(50)).await;
    println!("driver polled; still alive");
  });
}

AsyncFd::register 的安全要求是 fd 有效、在 AsyncFd 存活期间不被关闭,这里都满足(inner 一直活着)。普通 --release 跑 3 次,输出相同,什么也看不出来:

AsyncFd::register -> Err("Invalid argument (os error 22)")
driver polled; still alive

用 nightly(rustc 1.101.0-nightly,2026-10-05)加 RUSTFLAGS="-Zsanitizer=address"、--target aarch64-apple-darwin 再跑 3 次,3 次都以 AddressSanitizer 报错退出(退出码 134)。下面是第一次的节选:符号用 rustc-demangle 0.1.27 还原过,... 处删掉了中间的栈帧:

AsyncFd::register -> Err("Invalid argument (os error 22)")
=================================================================
==19720==ERROR: AddressSanitizer: heap-use-after-free on address 0x613000000150 at pc 0x00010441a1e8 bp 0x00016ba75fc0 sp 0x00016ba75fb8
READ of size 8 at 0x613000000150 thread T0
    #0 0x00010441a1e4 in core::sync::atomic::atomic_load::<usize, false>+0x104 (asyncfd:arm64+0x1000961e4)
    ...
    #4 0x0001043d1d4c in <tokio::runtime::io::scheduled_io::ScheduledIo>::set_readiness::<tokio::runtime::io::driver::dispatch_event::{closure#0}>+0x148 (asyncfd:arm64+0x10004dd4c)
    #5 0x0001043d0d80 in tokio::runtime::io::driver::dispatch_event+0x174 (asyncfd:arm64+0x10004cd80)
    #6 0x0001043cd608 in <tokio::runtime::io::driver::Driver>::turn+0x7b0 (asyncfd:arm64+0x100049608)
    ...

0x613000000150 is located 208 bytes inside of 256-byte region [0x613000000080,0x613000000180)
freed by thread T0 here:
    ...
    #8 0x000104390150 in <tokio::runtime::io::driver::Handle>::add_source::<mio::sys::unix::sourcefd::SourceFd> driver.rs:309
    #9 0x000104386968 in <tokio::runtime::io::registration::Registration>::new_with_interest_and_handle::<mio::sys::unix::sourcefd::SourceFd> registration.rs:78
    #10 0x000104388870 in <tokio::io::async_fd::AsyncFd<asyncfd::Fd>>::register_with_interest async_fd.rs:273
    #11 0x000104388ad8 in <tokio::io::async_fd::AsyncFd<asyncfd::Fd>>::register async_fd.rs:241
    #12 0x0001043895d0 in asyncfd::main::{closure#0} asyncfd.rs:20
    ...
SUMMARY: AddressSanitizer: heap-use-after-free (asyncfd:arm64+0x1000961e4) in core::sync::atomic::atomic_load::<usize, false>+0x104

释放发生在 add_source 里(第 309 行是函数末尾,scheduled_io 在这里被 drop),读发生在下一次 turn 的 dispatch_event 里,读的是 ScheduledIo 里的就绪状态字。没开 ASan 时,这次读写落在已经还给分配器的内存上,后果取决于那块内存后来被谁用了。

所以在 macOS 上用 tokio 包一个 kqueue fd,必须显式只要读:

// SAFETY: `inner` outlives the AsyncFd and its fd is not closed.
let afd = unsafe { AsyncFd::register_with_interest(Fd(inner.as_raw_fd()), Interest::READABLE) }.unwrap();

src/bin/asyncfd_read.rs 用这一行替换上面的注册,wake 之后 afd.readable().await,同样开 ASan 跑 3 次,退出码都是 0,输出相同:

register_with_interest(READABLE) -> Ok
readable: Ready { is_readable: true, is_writable: false, is_read_closed: false, is_write_closed: false, is_error: false }

tokio 的 TcpStream、UdpSocket、UnixStream 这些类型不受影响:socket 两个过滤器都接受,register 要么全成要么全败。受影响的只有 AsyncFd 包一个「读写只支持一半」的 fd 这一种用法。

怎么确认自己没踩到

  • 在 macOS 上,找所有 register / reregister 的 Err 分支:如果只是记日志或把错误往上抛,而 fd 还开着、token 还会被复用,就是这个坑。
  • 找所有用默认兴趣(READABLE | WRITABLE)注册非 socket fd 的地方,尤其是 AsyncFd::register、AsyncFd::new。用 probe.rs 那样的程序对你的 fd 类型单独试一次 R 和 W。
  • 想确定一个 fd 在 kqueue 里还剩什么,最直接的办法是像 partial.rs 那样让它就绪,再看 poll。

动脑筋答案

nested.rs 在 Linux 6.12.28(Apple container 里的 Alpine,aarch64 musl 静态链接)、rustc 1.99.0 上,用 strace -f -e trace=epoll_create1,epoll_ctl,epoll_pwait 跑,3 次输出相同(去掉了容器的进度行):

epoll_create1(EPOLL_CLOEXEC)            = 3
epoll_create1(EPOLL_CLOEXEC)            = 4
epoll_ctl(3, EPOLL_CTL_ADD, 4, {events=EPOLLIN|EPOLLRDHUP|EPOLLET, data=0x1}) = 0
epoll_ctl(4, EPOLL_CTL_ADD, 5, {events=EPOLLIN|EPOLLRDHUP|EPOLLET, data=0x5}) = 0
epoll_pwait(3, [{events=EPOLLIN, data=0x1}], 8, 100, NULL, 8) = 1
READABLE           register Ok(())
                   then poll: 1 event(s) ["token=1 readable=true writable=false"]
epoll_create1(EPOLL_CLOEXEC)            = 3
epoll_create1(EPOLL_CLOEXEC)            = 4
epoll_ctl(3, EPOLL_CTL_ADD, 4, {events=EPOLLOUT|EPOLLET, data=0x1}) = 0
epoll_ctl(4, EPOLL_CTL_ADD, 5, {events=EPOLLIN|EPOLLRDHUP|EPOLLET, data=0x5}) = 0
epoll_pwait(3, [], 8, 100, NULL, 8)     = 0
WRITABLE           register Ok(())
                   then poll: 0 event(s) []
epoll_create1(EPOLL_CLOEXEC)            = 3
epoll_create1(EPOLL_CLOEXEC)            = 4
epoll_ctl(3, EPOLL_CTL_ADD, 4, {events=EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET, data=0x1}) = 0
epoll_ctl(4, EPOLL_CTL_ADD, 5, {events=EPOLLIN|EPOLLRDHUP|EPOLLET, data=0x5}) = 0
epoll_pwait(3, [{events=EPOLLIN, data=0x1}], 8, 100, NULL, 8) = 1
READABLE|WRITABLE  register Ok(())
                   then poll: 1 event(s) ["token=1 readable=true writable=false"]
+++ exited with 0 +++

答案是 Ok(())。

  • epoll fd 可以放进另一个 epoll,EPOLLOUT 也被接受:一个 fd 一项,掩码里有什么内核就记什么,epoll fd 永远不会报可写,所以 WRITABLE 那一轮 0 个事件,但注册本身成功。
  • 三种兴趣在 Linux 上都是一次成功的 epoll_ctl,不存在「一半」。同一个程序在 macOS 上两种报错、Linux 上全部成功,这也是这个坑在 Linux 上开发、macOS 上才暴露(或者反过来)的原因。

小结

  • kqueue 的 register / reregister 每个方向一项,逐项生效、不回滚;返回 Err 时可能已经注册了一个方向(或者,reregister 时已经删掉了一个方向、换掉了 token)。
  • 实测 macOS 上 16 种常见 fd 里只有 kqueue fd 是「读能加、写加不上」,触发路线是把另一个事件循环的 fd 嵌进来;目录和字符设备两项都失败,普通文件、FIFO、pipe、socket、伪终端两项都成功。
  • deregister 不会部分失败(删不存在的项回 ENOENT,被忽略),所以拿到 Err 之后 deregister 一次就能回到干净状态。
  • mio 不回滚,因为它不知道成功的那一项之前是否已经存在;epoll 一个 fd 一项,没有中间状态。
  • tokio 1.54.1 的 add_source 在 Err 时释放 ScheduledIo 却不 deregister;macOS 上 AsyncFd::register 一个 kqueue fd,下一次事件就是 use-after-free(ASan 复现)。用 register_with_interest(..., Interest::READABLE)。

💬 关联讨论

发起讨论
加载中...