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
@@ -7,6 +7,8 @@
|
||||
//! * 重定向上限 20 跳(承 HMCL),跳转后仍按最终 URL 做 host 校验由调用方负责;
|
||||
//! * 错误分类只做「同源重试 / 立刻换源 / 整体失败」三态,不做细颗粒映射。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use reqwest::header::{
|
||||
HeaderMap, HeaderName, ACCEPT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE, ETAG, LAST_MODIFIED,
|
||||
};
|
||||
@@ -14,6 +16,7 @@ use reqwest::redirect::Policy;
|
||||
|
||||
use super::config::EngineConfig;
|
||||
use super::error::{DownloadError, DownloadErrorKind};
|
||||
use super::stall::{Deadlines, StallReport, StallStage};
|
||||
|
||||
/// 重定向上限(承 HMCL 的 20 跳)。
|
||||
pub const MAX_REDIRECTS: usize = 20;
|
||||
@@ -57,6 +60,68 @@ pub fn build_client(cfg: &EngineConfig) -> Result<reqwest::Client, DownloadError
|
||||
})
|
||||
}
|
||||
|
||||
/// **两处 `send()` 的唯一上界构造点**(`transfer.rs` 的单连接请求、`chunked.rs` 的分块请求)。
|
||||
///
|
||||
/// 上界只覆盖「请求发出 → 响应头到手」,正文由 [`read_step`] 的空闲判定管;为什么选
|
||||
/// 「单次请求上界」而不是「整作业总预算」、这些数字怎么来的,见 [`super::stall`] 模块头
|
||||
/// (**全仓只写那一处**)。
|
||||
///
|
||||
/// 连接失败/等响应头超时会把阶段(`connect` / `await_headers`)与三个数挂进错误;
|
||||
/// 其它错误(4xx、DNS 之外的协议问题)走原分类,不冒充失速。
|
||||
pub async fn send_bounded(
|
||||
request: reqwest::RequestBuilder,
|
||||
url: &str,
|
||||
deadlines: Deadlines,
|
||||
expected: Option<u64>,
|
||||
) -> Result<reqwest::Response, DownloadError> {
|
||||
let started = std::time::Instant::now();
|
||||
match tokio::time::timeout(deadlines.response, request.send()).await {
|
||||
Ok(Ok(response)) => Ok(response),
|
||||
Ok(Err(err)) => {
|
||||
// 只有**超时**才是失速:连接被拒 / DNS 失败 / 证书错误照旧走原分类,
|
||||
// 别把「连不上」包装成 Timeout(那会让上层对错误的判断全部偏一档)。
|
||||
if err.is_timeout() {
|
||||
let stage = if err.is_connect() {
|
||||
StallStage::Connect
|
||||
} else {
|
||||
StallStage::AwaitHeaders
|
||||
};
|
||||
return Err(DownloadError::stalled(
|
||||
StallReport::deadline(stage, 0, expected, started.elapsed()),
|
||||
format!("{err}({url})"),
|
||||
));
|
||||
}
|
||||
Err(classify_reqwest(&err, url))
|
||||
}
|
||||
Err(_) => Err(DownloadError::stalled(
|
||||
StallReport::deadline(StallStage::AwaitHeaders, 0, expected, deadlines.response),
|
||||
format!("等响应头超过 {}ms({url})", deadlines.response.as_millis()),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 等下一块正文(两处读循环共用这一处构造):超时 ⇒ 阶段「等字节」+ 已收/预期/无进展三个数。
|
||||
///
|
||||
/// 注意它是**空闲**判定:`step` 只要在两块之间按时完成就一直续期,慢但持续的流不会被它误杀。
|
||||
pub async fn read_step<F, T>(
|
||||
step: F,
|
||||
idle: Duration,
|
||||
received: u64,
|
||||
expected: Option<u64>,
|
||||
url: &str,
|
||||
) -> Result<Option<T>, DownloadError>
|
||||
where
|
||||
F: std::future::Future<Output = Option<T>>,
|
||||
{
|
||||
match tokio::time::timeout(idle, step).await {
|
||||
Ok(item) => Ok(item),
|
||||
Err(_) => Err(DownloadError::stalled(
|
||||
StallReport::no_progress(StallStage::AwaitBytes, received, expected, idle),
|
||||
format!("等下一块字节超过 {}ms({url})", idle.as_millis()),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 把 `reqwest::Error` 映射成我们的错误分类。
|
||||
#[must_use]
|
||||
pub fn classify_reqwest(err: &reqwest::Error, url: &str) -> DownloadError {
|
||||
|
||||
Reference in new issue
Block a user