全仓下载路径此前只有两处无界 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 提交时它仍是逐字节原文)。
171 lines
6.9 KiB
Rust
171 lines
6.9 KiB
Rust
//! 分块作业的两道失速判据(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();
|
||
}
|