从连接到分发
主线位置:请求主线的前半段,从
topcoat::start到dispatch把请求交给 layer 链为止。layer 链本身在下一页。
flowchart TB
start["start:HOST、PORT"] --> su["serve_until:notify_ready"]
su --> loop{"select:accept 还是关停"}
loop -->|"Ok(conn)"| spawn["spawn:auto::Builder"]
spawn --> loop
loop -->|"Err(e)"| err["? 返回,整个 serve 结束"]
loop -->|关停信号| drain["drop listener,drop drain_tx"]
drain --> wait{"所有连接结束,或宽限期到"}
wait --> cut["drop cutoff_tx:切断剩下的连接"]
spawn -.->|每个请求| rsc["RouterService::call"]
rsc --> hw["handle_with:每次 poll 都 catch_unwind"]
hw --> rw["handle_inner:rewrite 循环"]
rw --> disp["dispatch:matchit、OriginLayer"]
start、serve、serve_until
门面 crate 的 serve.rs 有三个入口,一个套一个:
| 函数 | 做什么 |
|---|---|
start(service) | 读 HOST(默认 127.0.0.1)和 PORT(默认 3000),绑 TcpListener,调 serve |
serve(listener, service) | 用「Ctrl+C 或 Unix 上的 SIGTERM」作关停信号,调 serve_until |
serve_until(listener, service, signal) | dev::notify_ready 之后进 internal_serve |
service 的类型是 impl Into<RouterService>,所以直接传 Router 就行;要改关停宽限期才需要自己建 RouterService。listener 是 router crate 的 Listener trait,实现了 TcpListener 和 Unix 上的 UnixListener 两个:accept 返回流和一个 Option<SocketAddr>,Unix 套接字没有对端 IP,给 None。tcp_addr 只是为了告诉 topcoat dev 往哪里转发。
默认绑 127.0.0.1 而不是 0.0.0.0:开发时不会意外暴露到局域网,部署到容器里则必须显式设 HOST=0.0.0.0,否则从容器外连不上。
accept 循环
internal_serve 的主体:
loop {
let accepted = tokio::select! {
accepted = listener.accept() => accepted,
() = &mut shutdown => break,
};
let (stream, remote_addr) = accepted?;
let io = TokioIo::new(stream);
let service = service.for_connection(remote_addr);
// ...
tokio::spawn(async move {
// ...
let builder = auto::Builder::new(TokioExecutor::new());
let mut connection = pin!(builder.serve_connection_with_upgrades(io, service));
// ...
});
}
每个连接一个任务,任务里用 hyper-util 的 auto::Builder 按前几个字节决定走 HTTP/1 还是 HTTP/2(hyper 卷的 auto);用 serve_connection_with_upgrades 而不是 serve_connection,是为了让 WebSocket 这类协议切换能用。for_connection 克隆一份 RouterService,只把 remote_addr 换成这个连接的对端地址,路由表是 Arc 共享的。
这段循环里有三处要单独说。
accept 出错,整个服务停止
accepted? 把 accept 的任何错误直接返回,internal_serve 结束,start 返回 Err。函数文档写得很明白:「Returns an I/O error if accepting a connection fails.」
最常见的 accept 错误是文件描述符耗尽(EMFILE)。把进程的上限调到 24,再用 40 个空闲连接去占:
(ulimit -n 24; PORT=38104 exec ./dispatch)
connect 16 failed: [Errno 61] Connection refused
opened 16
after: failed [Errno 61] Connection refused
thread 'main' (2120768) panicked at src/bin/dispatch.rs:21:32:
called `Result::unwrap()` on an `Err` value: Os { code: 24, kind: TooManyOpenFiles, message: "Too many open files" }
进程自己已经占了几个描述符(标准输入输出、kqueue、监听 socket),再接十几个连接就到了上限,下一次 accept 返回 EMFILE,start(...).await.unwrap() 拿到这个错误,进程退出;之后的连接全部被拒绝。在线的连接也随进程一起没了。
axum 0.8.9 在同一个位置的做法不一样,handle_accept_error:
async fn handle_accept_error(e: io::Error) {
if is_connection_error(&e) {
return;
}
// ...
error!("accept error: {e}");
tokio::time::sleep(Duration::from_secs(1)).await;
}
连接级的错误(对端在 accept 之前就重置了之类)直接忽略,其他错误记一条日志、睡一秒、继续 accept,这是从 hyper 0.14 的 Server 继承下来的做法。topcoat 0.10.0 对这两类都是 ?:理论上一个客户端在握手完成后、accept 之前发 RST,触发 ECONNABORTED,也会让整个服务器退出(这个时序很难在本机稳定复现,未实测,按源码如此)。生产部署时要么把 start 包在一个会重启的外层循环里,要么交给进程管理器。
连接错误被丢掉
if let Err(_error) = result {
// TODO: Surface real connection errors without the noise. Most
// are benign (e.g. the client aborting an in-flight shard
// request), so for now they are dropped. See how axum demotes
// these to a `trace`-level log.
}
整个 topcoat-router 和 topcoat-core 里没有一行 tracing、log 或 eprintln!。连接级的错误(协议错误、对端中途断开)在这里被静默丢弃,源码自己留了 TODO。
0.10.0 没给 hyper 配计时器
auto::Builder::new(TokioExecutor::new()) 只给了执行器,没有给计时器。hyper 的 HTTP/1 默认有 30 秒的「读请求头超时」,但没有计时器时这个超时不生效(hyper 卷:header_read_timeout 不生效)。实测:连上之后只发半个请求头,不发结尾的空行,
s.sendall(b"GET /api/items HTTP/1.1\r\nHost: x\r\n")
after 40.0s: still open, nothing received
40 秒后连接还挂着,服务端什么也没发。这正是慢速攻击(slowloris)利用的口子:每个这样的连接占一个任务和一个文件描述符,再配合上一节「fd 耗尽就退出」,后果会被放大。tag 之后的 #497 在这里加了两行 builder.http1().timer(TokioTimer::new()) 和 builder.http2().timer(...);对 0.10.0,要在前面放一个有超时的反向代理。
优雅关停:三个只靠关闭的 watch 通道
关停部分没有用 hyper-util 的 GracefulShutdown(hyper 卷的优雅停机),而是三个 tokio::sync::watch 通道,且一个值也不发:
// Each channel signals by closing, not by sending: connection tasks hold
// receiver clones, and dropping the sender resolves their `changed()`.
//
// Announces the shutdown; tasks switch into graceful shutdown.
let (drain_tx, drain_rx) = watch::channel(());
// Announces the end of the grace period; tasks drop their connection.
let (cutoff_tx, cutoff_rx) = watch::channel(());
// Tracks live tasks the other way around: each holds a receiver clone,
// and `done_tx.closed()` resolves once the last one is dropped.
let (done_tx, done_rx) = watch::channel(());
watch::Receiver::changed() 在发送端被 drop 时返回 Err,所以「drop 发送端」就是一次广播;反过来,watch::Sender::closed() 在最后一个接收端被 drop 时完成,所以让每个任务持有一个 done_rx 的克隆,就能等「所有任务都结束了」(tokio 卷的 watch)。每个连接任务里:
let result = tokio::select! {
result = connection.as_mut() => result,
_ = drain_rx.changed() => {
// Finish the in-flight request, then close: HTTP/1 stops
// keep-alive, HTTP/2 sends GOAWAY.
connection.as_mut().graceful_shutdown();
tokio::select! {
result = connection.as_mut() => result,
// The grace period ended; drop the connection as is.
_ = cutoff_rx.changed() => return,
}
}
};
关停信号到来后,循环之后的代码按顺序做四件事:
drop(listener);
// ...
drop(drain_rx);
drop(drain_tx);
drop(done_rx);
tokio::select! {
() = done_tx.closed() => {}
() = tokio::time::sleep(service.shutdown_timeout) => {}
}
// ...
drop(cutoff_rx);
drop(cutoff_tx);
done_tx.closed().await;
- 先 drop listener,端口立刻释放,新进程可以马上绑上来。
- drop
drain_tx,所有连接任务进入graceful_shutdown:HTTP/1 处理完当前请求就关,HTTP/2 发 GOAWAY。 - 等所有任务结束,最多等
shutdown_timeout(默认 30 秒)。 - drop
cutoff_tx,还没结束的任务直接return,连接连同在途请求的 future 一起被丢掉;再等这些任务真的退出。
实验:宽限期设 2 秒,先发一个 1 秒的请求和一个 5 秒的请求,200 毫秒后触发关停。
let listener = TcpListener::bind("127.0.0.1:38103").await?;
let service = RouterService::new(module_router!().build()).shutdown_timeout(Duration::from_secs(2));
let signal = async {
STOP.notified().await;
println!("[{:>5}ms] shutdown signal", t());
};
let result = topcoat::serve_until(listener, service, signal).await;
println!("[{:>5}ms] serve_until returned {result:?}", t());
/stop 路由调 STOP.notify_one();/slow5 里放了一个在 drop 时打印的守卫。关停之后马上用 curl 发一个新请求,再用一个带 SO_REUSEADDR 的 socket(mio 的 TcpListener::bind 在 Unix 上也这么设)去绑同一个端口:
-- new connection after stop:
curl exit=7
rebind ok while draining
[ 507ms] slow1 start
[ 507ms] slow5 start
[ 724ms] shutdown signal
[ 1510ms] slow1 done
[ 2727ms] slow5 future dropped
[ 2727ms] serve_until returned Ok(())
-- slow1:
[ 1007ms] read 147 bytes:
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
connection: close
content-length: 11
slow1 done
[ 1007ms] <EOF>
-- slow5:
[ 2221ms] <EOF>
- 关停后新连接被拒(curl 退出码 7,连不上),端口已经能被别人绑上:第 1 步。
/slow1正常完成,响应头里多了connection: close,这是graceful_shutdown让 HTTP/1 不再保持连接:第 2 步。- 724 毫秒收到信号,2727 毫秒
/slow5的 future 被 drop,正好 2 秒宽限期;客户端只收到 EOF,没有任何响应:第 4 步。 serve_until返回Ok(())。
被切断的请求没有任何收尾机会,handler 里如果有「写了一半」的状态,只能靠 Drop。要更长的宽限期就调大 shutdown_timeout,要立刻停就传 Duration::ZERO。
RouterService:hyper 的 Service
impl Service<Request<Incoming>> for RouterService {
type Response = Response;
type Error = Infallible;
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
fn call(&self, mut request: Request<Incoming>) -> Self::Future {
let router = self.router.clone();
if let Some(addr) = self.remote_addr {
request.extensions_mut().insert(RemoteAddr(addr));
}
Box::pin(async move { Ok(router.handle(request.map(Body::new)).await) })
}
}
这是 hyper::service::Service,call 拿 &self、没有 poll_ready(hyper 卷的 Service 与 Body),和 axum 那样「先把 tower Service 转成 hyper 的」不同,这里一步到位。错误类型是 Infallible:路由里发生的一切,包括 panic,都要在 router.handle 里变成一个 Response。Body::new 把 hyper 的 Incoming 装箱成 topcoat 自己的 Body(一个 UnsyncBoxBody<Bytes, BoxError>)。
catch_unwind 包在每一次 poll 上
let mut future = pin!(self.handle_inner(request, RewriteChain::carrying(values)));
poll_fn(|cx| {
// The whole request and its context are discarded after a panic,
// so no potentially inconsistent request-local state is reused.
match catch_unwind(AssertUnwindSafe(|| future.as_mut().poll(cx))) {
Ok(Poll::Ready(response)) => Poll::Ready(response),
Ok(Poll::Pending) => Poll::Pending,
Err(_) => Poll::Ready(internal_server_response()),
}
})
.await
一个 async 函数的 panic 可能发生在任意一次 poll 里:第一次 poll 走到第一个 .await,之后每次被唤醒再往下走一段。catch_unwind 只能接住它包住的那次调用里的 panic,所以要在 poll_fn 里每次都包。panic 之后 future 不能再 poll,这里也不再 poll 它,直接返回 500,连同整个请求的上下文一起丢掉,注释里说的就是这个。
这一层只管到「首屏的 Response 返回」为止。流式的响应体是 hyper 之后才去 poll 的,那时已经离开了 handle_with。所以 Body::poll_frame 里还有第二层 catch_unwind:响应体 poll 时 panic,就把 body 换成一个空的,返回 BodyPanicError。注释说如果不在这里接住,panic 会落到 hyper 的连接任务里,「take down the connection and every other request on it」。
实测(/boom 里直接 panic!("boom"),紧接着再请求一个正常的路由):
### http://127.0.0.1:38102/boom
HTTP/1.1 500 Internal Server Error
content-length: 21
internal server error
### http://127.0.0.1:38102/api/items
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 6
items
服务器的标准错误里是默认 panic hook 打印的那段 thread 'tokio-rt-worker' ... panicked at src/bin/dispatch.rs:79:3: boom。注意这个 500 响应连 content-type 都没有:它是 internal_server_response 手工拼的,不经过任何 IntoResponse,也不经过 layer(layer 链在 panic 时已经被丢掉了)。
handle_inner:每一轮一个新的 Cx
// Resolve the client's address once from the original request,
// before any rewrite can replace its headers.
let client_ip = ClientIp(inner.trusted_proxies.resolve(&request));
let (mut parts, mut body) = request.into_parts();
let original = OriginalParts(Arc::new(parts.clone()));
let (cx, result) = loop {
// Rewrites start with a fresh context. Keep only the values
// passed by the caller or a rewrite, and discard any other
// request state. Add the router's values last so they win.
let cx = Cx::new(Arc::clone(&inner.app_context))
.with_many(chain.context().clone())
.with_many((
Arc::clone(&self.inner),
ResponseHeaders::new(),
RawPathParams::default(),
original.clone(),
client_ip,
parts,
));
// ...
let (cx, result) = self.dispatch(cx, body).await;
// ...
};
- 客户端 IP 和原始请求
Parts在循环外算一次。rewrite 可以换掉路径、方法、请求头,但client_ip(cx)和original_uri(cx)永远是到达时的那一份。 Cx是一张按类型存值的表(03 章细讲),with_many后放的覆盖先放的。路由器自己的值(路由表句柄、响应头收集器、路径参数、请求Parts)最后放,调用方通过handle_with传进来的值盖不掉它们。- 循环里还有一步
initial_identity:请求头x-topcoat-identity存在时,从它解析出这次渲染的起始身份。浏览器运行时重跑页面的一部分时会带上它(05 章),值不合法就是 400。
rewrite:错误即控制流
handler 返回 Err(rewrite("/api/items", body).into()),就是「把这个请求当成发往 /api/items 的请求重新处理一遍」,服务端内部完成,浏览器看不到跳转。实现上 RewriteError 是一个普通的 Error,像 404 一样沿 layer 链冒泡出来,在循环里被认出:
let rewrite = match result {
Ok(response) => break (cx, Ok(response)),
Err(error) => match error.downcast::<RewriteError>() {
Ok(rewrite) => rewrite,
Err(failed) => break (cx, Err(failed.into_error())),
},
};
(parts, body) = match chain.follow(crate::request::parts(&cx), rewrite) {
Ok(request) => request.into_parts(),
Err(error) => break (cx, Err(error)),
};
RewriteChain::follow 记下走过的「方法 + 路径」,下一跳已经走过就是环,走过超过 REWRITE_LIMIT(8)次也报错。rewrite 可以带新的方法、请求头和要放进下一轮上下文的值。
实测,/old rewrite 到 /api/items,/loop-a 和 /loop-b 互相 rewrite(服务器端每个 layer 都打印进出):
pathless > GET /old
root > /old
handler old: rewrite to /api/items (/old)
root < Err(rewrite to /api/items)
pathless < Err(rewrite to /api/items)
pathless > GET /api/items
root > /api/items
api > /api/items
handler list_items (GET)
api < 200 OK
root < 200 OK
pathless < 200 OK
pathless > GET /loop-a
root > /loop-a
root < Err(rewrite to /loop-b)
pathless < Err(rewrite to /loop-b)
pathless > GET /loop-b
root > /loop-b
root < Err(rewrite to /loop-a)
pathless < Err(rewrite to /loop-a)
- 每一轮 rewrite 都从最外层的 layer 重新走一遍;layer 看到的是
Err(rewrite to ...),可以检查,但不应该吞掉它。 /old最后得到的是/api/items的 200,客户端只看到这个结果。- 环在第二跳被发现,客户端拿到
500 internal server error。错误消息本来写得很清楚(「the rewrite to GET /loop-a creates a cycle: GET /loop-a -> GET /loop-b -> GET /loop-a」),但它不是任何一种已知的路由错误,按请求、响应与错误里的规则变成了 500,消息被丢掉,日志里也没有。
dispatch:路径、方法、Origin
dispatch 的匹配顺序:
Endpoints::at:matchit 按路径找端点。找不到:终点是NotFound,外面只包「没有路径的 layer」。endpoint.get(&method):这个方法有专门注册的路由就用它。endpoint.any():否则用#[route(*)]那种「所有方法」的路由。- 都没有:终点是
MethodNotAllowed,同样只包没有路径的 layer。 - 无论哪种,最外面都是
inner.origin.handle(...),也就是OriginLayer。
HEAD 不是在这里特殊处理的,而是在建表时:alias_head_to_get 把没有显式注册 HEAD 的端点的 HEAD 槽指向 GET 的路由。所以 HEAD 请求跑的就是 GET 的 handler,handler 里读到的方法仍然是 HEAD;响应体由 hyper 丢掉。405 的 Allow 头里也就会列出 HEAD。
实测(一个 GET 和一个 POST 注册在 /api/items,外加一个没有路径的 layer、一个根路径 layer、一个 /api layer):
### -I http://127.0.0.1:38102/api/items
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 6
### -X DELETE http://127.0.0.1:38102/api/items
HTTP/1.1 405 Method Not Allowed
allow: GET, POST, HEAD
content-length: 0
### http://127.0.0.1:38102/nope
HTTP/1.1 404 Not Found
content-type: text/plain; charset=utf-8
content-length: 9
not found
### -X POST -H Sec-Fetch-Site: cross-site http://127.0.0.1:38102/api/items
HTTP/1.1 403 Forbidden
content-type: text/plain; charset=utf-8
content-length: 9
forbidden
对应的服务器打印:
pathless > HEAD /api/items
root > /api/items
api > /api/items
handler list_items (HEAD)
api < 200 OK
root < 200 OK
pathless < 200 OK
pathless > DELETE /api/items
pathless < Err(method not allowed)
pathless > GET /nope
pathless < Err(not found)
HEAD走完了整条 layer 链,handler 看到的方法是HEAD。- 405 和 404 只经过了没有路径的那个 layer,根路径和
/api的 layer 都没有跑;layer 看到的是Err,响应在handle_inner末尾的respond里才生成。405 的响应体是空的,404 是not found。 - 跨站的
POST在OriginLayer就被拒了,服务器端一行打印都没有:它在所有 layer 外面(07 章讲判定规则)。带Origin: http://evil.example头的POST结果相同。
小结
start读HOST/PORT(默认127.0.0.1:3000);serve加上 Ctrl+C /SIGTERM;serve_until进internal_serve,每个连接一个auto::Builder任务。- accept 出错直接
?返回,整个服务停止(EMFILE实测复现);axum 0.8.9 是记日志、睡一秒、继续。连接级错误被静默丢弃。 - 0.10.0 没给 hyper 配计时器,只发半个请求头的连接 40 秒后仍然挂着;#497 在 tag 之后修了。
- 优雅关停用三个只靠 drop 发信号的
watch通道:先释放端口,再让连接优雅结束,宽限期(默认 30 秒)到了直接丢掉剩下的连接。 handle_with在每次 poll 上catch_unwind,panic 变成没有content-type的 500;流式阶段的 panic 由Body::poll_frame的第二层接住。- rewrite 是一个冒泡的错误,每轮重建
Cx、从最外层 layer 重走;环和超过 8 次都变成 500,原因不会出现在任何地方。 - 分发顺序:具体方法 → any → 405;
HEAD在建表时指向GET;404、405 只经过没有路径的 layer;OriginLayer永远最外层。