feat(download): 给每条下载 await 上界并在超时时报出「卡在哪一段」(M289 / P19)

全仓下载路径此前只有两处无界 await(分块与单连接的 `send()`),reqwest 自己的
`timeout` 又是 `None`:服务器接了连接不回一个字节 ⇒ future 永不结束,
`ull java install` 那条 `silent()` 路径零事件零日志,用户只看到「卡住不动」。

上界选「单次请求 + 读空闲」,**不是**「整作业总预算」:
* `region.rs` 的 3s 是探测极小响应(整作业 = 一次 GET,两者同一件事);
* `font.rs` 的 ATTEMPT/TOTAL 是几百 KB 的字体(整作业几秒就完);
* 188MB 的 JDK 两者都不能照抄,所以分三段互不顶替的界,理由与取数**只写在**
  `crates/core/src/download/stall.rs` 模块头一处(含「别改成 reqwest 的 timeout,
  它覆盖整个响应体读取」)。

改动:
* 新增 `download::stall`:`StallStage`(连接 / 等响应头 / 等字节 / 等分块回收 / 校验)+
  `StallReason`(期限用完 / 没有新字节)+ `StallReport`(阶段 + 已收 / 预期字节 +
  无进展时长)+ `job_budget`(总期限按 32 KiB/s 平均吞吐反推)。
* `http::send_bounded` / `http::read_step`:两处 `send()` 与两处读循环的**唯一**构造点;
  超时错误带阶段与三个数,`message` 里也抄一份(整体失败时 `suppressed` 只搬 message)。
* `transfer_chunk::await_chunks`:`JoinSet::join_next` 加总期限与无进度窗口,两者靠
  `StallReason` 分开报;窗口内有新字节就续期(慢但有效不误杀)。
* `verify_part` 挪到 blocking 池并套上界:校验阶段卡住也报得出来(阶段=校验)。
* `engine.rs`:整体失败时把**最后一次**尝试的失速现场带上去,并写进用户可见的那行字。
* 配置:`response_timeout` 60s / `chunk_stall_timeout` 120s / `chunk_job_floor` 10min /
  `verify_timeout` 120s(`EngineConfig`,未动设置 schema)。

测试(全离线、`127.0.0.1`、<2s):夹具新增 `silent()` / `stalling_after` / `short_body` /
`oversize_body` / `dripping`;用例覆盖静默服务端、中途卡住、短包、超长包、慢但持续
(4 倍于上界也要成功)、超时后引擎仍能下载(RAII 许可自还,不写手动归还)、以及分块
作业的「无进度 vs 总期限」两条。

另:`config/tests.rs` 的一处表达式在去缩进后行宽变化,随本 commit 由 rustfmt 合成一行
(纯搬移 commit 提交时它仍是逐字节原文)。
This commit is contained in:
AskaEth committed 2026-11-12 23:24:19 +08:00
1 parent 61c8a4eff9
commit cc0def3415
16 files changed
+1243 -68

No files matched your search

