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

+312
View File
@@ -0,0 +1,312 @@
//! 失速诊断与下载的三段上界(M289 / 台账 P19)。
//!
//! # 事实:下载引擎曾是全仓唯一没有超时的那条路
//!
//! 全仓下载路径此前只有两处**无界** `await`:分块请求与单连接请求的 `send()`。
//! `build_client` 只设了 `connect_timeout`,reqwest 自己的 `timeout` 默认 `None` ——
//! 于是「服务器接了连接、不回一个字节」= 这个 future 永不结束,而下 188MB 的作业
//! 就在那里静静挂着(`ull java install` 走的是 `silent()` 引擎:零事件、零日志,
//! 用户只能看到一个不动的主界面)。
//!
//! 同仓两个先例:`region.rs` 给探测的 `send()` 包了 3s,`font.rs` 给整个
//! `download_bytes` 包了总预算——下载引擎自己反而是唯一没有界的那条路。
//!
//! # 取舍:三段可以共存的界,**不是**「整作业必须在 N 秒内下完」
//!
//! 两个先例为什么取值天差地别,看它们的作业有多大:
//!
//! * `region.rs:288` 的 3s —— 探测一个**极小响应**(`Range: bytes=0-0`)。整个作业就是
//! 一次只用拿几个字节的 GET,所以「单次请求上界」与「整作业预算」在这里是同一件事,
//! 3s 对两者都成立;它紧是因为**慢探测本身就是坏结果**。
//! * `font.rs:232-238` 的 `ATTEMPT_DEADLINE` / `TOTAL_DEADLINE` —— 字体是几百 KB 的
//! 单文件,整作业几秒就完,所以能一刀切死「慢」;代价是它必须对最后一个候选放宽
//! (每个候选都从 0 字节重下,单次上限就是一条吞吐门槛,卡死最后一个等于把
//! 「慢但能下完」判成永久失败——那里的注释已经记了这笔账)。
//!
//! 下 188MB 的 JDK 与两者都不同:整作业预算必须 ≥ 慢链路上的真实耗时,否则
//! 「慢但有效」被判死——这是加超时最常见的自杀方式。所以这里**不设**「作业必须 N 秒
//! 内下完」,而设三段互不顶替的界:
//!
//! 1. [`StallStage::AwaitHeaders`](`EngineConfig::response_timeout`,**单次请求**):
//! `send()` 只到「响应头到手」为止,不含正文,所以对 188MB 的作业它是一次性开销,
//! 取多紧都不会误杀正在下的大文件;
//! 2. [`StallStage::AwaitBytes`](`EngineConfig::read_timeout`,**空闲**判定):
//! 字节一直在动就一直续期。每秒只挤几 KB 的链路也算「在动」,只有**真的不动**了才判失速;
//! 3. [`StallStage::AwaitChunkJoin`](分块作业):一个分块卡住不许 4 条连接陪着挂到天荒地老。
//! 这里同时给「无进度窗口」与「总期限」,但总期限按**平均吞吐下限**反推
//! ([`job_budget`]),不是拍一个固定秒数:能把 188MB 在期限内下完的慢链路照样活。
//!
//! **不要**把它改成 `reqwest::ClientBuilder::timeout(...)`:reqwest 的 `timeout` 覆盖
//! **整个响应体**读取,那正好就是「慢但有效被判死」的那个坑(它的默认 `None` 也是 P19
//! 记的另一半事实)。每个阶段各自的界才分得清「连不上」「不回话」「不回字节」。
//!
//! # 数字从哪来(全仓只写这一处)
//!
//! * `response_timeout` = 60s:连接已有 10s 的 `connect_timeout` 兜底,这 60s 是**连接
//! 建立之后**等响应头的余额——CDN 排队、回源冷启动都在几十秒量级;响应头本身只有
//! 几百字节,能回的话 60s 内必回。
//! * `read_timeout` = 30s(既有值,不动):空闲判定。持续有字节就永远不触发。
//! * `chunk_stall_timeout` = 120s:4 条连接**整体**一个新字节都没有两分钟,只可能是
//! 分块任务卡住(写盘卡在杀软/网络盘上时,读超时不会响,只有这个会)。
//! * [`MIN_AVG_THROUGHPUT`] = 32 KiB/s:分块作业的总期限 = 总字节 / 32 KiB/s
//! (188MB ≈ 100 分钟)。即「整作业的平均速度掉到 32 KiB/s 以下才判死」;比这更快的
//! 慢链路仍会下完。**这是平均吞吐门槛,不是慢链路的死刑**:真实公网慢链路的取值
//! 没有真机验证过(M289 报告里列为未验项)。
//! * `verify_timeout` = 120s:188MB 的 SHA-1 在正常盘上不到 1s,120s 只抓「整文件重算
//! 摘要被卡住」(网络盘掉线、杀软锁文件),不参与慢链路判定。
//!
//! # 口径(读这两个数之前先看这里)
//!
//! `received` / `expected` 是**该阶段自己的口径**:等字节 = 这条连接收到多少 / 该连接
//! 被要求收多少(分块就是那一段的长度);等分块回收 = 整个作业收了多少 / 作业总长。
use std::fmt;
use std::time::Duration;
/// 分块作业的**最低平均吞吐**(字节/秒):整作业总期限按它反推(见 [`job_budget`])。
pub const MIN_AVG_THROUGHPUT: u64 = 32 * 1024;
/// 卡在哪一段(诊断字段;`as_str` 是稳定 id,`label` 是给人看的中文)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum StallStage {
/// 连接还没建立(DNS / TCP / TLS)。
Connect,
/// 连接已建立,等响应头(`send()` 的上界)。
AwaitHeaders,
/// 响应头已到,等下一块正文字节(读空闲判定的上界)。
AwaitBytes,
/// 分块作业在等分块任务回收(整作业的无进度 / 总期限上界)。
AwaitChunkJoin,
/// 整文件重算摘要(校验阶段)。
Verify,
}
impl StallStage {
/// 稳定字符串 id(日志 / 诊断页用,不要拿来显示给用户)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Connect => "connect",
Self::AwaitHeaders => "await_headers",
Self::AwaitBytes => "await_bytes",
Self::AwaitChunkJoin => "await_chunk_join",
Self::Verify => "verify",
}
}
/// 给人看的中文短名。
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Connect => "建立连接",
Self::AwaitHeaders => "等响应头",
Self::AwaitBytes => "等字节",
Self::AwaitChunkJoin => "等分块回收",
Self::Verify => "校验",
}
}
}
/// 为什么判失速:期限用完,还是「一段时间没有新字节」。
///
/// 两者必须分得开:一条 1 KB/s 但一直在动的链路**没有**无进展,只是慢;
/// 而 4 条连接同时不动两分钟与「整作业平均吞吐跌破下限」是两种不同的坏法,
/// 修法也不同(前者等一等/换源,后者基本只能换源)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum StallReason {
/// 一段期限用完(等响应头 / 校验 / 整作业总期限)。
Deadline,
/// 已有一段时间没有任何新字节。
NoProgress,
}
impl StallReason {
/// 稳定字符串 id。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Deadline => "deadline",
Self::NoProgress => "no_progress",
}
}
/// 给人看的中文短名。
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Deadline => "期限用完",
Self::NoProgress => "没有新字节",
}
}
}
/// 一次失速的现场:阶段 + 已收 / 预期字节 + 无进展时长(口径见模块头)。
///
/// 挂在 [`super::error::DownloadError::stall`] 上(结构化),同时由
/// [`super::error::DownloadError::stalled`] 抄一份进 `message`——因为整体失败时
/// `suppressed` 只搬 `message`,而上层(CLI / 诊断页)正靠那一行回答「卡在哪」。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StallReport {
/// 卡在哪一段。
pub stage: StallStage,
/// 为什么判失速。
pub reason: StallReason,
/// 已收字节(口径见模块头)。
pub received: u64,
/// 预期字节(未知时 `None`:总长不明的大文件)。
pub expected: Option<u64>,
/// 已经多久没有新字节。
pub idle: Duration,
}
impl StallReport {
/// 「一段期限用完」的现场。
#[must_use]
pub const fn deadline(
stage: StallStage,
received: u64,
expected: Option<u64>,
waited: Duration,
) -> Self {
Self {
stage,
reason: StallReason::Deadline,
received,
expected,
idle: waited,
}
}
/// 「已 `idle` 没有新字节」的现场。
#[must_use]
pub const fn no_progress(
stage: StallStage,
received: u64,
expected: Option<u64>,
idle: Duration,
) -> Self {
Self {
stage,
reason: StallReason::NoProgress,
received,
expected,
idle,
}
}
}
impl fmt::Display for StallReport {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"阶段={}({});已收 {} 字节",
self.stage.label(),
self.stage.as_str(),
self.received
)?;
match self.expected {
Some(expected) => write!(f, " / 预期 {expected} 字节")?,
None => write!(f, " / 预期未知")?,
}
write!(
f,
";已 {}ms 无进展;原因={}",
self.idle.as_millis(),
self.reason.label()
)
}
}
/// 两段可以共存的界(等响应头 / 等下一块字节),由 `EngineConfig::deadlines()` 构造。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Deadlines {
/// 发出请求 → 响应头到手的上界(单次请求,不含正文)。
pub response: Duration,
/// 两块正文之间的空闲上界(字节在动就一直续期)。
pub read_idle: Duration,
}
/// 分块作业的总期限:`max(floor, 总字节 / MIN_AVG_THROUGHPUT)`。
///
/// 为什么不是固定秒数:固定值对小作业太松、对大作业太紧;按平均吞吐反推,
/// 「能下得完的慢链路」永远不会撞上它,撞上的只有「慢到已经没意义」的作业。
#[must_use]
pub fn job_budget(total: u64, floor: Duration) -> Duration {
floor.max(Duration::from_secs(total / MIN_AVG_THROUGHPUT))
}
#[cfg(test)]
mod tests {
use super::{job_budget, Deadlines, StallReason, StallReport, StallStage, MIN_AVG_THROUGHPUT};
use std::time::Duration;
/// 总期限按平均吞吐反推:188MB 的作业拿到 ≈100 分钟,小作业只拿到下限。
///
/// 变异证明:把 `job_budget` 写成常量(比如 `Duration::from_secs(30)`)⇒ 第一条红;
/// 把 `floor.max(..)` 写成 `min` ⇒ 第二条红(188MB 只剩 100 分钟里的 `floor`)。
#[test]
fn the_job_budget_scales_with_size_and_never_drops_below_the_floor() {
let floor = Duration::from_secs(600);
let big = job_budget(188 * 1024 * 1024, floor);
let by_throughput = Duration::from_secs(188 * 1024 * 1024 / MIN_AVG_THROUGHPUT);
assert_eq!(big, by_throughput, "大作业的期限由平均吞吐决定");
assert!(big > Duration::from_secs(90 * 60), "188MB 至少给到 90 分钟");
assert_eq!(
job_budget(8 * 1024, floor),
floor,
"小作业只拿到下限(吞吐算式在这里没有意义)"
);
}
/// 诊断串必须同时有:阶段(中英文都给)、已收字节、预期字节、无进展时长、原因。
///
/// 变异证明:删掉 `stage` 那一段 ⇒ 前两条 red;把 `received` / `expected` 写成常量
/// 或删掉 ⇒ 对应断言 red;把 `reason` 删掉 ⇒ 最后一条 red。
#[test]
fn the_report_spells_out_stage_three_numbers_and_reason() {
let report = StallReport::no_progress(
StallStage::AwaitBytes,
4096,
Some(188 * 1024 * 1024),
Duration::from_millis(30_000),
);
let text = report.to_string();
assert!(text.contains("等字节"), "{text}");
assert!(text.contains("await_bytes"), "{text}");
assert!(text.contains("已收 4096 字节"), "{text}");
assert!(text.contains("预期 197132288 字节"), "{text}");
assert!(text.contains("已 30000ms 无进展"), "{text}");
assert!(text.contains("原因=没有新字节"), "{text}");
assert!(report.reason == StallReason::NoProgress);
// 总长未知也要能说清楚(不是「预期 0」)。
let unknown =
StallReport::deadline(StallStage::AwaitHeaders, 0, None, Duration::from_secs(60));
assert!(unknown.to_string().contains("预期未知"), "{unknown}");
assert_eq!(unknown.reason, StallReason::Deadline);
}
/// 阶段 id 是日志里的 grep 锚点:改名字等于改诊断口径,得先改这里。
#[test]
fn stage_and_reason_ids_are_stable() {
let stages = [
(StallStage::Connect, "connect", "建立连接"),
(StallStage::AwaitHeaders, "await_headers", "等响应头"),
(StallStage::AwaitBytes, "await_bytes", "等字节"),
(StallStage::AwaitChunkJoin, "await_chunk_join", "等分块回收"),
(StallStage::Verify, "verify", "校验"),
];
for (stage, id, label) in stages {
assert_eq!(stage.as_str(), id);
assert_eq!(stage.label(), label);
}
assert_eq!(StallReason::Deadline.as_str(), "deadline");
assert_eq!(StallReason::NoProgress.as_str(), "no_progress");
let deadlines = Deadlines {
response: Duration::from_secs(60),
read_idle: Duration::from_secs(30),
};
assert_eq!(deadlines.response, Duration::from_secs(60));
}
}