Rust in Everything
基于 rustls 0.23.46

write 返回 Ok(0),read_tls 报 received plaintext buffer full

主线位置:记录主线的两头各有一个有上限的缓冲。出口是 writer() 写进去、write_tls 取走的 sendable_tls(握手前是 sendable_plaintext);入口是 process_new_packets 解出来、reader() 取走的 received_plaintext。上限怎么算(握手后比较的是密文)见发送路径,接收侧为什么在 32768 而不是 16384 报错见去帧与缓冲。

flowchart TB
  w["writer write"] --> st["sendable_tls 64 KiB"]
  st -->|write_tls| net["套接字"]
  st -->|满| zero["Ok 0 或 WriteZero"]
  net2["套接字"] -->|read_tls| pnp["process_new_packets"]
  pnp --> rp["received_plaintext 16 KiB"]
  rp -->|reader read| app["应用"]
  rp -->|超过上限| full["read_tls 报 buffer full"]

实验 crate:exp-rustls/pita-lab,src/bin/p08_buffers.rs。一次写 200000 字节。M2 Max、macOS 27、rustc 1.99.0,release,ring provider,TLS 1.3,TLS13_AES_256_GCM_SHA384,内存握手。

现象

发送侧。 自己驱动 sans-IO 循环、想「先把数据交给 rustls,再统一发」:

== send side, after the handshake
write(200000) -> Ok(65536)
write(200000) again -> Ok(0)
write_all(200000) -> Err kind WriteZero, Display: failed to write whole buffer

== send side, before the handshake
is_handshaking=true
write(200000) -> Ok(65536)
write(1) -> Ok(0)
  • write 只收下 65536 字节,再写就是 Ok(0)。不报错,也不阻塞。
  • write_all 把 Ok(0) 当成「写不进去了」,报 WriteZero。很多人第一次看到这个错误会以为是套接字出了问题,其实套接字一个字节都还没碰。
  • 握手完成之前写也一样,只是放进的是另一个缓冲。

接收侧。 循环里只做 read_tls + process_new_packets,打算最后再读明文:

== receive side: process_new_packets but never reader()
read_tls #1 -> 4096, plaintext_bytes_to_read 0
read_tls #2 -> 4096, plaintext_bytes_to_read 0
read_tls #3 -> 4096, plaintext_bytes_to_read 0
read_tls #4 -> 4096, plaintext_bytes_to_read 0
read_tls #5 -> 2053, plaintext_bytes_to_read 16384
read_tls #6 -> 16406, plaintext_bytes_to_read 32768
read_tls #7 -> Err kind Other, Display: received plaintext buffer full; plaintext buffered 32768, wants_read false

第 7 次 read_tls 报错,错误的 kind 是 Other。这时缓冲里已经有两条完整记录的明文(2 × 16384),wants_read() 早就是 false 了。

原因

发送侧是 writer() 背后的 ChunkVecBuffer 有上限,默认 DEFAULT_BUFFER_LIMIT = 64 KiB;apply_limit 按剩余空间截短这次写入,空间为 0 就收下 0 字节。Writer 实现的是 io::Write,Ok(0) 是它表达「现在放不下」的唯一方式。标准库的 write_all 规定遇到 Ok(0) 就返回 WriteZero,因为对普通的 writer 来说那意味着永远写不进去;但这里只要调一次 write_tls 腾出空间,就又能写了。

