dispatch task is gone
主线位置:客户端主线。请求放进通道、连接任务把它写出去以后,回调攥在连接任务里的
dispatch::Client手上;连接任务还没交回响应就被 drop,回调在Drop里报这个错误。
flowchart TB ta["测试 a 的运行时"] --> conn["a 建的连接任务"] pool["static Client 的池"] --> conn tb["测试 b 的请求"] --> pool conn --> inflight["b 的请求在途"] aend["测试 a 结束,运行时关闭"] --> dropconn["连接任务被 drop"] dropconn --> cb["回调 Drop"] cb --> err["b 收到 DispatchGone"]
错误的产生过程在 conn::http1 客户端 的「dispatch task is gone」一节里用 abort、关运行时、body panic 三种方式逐个触发过:回调被 drop 时还没发过结果,Drop for Callback 补发一个由 dispatch_gone 构造的 User(DispatchGone)。Display 只有 dispatch task is gone,source() 里是两句之一:
crate::Error::new_user_dispatch_gone().with(if std::thread::panicking() {
"user code panicked"
} else {
"runtime dropped the dispatch task"
})
这一页讲它在实际项目里最常出现的样子,以及为什么最顺手的修法修不好。
复现:测试之间共用一个 static Client
测试代码里常见这样的写法:一个 static 的 Client,所有测试共用,省得每个测试都建连接。被测的服务端也是全局的,跑在自己的线程和运行时上,每个响应 20ms。
hyper = { version = "=1.12.0", features = ["client", "server", "http1"] }
hyper-util = { version = "=0.1.21", features = ["client-legacy", "http1", "tokio"] }
http-body-util = "=0.1.5"
bytes = "1"
tokio = { version = "=1.54.1", features = ["macros", "rt-multi-thread", "net", "time", "io-util"] }
// tests/shared_client.rs
use std::convert::Infallible;
use std::net::SocketAddr;
use std::sync::LazyLock;
use std::time::Duration;
use bytes::Bytes;
use http_body_util::{BodyExt, Empty, Full};
use hyper::{Request, Response};
use hyper_util::client::legacy::Client;
use hyper_util::client::legacy::connect::HttpConnector;
use hyper_util::rt::{TokioExecutor, TokioIo};
// A test server on its own thread and runtime; every response takes 20ms.
static SERVER: LazyLock<SocketAddr> = LazyLock::new(|| {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
listener.set_nonblocking(true).unwrap();
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async move {
let listener = tokio::net::TcpListener::from_std(listener).unwrap();
loop {
let (stream, _) = listener.accept().await.unwrap();
tokio::spawn(async move {
let svc = hyper::service::service_fn(|_req: Request<hyper::body::Incoming>| async {
tokio::time::sleep(Duration::from_millis(20)).await;
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from_static(b"ok"))))
});
let _ = hyper::server::conn::http1::Builder::new()
.serve_connection(TokioIo::new(stream), svc)
.await;
});
}
});
});
addr
});
// One client for the whole test binary.
static CLIENT: LazyLock<Client<HttpConnector, Empty<Bytes>>> =
LazyLock::new(|| Client::builder(TokioExecutor::new()).build_http());
async fn get_ok(n: usize) {
let url: hyper::Uri = format!("http://{}/", *SERVER).parse().unwrap();
for _ in 0..n {
let resp = CLIENT.get(url.clone()).await.unwrap();
resp.into_body().collect().await.unwrap();
}
}
#[tokio::test]
async fn a() {
get_ok(3).await;
}
#[tokio::test]
async fn b() {
get_ok(3).await;
}
#[tokio::test]
async fn c() {
get_ok(3).await;
}
#[tokio::test]
async fn d() {
get_ok(3).await;
}
cargo test --release --test shared_client,M2 Max、rustc 1.99.0。连跑 3 次,第一次只有 c 失败,第三次全过,第二次是这样:
running 4 tests
. 1/4
c --- FAILED
a --- FAILED
b --- FAILED
failures:
---- c stdout ----
thread 'c' (780646) panicked at tests/shared_client.rs:46:46:
called `Result::unwrap()` on an `Err` value: hyper_util::client::legacy::Error(SendRequest, hyper::Error(User(DispatchGone), "runtime dropped the dispatch task"))
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
---- a stdout ----
thread 'a' (780644) panicked at tests/shared_client.rs:46:46:
called `Result::unwrap()` on an `Err` value: hyper_util::client::legacy::Error(SendRequest, hyper::Error(User(DispatchGone), "user code panicked"))
---- b stdout ----
thread 'b' (780645) panicked at tests/shared_client.rs:46:46:
called `Result::unwrap()` on an `Err` value: hyper_util::client::legacy::Error(SendRequest, hyper::Error(User(DispatchGone), "user code panicked"))
failures:
a
b
c
test result: FAILED. 1 passed; 3 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.07s
直接跑测试二进制 3 轮、每轮 200 次,失败的运行分别是 93、105、114 次;失败的测试里大部分是 runtime dropped the dispatch task,少数是 user code panicked(三轮合计 351 个失败的测试里有 17 个)。加 --test-threads=1 串行跑,3 轮各 200 次,一次都没有失败。本地串行全绿、CI 上一并行就红,是这个坑的样子。
为什么
三件事凑在一起:
- 连接任务跑在建连接的那个测试的运行时上。 legacy
Client握手以后用执行器 spawn 连接任务,TokioExecutor::execute就是tokio::spawn,落在调用者当前的运行时上。#[tokio::test]给每个测试建一个运行时,测试函数返回时关掉它,上面的任务全部被 drop。 - 池子让别的测试用上这条连接。 池的键只是 scheme 和 authority(
PoolKey),四个测试打同一个地址,一个测试建的连接回到池里,另一个测试的下一个请求就可能借走它。 - 请求在途时那个运行时没了。 建连接的测试先跑完(上面第二次运行里是唯一通过的
d),它的运行时关闭,连接任务被 drop,攥在里面的回调走Drop,正借着这条连接等响应的c收到DispatchGone。
串行时为什么没事:上一个测试的运行时关掉时,没有别的请求在途。连接任务被 drop,请求通道的接收端随之没了,SendRequest::is_ready() 变假,下一个测试 checkout 时池子丢掉这条连接、新建一条(连接池),不会有请求落到死连接上。
问: 我的代码里没有 panic,为什么有的测试报 user code panicked?
答: 这句话说的是「drop 回调的那个线程正在 panic」,不是「你的代码 panic 了」。dispatch_gone 只看 std::thread::panicking()。上面第二次运行里,c 先因为 runtime dropped 失败,unwrap panic;panic 沿着 c 的测试函数往外展开,#[tokio::test] 生成的运行时在展开过程中被 drop,它上面恰好有 a、b 正在用的连接任务,这些回调是在一个正在 panic 的线程上被 drop 的,于是报 user code panicked。另跑 300 次,出现 user code panicked 的 20 次运行里,每一次都同时有一个 runtime dropped 的失败,它总是第二个倒下的。
修法
最简单的是每个测试自己建 Client。get_ok 改成在函数里 Client::builder(TokioExecutor::new()).build_http(),其余不变,3 轮各 200 次并行运行,全部通过。连接和它的任务都属于这个测试,和测试同生共死。
确实要共用(比如建连接很贵、要测连接复用),就得让连接任务跑在一个和测试二进制同寿的运行时上。最顺手的写法是换一个执行器:
// tests/shared_client_exec.rs:只有 CLIENT 不同
// The connection tasks run on a runtime that lives as long as the test binary.
static RT: LazyLock<tokio::runtime::Runtime> = LazyLock::new(|| {
tokio::runtime::Builder::new_multi_thread().worker_threads(1).enable_all().build().unwrap()
});
#[derive(Clone)]
struct LongLived;
impl<F> hyper::rt::Executor<F> for LongLived
where
F: Future + Send + 'static,
F::Output: Send + 'static,
{
fn execute(&self, fut: F) {
RT.spawn(fut);
}
}
static CLIENT: LazyLock<Client<HttpConnector, Empty<Bytes>>> =
LazyLock::new(|| Client::builder(LongLived).build_http());
3 轮各 200 次并行运行,失败 62、57、45 次,错误换了一个:
called `Result::unwrap()` on an `Err` value: hyper_util::client::legacy::Error(SendRequest, hyper::Error(Io, Custom { kind: Other, error: "A Tokio 1.x context was found, but it is being shutdown." }))
连接任务确实在 RT 上了,但 socket 不是。legacy Client 建连接(DNS、TCP connect、握手)是在调用 request 的那个任务里做的:connect_to 返回的 lazy future 在 connection_for 里和 checkout 赛跑,握手完成后才把 conn 交给执行器(见连接池的「和新建连接赛跑」)。TcpStream 在哪个运行时里创建,就注册在哪个运行时的反应器上(反应器)。测试的运行时关闭时,反应器也关了,挂在上面的 socket 再读写,tokio 返回这条 IO 错误(tokio 1.54.1 的 runtime/io/registration.rs),连接任务读到它,交给在途的请求。
所以要共用,整个请求都得在长寿的运行时上发,测试自己的运行时只等结果:
// tests/shared_client_rt.rs:只有 CLIENT 和 get_ok 不同
// The client, its sockets and its connection tasks all live on this runtime.
static RT: LazyLock<tokio::runtime::Runtime> = LazyLock::new(|| {
tokio::runtime::Builder::new_multi_thread().worker_threads(1).enable_all().build().unwrap()
});
static CLIENT: LazyLock<Client<HttpConnector, Empty<Bytes>>> =
LazyLock::new(|| Client::builder(TokioExecutor::new()).build_http());
async fn get_ok(n: usize) {
let url: hyper::Uri = format!("http://{}/", *SERVER).parse().unwrap();
// Run the requests on RT; the test's own runtime only waits for the result.
RT.spawn(async move {
for _ in 0..n {
let resp = CLIENT.get(url.clone()).await.unwrap();
resp.into_body().collect().await.unwrap();
}
})
.await
.unwrap();
}
3 轮各 200 次并行运行,全部通过。RT.spawn 里 TokioExecutor 的 tokio::spawn 也落在 RT 上,连接、socket、连接任务都和测试二进制同寿。tower 的 Buffer 有一模一样的坑和修法,见 buffer's worker closed unexpectedly;reqwest 的 Client 底下就是这个 legacy Client,放进 static 共用时道理相同。
响应 body 读到一半时
上面的错误都出在等响应头的时候。如果响应头已经交回、body 还在路上,连接任务被 drop,回调早就用掉了,DispatchGone 不会出现。body 这一侧报什么?下面让连接任务跑在另一个运行时上,读到 body 的第一块后关掉那个运行时:
use std::error::Error as _;
use std::time::Duration;
use bytes::Bytes;
use http_body_util::{BodyExt, Empty};
use hyper::Request;
use hyper::client::conn::http1;
use hyper_util::rt::TokioIo;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
#[tokio::main(flavor = "current_thread")]
async fn main() {
// Server: response head plus 3 of 10 body bytes, then nothing for a while.
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let (mut s, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 1024];
let _ = s.read(&mut buf).await.unwrap();
s.write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 10\r\n\r\nabc").await.unwrap();
tokio::time::sleep(Duration::from_secs(5)).await;
});
// The connection task runs on another runtime, which is shut down mid-body.
let other = tokio::runtime::Builder::new_multi_thread().worker_threads(1).enable_all().build().unwrap();
let io = TokioIo::new(TcpStream::connect(addr).await.unwrap());
let (mut tx, conn) = http1::handshake::<_, Empty<Bytes>>(io).await.unwrap();
other.spawn(conn);
let resp = tx.send_request(Request::get("/").header("host", "x").body(Empty::new()).unwrap()).await.unwrap();
println!("head: {}", resp.status());
let mut body = resp.into_body();
let first = body.frame().await.unwrap().unwrap().into_data().unwrap();
println!("first frame: {first:?}");
other.shutdown_background();
match body.frame().await {
Some(Err(e)) => println!("next frame: Err({e}) source={:?} incomplete={}", e.source().map(|s| s.to_string()), e.is_incomplete_message()),
other => println!("next frame: {other:?}"),
}
}
依赖同上。连跑 3 次输出相同:
head: 200 OK
first frame: b"abc"
next frame: Err(connection closed before message completed) source=None incomplete=true
body 收到的是 IncompleteMessage,source() 为空。它来自 SenderDropGuard:Dispatcher 持有 body 发送端时被 drop,它的 Drop 往 body 里发一个 new_incomplete()。所以运行时没了,在等响应头的请求看到 dispatch task is gone,在读 body 的请求看到 connection closed before message completed,后者和连接被对端关掉是同一句话,排查时容易往网络上想。
hyper 的 issue #3995 标题是「Hyper returns a partial byte array when its runtime is closed mid-read」,报告者在 hyper 1.6.0 上遇到:对象存储客户端关闭时,读到的对象只有一半,下游拿着半截数据 panic。原因是 Dispatcher 被 drop 时 body 发送端跟着被 drop,接收端看到的是通道关闭,也就是 body 正常结束,collect() 返回 Ok。
修复是 PR #4069(「fix(http1): send error when dispatcher is dropped mid-body」,接续未合并的 #4016),提交说明:「Wrap body_tx in a SenderDropGuard that sends an IncompleteMessage error if the Dispatcher future is dropped while still owning a body Sender, preventing silent data truncation.」第一个包含它的发布是 v1.10.0。上面实验里的 Err(connection closed before message completed),在 v1.10.0 之前是一个提前到来的 None。
小结
dispatch task is gone说明请求在途时连接任务被 drop 了:任务被 abort、所在运行时关闭,或者任务里 panic。原因字符串只看 drop 时线程是否正在 panic,user code panicked不一定是你的代码 panic。- 多个
#[tokio::test]共用一个static的 legacyClient(或 reqwestClient),连接任务属于第一个建它的测试的运行时。串行跑全过,并行时约一半的运行失败。 - 每个测试自己建
Client最简单。要共用,请求本身也要在长寿的运行时上发:只换执行器,socket 还注册在测试的运行时上,换来的是A Tokio 1.x context was found, but it is being shutdown.。 - 响应 body 读到一半时运行时没了,body 报
IncompleteMessage(v1.10.0 起;之前是静默截断)。