Rust in Everything
基于 rustls 0.23.46

握手卡住,没有任何报错

主线位置:握手主线的每一轮都要调用方推:read_tls 收字节、process_new_packets 处理、write_tls 交出回复。五个方法各做什么、wants_read / wants_write 怎么算、文档示例循环漏掉 write_tls 或 process_new_packets 时的表现,见 sans-IO。本页讲一种更隐蔽的写法:循环条件用 is_handshaking()。

sequenceDiagram
  participant C as 客户端循环
  participant S as 服务端
  C->>S: ClientHello
  S->>C: ServerHello 到 Finished
  Note over C: is_handshaking 变假
  Note over C: Finished 还在 sendable_tls
  C->>C: 退出循环 等服务端问候
  Note over S: 等客户端 Finished
  Note over C,S: 两边都在读 直到超时

实验 crate:exp-rustls/pita-lab,src/bin/p05_stall.rs。服务端用 complete_io 正确驱动,客户端是手写的循环;两边的 TcpStream 设了读超时(服务端 2 秒、客户端 3 秒),否则会永远卡住。M2 Max、macOS 27、rustc 1.99.0,release,ring provider,TLS 1.3,127.0.0.1。

现象

客户端先握手、再等服务端发一句问候。握手循环写成「握手没完成就继续」:

while conn.is_handshaking() {
  while conn.wants_write() {
    conn.write_tls(sock)?;
  }
  if conn.wants_read() {
    conn.read_tls(sock)?;
    conn.process_new_packets().map_err(std::io::Error::other)?;
  }
}
// 握手完成,等服务端的问候:reader() 是 WouldBlock 就再 read_tls

看上去每一步都有。运行:

== tcp, client loop while is_handshaking() only
  [client] loop exited: is_handshaking=false wants_write=true
  [client] error: Os { code: 35, kind: WouldBlock, message: "Resource temporarily unavailable" }
  [server] complete_io: Os { code: 35, kind: WouldBlock, message: "Resource temporarily unavailable" }
  • 循环正常退出,is_handshaking 已经是 false,但 wants_write 还是 true。
  • 之后客户端等问候,服务端等客户端,两边都卡在读上,直到各自的读超时到期。macOS 上 SO_RCVTIMEO 到期报的是 errno 35(EAGAIN),Rust 把它归为 WouldBlock。
  • 没有一行日志、没有一个 rustls 错误说明原因。不设读超时,这个程序就永远挂着。

原因

把服务端的第一轮交给客户端处理之后,立刻看两边的状态(内存里,不经过套接字):

== in memory: client right after processing the server's first flight
  client                             is_handshaking=false wants_write=true  wants_read=true
  server                             is_handshaking=true  wants_write=false wants_read=true

客户端验完服务端的 Finished,就有了应用流量密钥,is_handshaking 只看能不能收发应用数据:

    /// Returns true if the connection is currently performing the TLS handshake.
    ///
    /// During this time plaintext written to the connection is buffered in memory. After
    /// [`Connection::process_new_packets()`] has been called, this might start to return `false`
    /// while the final handshake packets still need to be extracted from the connection's buffers.
    ///
    /// [`Connection::process_new_packets()`]: crate::Connection::process_new_packets
    pub fn is_handshaking(&self) -> bool {
        !(self.may_send_application_data && self.may_receive_application_data)
    }

文档注释已经写了:它变成 false 的时候,最后几条握手消息可能还在缓冲里。这里就是客户端的 CCS 和 Finished(80 字节,见一次握手)。循环条件是 is_handshaking(),write_tls 又只在循环顶部,于是循环在发 Finished 之前退出。服务端没收到 Finished,自己的 is_handshaking 一直是 true,不会发问候。

rustls 不会自己碰套接字,它只能把「我还有东西要发」写在 wants_write() 里。调用方不看,字节就一直躺在 sendable_tls。

漏掉 process_new_packets 是同一类问题:read_tls 只是把字节搬进缓冲,不处理就没有回复,sans-IO 里有内存和 TCP 两种复现。

