Rust in Everything
基于 rustls 0.23.46

出过一次错,之后永远是同一个错

主线位置:握手主线和记录主线共用的 process_new_packets。连接的状态是 Result<Box<dyn State>, Error>,出错时整个状态换成 Err。这个设计和「出错后先 write_tls 发告警」的说明见 sans-IO,状态机本身见状态机。本页讲调用方以为「再试一次」能好时会看到什么。

stateDiagram-v2
  [*] --> Running
  Running --> Running: 正常消息
  Running --> Failed: 任何错误
  Failed --> Failed: process_new_packets 同一错误
  Failed --> Failed: writer write 同一错误
  Failed --> Failed: reader read WouldBlock
  Failed --> [*]: write_tls 发告警后丢弃

实验 crate:exp-rustls/pita-lab,src/bin/p06_sticky.rs;对照用的 exp-rustls/pita-r45 是同一份代码,只把 rustls 换成 0.23.45。M2 Max、macOS 27、rustc 1.99.0,release,ring provider,TLS 1.3,内存握手。

现象

握手失败之后。 客户端信任的是另一个 CA,处理服务端第一轮时报 UnknownIssuer。接着照常调用各个方法:

== A: handshake fails on the client (wrong root)
process_new_packets #1: Err(InvalidCertificate(UnknownIssuer))
process_new_packets #2: Err(InvalidCertificate(UnknownIssuer))
process_new_packets #3: Err(InvalidCertificate(UnknownIssuer))
is_handshaking=true wants_write=true wants_read=false
write_tls after the error -> 30 bytes [14, 03, 03, 00, 01, 01, 17, 03]
writer().write(b"GET /") -> Err(Custom { kind: InvalidData, error: InvalidCertificate(UnknownIssuer) })
wants_write after that write: false
read_tls(6 more bytes) -> Ok(6)
process_new_packets #4: Err(InvalidCertificate(UnknownIssuer))
reader().read -> Err(Kind(WouldBlock))
  • process_new_packets 无论调几次、中间喂不喂新字节,都是同一个错误。
  • read_tls 照收不误,收进来的字节不会再被处理。
  • 出错时已经有 30 字节排队:6 字节的 CCS 加 24 字节加密的告警。要 write_tls 才会发出去。
  • writer().write 返回包着同一个错误的 InvalidData。
  • reader().read 不报这个错误,只报 WouldBlock。

握手之后,一条坏记录。 服务端收到一条 37 字节的垃圾应用数据记录,之后客户端再发一条完全正常的:

== B: one bad record after the handshake (server side)
server after junk record: Err(DecryptError)
server after a good record: Err(DecryptError)
server write_tls after the error -> 24 bytes
server writer().write(b"200 OK") -> Err(Custom { kind: InvalidData, error: DecryptError }), then wants_write=false
client after the server's alert: Err(AlertReceived(BadRecordMac))

一条坏记录就让整个连接作废,后面的好记录也解不出来。客户端收到 bad_record_mac 告警,它这一侧也跟着粘住。

重试循环。 对这个服务端连接反复调 process_new_packets:

== C: retry loop on process_new_packets
still failing after 1000000 retries: Err(DecryptError)

一百万次都是同一个结果。在真实程序里,这样的循环就是一个吃满 CPU 的死循环。

原因

ConnectionCore::process_new_packets 的第一件事是把状态取出来;已经是 Err 就原样放回、原样返回:

        let mut state = match mem::replace(&mut self.state, Err(Error::HandshakeNotComplete)) {
            Ok(state) => state,
            Err(e) => {
                self.state = Err(e.clone());
                return Err(e);
            }
        };

处理过程中任何一条消息出错,self.state 被换成 Err(e),旧的 Box<dyn State> 连同里面的密钥日程、转录哈希一起丢掉。没有可以「回到」的状态,所以也没有重试的余地:TLS 规定致命错误之后连接必须关闭,rustls 用类型把这一点写死了。