+121 -11
View File
@@ -8,6 +8,7 @@
use std::net::SocketAddr;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
@@ -74,8 +75,23 @@ pub struct MockResponse {
pub etag: Option<String>,
/// 返回 206 时故意把 `Content-Range` 写成从 0 开始(模拟段错位的坏服务器)。
pub lie_content_range: bool,
/// 读完请求头后**一个字节都不写**(服务端接了连接但永不回话)。
///
/// 这是「无界 `await`」唯一能表达成用例的形态:读方不设上界就永远不返回。
pub silent: bool,
/// 写出这么多字节正文后就**停住不再写**(中途卡住),连接保持打开。
pub stall_at: Option<usize>,
/// 正文实际只发这么多字节(头部仍声称全量 = 短包)。
pub send_len: Option<usize>,
/// 正文之后**多**发这么多字节,且故意不发 `Content-Length`(超长包:HTTP/1.1 靠关连接分帧)。
pub trailing_extra: Option<usize>,
/// 正文按 `piece` 字节一片、片间睡 `gap` 滴流(慢但持续,用来证明超时不会误杀它)。
pub drip: Option<(usize, Duration)>,
}
/// 夹具「挂住」的时长:远大于用例自己的上界,保证读方只能靠超时脱身。
const HOLD: Duration = Duration::from_secs(30);
impl MockResponse {
/// 200 + 完整内容(支持 Range)。
pub fn ok(body: impl Into<Vec<u8>>) -> Self {
@@ -85,6 +101,11 @@ impl MockResponse {
range_support: true,
etag: Some("\"v1\"".to_string()),
lie_content_range: false,
silent: false,
stall_at: None,
send_len: None,
trailing_extra: None,
drip: None,
}
}
@@ -96,9 +117,46 @@ impl MockResponse {
range_support: false,
etag: None,
lie_content_range: false,
silent: false,
stall_at: None,
send_len: None,
trailing_extra: None,
drip: None,
}
}
/// 200 但**永不回话**:读完请求头就挂住,一个字都不写。
pub fn silent() -> Self {
Self {
silent: true,
..Self::ok(Vec::new())
}
}
/// 写完 `bytes` 字节正文后停住(中途卡住,连接不断)。
pub fn stalling_after(mut self, bytes: usize) -> Self {
self.stall_at = Some(bytes);
self
}
/// 头部声称全量、正文只发 `sent` 字节(短包,客户端读不满)。
pub fn short_body(mut self, sent: usize) -> Self {
self.send_len = Some(sent);
self
}
/// 正文之后多发 `extra` 字节且不发 `Content-Length`(超长包)。
pub fn oversize_body(mut self, extra: usize) -> Self {
self.trailing_extra = Some(extra);
self
}
/// 正文按 `piece` 字节一片、片间 `gap` 滴流(慢但持续)。
pub fn dripping(mut self, piece: usize, gap: Duration) -> Self {
self.drip = Some((piece, gap));
self
}
/// 声明不支持 Range(模拟部分镜像)。
pub fn without_range(mut self) -> Self {
self.range_support = false;
@@ -198,7 +256,15 @@ async fn serve(mut stream: TcpStream, response: MockResponse) -> std::io::Result
.map(str::trim)
.and_then(parse_range_header);
if response.silent {
// 接了连接、读完请求头、一个字都不回:读方不设上界就永远挂在这里。
tokio::time::sleep(HOLD).await;
return Ok(());
}
let total = response.body.len() as u64;
// 超长包不能发 `Content-Length`,否则读方按声明截断、永远看不到多余字节。
let with_length = response.trailing_extra.is_none();
let (status, headers, body) = match (range, response.range_support) {
(Some((start, requested_end)), true) if start < total => {
// 尊重请求里的结束偏移(分块下载必须逐段精确,否则组装后长度对不上)。
@@ -211,15 +277,14 @@ async fn serve(mut stream: TcpStream, response: MockResponse) -> std::io::Result
} else {
(start, end)
};
(
206,
vec![
format!("Content-Range: bytes {claimed_start}-{claimed_end}/{total}"),
format!("Content-Length: {}", slice.len()),
"Accept-Ranges: bytes".to_string(),
],
slice,
)
let mut headers = vec![
format!("Content-Range: bytes {claimed_start}-{claimed_end}/{total}"),
"Accept-Ranges: bytes".to_string(),
];
if with_length {
headers.push(format!("Content-Length: {}", slice.len()));
}
(206, headers, slice)
}
(Some(_), true) => (
416,
@@ -230,7 +295,10 @@ async fn serve(mut stream: TcpStream, response: MockResponse) -> std::io::Result
Vec::new(),
),
_ => {
let mut headers = vec![format!("Content-Length: {total}")];
let mut headers = Vec::new();
if with_length {
headers.push(format!("Content-Length: {total}"));
}
if response.range_support {
headers.push("Accept-Ranges: bytes".to_string());
}
@@ -247,11 +315,53 @@ async fn serve(mut stream: TcpStream, response: MockResponse) -> std::io::Result
}
head.push_str("Connection: close\r\n\r\n");
stream.write_all(head.as_bytes()).await?;
stream.write_all(&body).await?;
write_body(&mut stream, &body, &response).await?;
stream.flush().await?;
Ok(())
}
/// 按夹具的口径写正文:正常 / 卡在中途 / 短包 / 超长尾巴 / 滴流。
async fn write_body(
stream: &mut TcpStream,
body: &[u8],
response: &MockResponse,
) -> std::io::Result<()> {
if let Some(stall_at) = response.stall_at {
// 头部 + 这么多字节已发出,之后一个字不再写(连接不断):读方只能靠超时脱身。
let sent = stall_at.min(body.len());
stream.write_all(&body[..sent]).await?;
stream.flush().await?;
tokio::time::sleep(HOLD).await;
return Ok(());
}
let limit = response.send_len.unwrap_or(body.len()).min(body.len());
match response.drip {
Some((piece, gap)) => {
let piece = piece.max(1);
let mut offset = 0;
while offset < limit {
let end = (offset + piece).min(limit);
stream.write_all(&body[offset..end]).await?;
stream.flush().await?;
offset = end;
if offset < limit {
tokio::time::sleep(gap).await;
}
}
}
None => {
stream.write_all(&body[..limit]).await?;
stream.flush().await?;
}
}
if let Some(extra) = response.trailing_extra {
let filler = vec![0_u8; extra];
stream.write_all(&filler).await?;
stream.flush().await?;
}
Ok(())
}
/// 解析 `Range: bytes=<start>-<end>`(`end` 可缺省 = 到文件末尾)。
fn parse_range_header(value: &str) -> Option<(u64, Option<u64>)> {
let (start, end) = value.split_once('-')?;