Rust in Everything
基于 mio 1.2.4

Socket not connected (os error 107)

主线位置:注册主线和事件主线的交界。TcpStream::connect 发出 SYN 就返回,连接的结果要靠一次可写事件(事件主线)带回来,再用 take_error 和 peer_addr 读出来。

flowchart TB
  c["connect 返回 Ok"] --> r["注册 WRITABLE"]
  r --> w["等可写事件"]
  w --> te{"take_error"}
  te -->|"Some e"| fail["连接失败,原因是 e"]
  te -->|None| pa{"peer_addr"}
  pa -->|"Ok addr"| ok["连上了"]
  pa -->|NotConnected| w
  pa -->|其他错误| fail2["失败"]

Socket not connected (os error 107) 是 Linux 的 ENOTCONN(本页的 Linux 是 musl;glibc 上同一个错误的文字是「Transport endpoint is not connected」),macOS 上是 Socket is not connected (os error 57)。在 mio 里看到它,多半是把一个还没连上(或者已经被拒)的 TcpStream 当成了连上的。TcpStream::connect 文档里的六步流程、成功和被拒时各个事件位和 strace、只注册 READABLE 等不到成功的实验,都在 TCP 的「connect:立刻返回的 connect」里。这一页把三种走错的顺序各跑一遍,看每种错误长什么样。

本页实验在两个平台上运行:M2 Max、macOS、rustc 1.99.0;Linux 6.12.28(Apple container 里的 Alpine,aarch64 musl 静态链接)、rustc 1.99.0。都是 --release,依赖:

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

复现:三种错的顺序,一种对的

src/bin/connect_early.rs 对两个地址各做四件事:一个正在监听的端口(listening),一个刚 bind 完就 drop 掉、没人监听的端口(closed)。

  • A:connect 之后立刻 write,不等事件。
  • B:等到可写事件,不看 take_error,直接 write 两次、read 一次,最后才 take_error。
  • C:等到可写事件,按文档第 5 步先看 peer_addr,NotConnected 就回去再等,最多 3 轮(每轮 poll 200ms),最后才 take_error。
  • D:文档的顺序:等可写事件,先 take_error,再 peer_addr(wait_connected)。
use std::io::{self, Read, Write};
use std::net::SocketAddr;
use std::time::Duration;

use mio::net::TcpStream;
use mio::{Events, Interest, Poll, Token};

fn kind<T: std::fmt::Debug>(r: io::Result<T>) -> String {
  match r {
    Ok(v) => format!("Ok({v:?})"),
    Err(e) => format!("Err({e})"),
  }
}

fn writable(poll: &mut Poll, events: &mut Events) -> io::Result<usize> {
  poll.poll(events, Some(Duration::from_millis(200)))?;
  Ok(events.iter().filter(|e| e.is_writable()).count())
}

fn targets() -> io::Result<(std::net::TcpListener, SocketAddr)> {
  let listening = std::net::TcpListener::bind("127.0.0.1:0")?;
  // bind then drop: nobody listens on this port any more
  let closed = std::net::TcpListener::bind("127.0.0.1:0")?.local_addr()?;
  Ok((listening, closed))
}