📝NOTE
冷知识:complete_io 自己也踩过这个坑

60cfb9b4(2023-04-06,「conn: ensure complete_io flushes pending writes.」,首次发布于 0.21.1)之前,complete_io 也是按 is_handshaking() 判断握手是否完成。提交说明写道:「Previously, calls to complete_io() may return as if handshaking has completed, but leave pending TLS writes queued that won't be sent until a subsequent call to complete_io() is made」,原因正是「is_handshaking() can begin to return false after calls to process_new_packets() while there are final handshake packets put in the connection's buffers」。修法是在 complete_io 里加了一个分支,0.23.46 里还在:

            // if we're doing IO until handshaked, and we believe we've finished handshaking,
            // but process_new_packets() has queued TLS data to send, loop around again to write
            // the queued messages.
            if until_handshaked && !self.is_handshaking() && self.wants_write() {
                continue;
            }

同一个提交给两个单元测试加了「complete_io() 之后没有待写数据」的断言,说明里说这两条断言在修之前会失败。

修法

用 wants_write() 决定要不要写,而不是用握手状态。 最小的改法是在循环退出后再冲一次:

while conn.wants_write() {
  conn.write_tls(sock)?;
}
== tcp, client loop with a final write_tls
  [client] loop exited: is_handshaking=false wants_write=true
  [server] handshake done, greeting sent
  [client] got "hello"

更稳妥的结构是每一圈都按顺序做完三件事,退出条件只看「还有没有事做」:

  1. wants_write() 为真就 write_tls,直到为假或套接字写不进去;
  2. wants_read() 为真且套接字可读就 read_tls,读到字节立刻 process_new_packets;
  3. process_new_packets 之后回到第 1 步,因为它可能刚刚排进了要发的字节。

能不手写就不手写。 阻塞 IO 用 complete_io、Stream / StreamOwned(sans-IO 里有它们在 127.0.0.1 上的逐次读写记录);异步用 tokio-rustls,它在每次 poll_write 之后把 sendable_tls 冲到底层 IO。实验里的服务端就是一个 while conn.is_handshaking() { conn.complete_io(&mut sock)?; },它没有卡住,原因就是上面那个 continue。

区分两种 WouldBlock。 reader().read 在没有明文时也返回 WouldBlock,那是「需要更多 TLS 字节」,接着 read_tls 就对了;套接字读超时报的 WouldBlock 才是「对端什么也没发」。上面的客户端两种都碰到了,后一种才是症状。

怎么检查

卡住时打印三个查询。 在读之前加一行:

eprintln!("is_handshaking={} wants_write={} wants_read={}",
  conn.is_handshaking(), conn.wants_write(), conn.wants_read());

wants_write=true 时去读,一定是循环漏了写。两边都 wants_write=false、wants_read=true 时才是真的在等对方。

给套接字设读超时。 开发期把 set_read_timeout 设成几秒,卡死就会变成一个 WouldBlock / TimedOut 错误,至少能看到停在哪一步。

抓字节数。 握手一般是固定的几轮;在 write_tls / read_tls 外面记下每次的字节数,和一次握手的记录对照,少了哪一轮一目了然。

小结

  • rustls 不碰套接字,调用方漏掉一次 write_tls 或 process_new_packets,连接就停住,没有错误,两边都在读。
  • 客户端处理完服务端第一轮后 is_handshaking() 已经是 false,但 CCS 和 Finished 还在缓冲里;以 is_handshaking() 为条件、只在顶部写的循环会漏发它们。
  • 写不写看 wants_write();每次 process_new_packets 之后都要再检查一次。
  • complete_io 在 0.21.1 之前有同样的问题(60cfb9b4),现在专门有一个分支处理它;能用 complete_io、Stream 或 tokio-rustls 就别手写。
  • 卡住时先打印 is_handshaking / wants_write / wants_read,开发期给套接字设读超时。

💬 关联讨论

发起讨论
加载中...