You must supply a timer. 与 timeout header_read_timeout set, but no timer set
主线位置:两条主线建连接的那一步。服务端在
serve_connection(HTTP/1)或 HTTP/2 握手后建 ping 通道时检查 timer,客户端在handshake里建 ping 通道时检查;没有 timer 的那一刻就 panic,panic 发生在 poll 这个连接 future 的任务里。
flowchart TB
optnode["设了要计时的选项"] --> which{"哪一种"}
which -->|"h2 keep-alive"| sleepnode["Time::sleep"]
which -->|"h1 显式读头超时"| checknode["Time::check"]
sleepnode --> empty1{"timer 是 Empty?"}
checknode --> empty2{"timer 是 Empty?"}
empty1 -->|是| p1["You must supply a timer."]
empty2 -->|是| p2["no timer set"]
p1 --> dead["这条连接的任务死掉"]
p2 --> dead
两条 panic 信息都在 common/time.rs:Time::sleep 在 Time::Empty 时 panic!("You must supply a timer."),Time::check 在「用户显式配置了超时但没有 timer」时 panic!("timeout `{name}` set, but no timer set")。Time 是什么、哪些方法会 panic、默认超时为什么不 panic 而是失效,Executor 与 Timer 已经用 http1::Builder、http2::Builder 逐项试过。这一页看它在 auto::Builder 和 legacy Client 里是什么样子,那是大多数人真正写的代码。
复现
服务端照 axum::serve 的样子写:accept 一条连接就 spawn 一个任务,任务里 auto::Builder::serve_connection。额外开一个任务等连接任务的 JoinHandle,报告它是正常结束、返回错误还是 panic。每种配置各用一个 HTTP/1 客户端和一个 HTTP/2 先验知识客户端连一次。最后一项是 legacy Client 开 HTTP/2 keep-alive。
hyper = { version = "=1.12.0", features = ["client", "server", "http1", "http2"] }
hyper-util = { version = "=0.1.21", features = ["client-legacy", "server-auto", "http1", "http2", "tokio"] }
http-body-util = "=0.1.5"
bytes = "1"
tokio = { version = "=1.54.1", features = ["macros", "rt-multi-thread", "net", "time", "sync"] }
use std::convert::Infallible;
use std::sync::Mutex;
use std::time::Duration;
use bytes::Bytes;
use http_body_util::{Empty, Full};
use hyper::client::conn::{http1 as client_h1, http2 as client_h2};
use hyper::service::service_fn;
use hyper::{Request, Response};
use hyper_util::client::legacy::Client;
use hyper_util::rt::{TokioExecutor, TokioIo, TokioTimer};
use hyper_util::server::conn::auto;
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::mpsc;
static PANIC_MSG: Mutex<Option<String>> = Mutex::new(None);
async fn hello(_: Request<hyper::body::Incoming>) -> Result<Response<Full<Bytes>>, Infallible> {
Ok(Response::new(Full::new(Bytes::from_static(b"ok"))))
}
fn panic_text() -> String {
PANIC_MSG.lock().unwrap().take().unwrap_or_default()
}
// An accept loop like axum::serve's: one task per connection; report how each task ended.
async fn spawn_server(make: fn() -> auto::Builder<TokioExecutor>) -> (std::net::SocketAddr, mpsc::UnboundedReceiver<String>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = mpsc::unbounded_channel();
tokio::spawn(async move {
loop {
let (stream, _) = listener.accept().await.unwrap();
let task = tokio::spawn(async move {
make().serve_connection(TokioIo::new(stream), service_fn(hello)).await.map_err(|e| e.to_string())
});
let tx = tx.clone();
tokio::spawn(async move {
let s = match task.await {
Ok(Ok(())) => "Ok(())".to_string(),
Ok(Err(e)) => format!("Err({e})"),
Err(e) if e.is_panic() => format!("panic: {}", panic_text()),
Err(e) => format!("{e}"),
};
let _ = tx.send(s);
});
}
});
(addr, rx)
}
async fn h1_get(addr: std::net::SocketAddr) -> String {
let io = TokioIo::new(TcpStream::connect(addr).await.unwrap());
let (mut tx, conn) = match client_h1::handshake::<_, Empty<Bytes>>(io).await {
Ok(p) => p,
Err(e) => return format!("Err({e})"),
};
tokio::spawn(conn);
match tx.send_request(Request::get("/").header("host", "x").body(Empty::new()).unwrap()).await {
Ok(r) => format!("{}", r.status()),
Err(e) => format!("Err({e})"),
}
}
async fn h2_get(addr: std::net::SocketAddr) -> String {
let io = TokioIo::new(TcpStream::connect(addr).await.unwrap());
let (mut tx, conn) = match client_h2::handshake::<_, _, Empty<Bytes>>(TokioExecutor::new(), io).await {
Ok(p) => p,
Err(e) => return format!("Err({e})"),
};
tokio::spawn(conn);
match tx.send_request(Request::get("http://x/").body(Empty::new()).unwrap()).await {
Ok(r) => format!("{}", r.status()),
Err(e) => format!("Err({e})"),
}
}
async fn server_case(name: &str, make: fn() -> auto::Builder<TokioExecutor>) {
println!("{name}");
let (addr, mut rx) = spawn_server(make).await;
for (proto, client) in [("h1", h1_get(addr).await), ("h2", h2_get(addr).await)] {
let server = tokio::time::timeout(Duration::from_millis(500), rx.recv()).await;
let server = match server {
Ok(Some(s)) => s,
_ => "(still running)".to_string(),
};
println!(" {proto} client: {client:<24} server conn: {server}");
}
}
#[tokio::main]
async fn main() {
std::panic::set_hook(Box::new(|info| {
let loc = info.location().map(|l| {
let file = l.file();
let short = file.find("hyper-1.12.0/").map_or(file, |i| &file[i..]);
format!("{short}:{}", l.line())
});
let msg = info
.payload()
.downcast_ref::<String>()
.cloned()
.or_else(|| info.payload().downcast_ref::<&str>().map(|s| s.to_string()))
.unwrap_or_default();
*PANIC_MSG.lock().unwrap() = Some(format!("{msg} at {}", loc.unwrap_or_default()));
}));
server_case("A http2().keep_alive_interval, no timer", || {
let mut b = auto::Builder::new(TokioExecutor::new());
b.http2().keep_alive_interval(Duration::from_secs(10));
b
})
.await;
server_case("B timer on http1() only, http2().keep_alive_interval", || {
let mut b = auto::Builder::new(TokioExecutor::new());
b.http1().timer(TokioTimer::new());
b.http2().keep_alive_interval(Duration::from_secs(10));
b
})
.await;
server_case("C http1().header_read_timeout, no timer", || {
let mut b = auto::Builder::new(TokioExecutor::new());
b.http1().header_read_timeout(Duration::from_secs(5));
b
})
.await;
server_case("D both, timer on both halves", || {
let mut b = auto::Builder::new(TokioExecutor::new());
b.http1().timer(TokioTimer::new()).header_read_timeout(Duration::from_secs(5));
b.http2().timer(TokioTimer::new()).keep_alive_interval(Duration::from_secs(10));
b
})
.await;
// legacy Client: HTTP/2 keep-alive without a timer
println!("E legacy Client, http2_keep_alive_interval, no timer");
let (addr, _rx) = spawn_server(|| auto::Builder::new(TokioExecutor::new())).await;
let client = Client::builder(TokioExecutor::new())
.http2_only(true)
.http2_keep_alive_interval(Duration::from_secs(10))
.build_http::<Empty<Bytes>>();
let url: hyper::Uri = format!("http://{addr}/").parse().unwrap();
let r = tokio::spawn(async move { client.get(url).await.map(|r| r.status()) }).await;
match r {
Ok(Ok(s)) => println!(" request: {s}"),
Ok(Err(e)) => println!(" request: Err({e:?})"),
Err(e) if e.is_panic() => println!(" request task: panic: {}", panic_text()),
Err(e) => println!(" request task: {e}"),
}
}
M2 Max、rustc 1.99.0、--release,连跑 3 次输出相同:
A http2().keep_alive_interval, no timer
h1 client: 200 OK server conn: Ok(())
h2 client: Err(connection error) server conn: panic: You must supply a timer. at hyper-1.12.0/src/common/time.rs:38
B timer on http1() only, http2().keep_alive_interval
h1 client: 200 OK server conn: Ok(())
h2 client: Err(connection error) server conn: panic: You must supply a timer. at hyper-1.12.0/src/common/time.rs:38
C http1().header_read_timeout, no timer
h1 client: Err(connection error) server conn: panic: timeout `header_read_timeout` set, but no timer set at hyper-1.12.0/src/common/time.rs:84
h2 client: 200 OK server conn: Ok(())
D both, timer on both halves
h1 client: 200 OK server conn: Ok(())
h2 client: 200 OK server conn: Ok(())
E legacy Client, http2_keep_alive_interval, no timer
request task: panic: You must supply a timer. at hyper-1.12.0/src/common/time.rs:38
读输出
A:只有一种客户端触发。 HTTP/1 客户端一切正常,HTTP/2 客户端一连上,服务端握手完成、ping::channel 里 timer.sleep(interval) 就 panic。用 curl 默认参数、浏览器打明文端口测,走的都是 HTTP/1,测不出来;上线后第一个 HTTP/2 客户端(gRPC、带先验知识的代理、ALPN 选了 h2 的 TLS 客户端)才撞上。
B:timer 是分两半设的。 auto::Builder 自己没有 timer 方法,只有 Http1Builder::timer 和 Http2Builder::timer,分别写进里面的 http1::Builder 和 http2::Builder。B 只给了 HTTP/1 那一半,HTTP/2 的 Time 仍是 Empty,结果和 A 一样。
C:panic 推迟到第一条 HTTP/1 连接。 用 http1::Builder 时,这个 panic 发生在调用 serve_connection 的地方。auto 下,http1::Builder::serve_connection 要等 ReadVersion 认出 HTTP/1 以后,在 Connection::poll 里才调用,于是 panic 落在每一条 HTTP/1 连接的任务里。HTTP/2 客户端不受影响。
三种 panic 都只杀掉一条连接。 连接任务是 accept 循环 spawn 出来的,tokio 把任务里的 panic 交给它的 JoinHandle,accept 循环照常运行。客户端看到的只是 connection error(服务端的 socket 随任务一起被 drop)。进程不退出,健康检查如果走另一种协议,也一直是绿的;日志里只有默认 panic hook 打到 stderr 的那一行。
E:legacy Client 在调用者的任务里 panic。 legacy Client 的握手在发请求的那个任务里做(connect_to 里 h2_builder.handshake(io).await),HTTP/2 握手建 ping 通道时 panic,炸掉的是调用 client.get(...) 的任务。客户端的 Builder::timer 只转给 HTTP/2 的 builder;它和 pool_timer 是两个东西,后者给连接池的后台清理用(连接池),设了 pool_timer 不能免掉这个 panic。
问: 既然缺 timer 一定会 panic,为什么不做成编译错误?
答: 因为 builder 上没有一个表示 timer 的类型参数。timer 存在 crate 私有的 Time 枚举里,Time::Timer(Arc<dyn Timer + Send + Sync>) 或者 Time::Empty,http1::Builder、http2::Builder<E> 的类型里看不出有没有设过。编译器只能看到类型,看不到「这个字段在运行时是哪个变体」。Executor 是类型参数(http2::Builder<E>),所以 HTTP/2 没给执行器写不出来;timer 不是。
这个设计有出处。
hyper 的 issue #2847(「Add Timer generic to Connection builders」)原文是:「Similar to the E: Executor generic, add a T: Timer parameter to the server and client connection builders.」
最后合进去的 PR #2974(「feat(rt): add Timer trait」,说明是「This adds a hyper::rt::Timer trait, and it is used in connection builders to configure a custom timer source for timeouts.」)关掉了这个 issue,但没有加类型参数:它新建了 common/time.rs,里面就是 Time::Timer(Arc<dyn Timer + Send + Sync>) 和 Time::Empty 两个变体,sleep 等方法对 Empty 写的正是今天这句 panic!("You must supply a timer.")。在这之前,HTTP/2 的 ping 直接调 tokio::time::sleep(interval),用的就是 tokio 的计时器,不存在「没给 timer」这回事。第一个包含它的发布是 v1.0.0-rc.1,正式版是 v1.0.0。后来「默认值缺 timer 只警告、显式配置才 panic」那一层(Time::check)是另一次改动,见 Executor 与 Timer 的冷知识。
修法
两半各给一个 timer,D 那一项就是:
let mut builder = auto::Builder::new(TokioExecutor::new());
builder.http1().timer(TokioTimer::new());
builder.http2().timer(TokioTimer::new());
legacy Client 开 HTTP/2 keep-alive 时加 .timer(TokioTimer::new());要后台清理空闲连接,另加 .pool_timer(TokioTimer::new())。
两半都给 timer,代价只是多一个 Arc。要注意的是行为会变:HTTP/1 那一半有了 timer,默认 30 秒的 header_read_timeout 才真正生效,读头慢于 30 秒的连接、空闲超过 30 秒的 keep-alive 连接会被关掉。这通常正是想要的,没有 timer 时它不生效,是下一页的主题:header_read_timeout 不生效,半个前言挂住连接。
小结
You must supply a timer.:开了需要sleep的选项(HTTP/2keep_alive_interval)却没给 timer。timeout `header_read_timeout` set, but no timer set:显式设了 HTTP/1 读头超时却没给 timer。auto::Builder的 timer 要在http1()和http2()上各设一次,只设一半,另一种协议的连接照样 panic。- panic 在连接任务里,只杀这一条连接,服务进程和别的协议的连接都不受影响,所以容易长期不被发现。legacy
Client的 panic 在发请求的任务里。 - 缺 timer 不是编译错误,因为 timer 存在一个运行时才知道变体的枚举里,不是类型参数。