握手卡住,没有任何报错
主线位置:握手主线的每一轮都要调用方推:
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 两种复现。
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"
更稳妥的结构是每一圈都按顺序做完三件事,退出条件只看「还有没有事做」:
wants_write()为真就write_tls,直到为假或套接字写不进去;wants_read()为真且套接字可读就read_tls,读到字节立刻process_new_packets;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,开发期给套接字设读超时。