Rust in Everything
基于 hyper 1.12.0

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。

📝NOTE
没有蠢问题

问: 既然缺 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 不是。

这个设计有出处。

📝NOTE
冷知识:Timer 本来要做成和 Executor 一样的类型参数

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/2 keep_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 存在一个运行时才知道变体的枚举里,不是类型参数。

💬 关联讨论

发起讨论
加载中...