写的方向在 0.23.46 加了同样的检查,check_error:

        fn check_error(&self) -> io::Result<()> {
            match &self.core.state {
                Ok(_) => Ok(()),
                Err(err) => Err(io::Error::new(io::ErrorKind::InvalidData, err.clone())),
            }
        }

Writer 的 write、write_vectored、flush 都先调它。

读的方向没有。Reader 只看明文缓冲和两个标志:check_no_bytes_state 在「没收到 close_notify、也没见到 EOF」时返回 WouldBlock,不看 core.state。所以只靠 reader() 判断连接是否健康的程序,看到的永远是「再等等」。

📝NOTE
冷知识:0.23.45 的 write 在出错之后还会成功

check_error 是 493d0893(2026-10-06,「conn: fuse writes for connections with errors」,没有 PR 编号,首次发布于 0.23.46)加的;紧接着的 7a69adee(「util: fuse flushes from connections with errors」)把 flush 也接了上去。pita-r45 用 rustls 0.23.45 跑同一个程序,差别只在写的那两行:

writer().write(b"GET /") -> Ok(5)
...
server writer().write(b"200 OK") -> Ok(6), then wants_write=true
server write_tls after that write -> 28 bytes, first byte Some(17)

0.23.45 的客户端在握手失败后收下了 5 字节明文,放进握手完成前的缓冲,永远不会发出去;服务端在 DecryptError 之后收下 6 字节,还加密排进了 sendable_tls:告警已经发出去之后,下一次 write_tls 又交出一条 28 字节、类型 0x17 的应用数据记录(实验在 pita-r45 里多打了这一行)。调用方从 write 的返回值上看不出任何问题。这个提交给 Writer::write 的文档加了一句:「If the connection previously encountered a fatal error ... this returns that error, even if buf is empty」。

修法

把 process_new_packets 的任何 Err 当作这个连接的终点。

  1. 再调一次 write_tls,把排队的告警发给对端(complete_io 出错时会自己做这一步,叫「last-gasp」写);
  2. 关掉套接字,丢掉连接对象;
  3. 需要的话,用新的 ClientConnection 重新连。

不要对同一个连接对象重试 process_new_packets,也不要在它上面继续 write:0.23.46 会把错误还给你,更早的版本会静默吞掉数据。

连接池要把出错的连接剔除。 池里的连接出过一次错,以后每个借到它的请求都会失败;看到 rustls 的错误(或 complete_io / Stream / tokio-rustls 包出来的 InvalidData)就不要归还。

分清能不能重连。 证书错误(InvalidCertificate)、协议错误(PeerMisbehaved、InappropriateMessage)重连多半还是一样,应该报出去;DecryptError 这类可能来自线路上的损坏,重连有意义,但同样是新连接。

怎么检查

  • 错误第二次出现时,看它是不是和第一次一模一样。 日志里同一个连接反复出现完全相同的 rustls 错误,就是代码在重试一个已经死掉的连接。
  • 在读循环里同时检查写或处理的结果。 只调 reader().read 的循环永远看不到这个错误;每次 read_tls 之后都要看 process_new_packets 的返回值。
  • 升级到 0.23.46 之后,留意新出现的 InvalidData。 以前被静默吞掉的写入,现在会在 write / flush 上报出来;它说明代码在出错的连接上继续写,原因在更早的那次错误。

小结

  • process_new_packets 一旦出错,连接状态变成 Err,之后每次调用都返回同一个错误,新来的好数据也不会被处理。
  • 0.23.46 起 writer().write / flush 也返回这个错误(493d0893、7a69adee);0.23.45 的 write 返回 Ok(n),数据被吞掉或排在告警后面。
  • reader().read 不看错误状态,只会 WouldBlock。
  • 任何错误都是终点:write_tls 发告警,关掉连接,需要就新建连接;连接池剔除出错的连接。

💬 关联讨论

发起讨论
加载中...