fn main() -> io::Result<()> {
  println!("os={}", std::env::consts::OS);
  let (listening, closed) = targets()?;
  let cases = [("listening", listening.local_addr()?), ("closed", closed)];

  println!("-- A. write right after connect, no event yet");
  for (name, addr) in cases {
    let mut s = TcpStream::connect(addr)?;
    println!("{name:<9} write = {}", kind(s.write(b"hi")));
  }

  println!("-- B. writable event, then write without take_error");
  for (name, addr) in cases {
    let mut poll = Poll::new()?;
    let mut events = Events::with_capacity(8);
    let mut s = TcpStream::connect(addr)?;
    poll.registry().register(&mut s, Token(0), Interest::WRITABLE)?;
    println!("{name:<9} writable events = {}", writable(&mut poll, &mut events)?);
    println!("{name:<9} write #1 = {}", kind(s.write(b"hi")));
    println!("{name:<9} write #2 = {}", kind(s.write(b"hi")));
    let mut buf = [0u8; 8];
    println!("{name:<9} read     = {}", kind(s.read(&mut buf)));
    println!("{name:<9} take_error = {}", kind(s.take_error()));
  }

  println!("-- C. step 5 before step 4: peer_addr first, NotConnected means wait again");
  for (name, addr) in cases {
    let mut poll = Poll::new()?;
    let mut events = Events::with_capacity(8);
    let mut s = TcpStream::connect(addr)?;
    poll.registry().register(&mut s, Token(0), Interest::WRITABLE)?;
    for round in 1..=3 {
      let n = writable(&mut poll, &mut events)?;
      let p = s.peer_addr();
      let not_yet = matches!(&p, Err(e) if e.kind() == io::ErrorKind::NotConnected);
      println!("{name:<9} round {round}: writable events = {n}, peer_addr = {}", kind(p));
      if !not_yet {
        break;
      }
    }
    println!("{name:<9} take_error = {}", kind(s.take_error()));
  }

  println!("-- D. the documented order: writable event, take_error, then peer_addr");
  for (name, addr) in cases {
    let mut poll = Poll::new()?;
    let mut events = Events::with_capacity(8);
    let mut s = TcpStream::connect(addr)?;
    poll.registry().register(&mut s, Token(0), Interest::WRITABLE)?;
    println!("{name:<9} {}", kind(wait_connected(&mut poll, &mut events, &s)));
  }
  Ok(())
}

// steps 3 to 6 of the TcpStream::connect docs
fn wait_connected(poll: &mut Poll, events: &mut Events, s: &TcpStream) -> io::Result<SocketAddr> {
  loop {
    poll.poll(events, None)?;
    if !events.iter().any(|e| e.token() == Token(0) && e.is_writable()) {
      continue;
    }
    if let Some(e) = s.take_error()? {
      return Err(e); // step 4: the connect failed
    }
    match s.peer_addr() {
      Ok(addr) => return Ok(addr), // step 6: connected
      Err(e) if e.kind() == io::ErrorKind::NotConnected => continue, // step 5: not yet
      Err(e) if e.raw_os_error() == Some(libc::EINPROGRESS) => continue,
      Err(e) => return Err(e),
    }
  }
}

macOS,跑 5 次,除端口外相同:

os=macos
-- A. write right after connect, no event yet
listening write = Err(Socket is not connected (os error 57))
closed    write = Err(Socket is not connected (os error 57))
-- B. writable event, then write without take_error
listening writable events = 1
listening write #1 = Ok(2)
listening write #2 = Ok(2)
listening read     = Err(Resource temporarily unavailable (os error 35))
listening take_error = Ok(None)
closed    writable events = 1
closed    write #1 = Err(Broken pipe (os error 32))
closed    write #2 = Err(Broken pipe (os error 32))
closed    read     = Err(Connection refused (os error 61))
closed    take_error = Ok(None)
-- C. step 5 before step 4: peer_addr first, NotConnected means wait again
listening round 1: writable events = 1, peer_addr = Ok(127.0.0.1:56389)
listening take_error = Ok(None)
closed    round 1: writable events = 1, peer_addr = Err(Invalid argument (os error 22))
closed    take_error = Ok(Some(Os { code: 61, kind: ConnectionRefused, message: "Connection refused" }))
-- D. the documented order: writable event, take_error, then peer_addr
listening Ok(127.0.0.1:56389)
closed    Err(Connection refused (os error 61))

Linux,跑 5 次,除端口外相同:

os=linux
-- A. write right after connect, no event yet
listening write = Ok(2)
closed    write = Err(Connection refused (os error 111))
-- B. writable event, then write without take_error
listening writable events = 1
listening write #1 = Ok(2)
listening write #2 = Ok(2)
listening read     = Err(Resource temporarily unavailable (os error 11))
listening take_error = Ok(None)
closed    writable events = 1
closed    write #1 = Err(Connection refused (os error 111))
closed    write #2 = Err(Broken pipe (os error 32))
closed    read     = Ok(0)
closed    take_error = Ok(None)
-- C. step 5 before step 4: peer_addr first, NotConnected means wait again
listening round 1: writable events = 1, peer_addr = Ok(127.0.0.1:43173)
listening take_error = Ok(None)
closed    round 1: writable events = 1, peer_addr = Err(Socket not connected (os error 107))
closed    round 2: writable events = 0, peer_addr = Err(Socket not connected (os error 107))
closed    round 3: writable events = 0, peer_addr = Err(Socket not connected (os error 107))
closed    take_error = Ok(Some(Os { code: 111, kind: ConnectionRefused, message: "Connection refused" }))
-- D. the documented order: writable event, take_error, then peer_addr
listening Ok(127.0.0.1:43173)
closed    Err(Connection refused (os error 111))

