Rust in Everything
基于 hyper 1.12.0

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 上一并行就红,是这个坑的样子。

为什么

三件事凑在一起:

  1. 连接任务跑在建连接的那个测试的运行时上。 legacy Client 握手以后用执行器 spawn 连接任务,TokioExecutor::execute 就是 tokio::spawn,落在调用者当前的运行时上。#[tokio::test] 给每个测试建一个运行时,测试函数返回时关掉它,上面的任务全部被 drop。
  2. 池子让别的测试用上这条连接。 池的键只是 scheme 和 authority(PoolKey),四个测试打同一个地址,一个测试建的连接回到池里,另一个测试的下一个请求就可能借走它。
  3. 请求在途时那个运行时没了。 建连接的测试先跑完(上面第二次运行里是唯一通过的 d),它的运行时关闭,连接任务被 drop,攥在里面的回调走 Drop,正借着这条连接等响应的 c 收到 DispatchGone。

串行时为什么没事:上一个测试的运行时关掉时,没有别的请求在途。连接任务被 drop,请求通道的接收端随之没了,SendRequest::is_ready() 变假,下一个测试 checkout 时池子丢掉这条连接、新建一条(连接池),不会有请求落到死连接上。

📝NOTE
没有蠢问题

问: 我的代码里没有 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,后者和连接被对端关掉是同一句话,排查时容易往网络上想。

📝NOTE
冷知识:body 被截断曾经是「正常结束」

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 的 legacy Client(或 reqwest Client),连接任务属于第一个建它的测试的运行时。串行跑全过,并行时约一半的运行失败。
  • 每个测试自己建 Client 最简单。要共用,请求本身也要在长寿的运行时上发:只换执行器,socket 还注册在测试的运行时上,换来的是 A Tokio 1.x context was found, but it is being shutdown.。
  • 响应 body 读到一半时运行时没了,body 报 IncompleteMessage(v1.10.0 起;之前是静默截断)。

💬 关联讨论

发起讨论
加载中...