header_read_timeout 不生效,半个前言挂住连接
主线位置:服务端主线的入口。
accept之后,连接先在auto的ReadVersion里等最多 24 个字节,再进入http1::Connection,在Conn::poll_read_head里等请求头。两段等待各有各的计时(或者没有计时),这一页量的就是两段各能挂多久。
flowchart TB acc["accept"] --> rv["ReadVersion:等前 24 字节"] rv -->|"认出 HTTP/1"| head["poll_read_head:等请求头"] rv -->|"认出 HTTP/2"| h2["HTTP/2 连接"] head --> svc["调用服务"] svc --> head rv -.- t0["没有任何超时"] head -.- t1["header_read_timeout:要有 timer"]
这个坑没有报错。表现是服务端上开着的连接越来越多,一部分是从没发完请求头的,一部分是发了一个请求就不再说话的 keep-alive 连接,它们都不会被服务端关掉。
复现
三个服务端:axum::serve 用默认配置;自己写的 accept 循环,auto::Builder 两半都给了 TokioTimer(header_read_timeout 用默认的 30 秒);同样的循环再加一个「首个请求 10 秒内必须到」的期限。每个服务端接四个裸 TCP 客户端:什么都不发;发半个请求头;发 HTTP/2 连接前言的前 16 个字节;发一个完整的请求,收完响应后不再发东西。客户端最多等 35 秒,看服务端什么时候关连接。
axum = "=0.8.9"
hyper = { version = "=1.12.0", features = ["server", "http1", "http2"] }
hyper-util = { version = "=0.1.21", features = ["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", "io-util"] }
use std::convert::Infallible;
use std::net::SocketAddr;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use bytes::Bytes;
use http_body_util::Full;
use hyper::service::service_fn;
use hyper::{Request, Response};
use hyper_util::rt::{TokioExecutor, TokioIo, TokioTimer};
use hyper_util::server::conn::auto;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
async fn hello(_: Request<hyper::body::Incoming>) -> Result<Response<Full<Bytes>>, Infallible> {
Ok(Response::new(Full::new(Bytes::from_static(b"ok"))))
}
// 1. axum::serve with its defaults.
async fn axum_server() -> SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let app = axum::Router::new().route("/", axum::routing::get(|| async { "ok" }));
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
addr
}
// 2. Own accept loop: auto::Builder with a timer on both halves.
// 3. The same, plus a deadline for the first request to reach the service.
async fn own_server(first_request_deadline: Option<Duration>) -> SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let (stream, _) = listener.accept().await.unwrap();
tokio::spawn(async move {
let mut builder = auto::Builder::new(TokioExecutor::new());
builder.http1().timer(TokioTimer::new()); // header_read_timeout: default 30s
builder.http2().timer(TokioTimer::new());
let seen = Arc::new(AtomicBool::new(false));
let seen2 = seen.clone();
let svc = service_fn(move |req| {
seen2.store(true, Ordering::Relaxed);
hello(req)
});
let conn = builder.serve_connection_with_upgrades(TokioIo::new(stream), svc);
tokio::pin!(conn);
let Some(deadline) = first_request_deadline else {
let _ = conn.await;
return;
};
tokio::select! {
_ = conn.as_mut() => {}
_ = tokio::time::sleep(deadline) => {
if !seen.load(Ordering::Relaxed) {
return; // drop the connection: no request in time
}
let _ = conn.await;
}
}
});
}
});
addr
}
// Send `bytes`, then wait up to 35s for the server to close the connection.
async fn probe(addr: SocketAddr, bytes: &'static [u8]) -> String {
let start = Instant::now();
let mut tcp = TcpStream::connect(addr).await.unwrap();
tcp.write_all(bytes).await.unwrap();
let mut got = 0;
let mut buf = [0u8; 1024];
loop {
let left = Duration::from_secs(35).saturating_sub(start.elapsed());
match tokio::time::timeout(left, tcp.read(&mut buf)).await {
Ok(Ok(0)) => return format!("closed at {:.1}s ({got} bytes)", start.elapsed().as_secs_f64()),
Ok(Ok(n)) => got += n,
Ok(Err(e)) => return format!("{} at {:.1}s", e.kind(), start.elapsed().as_secs_f64()),
Err(_) => return format!("open at 35s ({got} bytes)"),
}
}
}
const CASES: [(&str, &[u8]); 4] = [
("nothing", b""),
("half head", b"GET / HTTP/1.1\r\nHost: x\r\n"),
("half preface", b"PRI * HTTP/2.0\r\n"),
("1 request, idle", b"GET / HTTP/1.1\r\nHost: x\r\n\r\n"),
];
#[tokio::main]
async fn main() {
let servers = [
("axum::serve", axum_server().await),
("auto + timer", own_server(None).await),
("auto + timer + 10s first-request deadline", own_server(Some(Duration::from_secs(10))).await),
];
let mut jobs = Vec::new();
for (name, addr) in servers {
for (case, bytes) in CASES {
jobs.push((name, case, tokio::spawn(probe(addr, bytes))));
}
}
let mut last = "";
for (name, case, job) in jobs {
if name != last {
println!("{name}:");
last = name;
}
println!(" {case:<16} {}", job.await.unwrap());
}
}
M2 Max、rustc 1.99.0、--release,连跑 3 次输出相同:
axum::serve:
nothing open at 35s (0 bytes)
half head open at 35s (0 bytes)
half preface open at 35s (0 bytes)
1 request, idle open at 35s (118 bytes)
auto + timer:
nothing open at 35s (0 bytes)
half head closed at 30.0s (0 bytes)
half preface open at 35s (0 bytes)
1 request, idle closed at 30.0s (77 bytes)
auto + timer + 10s first-request deadline:
nothing closed at 10.0s (0 bytes)
half head closed at 10.0s (0 bytes)
half preface closed at 10.0s (0 bytes)
1 request, idle closed at 30.0s (77 bytes)
axum::serve 下四种连接 35 秒后都还开着,包括已经拿到 118 字节响应、之后什么都不发的那条。给了 timer,半个请求头和空闲的 keep-alive 连接在 30.0 秒被关掉,但什么都不发、发了半个前言的两条仍然开着。再加上首个请求的期限,前三种都在 10.0 秒被关掉。
为什么:两道闸,一道没有锁
第一道:header_read_timeout 要有 timer。 http1::Builder::new() 里读头超时是 Dur::Default(Some(30 秒)),serve_connection 用 Time::check 决定要不要设:timer 是 Empty 时返回 None,这条连接就没有读头超时(Executor 与 Timer 有完整的表)。axum::serve 在 axum 0.8.9 的 serve/mod.rs 里为每条连接建 builder:
tokio::spawn(async move {
#[allow(unused_mut)]
let mut builder = Builder::new(TokioExecutor::new());
// CONNECT protocol needed for HTTP/2 websockets
#[cfg(feature = "http2")]
builder.http2().enable_connect_protocol();
let mut conn = pin!(builder.serve_connection_with_upgrades(io, hyper_service));
只有执行器,没有 timer,也没有给调用方留改 builder 的口子。auto::Builder::new 里面就是 http1::Builder::new() 和 http2::Builder::new(executor),所以 30 秒的默认值在 axum 下从来没有生效过。
这个超时同时也是 keep-alive 连接的空闲上限:一个请求处理完,连接回到读头状态,Conn::poll_read_head 重新开始计时。所以「1 request, idle」那一行在有 timer 时 30 秒被关,没 timer 时一直开着。hyper 的 HTTP/1 服务端没有别的空闲超时。
第二道:ReadVersion 没有超时。 读头计时是在 poll_read_head 里开始的,要先有 http1::Connection;而 auto 在建 http1::Connection 之前,要先由 ReadVersion 读字节判断协议,它的 poll 里只有 poll_read(auto:一个端口两个协议逐行读过)。一个字节都不发,或者发的字节和 HTTP/2 前言一直相符(PRI * HTTP/2.0\r\n 是前言的前 16 个字节),连接就停在这一步,给了 timer 也没用。axum::serve 用的是 serve_connection_with_upgrades,它不看 http1_only / http2_only,总是先走 ReadVersion(同一页的「http1_only 与 http2_only」)。
hyper 的 issue #3962(「hyper_util: expose a timeout on ReadVersion in server::conn::auto」)写的就是这一页的第二道闸:「with auto there is no timer on the ReadVersion, which makes this timer unreachable until the ReadVersion is complete」,「The impact of this is that the user can open up a connection indefinitely without sending any data which is a DoS vector.」
维护者的回复提出了不同的修法:不预先读版本,而是「similar to how hyper did it in 0.14.x, using poll_without_shutdown, if the error is about the version, use into_parts and then convert into http2」,也就是先当 HTTP/1 处理(读头超时从第一个字节起就生效),解析出错且是 HTTP/2 前言时再转成 HTTP/2。hyper 缺的两块 API 由 PR #3996(「Add APIs to allow switching an HTTP1 connection to HTTP2 if H2 preface is seen」)补上:Error::is_parse_version_h2 和 http1::UpgradeableConnection::into_parts,第一个包含它们的发布是 v1.9.0。hyper-util 这一侧还没跟上:给 ReadVersion 加超时的两个 PR(hyper-util #294、#337,标题都是「feat(server): bound auto protocol detection with read_version_timeout」)都没有合并就关了,issue 仍然开着,0.1.21 的 auto 里没有任何读版本的超时。
修法
用 axum 时: axum::serve 改不了 builder。要么在前面放一个会对慢连接设上限的反向代理(本站的做法,见本站的服务端路径),要么不用 axum::serve,自己写 accept 循环,把 axum 的 Router 交给 hyper-util(TowerToHyperService,见 Service 与 Body)。
自己写 accept 循环时: 两步。
- 两半都给 timer,让默认的 30 秒读头超时(以及它附带的 keep-alive 空闲上限)生效。也可以显式设
header_read_timeout,那时没给 timer 会 panic(上一页)。 - 给「首个请求到达」设一个期限,补上
ReadVersion那一段。上面程序里的写法是:服务被调用时置一个标志,select!让连接和一个sleep赛跑;sleep先到而标志还没置,就 drop 连接 future,socket 随之关闭;标志已经置了,就接着等连接自己结束。
第 2 步只管第一个请求之前的那段时间。之后 HTTP/1 连接上每个请求头的读取由第 1 步的 header_read_timeout 管。期限从 serve_connection 开始算,要比正常客户端发出第一个请求所需的时间宽裕得多;TLS 握手如果在这之前单独 await,那一步要另外套超时。
小结
axum::serve(0.8.9)和auto::Builder::new都不设 timer,HTTP/1 默认 30 秒的header_read_timeout不生效:半个请求头的连接、空闲的 keep-alive 连接都一直开着,没有任何报错。- 给了 timer,
auto的ReadVersion这一步仍然没有超时:什么都不发、或者一直发和 HTTP/2 前言相符的字节,连接就停在这里。 - 修法:自己的 accept 循环里两半都给
TokioTimer,再给首个请求加一个期限;用axum::serve时只能靠前置代理。 - issue #3962 还开着;hyper v1.9.0 已经有了换一种做法所需的 API,hyper-util 0.1.21 还没用上。