逐段看。

A:不等事件就写。 macOS 上两个地址都是 ENOTCONN(57):握手还没完成,socket 不能发数据。Linux 上 listening 那一行写成功了,closed 那一行在写的时候就拿到了 ECONNREFUSED。在环回地址上,Linux 的握手(或者 RST)在 connect 返回到下一个系统调用之间就处理完了(TCP 里 connect 之后立刻 getpeername 也成功了)。这是时序上的巧合:同样的代码连远端地址,握手要一个往返,write 就会失败。在 Linux 上本地测试通过、在 macOS 上或者连真实网络时出错,就是这一段。

B:等到可写,但不看 take_error。 被拒的连接照样会产生可写事件(带着错误位,见 TCP),不看 take_error 就会以为连上了。之后的现象两个平台不一样:

closed 上的操作LinuxmacOS
第一次 writeConnection refused (os error 111)Broken pipe (os error 32)
第二次 writeBroken pipe (os error 32)Broken pipe (os error 32)
readOk(0)Connection refused (os error 61)
最后的 take_errorOk(None)Ok(None)

内核把异步 connect 的失败原因存在 SO_ERROR 里,谁先来取就交给谁,取一次就清零:Linux 上第一次 write 拿走了它,macOS 上是 read。之后再调 take_error 已经是 None。最难查的是 Linux 的 read:一个从没连上的 socket 读出了 Ok(0),看起来和「对端正常关闭」一模一样。

C:先 peer_addr,后 take_error。 这就是本页标题的来源。Linux 上被拒的连接,peer_addr 是 NotConnected,和「还在连」完全一样;按第 5 步回去等,第 2、3 轮的可写事件都是 0 个:边沿触发,连接失败那一次变化已经报过了,不会再报。没有轮数上限的程序会永远等下去。而 SO_ERROR 一直在那里:最后的 take_error 拿到了 ConnectionRefused。macOS 上 peer_addr 是 Invalid argument (os error 22),按第 5 步属于「another error」,流程会以失败结束,但报给用户的原因是「Invalid argument」,真正的原因同样只在 take_error 里。

D:文档的顺序。 两个平台都给出正确的结果:连上的拿到地址,被拒的拿到 Connection refused。

为什么

非阻塞 socket 上的 connect 几乎总是 EINPROGRESS,mio 的 connect 把它当成成功:

    match syscall!(connect(
        socket.as_raw_fd(),
        raw_addr.as_ptr(),
        raw_addr_length
    )) {
        Err(err) if err.raw_os_error() != Some(libc::EINPROGRESS) => Err(err),
        _ => Ok(()),
    }

所以 TcpStream::connect 的 Ok 只表示「SYN 已经发出」。文档的 Notes 一节第一句就是:「The returned TcpStream may not be connected (and thus usable), unlike the API found in std::net::TcpStream.」连接的结果在两个地方:失败原因在 SO_ERROR(take_error,「clearing the field in the process」),连上与否在 getpeername(peer_addr)。第一个是一次性的,第二个分不清「还在连」和「被拒了」(至少在 Linux 上),所以只能先读第一个。

📝NOTE
冷知识:文档里的这几步改过两次,两次都对应本页的一个坑

connect 返回的流可能没连上,这段说明是 PR #1526「Document unconnected TcpStream returned by TcpStream::connect」加的,首次发布在 0.8.0。那时只有五步,第 2 步写的是「Register the returned stream with at least [read interest]」,第 3 步是「Wait for a (readable) event」,第 4 步就是 peer_addr。

