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:
1 parent
61c8a4eff9
commit
cc0def3415
16 files changed
+1243
-68
No files matched your search
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user