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 轮(每轮poll200ms),最后才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 上的操作 | Linux | macOS |
|---|---|---|
第一次 write | Connection refused (os error 111) | Broken pipe (os error 32) |
第二次 write | Broken pipe (os error 32) | Broken pipe (os error 32) |
read | Ok(0) | Connection refused (os error 61) |
最后的 take_error | Ok(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 上),所以只能先读第一个。
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 就是一个实现:
- 注册时至少带
Interest::WRITABLE。 - 等到这个 token 的可写事件。
- 先
take_error():Some(e)就是连接失败,e是真正的原因。 - 再
peer_addr():Ok是连上了;NotConnected或EINPROGRESS是还没连上,回去等;其他错误是失败。 - 在这之前不要对它
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,返回的流已经有了结果。