本站的服务端路径
主线位置:服务端主线的入口和出口。字节 → 请求 → 响应 → 字节这一段,本站全交给 hyper;本页看的是两头:谁 accept 连接、给
auto::Builder设了什么,以及进程退出时连接上还没写完的响应怎么办。
flowchart TB mainrs["main.rs:dioxus::serve"] --> sr["serve_router"] sr -->|release| axs["axum::serve"] sr -->|debug| devloop["自己的 accept 循环"] axs --> hc["handle_connection"] hc --> ab["auto::Builder::new"] devloop --> ab ab --> rv["ReadVersion:无超时"] rv --> h1["http1 Connection:无 timer"] rv --> h2["http2 Connection"] h1 --> tth["TowerToHyperService"] h2 --> tth tth --> router["axum Router"] gw["Pingora 网关"] -.->|"127.0.0.1:8080"| axs
本页的站点源码链接都指向本站仓库的提交 89a61b1。那个提交的 Cargo.lock 锁的是 dioxus-server 0.7.9、axum 0.8.9、hyper 1.10.1、hyper-util 0.1.20;不是本卷的 1.12.0 / 0.1.21,差别放在本页最后一节。依赖的源码按「crate + 版本 + 文件」引用,hyper-util 的链接仍指向 0.1.21。
从 main.rs 到 axum::serve
站点的入口在 crates/app/src/main.rs 第 186 行:
dioxus::serve(|| async move {
use axum::routing::get;
// ...
let router = dioxus::server::router(App)
// S3:健康检查(ok/degraded),供 LB / 监控 / 部署脚本判活。
.route("/healthz", get(crate::server::health::healthz));
// ...
Ok(router)
});
闭包只负责组装 Router(四层中间件见 tower 卷的 本站的中间件栈),监听和服务连接都在 dioxus-server 里。dioxus-server 0.7.9 的 src/launch.rs,serve 建一个多线程 tokio 运行时,block_on 调 serve_router:
pub async fn serve_router(
mut serve_callback: impl FnMut() -> Pin<Box<dyn Future<Output = Result<Router, anyhow::Error>>>>,
addr: SocketAddr,
) {
dioxus_logger::initialize_default();
let listener = TcpListener::bind(addr)
.await
.with_context(|| format!("Failed to bind to address {addr}"))
.unwrap();
// If we're not in debug mode, just serve the app normally
if !cfg!(debug_assertions) {
axum::serve(listener, serve_callback().await.unwrap())
.await
.unwrap();
return;
}
// ...
地址来自 dioxus-cli-config 0.7.9 的 fullstack_address_or_localhost():读 IP、PORT 环境变量,默认 127.0.0.1:8080。镜像里 Dockerfile 设的是 IP=0.0.0.0、PORT=8080。
分支只看 cfg!(debug_assertions)。生产镜像用 dx bundle --platform web --release 构建,release 不开 debug 断言,所以线上走的是第一支:axum::serve(listener, router),没有任何额外配置。
debug 构建(本地 dx serve 默认如此)走下面那个手写的 accept 循环,为的是在同一个 select! 里接收热补丁消息。它对每条连接做的事和 axum 差不多:
// upgrades needed for websockets
let builder = HyperBuilder::new(TokioExecutor::new());
let connection = builder.serve_connection_with_upgrades(
tcp_stream,
TowerToHyperService::new(
make_service
.call(())
.await
.unwrap()
.map_request(|req: Request<Incoming>| req.map(Body::new)),
),
);
tokio::select! {
res = connection => {
// ...
}
_res = shutdown_rx.recv() => {}
}
和 release 路径比,有两处不同:没调 http2().enable_connect_protocol();收到热补丁时 shutdown_tx.send(()),每条连接的 select! 走第二支,connection future 被直接 drop,不经过 graceful_shutdown。开发时改一次代码,正在进行的请求会断,这是有意为之的取舍,线上碰不到。
axum::serve 给每条连接设了什么
axum 0.8.9 src/serve/mod.rs 的 handle_connection,每 accept 一条连接调一次:
let hyper_service = TowerToHyperService::new(tower_service);
let signal_tx = signal_tx.clone();
let close_rx = close_rx.clone();
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));
let mut signal_closed = pin!(signal_tx.closed().fuse());
loop {
tokio::select! {
result = conn.as_mut() => {
if let Err(_err) = result {
trace!("failed to serve connection: {_err:#}");
}
break;
}
_ = &mut signal_closed => {
trace!("signal received in task, starting graceful shutdown");
conn.as_mut().graceful_shutdown();
}
}
}
drop(close_rx);
});
dioxus-server 0.7.9 依赖 axum 时开了 http1、http2、ws 等特性,所以上面 #[cfg(feature = "http2")] 那一行在本站是编译进去的。逐项对照:
| 环节 | 本站的取值 | 效果 |
|---|---|---|
| 协议 | auto::Builder::new + serve_connection_with_upgrades | 先由 ReadVersion 判断 HTTP/1 还是 HTTP/2,见 auto |
| 执行器 | TokioExecutor | 只有 HTTP/2 用它 spawn 流任务,见 Executor 与 Timer |
| timer | 没设 | header_read_timeout 的默认 30 秒静默失效 |
| HTTP/2 | enable_connect_protocol() | 允许 HTTP/2 上用扩展 CONNECT 建 WebSocket |
| 其余 HTTP/1、HTTP/2 选项 | 全是默认 | keep-alive 开、读缓冲上限 417 792 字节等,见 服务端 Builder |
| 服务 | TowerToHyperService 包住 Router | 每个请求克隆一次服务,见 Service 与 Body 和 tower 卷的 Steer 与 MakeService |
axum 0.8.9 的 Serve 公开的方法只有 with_graceful_shutdown 和 local_addr,拿不到里面的 Builder。想设 timer 或改任何 hyper 选项,只能像 dioxus-server 的 debug 分支那样自己写 accept 循环。
没有 timer:慢连接不会被关
后果在本卷已经讲过两次:Time::check 遇到「默认值 + 没有 timer」只打一条(普通构建里根本不存在的)warn! 就返回 None,见 Executor 与 Timer;ReadVersion 读连接前言这一步本来就没有超时,有 timer 也管不到,见 auto。现象和复现见常见坑 header_read_timeout 不生效。
这里只在本站的配置上核对一次:一边是 axum::serve,一边是同样的 auto::Builder 只多一句 http1().timer(TokioTimer::new())。每边两个客户端,一个连上什么都不发,一个只发请求行 GET / HTTP/1.1\r\n;35 秒后补齐请求,看能不能拿到响应。
axum = "=0.8.9"
hyper = { version = "=1.12.0", features = ["client", "http1"] }
hyper-util = { version = "=0.1.21", features = ["tokio", "server-auto", "service"] }
http = "1"
tower = { version = "=0.5.3", features = ["util"] }
tokio = { version = "=1.54.1", features = ["macros", "rt-multi-thread", "net", "time", "io-util"] }
use std::time::{Duration, Instant};
use axum::{Router, routing::get};
use hyper::body::Incoming;
use hyper_util::{
rt::{TokioExecutor, TokioIo, TokioTimer},
server::conn::auto::Builder,
service::TowerToHyperService,
};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::{TcpListener, TcpStream},
};
use tower::ServiceExt;
fn app() -> Router {
Router::new().route("/", get(|| async { "ok" }))
}
// 本站 release 路径:axum::serve,内部 auto::Builder::new(TokioExecutor::new()),不设 timer
async fn axum_serve() -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move { axum::serve(listener, app()).await.unwrap() });
port
}
// 对照:同样的 auto::Builder,只多一句 http1().timer(TokioTimer::new())
async fn auto_with_timer() -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
loop {
let (stream, _) = listener.accept().await.unwrap();
tokio::spawn(async move {
let mut builder = Builder::new(TokioExecutor::new());
builder.http1().timer(TokioTimer::new());
let svc = app().map_request(|req: http::Request<Incoming>| req.map(axum::body::Body::new));
let _ = builder
.serve_connection_with_upgrades(TokioIo::new(stream), TowerToHyperService::new(svc))
.await;
});
}
});
port
}
async fn client(server: &'static str, port: u16, case: &'static str, first: &'static [u8]) {
let t0 = Instant::now();
let mut s = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
s.write_all(first).await.unwrap();
tokio::time::sleep(Duration::from_secs(35)).await;
let rest: &[u8] = if first.is_empty() { b"GET / HTTP/1.1\r\nhost: x\r\n\r\n" } else { b"host: x\r\n\r\n" };
let _ = s.write_all(rest).await;
let mut buf = [0u8; 256];
let got = match tokio::time::timeout(Duration::from_secs(2), s.read(&mut buf)).await {
Ok(Ok(0)) => "EOF (server closed)".to_string(),
Ok(Ok(n)) => String::from_utf8_lossy(&buf[..n]).lines().next().unwrap_or("").to_string(),
Ok(Err(e)) => format!("error: {e}"),
Err(_) => "no reply in 2s".to_string(),
};
println!("{server:<16} {case:<13} after {}s: {got}", t0.elapsed().as_secs());
}
#[tokio::main]
async fn main() {
let a = axum_serve().await;
let b = auto_with_timer().await;
let partial: &[u8] = b"GET / HTTP/1.1\r\n";
tokio::join!(
client("axum::serve", a, "nothing sent", b""),
client("axum::serve", a, "partial head", partial),
client("auto + timer", b, "nothing sent", b""),
client("auto + timer", b, "partial head", partial),
);
}
M2 Max、rustc 1.99.0、--release,连跑 3 次,输出相同:
auto + timer partial head after 35s: EOF (server closed)
axum::serve nothing sent after 35s: HTTP/1.1 200 OK
axum::serve partial head after 35s: HTTP/1.1 200 OK
auto + timer nothing sent after 35s: HTTP/1.1 200 OK
axum::serve的两条连接都撑过了 35 秒,补齐之后照常拿到 200。默认的 30 秒读头超时没有起作用。- 加了 timer 的对照组,半截请求头的连接在 35 秒之前就被关了(默认超时是 30 秒),客户端补发之后读到 EOF。
- 什么都不发的连接,加不加 timer 都活着:它停在
ReadVersion,还没进 HTTP/1。
所以本站的 app 自己挡不住慢速连接:只发半个请求头、或者连上就不说话的客户端,可以一直占着一个连接任务和一个文件描述符。线上挡在前面的是网关。
前面的网关
生产环境里 app 容器只把端口发布在本机:docker-compose.yml 写的是 ${APP_BIND:-127.0.0.1}:${APP_PORT:-8080}:8080。公网连接先到 crates/gateway 的 Pingora 网关,网关终止 TLS,再用明文 HTTP/1.1 转给 127.0.0.1:8080。
网关代码里没有一处设超时。main 用 Server::new(None)(不读配置文件),443 端口 http_proxy_service 加 TLS 并 enable_h2(),80 端口只做跳转;upstream_peer 返回 HttpPeer::new(UPSTREAM_ADDR, false, ""),用的全是默认选项。所以起作用的是网关锁定的 pingora 0.8.0 的默认值:
| 位置 | 默认值(pingora-core 0.8.0) | 管什么 |
|---|---|---|
| 下游 HTTP/1 会话 | src/apps/mod.rs 对每条新的 HTTP/1 下游连接 set_keepalive(Some(60)) | read_request 读请求头时,每一次 read 最多等 60 秒,到时直接结束这条连接 |
| 下游 HTTP/1 会话 | src/protocols/http/v1/server.rs 里 read_timeout: Some(60 秒)、write_timeout: None | 读请求 body 每次最多等 60 秒;写响应不限时 |
上游 HttpPeer | src/upstreams/peer.rs 里 PeerOptions 的 connection_timeout、read_timeout、idle_timeout、write_timeout 全是 None,alpn 是 ALPN::H1 | 网关等 app 不限时;到 app 的连接只用 HTTP/1.1 |
对 app 来说这意味着三件事:
- 连上不说话、或者请求头发一半就停的客户端,最多在网关那里耗 60 秒,碰不到 app。网关要先读完整个请求头才会走到
upstream_peer,app 收到的都是完整的请求头。 - 这个 60 秒是每次读的上限,不是整个请求头的上限。每 59 秒发一个字节的客户端,网关这边也会一直等,直到请求头超过 pingora 的
MAX_HEADER_SIZE(1 048 575 字节)。pingora-proxy 0.8.0 读请求头时只和停机通知做select!,外面没有总时限。 - app 只会看到 HTTP/1.1。
ReadVersion每次都在第一个字节判成 HTTP/1,enable_connect_protocol在线上用不到。
浏览器那一侧的 HTTP/2(enable_h2)走的是 pingora 的 HTTP/2 会话,超时规则不同,本页没有展开。
SIGTERM:在途请求被切断
Dockerfile 的入口是:
# tini 处理信号;server 自身就是入口可执行文件
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["/app/server"]
容器停止时,运行时给 PID 1(tini)发 SIGTERM,tini 把它转给子进程 /app/server。接下来发生什么,取决于 server 有没有处理 SIGTERM:
- 在本站提交
89a61b1的crates/下搜ctrl_c、SIGTERM、unix::signal、with_graceful_shutdown,没有一处命中;dioxus-server 0.7.9 和 axum 0.8.9 的src/里也没有信号处理。 serve_router的 release 分支是axum::serve(listener, router).await,没有with_graceful_shutdown。这时 axum 的Serve::run签名是async fn run(self) -> !:accept 循环永不结束,上面handle_connection里的signal_tx.closed()永远不会完成,graceful_shutdown那一支永远走不到。
没有处理函数,SIGTERM 按默认动作立即结束进程。连接任务没有机会写完响应,socket 由内核关掉。对端(线上是网关)读响应时看到的是连接被关。
问: Linux 上 PID 1 收到没有处理函数的 SIGTERM 不是会忽略吗?那 server 应该一直跑到被 SIGKILL 才对。
答: 那条规则只对 PID 命名空间里的 init 进程(容器里的 PID 1)成立,见 pid_namespaces(7)。这里的 PID 1 是 tini,/app/server 是它的子进程,普通进程的默认动作照常生效:收到 SIGTERM 就结束。Dockerfile 注释写的「PID 1 信号转发」正是 tini 的用处。没有 tini、server 自己当 PID 1 时,SIGTERM 才会被忽略,那时在途请求也不会被优雅处理,只是等到宽限期满被 SIGKILL。
下面的实验在本机复现这件事,不碰真实的服务器和容器。sigterm_server 是一个 axum 0.8.9 程序,/slow 睡 2 秒再回 done;按参数选三种跑法:plain 和本站一样直接 axum::serve,graceful 收到 SIGTERM 后走 with_graceful_shutdown,deadline 在 graceful 的基础上把排空时间限成 1 秒。sigterm_drive 把它当子进程启动,用 hyper 的客户端发 /slow,500 ms 后 kill -TERM 它,相当于 tini 转过来的那个信号。
axum = "=0.8.9"
hyper = { version = "=1.12.0", features = ["client", "http1"] }
hyper-util = { version = "=0.1.21", features = ["tokio"] }
http-body-util = "0.1"
tokio = { version = "=1.54.1", features = ["macros", "rt-multi-thread", "net", "time", "signal", "sync"] }
src/bin/sigterm_server.rs:
use std::{
sync::{Arc, OnceLock},
time::{Duration, Instant},
};
use axum::{Router, routing::get};
use tokio::{
net::TcpListener,
signal::unix::{SignalKind, signal},
sync::Notify,
};
#[tokio::main]
async fn main() {
let mode = std::env::args().nth(1).unwrap_or_default();
let app = Router::new().route(
"/slow",
get(|| async {
tokio::time::sleep(Duration::from_secs(2)).await;
"done"
}),
);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
println!("{}", listener.local_addr().unwrap().port());
if mode == "plain" {
// 和 dioxus-server release 路径一样:axum::serve(listener, router)
axum::serve(listener, app).await.unwrap();
return;
}
// 修法的形状:收到 SIGTERM 才让 axum 开始优雅停机,另给排空设一个期限
let drain_limit = if mode == "deadline" { Duration::from_secs(1) } else { Duration::from_secs(8) };
let mut term = signal(SignalKind::terminate()).unwrap();
let got_term = Arc::new(OnceLock::new());
let started = Arc::new(Notify::new());
let (mark, notify) = (got_term.clone(), started.clone());
let server = axum::serve(listener, app).with_graceful_shutdown(async move {
term.recv().await;
mark.set(Instant::now()).unwrap();
notify.notify_one();
eprintln!(" server: SIGTERM received, draining");
});
tokio::select! {
res = server => {
res.unwrap();
let waited = got_term.get().unwrap().elapsed().as_millis();
eprintln!(" server: drained {waited}ms after SIGTERM, exiting");
}
_ = async { started.notified().await; tokio::time::sleep(drain_limit).await } => {
eprintln!(" server: drain limit {drain_limit:?} hit, exiting anyway");
}
}
}
src/bin/sigterm_drive.rs:
use std::{
io::{BufRead, BufReader},
os::unix::process::ExitStatusExt,
process::{Command, Stdio},
time::{Duration, Instant},
};
use http_body_util::{BodyExt, Empty};
use hyper::{Request, body::Bytes, client::conn::http1};
use hyper_util::rt::TokioIo;
use tokio::net::TcpStream;
async fn run(mode: &str) {
println!("--- {mode}");
let exe = std::env::current_exe().unwrap().with_file_name("sigterm_server");
let mut child = Command::new(exe).arg(mode).stdout(Stdio::piped()).spawn().unwrap();
let mut line = String::new();
BufReader::new(child.stdout.take().unwrap()).read_line(&mut line).unwrap();
let port: u16 = line.trim().parse().unwrap();
let pid = child.id();
let t0 = Instant::now();
let stream = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
let (mut sender, conn) = http1::handshake(TokioIo::new(stream)).await.unwrap();
tokio::spawn(conn);
let req = Request::get("/slow").header("host", "x").body(Empty::<Bytes>::new()).unwrap();
let resp = tokio::spawn(async move { sender.send_request(req).await });
tokio::time::sleep(Duration::from_millis(500)).await;
println!("{:>5}ms client: kill -TERM <server pid>", t0.elapsed().as_millis());
Command::new("kill").args(["-TERM", &pid.to_string()]).status().unwrap();
match resp.await.unwrap() {
Ok(res) => {
let status = res.status();
let body = res.into_body().collect().await.unwrap().to_bytes();
println!("{:>5}ms client: {status} {body:?}", t0.elapsed().as_millis());
}
Err(e) => println!(
"{:>5}ms client: error: {e} (is_incomplete_message={})",
t0.elapsed().as_millis(),
e.is_incomplete_message()
),
}
let status = child.wait().unwrap();
println!(
"{:>5}ms server process: code={:?} signal={:?}",
t0.elapsed().as_millis(),
status.code(),
status.signal()
);
}
#[tokio::main]
async fn main() {
run("plain").await;
run("graceful").await;
run("deadline").await;
}
请求在第 0 ms 发出,handler 要 2 秒;SIGTERM 在第 500 ms 到。plain 没有任何信号处理;graceful 收到 SIGTERM 后开始优雅停机,排空期限 8 秒;deadline 一样,但期限只有 1 秒。三种跑法各自的客户端结果是 200 OK 还是一个错误、大约在第几毫秒?进程的退出码是什么,还是被信号杀掉的?
改法的形状
这是本站现在的一个真实缺口:每次发版或重启容器,正在处理的请求都会被切断,tokio::spawn 出去的后台任务(比如支付对账)也停在半路。改不改、什么时候改,由站点决定;这里只说改法长什么样。
dioxus::serve 在 release 下只调 axum::serve(listener, router),不留插手的地方,所以要绕开它,自己调 axum。站点已经在用 dioxus::server::router(App) 拿 Router,地址可以用 dioxus 重新导出的 dioxus::cli_config::fullstack_address_or_localhost() 取,和 dioxus 自己用的是同一个函数。剩下的就是上面 sigterm_server 的 graceful / deadline 分支:
axum::serve(...).with_graceful_shutdown(signal),signal等 SIGTERM。收到后 axum 停止 accept,对每条连接调graceful_shutdown:空闲的 keep-alive 连接立即关,正在处理的请求做完再关。机制见 优雅停机 末尾的「axum 的 with_graceful_shutdown」。- 必须另加期限。
with_graceful_shutdown返回的 future 要等所有连接结束,SSE 之类的长连接会让它一直不完成,见 优雅停机一直不结束。期限要短于容器运行时发 SIGKILL 之前的宽限期,否则进程还是被强杀,等于没设。 - 只在 release 下这样做。debug 分支的热重载依赖 dioxus-server 自己的 accept 循环,绕开它就没有热重载了。
deadline 那一组的结果说明期限的代价:到期时还没做完的请求一样被切断,期限只是把「立刻切断」变成「给它们一段时间」。
本站锁的版本与本卷的差别
本站锁 hyper 1.10.1、hyper-util 0.1.20。和本卷的 1.12.0 / 0.1.21 比,这条路径上:
- hyper-util 的
server/conn/auto/mod.rs、server/graceful.rs、service/glue.rs,hyper 的common/time.rs,两个版本之间的差别只有 edition 2024 带来的写法(ref模式、use<..>捕获、#[allow(clippy::panic)])和文档测试的cfg,行为相同。上面关于 timer、ReadVersion、graceful_shutdown的结论对本站的版本同样成立。 - hyper 的 CHANGELOG 里,1.11.0、1.11.1、1.12.0 三段中和 HTTP/1 服务端有关的修复,本站都还没有:
| 版本 | 条目 | 和本站的关系 |
|---|---|---|
| 1.11.0 | #4093 more strictly enforce max_buf_size when parsing | 读缓冲上限的检查更严,见 读写缓冲 |
| 1.11.0 | #4124 discard content-length header when received before transfer-encoding | 请求同时带两个长度头时的处理;线上请求先经过网关 |
| 1.11.0 | #4018 flush buffered data before shutdown | 关连接前先把写缓冲刷出去 |
| 1.11.1 | #4147 recognize \n\r\n as a head terminator in the partial-read fast path | 请求头分几次到达时的快速扫描 |
| 1.12.0 | #4201 let a Connection close token win over a later keep-alive | Connection 头里同时有 close 和 keep-alive 时以 close 为准 |
| 1.12.0 | #4183 add max_header_size limit for server and client | 新选项;axum::serve 拿不到 Builder,hyper-util 0.1.21 的 Http1Builder 也还没转发它,本站升级后也用不上 |
升级不会改变本页的两个结论:没有 timer,也没有优雅停机。这两件事都在 dioxus-server 和 axum 的调用方式里,不在 hyper 的版本里。
动脑筋答案
M2 Max、rustc 1.99.0、--release,连跑 3 次。三次只有毫秒数差几毫秒(plain 的错误在 505–509 ms,graceful 排空 1493–1498 ms,deadline 的错误在 1509–1510 ms),以下是其中一次:
--- plain
503ms client: kill -TERM <server pid>
507ms client: error: connection closed before message completed (is_incomplete_message=true)
507ms server process: code=None signal=Some(15)
--- graceful
502ms client: kill -TERM <server pid>
server: SIGTERM received, draining
server: drained 1493ms after SIGTERM, exiting
2002ms client: 200 OK b"done"
2004ms server process: code=Some(0) signal=None
--- deadline
503ms client: kill -TERM <server pid>
server: SIGTERM received, draining
server: drain limit 1s hit, exiting anyway
1509ms client: error: connection closed before message completed (is_incomplete_message=true)
1510ms server process: code=Some(0) signal=None
(server: 开头的行是子进程的 stderr,没有时间戳。)
- plain:SIGTERM 之后几毫秒,客户端就拿到
connection closed before message completed,is_incomplete_message()为真;进程没有退出码,是被信号 15 杀掉的。这就是本站现在的行为。客户端这一侧的错误为什么是这一个,见 connection closed before message completed。 - graceful:SIGTERM 之后 server 不再接新连接,但手上的请求照常做完:客户端在第 2002 ms 拿到
200 OK,serve在 SIGTERM 之后约 1.5 秒返回,进程以 0 退出。 - deadline:排空期限 1 秒先到,
select!走第二支,main返回,运行时关闭时 drop 掉连接任务。客户端在 SIGTERM 之后 1 秒拿到同样的错误;进程以 0 退出。
小结
- 线上路径:
dioxus::serve→serve_router的 release 分支 →axum::serve(listener, router)→ 每条连接auto::Builder::new(TokioExecutor::new())、开 HTTP/2 扩展 CONNECT、serve_connection_with_upgrades+TowerToHyperService。debug 分支是 dioxus-server 自己的 accept 循环,热补丁时直接 drop 连接。 - 没有 timer:
header_read_timeout的默认 30 秒不生效,ReadVersion本来就不计时;实测 35 秒的半截请求头在axum::serve上照样拿到 200。axum 0.8.9 不暴露 Builder,要改只能自己写 accept 循环。 - 线上慢连接由 Pingora 网关挡:网关代码不设超时,pingora 0.8.0 默认每次读请求头最多等 60 秒;到 app 的上游连接是 HTTP/1.1,不限时。
- 没有优雅停机:tini 把 SIGTERM 转给 server,server 没有处理函数,进程立即结束,在途请求的客户端得到
IncompleteMessage。改法是绕开dioxus::serve,with_graceful_shutdown等 SIGTERM,再加一个短于容器宽限期的排空期限。 - 本站的 hyper 1.10.1 / hyper-util 0.1.20 在这条路径上的行为和本卷版本相同;升级拿到的是 HTTP/1 解析和 keep-alive 的几处修复,不改变上面两个结论。