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,170 @@
|
||||
//! 分块作业的两道失速判据(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();
|
||||
}
|
||||
@@ -373,3 +373,7 @@ async fn rejects_lying_content_range_and_falls_back() {
|
||||
assert_eq!(std::fs::read(&dest).expect("读回落盘文件失败"), payload);
|
||||
server.stop();
|
||||
}
|
||||
|
||||
/// 分块作业的两道失速判据(无进度 / 总期限):拆到子模块守 400 行硬线。
|
||||
#[path = "chunked/timeout.rs"]
|
||||
mod timeout;
|
||||
@@ -0,0 +1,319 @@
|
||||
//! 超时的**可诊断性**与**不误杀**(M289 / 台账 P19 的下半段)。
|
||||
//!
|
||||
//! 这条 mission 的价值不在「加了超时」,而在两件别的事:
|
||||
//!
|
||||
//! 1. **下次挂住时能一眼看出卡在哪**:每个超时错误都带阶段(连接 / 等响应头 / 等字节 /
|
||||
//! 等分块回收 / 校验)+ 已收字节 / 预期字节 / 无进展时长三个数,且这几个数真的来自现场
|
||||
//! (不是常量)。变异:把 `StallReport` 的 `stage` 字段删掉、或把 `received` 写成 `0`
|
||||
//! 常量 ⇒ `the_stage_numbers_are_real` 红(下面贴了红原文)。
|
||||
//! 2. **慢但有效不许被误杀**:单次请求上界只覆盖「响应头到手」,正文靠空闲判定;
|
||||
//! 所以一条 4 秒才下完 96 字节的滴流必须成功。变异:把等响应头的上界套到整个传输上
|
||||
//! ⇒ `a_slow_but_steady_stream_is_not_killed` 红。
|
||||
//!
|
||||
//! 还有一条来自复审的纠正:`engine.rs` 的 `_permit` 是 RAII 守卫(future 被 drop 时自己
|
||||
//! 归还),所以这里**不写**「手动归还」补丁;用例断言的是**行为**——超时之后引擎还能
|
||||
//! 正常完成一次新下载(`max_concurrency = 1` 时这条才是可观测的)。
|
||||
//!
|
||||
//! 拆成子模块:`engine_mock_test.rs` 已 364 行,再加这几条会越 400 行硬线。
|
||||
//! 全离线(`127.0.0.1` 回环)、每条 <2s(含外层 `timeout` 保险)。
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
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::stall::{StallReason, StallStage};
|
||||
use unlucky_core::download::types::{DownloadRequest, ResourceKind};
|
||||
|
||||
use super::direct_region;
|
||||
use super::support::{sha1_hex, temp_dir, MockResponse, MockServer};
|
||||
|
||||
/// 外层保险:引擎若真的无界,用例在这里挂断并给出「这就是 bug」的说明,而不是跑不完。
|
||||
const GATE: Duration = Duration::from_secs(5);
|
||||
|
||||
fn engine(cfg: EngineConfig, tag: &str) -> (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(direct_region()),
|
||||
Arc::new(HealthRegistry::in_memory()),
|
||||
cache,
|
||||
);
|
||||
(engine, dir)
|
||||
}
|
||||
|
||||
fn config(response_ms: u64, read_ms: u64) -> EngineConfig {
|
||||
EngineConfig {
|
||||
max_attempts_per_source: 1,
|
||||
backoff: unlucky_core::download::backoff::BackoffPolicy::none(),
|
||||
connect_timeout: Duration::from_secs(2),
|
||||
response_timeout: Duration::from_millis(response_ms),
|
||||
read_timeout: Duration::from_millis(read_ms),
|
||||
chunked: ChunkedConfig::disabled(),
|
||||
..EngineConfig::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn silent_responses(count: usize) -> Vec<MockResponse> {
|
||||
(0..count).map(|_| MockResponse::silent()).collect()
|
||||
}
|
||||
|
||||
fn request(url: &str, dest: std::path::PathBuf, size: Option<u64>) -> DownloadRequest {
|
||||
let request = DownloadRequest::new(ResourceKind::ClientJar, url, &dest);
|
||||
match size {
|
||||
Some(size) => request.with_size(size),
|
||||
None => request,
|
||||
}
|
||||
}
|
||||
|
||||
/// `silent()`:服务端接了连接、读完请求头、一个字都不回。
|
||||
///
|
||||
/// 这就是 P19 记的完全一致现场——`send()` 无界 ⇒ 这个 future 永不结束。
|
||||
/// 断言四件事:①引擎**自己**在 2s 内返回(外层 5s 挂断 = 无界)②阶段是「等响应头」
|
||||
/// ③三个数是真的(已收 0 / 预期 = 文件长度 / 无进展 ≈ 上界)④用户看得见的那行字里
|
||||
/// 也有阶段与数字(`silent()` 引擎零事件、零日志,只有这一行)。
|
||||
#[tokio::test]
|
||||
async fn a_silent_server_times_out_and_names_the_stage_with_numbers() {
|
||||
let payload = vec![7_u8; 64 * 1024];
|
||||
let size = payload.len() as u64;
|
||||
let server = MockServer::start(silent_responses(2)).await;
|
||||
let (engine, dir) = engine(config(300, 300), "timeout-silent");
|
||||
let url = server.url("/silent.jar");
|
||||
|
||||
let started = Instant::now();
|
||||
let error = tokio::time::timeout(
|
||||
GATE,
|
||||
engine.download(request(&url, dir.join("silent.jar"), Some(size))),
|
||||
)
|
||||
.await
|
||||
.expect("引擎必须自己超时返回:外层 5s 挂断正说明内部仍是**无界**的(这就是要修的 bug)")
|
||||
.expect_err("silent 服务端必须失败");
|
||||
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::AwaitHeaders, "{stall}");
|
||||
assert_eq!(stall.reason, StallReason::Deadline, "{stall}");
|
||||
assert_eq!(stall.received, 0, "一个字节都还没到:{stall}");
|
||||
assert_eq!(
|
||||
stall.expected,
|
||||
Some(size),
|
||||
"预期字节是这次请求的可诊断参数:{stall}"
|
||||
);
|
||||
assert!(
|
||||
stall.idle >= Duration::from_millis(300),
|
||||
"无进展时长不得小于上界:{stall}"
|
||||
);
|
||||
|
||||
let text = error.to_string();
|
||||
for needle in ["等响应头", "await_headers", "已收 0 字节"] {
|
||||
assert!(text.contains(needle), "错误文案缺少「{needle}」:{text}");
|
||||
}
|
||||
assert!(
|
||||
text.contains("预期 65536 字节"),
|
||||
"错误文案缺少预期字节:{text}"
|
||||
);
|
||||
server.stop();
|
||||
}
|
||||
|
||||
/// `stall_after`:响应头到手、正文写出一截、然后卡住。
|
||||
///
|
||||
/// 阶段必须是「等字节」而不是「等响应头」——两者同样是「没数据」,但修法不同
|
||||
/// (前者等/换源,后者多半是源坏了)。三个数按**这条连接**的口径:已收 = 真读到的量。
|
||||
#[tokio::test]
|
||||
async fn a_stall_mid_body_is_named_await_bytes_with_the_real_byte_count() {
|
||||
let payload = vec![9_u8; 32 * 1024];
|
||||
let size = payload.len() as u64;
|
||||
let server =
|
||||
MockServer::start(vec![MockResponse::ok(payload.clone()).stalling_after(64)]).await;
|
||||
let (engine, dir) = engine(config(2_000, 300), "timeout-stall-body");
|
||||
let url = server.url("/stall.jar");
|
||||
|
||||
let started = Instant::now();
|
||||
let error = tokio::time::timeout(
|
||||
GATE,
|
||||
engine.download(request(&url, dir.join("stall.jar"), Some(size))),
|
||||
)
|
||||
.await
|
||||
.expect("读取必须有界(外层挂断 = 读循环仍无界)")
|
||||
.expect_err("中途卡住必须失败");
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
assert!(
|
||||
elapsed < Duration::from_secs(2),
|
||||
"应在上界附近返回:{elapsed:?}"
|
||||
);
|
||||
let stall = error.stall.as_deref().expect("中途卡住必须带失速现场");
|
||||
assert_eq!(stall.stage, StallStage::AwaitBytes, "{stall}");
|
||||
assert_eq!(stall.reason, StallReason::NoProgress, "{stall}");
|
||||
assert_eq!(
|
||||
stall.received, 64,
|
||||
"已收字节必须是**真实**读到的量(服务端只发了 64 字节):{stall}"
|
||||
);
|
||||
assert_eq!(stall.expected, Some(size), "{stall}");
|
||||
assert!(stall.idle >= Duration::from_millis(300), "{stall}");
|
||||
}
|
||||
|
||||
/// 阳性对照①:**短包**——头部声称全量、正文只到一半就关连接。
|
||||
///
|
||||
/// 必须快速、确定地失败(长度不符),不是挂住、也不是「成功但文件短了」。
|
||||
#[tokio::test]
|
||||
async fn a_short_body_fails_definitively_instead_of_hanging() {
|
||||
let payload = vec![3_u8; 8 * 1024];
|
||||
let size = payload.len() as u64;
|
||||
let server = MockServer::start(vec![MockResponse::ok(payload.clone()).short_body(4096)]).await;
|
||||
let (engine, dir) = engine(config(2_000, 2_000), "timeout-short-body");
|
||||
let url = server.url("/short.jar");
|
||||
|
||||
let started = Instant::now();
|
||||
let error = tokio::time::timeout(
|
||||
GATE,
|
||||
engine.download(request(&url, dir.join("short.jar"), Some(size))),
|
||||
)
|
||||
.await
|
||||
.expect("短包必须快速失败,不许等读取上界")
|
||||
.expect_err("短包必须失败");
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_secs(2),
|
||||
"短包应立即判失败"
|
||||
);
|
||||
assert_eq!(error.kind, DownloadErrorKind::AllSourcesFailed);
|
||||
// reqwest 对「Content-Length 没读满就被关掉」报的是 `error decoding response body`
|
||||
// (不是我们的「长度不符」),所以这里不钉它的文案:钉的是**有界 + 有成因 + 坏文件不落盘**。
|
||||
assert!(
|
||||
!error.suppressed.is_empty(),
|
||||
"短包必须留下一条逐源成因,而不是一句「下载失败」"
|
||||
);
|
||||
assert!(
|
||||
!dir.join("short.jar").exists(),
|
||||
"短包不许落盘(宁可失败也不能交付半个文件)"
|
||||
);
|
||||
assert!(error.stall.is_none(), "这不是失速(有明确终点):{error}");
|
||||
}
|
||||
|
||||
/// 阳性对照②:**超长包**——不发 `Content-Length`、正文之后再多吐一堆字节。
|
||||
///
|
||||
/// 客户端按期望长度收,多出来的字节不能悄悄算进文件:必须判长度不符。
|
||||
#[tokio::test]
|
||||
async fn an_oversize_body_is_rejected_by_length() {
|
||||
let payload = vec![5_u8; 8 * 1024];
|
||||
let size = payload.len() as u64;
|
||||
let server =
|
||||
MockServer::start(vec![MockResponse::ok(payload.clone()).oversize_body(4096)]).await;
|
||||
let (engine, dir) = engine(config(2_000, 2_000), "timeout-oversize-body");
|
||||
let url = server.url("/oversize.jar");
|
||||
|
||||
let error = tokio::time::timeout(
|
||||
GATE,
|
||||
engine.download(request(&url, dir.join("oversize.jar"), Some(size))),
|
||||
)
|
||||
.await
|
||||
.expect("超长包必须有界地失败")
|
||||
.expect_err("超长包必须失败");
|
||||
assert!(
|
||||
error
|
||||
.suppressed
|
||||
.iter()
|
||||
.any(|item| item.contains("长度不符")),
|
||||
"超长包要报长度不符(多出来的字节不许算进文件):{:?}",
|
||||
error.suppressed
|
||||
);
|
||||
}
|
||||
|
||||
/// **慢但有效不许被误杀**(加超时最常见的自杀方式)。
|
||||
///
|
||||
/// 服务端每 40ms 滴 8 字节,96 字节要 ≈440ms 才下完;等响应头的上界只有 100ms。
|
||||
/// 只要上界只覆盖「响应头到手」,这条就成功;一旦有人把它套到整个传输上就红。
|
||||
#[tokio::test]
|
||||
async fn a_slow_but_steady_stream_is_not_killed() {
|
||||
let payload: Vec<u8> = (0..96).map(|index| (index % 251) as u8).collect();
|
||||
let device = MockResponse::ok(payload.clone()).dripping(8, Duration::from_millis(40));
|
||||
let server = MockServer::start(vec![device]).await;
|
||||
let (engine, dir) = engine(config(100, 1_000), "timeout-slow-steady");
|
||||
let url = server.url("/slow.jar");
|
||||
let dest = dir.join("slow.jar");
|
||||
|
||||
let outcome = tokio::time::timeout(
|
||||
GATE,
|
||||
engine.download(
|
||||
DownloadRequest::new(ResourceKind::ClientJar, url.as_str(), &dest)
|
||||
.with_sha1(sha1_hex(&payload))
|
||||
.with_size(payload.len() as u64),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("慢但持续的流被误杀:时间上界被套到了整个传输上")
|
||||
.expect("慢但持续的流必须成功");
|
||||
|
||||
assert_eq!(outcome.bytes, payload.len() as u64);
|
||||
assert_eq!(std::fs::read(&dest).expect("读回落盘文件失败"), payload);
|
||||
server.stop();
|
||||
}
|
||||
|
||||
/// 复审纠正的判据:**超时之后引擎还能正常下载**(`_permit` 是 RAII,问题在无界,不在归还)。
|
||||
///
|
||||
/// `max_concurrency = 1` 让「许可有没有回到池里」可观测:第一条被超时打断后,
|
||||
/// 第二条若拿不到许可就会挂住 ⇒ 外层 `GATE` 挂断并报「许可没还回来」。
|
||||
#[tokio::test]
|
||||
async fn after_a_timeout_the_engine_still_downloads_again() {
|
||||
let payload = b"second download works".to_vec();
|
||||
let silent = MockServer::start(silent_responses(2)).await;
|
||||
let good = MockServer::start(vec![MockResponse::ok(payload.clone())]).await;
|
||||
let (engine, dir) = engine(
|
||||
EngineConfig {
|
||||
max_concurrency: 1,
|
||||
..config(200, 200)
|
||||
},
|
||||
"timeout-permit",
|
||||
);
|
||||
|
||||
let first = tokio::time::timeout(
|
||||
GATE,
|
||||
engine.download(request(
|
||||
&silent.url("/dead.jar"),
|
||||
dir.join("dead.jar"),
|
||||
None,
|
||||
)),
|
||||
)
|
||||
.await
|
||||
.expect("第一条必须自己超时(外层挂断 = 无界)")
|
||||
.expect_err("silent 服务端必须失败");
|
||||
assert_eq!(first.kind, DownloadErrorKind::AllSourcesFailed);
|
||||
assert_eq!(
|
||||
engine.concurrency_probe(),
|
||||
1,
|
||||
"超时之后许可必须回到池里(RAII 自还,不许写手动归还补丁)"
|
||||
);
|
||||
|
||||
let outcome = tokio::time::timeout(
|
||||
GATE,
|
||||
engine.download(
|
||||
DownloadRequest::new(
|
||||
ResourceKind::ClientJar,
|
||||
good.url("/ok.jar"),
|
||||
dir.join("ok.jar"),
|
||||
)
|
||||
.with_sha1(sha1_hex(&payload)),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("许可没还回来:第二次下载挂住了")
|
||||
.expect("超时之后引擎必须还能完成一次新下载");
|
||||
assert_eq!(outcome.bytes, payload.len() as u64);
|
||||
silent.stop();
|
||||
good.stop();
|
||||
}
|
||||
@@ -362,3 +362,7 @@ async fn reports_progress_events_and_errors_when_all_sources_fail() {
|
||||
/// 「404 不是源坏了」的守卫(M197 P2-2 的第二档):拆到子模块守 400 行硬线。
|
||||
#[path = "engine_mock/not_found.rs"]
|
||||
mod not_found;
|
||||
|
||||
/// 超时的可诊断性 / 不误杀 / 超时后许可自还(M289):同样拆到子模块守 400 行硬线。
|
||||
#[path = "engine_mock/timeout.rs"]
|
||||
mod timeout;
|
||||
@@ -8,6 +8,7 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
@@ -74,8 +75,23 @@ pub struct MockResponse {
|
||||
pub etag: Option<String>,
|
||||
/// 返回 206 时故意把 `Content-Range` 写成从 0 开始(模拟段错位的坏服务器)。
|
||||
pub lie_content_range: bool,
|
||||
/// 读完请求头后**一个字节都不写**(服务端接了连接但永不回话)。
|
||||
///
|
||||
/// 这是「无界 `await`」唯一能表达成用例的形态:读方不设上界就永远不返回。
|
||||
pub silent: bool,
|
||||
/// 写出这么多字节正文后就**停住不再写**(中途卡住),连接保持打开。
|
||||
pub stall_at: Option<usize>,
|
||||
/// 正文实际只发这么多字节(头部仍声称全量 = 短包)。
|
||||
pub send_len: Option<usize>,
|
||||
/// 正文之后**多**发这么多字节,且故意不发 `Content-Length`(超长包:HTTP/1.1 靠关连接分帧)。
|
||||
pub trailing_extra: Option<usize>,
|
||||
/// 正文按 `piece` 字节一片、片间睡 `gap` 滴流(慢但持续,用来证明超时不会误杀它)。
|
||||
pub drip: Option<(usize, Duration)>,
|
||||
}
|
||||
|
||||
/// 夹具「挂住」的时长:远大于用例自己的上界,保证读方只能靠超时脱身。
|
||||
const HOLD: Duration = Duration::from_secs(30);
|
||||
|
||||
impl MockResponse {
|
||||
/// 200 + 完整内容(支持 Range)。
|
||||
pub fn ok(body: impl Into<Vec<u8>>) -> Self {
|
||||
@@ -85,6 +101,11 @@ impl MockResponse {
|
||||
range_support: true,
|
||||
etag: Some("\"v1\"".to_string()),
|
||||
lie_content_range: false,
|
||||
silent: false,
|
||||
stall_at: None,
|
||||
send_len: None,
|
||||
trailing_extra: None,
|
||||
drip: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,9 +117,46 @@ impl MockResponse {
|
||||
range_support: false,
|
||||
etag: None,
|
||||
lie_content_range: false,
|
||||
silent: false,
|
||||
stall_at: None,
|
||||
send_len: None,
|
||||
trailing_extra: None,
|
||||
drip: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 200 但**永不回话**:读完请求头就挂住,一个字都不写。
|
||||
pub fn silent() -> Self {
|
||||
Self {
|
||||
silent: true,
|
||||
..Self::ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
/// 写完 `bytes` 字节正文后停住(中途卡住,连接不断)。
|
||||
pub fn stalling_after(mut self, bytes: usize) -> Self {
|
||||
self.stall_at = Some(bytes);
|
||||
self
|
||||
}
|
||||
|
||||
/// 头部声称全量、正文只发 `sent` 字节(短包,客户端读不满)。
|
||||
pub fn short_body(mut self, sent: usize) -> Self {
|
||||
self.send_len = Some(sent);
|
||||
self
|
||||
}
|
||||
|
||||
/// 正文之后多发 `extra` 字节且不发 `Content-Length`(超长包)。
|
||||
pub fn oversize_body(mut self, extra: usize) -> Self {
|
||||
self.trailing_extra = Some(extra);
|
||||
self
|
||||
}
|
||||
|
||||
/// 正文按 `piece` 字节一片、片间 `gap` 滴流(慢但持续)。
|
||||
pub fn dripping(mut self, piece: usize, gap: Duration) -> Self {
|
||||
self.drip = Some((piece, gap));
|
||||
self
|
||||
}
|
||||
|
||||
/// 声明不支持 Range(模拟部分镜像)。
|
||||
pub fn without_range(mut self) -> Self {
|
||||
self.range_support = false;
|
||||
@@ -198,7 +256,15 @@ async fn serve(mut stream: TcpStream, response: MockResponse) -> std::io::Result
|
||||
.map(str::trim)
|
||||
.and_then(parse_range_header);
|
||||
|
||||
if response.silent {
|
||||
// 接了连接、读完请求头、一个字都不回:读方不设上界就永远挂在这里。
|
||||
tokio::time::sleep(HOLD).await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let total = response.body.len() as u64;
|
||||
// 超长包不能发 `Content-Length`,否则读方按声明截断、永远看不到多余字节。
|
||||
let with_length = response.trailing_extra.is_none();
|
||||
let (status, headers, body) = match (range, response.range_support) {
|
||||
(Some((start, requested_end)), true) if start < total => {
|
||||
// 尊重请求里的结束偏移(分块下载必须逐段精确,否则组装后长度对不上)。
|
||||
@@ -211,15 +277,14 @@ async fn serve(mut stream: TcpStream, response: MockResponse) -> std::io::Result
|
||||
} else {
|
||||
(start, end)
|
||||
};
|
||||
(
|
||||
206,
|
||||
vec![
|
||||
format!("Content-Range: bytes {claimed_start}-{claimed_end}/{total}"),
|
||||
format!("Content-Length: {}", slice.len()),
|
||||
"Accept-Ranges: bytes".to_string(),
|
||||
],
|
||||
slice,
|
||||
)
|
||||
let mut headers = vec![
|
||||
format!("Content-Range: bytes {claimed_start}-{claimed_end}/{total}"),
|
||||
"Accept-Ranges: bytes".to_string(),
|
||||
];
|
||||
if with_length {
|
||||
headers.push(format!("Content-Length: {}", slice.len()));
|
||||
}
|
||||
(206, headers, slice)
|
||||
}
|
||||
(Some(_), true) => (
|
||||
416,
|
||||
@@ -230,7 +295,10 @@ async fn serve(mut stream: TcpStream, response: MockResponse) -> std::io::Result
|
||||
Vec::new(),
|
||||
),
|
||||
_ => {
|
||||
let mut headers = vec![format!("Content-Length: {total}")];
|
||||
let mut headers = Vec::new();
|
||||
if with_length {
|
||||
headers.push(format!("Content-Length: {total}"));
|
||||
}
|
||||
if response.range_support {
|
||||
headers.push("Accept-Ranges: bytes".to_string());
|
||||
}
|
||||
@@ -247,11 +315,53 @@ async fn serve(mut stream: TcpStream, response: MockResponse) -> std::io::Result
|
||||
}
|
||||
head.push_str("Connection: close\r\n\r\n");
|
||||
stream.write_all(head.as_bytes()).await?;
|
||||
stream.write_all(&body).await?;
|
||||
write_body(&mut stream, &body, &response).await?;
|
||||
stream.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 按夹具的口径写正文:正常 / 卡在中途 / 短包 / 超长尾巴 / 滴流。
|
||||
async fn write_body(
|
||||
stream: &mut TcpStream,
|
||||
body: &[u8],
|
||||
response: &MockResponse,
|
||||
) -> std::io::Result<()> {
|
||||
if let Some(stall_at) = response.stall_at {
|
||||
// 头部 + 这么多字节已发出,之后一个字不再写(连接不断):读方只能靠超时脱身。
|
||||
let sent = stall_at.min(body.len());
|
||||
stream.write_all(&body[..sent]).await?;
|
||||
stream.flush().await?;
|
||||
tokio::time::sleep(HOLD).await;
|
||||
return Ok(());
|
||||
}
|
||||
let limit = response.send_len.unwrap_or(body.len()).min(body.len());
|
||||
match response.drip {
|
||||
Some((piece, gap)) => {
|
||||
let piece = piece.max(1);
|
||||
let mut offset = 0;
|
||||
while offset < limit {
|
||||
let end = (offset + piece).min(limit);
|
||||
stream.write_all(&body[offset..end]).await?;
|
||||
stream.flush().await?;
|
||||
offset = end;
|
||||
if offset < limit {
|
||||
tokio::time::sleep(gap).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
stream.write_all(&body[..limit]).await?;
|
||||
stream.flush().await?;
|
||||
}
|
||||
}
|
||||
if let Some(extra) = response.trailing_extra {
|
||||
let filler = vec![0_u8; extra];
|
||||
stream.write_all(&filler).await?;
|
||||
stream.flush().await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 解析 `Range: bytes=<start>-<end>`(`end` 可缺省 = 到文件末尾)。
|
||||
fn parse_range_header(value: &str) -> Option<(u64, Option<u64>)> {
|
||||
let (start, end) = value.split_once('-')?;
|
||||
|
||||
Reference in new issue
Block a user