Files
Unlucky-Launcher/crates/core/tests/download/chunked/timeout.rs
T
AskaEth cc0def3415 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 提交时它仍是逐字节原文)。
2026-11-12 23:24:19 +08:00

171 lines
6.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 分块作业的两道失速判据(M289 / 台账 P19 的「188MB 落完 + 永久挂住」现场)。
//!
//! `JoinSet::join_next` 此前**没有总期限**:一个分块卡在 #1,`while let Some(...)` 就永远
//! 不结束——文件已经 `set_len(total)` 预分配(NTFS 上真占空间),复审看到的 `.ullpart`
//! 是满大小,看起来像「下完了」。这里钉住两件事,且两者必须**分得开**(错误里靠
//! `StallReason` 区分,而不是一律「下载失败」):
//!
//! * 无进度:`counter` 连续 `chunk_stall_timeout` 没有新字节 ⇒ `reason = no_progress`;
//! * 总期限:`chunk_job_budget`(按平均吞吐下限反推)用完 ⇒ `reason = deadline`。
//!
//! 变异:把 `await_chunks` 换回 `while let Some(joined) = set.join_next().await`(无期限)
//! ⇒ 两条都靠外层 `GATE` 挂断而红;把 `StallReason` 的分支写成常量 ⇒ 第二条红
//! (它断言的是 `deadline`,而无进度那条断言 `no_progress`)。
//!
//! 拆成子模块:`chunked_test.rs` 已 379 行。
use std::sync::Arc;
use std::time::{Duration, Instant};
use unlucky_core::download::backoff::BackoffPolicy;
use unlucky_core::download::cache::ContentCache;
use unlucky_core::download::chunked::ChunkedConfig;
use unlucky_core::download::config::EngineConfig;
use unlucky_core::download::engine::DownloadEngine;
use unlucky_core::download::error::DownloadErrorKind;
use unlucky_core::download::health::HealthRegistry;
use unlucky_core::download::mirrors::MirrorTable;
use unlucky_core::download::region::RegionProfile;
use unlucky_core::download::stall::{StallReason, StallStage};
use unlucky_core::download::types::{DownloadRequest, ResourceKind};
use super::support::{temp_dir, MockResponse, MockServer};
/// 外层保险:分块作业若真的无期限,用例在这里挂断并说明「这就是 bug」。
const GATE: Duration = Duration::from_secs(5);
fn config() -> EngineConfig {
EngineConfig {
max_attempts_per_source: 1,
backoff: BackoffPolicy::none(),
connect_timeout: Duration::from_secs(2),
response_timeout: Duration::from_secs(2),
// 读空闲给到 10s:让**整体**判据先响,证明它自己有效(不是靠单块读超时兜底)。
read_timeout: Duration::from_secs(10),
chunk_stall_timeout: Duration::from_secs(10),
chunk_job_floor: Duration::from_secs(10),
verify_timeout: Duration::from_secs(10),
chunked: ChunkedConfig {
enabled: true,
connections: 4,
min_size: 1024,
},
..EngineConfig::default()
}
}
fn harness(tag: &str, cfg: EngineConfig) -> (DownloadEngine, std::path::PathBuf) {
let dir = temp_dir(tag);
let cache = Arc::new(ContentCache::open(dir.join("cache")));
let engine = DownloadEngine::new(
cfg,
Arc::new(MirrorTable::builtin()),
Arc::new(RegionProfile::direct(0)),
Arc::new(HealthRegistry::in_memory()),
cache,
);
(engine, dir)
}
fn request(url: &str, dest: std::path::PathBuf, size: u64) -> DownloadRequest {
DownloadRequest::new(ResourceKind::JavaRuntime, url, &dest).with_size(size)
}
/// 4 条分块连接全部「回了头、不给字节」:整个作业两分钟不动 ⇒ 「等分块回收 + 没有新字节」。
#[tokio::test]
async fn a_chunked_job_with_no_progress_is_named_await_chunk_join() {
let payload = vec![1_u8; 16 * 1024];
let size = payload.len() as u64;
let stalled = |_| MockResponse::ok(payload.clone()).stalling_after(0);
let server = MockServer::start((0..4).map(stalled).collect()).await;
let (engine, dir) = harness(
"chunked-timeout-stall",
EngineConfig {
chunk_stall_timeout: Duration::from_millis(200),
..config()
},
);
let url = server.url("/jdk.zip");
let started = Instant::now();
let error = tokio::time::timeout(
GATE,
engine.download(request(&url, dir.join("jdk.zip"), size)),
)
.await
.expect("分块回收必须有期限:外层 5s 挂断正说明 `join_next` 仍无界")
.expect_err("4 条连接都不给字节时必须失败");
let elapsed = started.elapsed();
assert!(
elapsed < Duration::from_secs(2),
"应在无进度窗口附近返回:{elapsed:?}"
);
assert_eq!(error.kind, DownloadErrorKind::AllSourcesFailed);
let stall = error.stall.as_deref().expect("分块失速必须带现场");
assert_eq!(stall.stage, StallStage::AwaitChunkJoin, "{stall}");
assert_eq!(stall.reason, StallReason::NoProgress, "{stall}");
assert_eq!(
stall.received, 0,
"一个字节都没到(口径是整个作业):{stall}"
);
assert_eq!(stall.expected, Some(size), "{stall}");
assert!(
stall.idle >= Duration::from_millis(200),
"无进展时长必须 ≥ 判定窗口:{stall}"
);
assert!(
error.to_string().contains("等分块回收"),
"用户那行字要说清卡在哪:{error}"
);
server.stop();
}
/// 分块作业的**总期限**:一直在动,但平均吞吐低到没意义 ⇒ 「等分块回收 + 期限用完」。
///
/// 与上一条的区别只有 `reason`:这条的 `received > 0`(一直在动),上一条是 0。
#[tokio::test]
async fn a_chunked_job_that_is_too_slow_hits_the_total_budget() {
let payload = vec![2_u8; 16 * 1024];
let size = payload.len() as u64;
// 每条连接每 20ms 滴 1 字节:一直在动(不会触发无进度),但远低于平均吞吐下限。
let dripping = |_| MockResponse::ok(payload.clone()).dripping(1, Duration::from_millis(20));
let server = MockServer::start((0..4).map(dripping).collect()).await;
let (engine, dir) = harness(
"chunked-timeout-budget",
EngineConfig {
chunk_job_floor: Duration::from_millis(300),
..config()
},
);
let url = server.url("/jdk.zip");
let started = Instant::now();
let error = tokio::time::timeout(
GATE,
engine.download(request(&url, dir.join("jdk.zip"), size)),
)
.await
.expect("分块作业总期限必须有界(外层挂断 = 无界)")
.expect_err("慢到没有意义的作业必须在总期限处失败");
let elapsed = started.elapsed();
assert!(
elapsed < Duration::from_secs(2),
"总期限 300ms,实际 {elapsed:?}(太长说明期限没被用上)"
);
let stall = error.stall.as_deref().expect("总期限失败也要带现场");
assert_eq!(stall.stage, StallStage::AwaitChunkJoin, "{stall}");
assert_eq!(
stall.reason,
StallReason::Deadline,
"总期限要用 deadline,不能与「无进度」混成一句:{stall}"
);
assert!(
stall.received > 0,
"这条一直在动,已收字节必须 > 0(0 就说明它其实是卡住而不是慢):{stall}"
);
assert_eq!(stall.expected, Some(size), "{stall}");
server.stop();
}