接收侧的检查在 read_tls 入口:

    pub fn read_tls(&mut self, rd: &mut dyn io::Read) -> Result<usize, io::Error> {
        if self.received_plaintext.is_full() {
            return Err(io::Error::new(
                io::ErrorKind::Other,
                "received plaintext buffer full",
            ));
        }
        // ...

上限 DEFAULT_RECEIVED_PLAINTEXT_LIMIT 是 16 KiB,is_full 判断的是严格大于。process_new_packets 不受这个上限约束,一次解出几条就放几条;所以第 5 次之后正好 16384(不算满),第 6 次读进一整条 16406 字节的记录后变成 32768,第 7 次才被拒。这个错误是背压(backpressure):rustls 要调用方先把明文取走,再给它更多密文。

两个上限都只在调用方不按 wants_write() / wants_read() 行事时才会撞上。Stream、tokio-rustls 这类封装每写一段就冲一次 sendable_tls,有明文没读就不再读套接字,所以用它们的程序看不到这两个错误。

📝NOTE
冷知识:接收侧的上限是 0.20.8 才加的

ad61765e(2022-10-23,「Exert backpressure when the received_plaintext buffer is full」,没有 PR 编号,首次发布于 0.20.8)之前,received_plaintext 用 ChunkVecBuffer::new(Some(0)) 构造,read_tls 不看它:调用方只管 read_tls 和 process_new_packets、不读明文时,解出来的明文没有任何地方拦着。这个提交加了今天这段检查和 DEFAULT_RECEIVED_PLAINTEXT_LIMIT = 16 * 1024,并把 read_tls 文档里的说法改成:返回 ErrorKind::Other 是在「signal backpressure」,要清空明文缓冲,「you should empty it through the [reader()] after the call to [process_new_packets()]」。上面第 7 次的报错就来自这次修改。

修法

发送:写一段、交出一段。 每次 write 之后把 sendable_tls 冲到套接字,再写剩下的:

let mut off = 0;
while off < big.len() {
  off += conn.writer().write(&big[off..])?;
  while conn.wants_write() {
    conn.write_tls(&mut sock)?;
  }
}
== fix 1: write a chunk, write_tls, repeat
sent 200000 plaintext bytes in 4 write/write_tls rounds, 200286 TLS bytes

200000 字节明文用了 4 轮(每轮最多 65536 字节),变成 13 条记录、200286 字节密文(每条多 5 字节头、1 字节内层类型、16 字节标签)。真实套接字写不进去时 write_tls 会返回 WouldBlock,这时等套接字可写,不要再往 writer() 里塞。

发送:取消上限。 确定内存由自己控制时:

== fix 2: set_buffer_limit(None)
write_all(200000) -> Ok(()), tls_bytes_to_write 200286

set_buffer_limit 同时作用于握手前后的两个发送缓冲。None 之后整块数据会以密文形式全部留在内存里,直到 write_tls 交出去;对端读得慢时,这块内存就一直在。

接收:每次 process_new_packets 之后,把 reader() 读到 WouldBlock 为止。

== fix: drain reader() after every process_new_packets
received 200000 plaintext bytes

接收侧没有「取消上限」的接口,上限是固定的;唯一的办法是及时读。

怎么检查

  • WriteZero 出现在 rustls 的 writer() 上,而不是套接字上:检查这次 write_all 之前有没有 write_tls。打印 process_new_packets() 返回的 IoState::tls_bytes_to_write(),在 65536 附近就是撞上了上限。
  • received plaintext buffer full:它的 kind 是 Other,按 kind 分类错误的代码容易把它归进「未知错误」。看到它,找读循环里漏掉 reader() 的分支;IoState::plaintext_bytes_to_read() 会是 16384 以上。
  • 用 wants_read() 门住 read_tls:它在有明文没读时返回 false,按它驱动的循环不会撞上接收侧的错误。

小结

  • 发送缓冲默认 64 KiB:满了 write 返回 Ok(0),write_all 报 WriteZero: failed to write whole buffer,握手前后都一样,不阻塞。
  • 接收侧明文缓冲上限 16 KiB(严格大于才算满):不读明文,read_tls 报 received plaintext buffer full(kind Other),实验里在缓冲 32768 字节时。
  • 发送侧:写一段就 write_tls,或 set_buffer_limit(None) 自己管内存。接收侧:每次处理后把 reader() 读空。
  • 接收侧的背压检查从 0.20.8(ad61765e)开始才有。

💬 关联讨论

发起讨论
加载中...