Rust in Everything
基于 pingora-core 0.9.0

capacity must be greater than zero

主线位置:请求主线的第一步,accept 循环把 TcpStream 转成 pingora 的 Stream。为什么先建零容量的缓冲区、set_buffer 什么时候换掉它,见 L4 流;tokio 1.54 改了什么、本卷为什么钉 tokio 1.53.2,见怎么读这一卷。本页讲撞上之后的样子和应用这边怎么挡住它。

flowchart TB
  acc["accept"] --> into["into Stream"]
  into --> buf["with_capacity 0 0"]
  buf --> ver{"tokio 版本"}
  ver -->|"1.53 及以前"| ok["set_buffer 换缓冲"]
  ver -->|"1.54 起"| pan["assert panic"]
  pan --> ep["run_endpoint 结束"]
  ep --> gone["监听 socket 关闭"]
  gone --> alive["进程仍在"]

实验 crate exp-pingora/pita-tokio154(最小反代,127.0.0.1:19200 → 19201,Cargo.lock 里 tokio 1.54.1)、exp-pingora/pita-tokiofix(同一份代码,Cargo.toml 给 tokio 加上界)和 exp-pingora/pita-gw154(本站网关的拷贝,pingora 0.8.0,tokio 换成 1.54.1);M2 Max、macOS 27、rustc 1.99.0,debug。

现象

代码就是 pingora 文档里那种最小的代理,没有任何特别之处。区别只在锁文件:今天新建项目,cargo 把 tokio = "1" 解析到 1.54.1。起代理,连发两次 curl:

curl exit=56
curl exit=7
process alive: yes
listening sockets:
(lsof exit=1)

代理的输出:

thread 'Pingora HTTP Proxy Service' (5921143) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/pingora-core-0.9.0/src/protocols/l4/stream.rs:549:26:
capacity must be greater than zero
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
[2026-10-29T06:56:49Z INFO  pingora_core::server] service 'Pingora HTTP Proxy Service' exited.
  • 第一次 curl 是连接被重置(56),第二次是连接被拒绝(7):第一个连接让监听循环 panic,监听 socket 随之关闭。
  • 进程还在,lsof -a -p <pid> -iTCP -sTCP:LISTEN 什么都列不出来。只看进程活没活的监控不会报警。
  • panic 指向 pingora 的 stream.rs:549,不是用户代码,也不是 tokio。这是 tokio 给 with_capacity 标了 #[track_caller] 的结果。

本站网关的拷贝换上 tokio 1.54.1,443 和 80 各连两次:

https exit=35
http exit=56
https exit=7
http exit=7
thread 'Pingora HTTP Proxy Service' (5921584) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/pingora-core-0.8.0/src/protocols/l4/stream.rs:473:26:
capacity must be greater than zero

0.8.0 是同一个 panic,行号不同。两个服务各死各的:每个服务的第一个连接杀掉它自己的监听。本站网关现在的锁文件是 tokio 1.52.3,没有中招,靠的是什么、哪一步会让它中招,见在本站中。

出处

impl From<TcpStream> for Stream:

impl From<TcpStream> for Stream {
    fn from(s: TcpStream) -> Self {
        Stream {
            stream: Some(BufStream::with_capacity(
                0,
                0,
                RawStreamWrapper::new(RawStream::Tcp(s)),
            )),
            // ...

tokio 1.54.0 的 #8494(dd826ef1)在 BufReader::with_capacity 开头加了 assert!(capacity > 0, "capacity must be greater than zero"),BufStream::with_capacity 的读容量就是传给它的。这次转换发生在 Listener::accept 里,也就是 run_endpoint 这个监听任务自己里面,不在每个连接各自的任务里,所以死的是整条监听。

修复在 main 上:5b30d71(2026-10-19,「Keep zero-capacity L4 read buffers working on tokio 1.54」)把缓冲换成一个两态的枚举,读容量为 0 时只留 BufWriter。提交说明写的是「On tokio 1.54.0 every accept and connect panics」:连上游的那一侧也走同样的构造,只是下游 accept 先撞上。git tag --contains 5b30d71 是空的,0.9.0 之后还没有发版。修复的写法见 L4 流。

修法

pingora 没发新版之前,只能在应用这边不让 tokio 升到 1.54。最稳的是在应用自己的 Cargo.toml 里写上界:

tokio = { version = ">=1, <1.54", features = ["full"] }

pita-tokiofix 就是 pita-tokio154 只改这一行,cargo update --offline:

     Locking 1 package to highest Rust 1.99.0 compatible version
 Downgrading tokio v1.54.1 -> v1.53.2 (available: v1.54.1)

同样两次 curl:

http=200 curl exit=0
http=200 curl exit=0

再 cargo update --offline --dry-run,输出是 Locking 0 packages:上界写在 Cargo.toml 里,以后谁执行 cargo update 都不会再把它带到 1.54。

另一种做法是只改锁文件:cargo update -p tokio --precise 1.53.2,本卷所有实验 crate 就是这样钉的。它不改 Cargo.toml,下一次不带参数的 cargo update 就会把 tokio 带回 1.54。所以锁文件只能配合 cargo build --locked(本站网关的 Dockerfile 这样做)一起用;应用自己要长期挡住它,用上界。pingora 发出带 5b30d71 的版本以后,把上界删掉。

📝NOTE
没有蠢问题

问: pingora 自己要求的是 tokio = "1",我在应用里写 <1.54,为什么能管住 pingora 用的那个 tokio?

答: 同一个依赖图里,semver 兼容的同一个大版本只会有一份 tokio,cargo 要挑一个同时满足所有人要求的版本。pingora 要「1.x」,应用要「1.x 且小于 1.54」,交集里最高的就是 1.53.2,pingora 编译时用的也是它。反过来,如果图里有另一个依赖要求 tokio >= 1.54,cargo 会直接报解析失败,而不是悄悄选一个。

怎么确认没中招

  • 看解析出来的版本:cargo tree -i tokio --depth 0。pita-tokio154 打出 tokio v1.54.1,pita-tokiofix 是 tokio v1.53.2,网关的拷贝是 tokio v1.52.3。是 1.54 或更高,而 pingora 还是 0.9.0 或更早,就会中招。
  • 部署之后发一个真实请求,而不是只看进程:这个 panic 不会让进程退出,restart 策略和只看进程的健康检查都发现不了。按端口探活,或者看日志里有没有 capacity must be greater than zero 和紧跟的 service ... exited。
  • 升级 pingora 时看 CHANGELOG 里有没有 5b30d71 那条修复;有了再去掉上界。

小结

  • tokio 1.54 起,BufReader::with_capacity(0, ..) 直接 panic;pingora 0.9.0 和 0.8.0 每个连接都这样建缓冲,第一个连接就让监听循环 panic。
  • 现象是第一次请求被重置、之后连接被拒,进程活着但不再监听;报错位置在 pingora 的 stream.rs:549(0.8.0 是 :473)。
  • 修复 5b30d71 在 main 上、没有发版;应用在 Cargo.toml 写 tokio = ">=1, <1.54",cargo update 也不会再越过它。

💬 关联讨论

发起讨论
加载中...