PR #1565「Fix incorrect rustdoc for TcpStream::connect」把读改成写:「It is Interest::WRITABLE we should care about after non-blocking TcpStream::connect.」,引用了 Linux connect(2) 手册里「select(2) or poll(2) for completion by selecting the socket for writing」,以及 tokio 的 connect_mio。首次发布在 0.8.3。只注册 READABLE 等不到成功,TCP 的动脑筋跑过。

2023 年 12 月,有人在这个已合并的 PR 下面评论,说的正是上面的 C 段:「I've been experiment with refused connections and we never get another writable event and the error is always NotConnected if I keep on polling.」讨论的结论是在 peer_addr 之前加一步 take_error。PR #1726「Mentioned TcpStream::take error on TcpStream::connect documentation.」(说明只有一句「As discussed on #1565 .」)把它插成新的第 4 步,原来的第 4、5 步顺延成 5、6。首次发布在 1.0.0。按 0.8 文档写的代码,在 Linux 上遇到被拒的连接,就是 C 段的样子。

修法

照文档的顺序,上面程序里的 wait_connected 就是一个实现:

  1. 注册时至少带 Interest::WRITABLE。
  2. 等到这个 token 的可写事件。
  3. 先 take_error():Some(e) 就是连接失败,e 是真正的原因。
  4. 再 peer_addr():Ok 是连上了;NotConnected 或 EINPROGRESS 是还没连上,回去等;其他错误是失败。
  5. 在这之前不要对它 read / write:它们会拿走 SO_ERROR,或者在 macOS 上直接 ENOTCONN。

tokio 怎么做

tokio 的 TcpStream::connect 在返回之前就把这件事做完了。connect_mio 等到可写,take_error() 有错误就返回,没有就当作连上:

            poll_fn(|cx| stream.io.registration().poll_write_ready(cx)).await?;

            if let Some(e) = stream.io.take_error()? {
                return Err(e);
            }

            Ok(stream)

它只做到文档的第 4 步,不看 peer_addr。TcpSocket::connect 和 UnixStream::connect 也走各自的 connect_mio。所以用 tokio 时,connect().await 返回的 TcpStream 一定已经有了结果,被拒的连接在 connect().await 那里就是 Err(ConnectionRefused),本页的 A、B、C 三种情况都碰不到。只有绕过它的代码才需要自己处理:比如用 mio 或 socket2 自己发起非阻塞 connect,再交给 tokio 的 from_std。

怎么确认自己没踩

  • 用一个关闭的端口测试。 bind 到 127.0.0.1:0 拿到端口、立刻 drop,再连它,就是一个必然被拒的连接。正确的代码报 ConnectionRefused;先 peer_addr 的代码在 Linux 上一直等,不看 take_error 的代码会在之后的读写里得到 Broken pipe 或 Ok(0)。
  • 在 macOS 上也跑一遍。 Linux 环回地址上握手几乎是瞬间完成的,「不等事件就写」在 Linux 本地测试里通过,在 macOS 上立刻 ENOTCONN。
  • 看 strace。 正确的顺序是:connect 返回 EINPROGRESS → epoll_ctl 里带 EPOLLOUT → epoll_pwait 报出 EPOLLOUT → getsockopt(SO_ERROR) → getpeername → 之后才有 sendto / recvfrom。TCP 里有一份完整的。

小结

  • TcpStream::connect 把 EINPROGRESS 当成 Ok,返回的流还没连上。不等事件就写:macOS 上 ENOTCONN(57),Linux 环回上可能侥幸成功,被拒时是 ECONNREFUSED。
  • 被拒的连接也会产生可写事件。不看 take_error 就读写,SO_ERROR 被第一个读写拿走:Linux 上 write 报 Connection refused、之后 Broken pipe、read 返回 Ok(0);macOS 上 write 报 Broken pipe、read 报 Connection refused。
  • 先 peer_addr 后 take_error:Linux 上被拒的连接一直是 NotConnected (os error 107),边沿触发不会再报可写,程序永远等;macOS 上是 EINVAL,原因丢了。
  • 正确顺序:注册 WRITABLE、等可写事件、先 take_error、再 peer_addr。文档的这几步在 0.8.3(#1565,读改写)和 1.0.0(#1726,加 take_error)各修过一次。
  • tokio 的 connect().await 内部等可写并检查 take_error,返回的流已经有了结果。

💬 关联讨论

发起讨论
加载中...