Files
Unlucky-Launcher/crates/core/tests/download/chunked_test.rs
T
AskaEth ffca0f4454 fix(download): 换源后不再沿用旧源校验器 + 三条 p3 收口(M293 r1)
复审 r1(p2-1)指出的链条:源 A 落 3/4 段失败 ⇒ 元数据 url=A + A 的 ETag;换源 B 时
`decide` 判 UrlChanged、删盘重来,但 `ChunkJournal::begin` 把 A 的校验器原样写进 url=B 的
新元数据,而 `record` 只在 `etag.is_none()` 时写入 ⇒ B 的真实校验器永远盖不上。于是拿 **A 的
ETag** 对 B 发 `If-Range`:按 RFC 7233 §3.2 服务器回 200 + 完整正文 ⇒ `fetch_chunk` 判
RangeUnsupported ⇒ 丢掉整份 ⇒ 单连接从头下 —— 债务以另一条路径复现。

修法(复审给的选项 A):校验器与**记下它的 URL** 绑定,`same_source = previous.filter(|m|
m.url == candidate.url)`,`etag` / `last_modified` / `If-Range` 三处全部走它,让「谁能当校验器」
只有一个判定点。不选 B(换源时清空)是因为 B 只是 A 在「URL 变了」这一特例上的写法,条件放在
读取处比散到写入处更难漏;不选 C(多源各记一份)是因为 `ResumeMeta` 本来就有 url,再加一个来源
字段会多一处可与 url 漂移的状态。

夹具此前不解析 `If-Range`,这一跳只能读代码推。现在 `support/` 记录每个请求的 `If-Range` 取值,
并按 RFC 7233 §3.2 处置(对不上就忽略 Range、回 200 + 完整正文),于是「真实服务器上的后果」也
进了用例。

判据(新用例 `chunked/switch.rs`,两个夹具服务器当一个官方源 + 一个镜像源):
* 源 B 收到的 `If-Range` 只许是它自己的 `"src-b"`(源 A 同源续传照旧带 `"src-a"` 作对照);
* 源 B 的字节数仍是 3 段 + 卡住的 1KiB + 补 1 段 = 41984(1.025×),换源没把同源续传打回整份重下;
* 最终文件逐字节正确;
* 第二条用例走「新源自己不发 ETag」那一支:`NoValidator` ⇒ 新源重试整份重下(`resumed_from=0`、
  8 个请求、72704 字节),一个条件请求都不许发 —— 这是「无法证明就不复用」的明确取舍。
变异(去掉 URL 过滤)⇒ 两条都红,实测原文:
  assertion `left == right` failed: 源 B 收到了不属于它的校验器
  left: ["\"src-a\""]  right: ["\"src-b\""]
  新源没有任何校验器,就不许发条件请求(带 A 的值更是串源):["\"src-a\""]
同一变异下还实测到了后果:`b_bytes=113664 b_hits=6 resumed_from=0`(200 白下一份 + 单连接整份
重下 = 2.77×),即复审预测的那一跳确实存在。

三条 p3 一并收口:
* p3-1:`abort_all()` 之前用 `try_join_next` 把「已落完但还没被收走」的段记进账
  (`absorb_completed`,总长判据与正常回收同一道)。这是尽力减少浪费、不是正确性前提,且
  它是「谁先完成」的竞态 ⇒ 没有确定性用例能钉住,已在文档里写明边界。
* p3-2:`verify_chunks` 改为返回 `Option<u64>`(可复用字节数),`AttemptOk.resumed_from`
  直接取它,删掉原来「记录另算一遍」的双份口径。
* p3-3:`chunk_resume` 模块头新增「已知代价」:重读量正比于**复用的字节**(不是整份文件)、
  为什么不能用「相信摘要」来省掉、以及超时后 `spawn_blocking` 线程不可取消在 Windows 上的
  句柄风险。

拆文件守 400 行硬线:`await_chunks` + `absorb_completed` 移到 `transfer_chunk/join.rs`
(逐字节搬移),`MockResponse` 移到 `support/response.rs`(逐字节搬移,只加 `with_etag` /
`without_etag` 两个构造器)。

门禁:lib 903 / engine_mock 17 / chunked 20 / resume 12 / partfile 7 / cache 6 全绿,
clippy -D warnings 0、fmt --check 0;字节判据复测未变差(resume 41984 = 1.025×,
noresume 72704 = 1.775×)。
2026-11-13 06:45:55 +08:00

388 lines
14 KiB
Rust

//! 分块规划与 Range 语义的单测(纯函数)+ 真跑 4 连接的端到端用例(mock 源)。
mod support;
use std::sync::Arc;
use std::time::Duration;
use unlucky_core::download::backoff::BackoffPolicy;
use unlucky_core::download::cache::ContentCache;
use unlucky_core::download::chunked::{
content_range_matches, parse_content_range, plan, range_support, ByteRange, ChunkedConfig,
ContentRange, RangeSupport, MAX_CONNECTIONS,
};
use unlucky_core::download::config::EngineConfig;
use unlucky_core::download::engine::DownloadEngine;
use unlucky_core::download::health::HealthRegistry;
use unlucky_core::download::mirrors::MirrorTable;
use unlucky_core::download::region::RegionProfile;
use unlucky_core::download::types::{DownloadRequest, ResourceKind};
use support::{sha1_hex, temp_dir, MockResponse, MockServer};
#[test]
fn plan_partitions_whole_file_without_gaps() {
let cases = [
(1_u64, 4_usize),
(3, 4),
(100, 4),
(1000, 7),
(123_456_789, 4),
];
for (total, connections) in cases {
let ranges = plan(total, connections);
assert!(!ranges.is_empty(), "total={total} 不能切出空集");
assert_eq!(ranges[0].start, 0, "必须从 0 开始");
assert_eq!(ranges.last().unwrap().end, total - 1, "必须覆盖到末尾");
let mut next = 0_u64;
for range in &ranges {
assert_eq!(range.start, next, "段之间不能有空洞或重叠");
assert!(!range.is_empty());
next = range.end + 1;
}
assert_eq!(next, total);
assert_eq!(ranges.iter().map(ByteRange::len).sum::<u64>(), total);
}
}
#[test]
fn plan_caps_connections_and_handles_edge_cases() {
assert!(plan(0, 4).is_empty());
// 文件比连接数还小:每段 1 字节。
let tiny = plan(3, 4);
assert_eq!(tiny.len(), 3);
assert!(tiny.iter().all(|range| range.len() == 1));
// 连接数被夹到上限。
let many = plan(1_000_000, 100);
assert_eq!(many.len(), MAX_CONNECTIONS);
// 单连接。
assert_eq!(plan(120, 1), vec![ByteRange::new(0, 119)]);
}
#[test]
fn byte_range_headers_are_closed_intervals() {
let range = ByteRange::new(100, 199);
assert_eq!(range.len(), 100);
assert_eq!(range.header_value(), "bytes=100-199");
assert!(ByteRange::new(10, 9).is_empty());
assert_eq!(ByteRange::new(10, 9).len(), 0);
}
#[test]
fn parse_content_range_accepts_servers_forms() {
assert_eq!(
parse_content_range("bytes 0-99/1000"),
Some(ContentRange {
start: 0,
end: 99,
total: Some(1000)
})
);
assert_eq!(
parse_content_range(" bytes 100-199/* "),
Some(ContentRange {
start: 100,
end: 199,
total: None
})
);
// 畸形一律 None:调用方据此退化。
assert_eq!(parse_content_range("bytes 100-99/1000"), None);
assert_eq!(parse_content_range("items 0-9/10"), None);
assert_eq!(parse_content_range("bytes 0-9"), None);
assert_eq!(parse_content_range(""), None);
}
#[test]
fn range_support_matrix() {
assert_eq!(range_support(Some("bytes"), 200), RangeSupport::Supported);
assert_eq!(range_support(None, 206), RangeSupport::Supported);
assert_eq!(range_support(Some("none"), 200), RangeSupport::Unsupported);
assert_eq!(
range_support(Some("crumbs"), 200),
RangeSupport::Unsupported
);
assert_eq!(range_support(None, 200), RangeSupport::Unknown);
}
#[test]
fn chunked_config_gate() {
let config = ChunkedConfig::default();
assert!(config.enabled);
assert_eq!(config.connections, 4);
// 只有大文件 + 大资源类型才分块。
assert!(config.applies_to(ResourceKind::JavaRuntime, Some(200 * 1024 * 1024)));
assert!(!config.applies_to(ResourceKind::JavaRuntime, Some(1024)));
assert!(!config.applies_to(ResourceKind::ClientJar, Some(200 * 1024 * 1024)));
assert!(!config.applies_to(ResourceKind::JavaRuntime, None));
// 设置项关掉后一律不分块。
assert!(!ChunkedConfig::disabled().applies_to(ResourceKind::JavaRuntime, Some(u64::MAX)));
assert_eq!(
ChunkedConfig {
connections: 999,
..ChunkedConfig::default()
}
.effective_connections(),
MAX_CONNECTIONS
);
}
/// 端到端:`min_size` 调到 1KiB 后,20KiB 的 JDK 类资源真的被切成 4 段并行取。
///
/// 断言三件事:①mock 源收到 4 次带 `Range` 的请求(不是 1 次)②拼装后的文件逐字节正确
/// ③流式 sha1 校验通过(证明分块乱序写没有错位)。
#[tokio::test]
async fn downloads_large_file_over_four_parallel_ranges() {
let payload: Vec<u8> = (0..20 * 1024).map(|index| (index % 251) as u8).collect();
let server = MockServer::start(vec![
MockResponse::ok(payload.clone()),
MockResponse::ok(payload.clone()),
MockResponse::ok(payload.clone()),
MockResponse::ok(payload.clone()),
])
.await;
let url = server.url("/jdk.zip");
let dir = temp_dir("chunked-e2e");
let engine = DownloadEngine::new(
EngineConfig {
max_attempts_per_source: 1,
backoff: BackoffPolicy {
base_ms: 1,
max_ms: 2,
jitter_ratio: 0.0,
max_attempts: 1,
},
read_timeout: Duration::from_secs(5),
connect_timeout: Duration::from_secs(5),
// 真开分块:4 条连接,1KiB 以上就切。
chunked: ChunkedConfig {
enabled: true,
connections: 4,
min_size: 1024,
},
..EngineConfig::default()
},
Arc::new(MirrorTable::builtin()),
Arc::new(RegionProfile::direct(0)),
Arc::new(HealthRegistry::in_memory()),
Arc::new(ContentCache::open(dir.join("cache"))),
);
let dest = dir.join("jdk.zip");
let outcome = engine
.download(
DownloadRequest::new(ResourceKind::JavaRuntime, url.as_str(), &dest)
.with_sha1(sha1_hex(&payload))
.with_size(payload.len() as u64),
)
.await
.expect("分块下载失败");
assert_eq!(outcome.bytes, payload.len() as u64);
assert_eq!(outcome.source, "official");
assert_eq!(
server.hits(),
4,
"4 条连接应各发一次带 Range 的请求(退化单连接会只有 1 次)"
);
assert_eq!(std::fs::read(&dest).expect("读回落盘文件失败"), payload);
assert!(
support::part_files(&dir).is_empty(),
"中间文件必须在提交后消失:{:?}",
support::part_files(&dir)
);
server.stop();
}
/// 端到端:目标目录**还不存在**时分块下载也要能落盘。
///
/// 回归用例。分块路径先 `OpenOptions` 预分配整文件(不走 `PartFile::open_append`
/// 那层建目录),所以「下到新建目录」曾让所有候选源瞬间全部失败
/// (现场:`data/java-managed/.cache/` 未创建时装 JDK 六个源全挂)。
#[tokio::test]
async fn chunked_download_creates_the_destination_directory() {
let payload: Vec<u8> = (0..8 * 1024).map(|index| (index % 31) as u8).collect();
let responses = vec![
MockResponse::ok(payload.clone()),
MockResponse::ok(payload.clone()),
MockResponse::ok(payload.clone()),
MockResponse::ok(payload.clone()),
];
let server = MockServer::start(responses).await;
let url = server.url("/jdk.zip");
let dir = temp_dir("chunked-mkdir");
let engine = DownloadEngine::new(
EngineConfig {
max_attempts_per_source: 1,
backoff: BackoffPolicy {
base_ms: 1,
max_ms: 2,
jitter_ratio: 0.0,
max_attempts: 1,
},
read_timeout: Duration::from_secs(5),
connect_timeout: Duration::from_secs(5),
chunked: ChunkedConfig {
enabled: true,
connections: 4,
min_size: 1024,
},
..EngineConfig::default()
},
Arc::new(MirrorTable::builtin()),
Arc::new(RegionProfile::direct(0)),
Arc::new(HealthRegistry::in_memory()),
Arc::new(ContentCache::open(dir.join("cache"))),
);
let dest = dir.join("java-managed/.cache/jdk.zip");
assert!(
!dest.parent().expect("父目录").exists(),
"前置条件:目录不存在"
);
let outcome = engine
.download(
DownloadRequest::new(ResourceKind::JavaRuntime, url.as_str(), &dest)
.with_sha1(sha1_hex(&payload))
.with_size(payload.len() as u64),
)
.await
.expect("分块下载应成功");
assert_eq!(outcome.bytes, payload.len() as u64);
assert_eq!(std::fs::read(&dest).expect("读回落盘文件失败"), payload);
server.stop();
}
/// 端到端:服务器声明支持 Range 但实际无视(返回 200)→ 自动退化单连接,结果仍然正确。
#[tokio::test]
async fn falls_back_to_single_connection_when_server_ignores_range() {
let payload: Vec<u8> = (0..8 * 1024).map(|index| (index % 97) as u8).collect();
// 每个请求都会拿到 200 + 整份内容(range_support = false)。
// 响应条数给足:4 个分块请求可能都发出去才被 abort,退化后的整份请求是第 5 条。
let no_range = || MockResponse::ok(payload.clone()).without_range();
let server = MockServer::start(vec![
no_range(),
no_range(),
no_range(),
no_range(),
no_range(),
no_range(),
])
.await;
let url = server.url("/client.jar");
let dir = temp_dir("chunked-fallback");
let engine = DownloadEngine::new(
EngineConfig {
max_attempts_per_source: 1,
backoff: BackoffPolicy::none(),
chunked: ChunkedConfig {
enabled: true,
connections: 4,
min_size: 1024,
},
..EngineConfig::default()
},
Arc::new(MirrorTable::builtin()),
Arc::new(RegionProfile::direct(0)),
Arc::new(HealthRegistry::in_memory()),
Arc::new(ContentCache::open(dir.join("cache"))),
);
let dest = dir.join("client.jar");
let outcome = engine
.download(
DownloadRequest::new(ResourceKind::JavaRuntime, url.as_str(), &dest)
.with_sha1(sha1_hex(&payload))
.with_size(payload.len() as u64),
)
.await
.expect("退化后应仍能下载成功");
assert_eq!(outcome.bytes, payload.len() as u64);
assert_eq!(std::fs::read(&dest).expect("读回落盘文件失败"), payload);
server.stop();
}
#[test]
fn content_range_must_match_requested_segment() {
let requested = ByteRange::new(5120, 10239);
// 严格一致才放行。
assert!(content_range_matches(
requested,
Some("bytes 5120-10239/20480")
));
// 起止任一不符 → 判失败(否则「长度恰好但位置错」的响应会拼出坏文件)。
assert!(!content_range_matches(
requested,
Some("bytes 0-5119/20480")
));
assert!(!content_range_matches(
requested,
Some("bytes 5120-20479/20480")
));
// 畸形头判失败。
assert!(!content_range_matches(requested, Some("garbage")));
// 缺头从宽(长度校验仍兜底)。
assert!(content_range_matches(requested, None));
}
/// 端到端:服务器回 206 但 `Content-Range` 谎报段位置 → 分块路径判不符、丢弃中间文件、
/// 退化单连接重下,最终文件仍逐字节正确(不会交付拼错的文件)。
#[tokio::test]
async fn rejects_lying_content_range_and_falls_back() {
let payload: Vec<u8> = (0..8 * 1024).map(|index| (index % 89) as u8).collect();
let lying = || MockResponse::ok(payload.clone()).with_wrong_content_range();
let server = MockServer::start(vec![
lying(),
lying(),
lying(),
lying(),
MockResponse::ok(payload.clone()),
])
.await;
let url = server.url("/jdk.zip");
let dir = temp_dir("chunked-lying-range");
let engine = DownloadEngine::new(
EngineConfig {
max_attempts_per_source: 1,
backoff: BackoffPolicy::none(),
chunked: ChunkedConfig {
enabled: true,
connections: 4,
min_size: 1024,
},
..EngineConfig::default()
},
Arc::new(MirrorTable::builtin()),
Arc::new(RegionProfile::direct(0)),
Arc::new(HealthRegistry::in_memory()),
Arc::new(ContentCache::open(dir.join("cache"))),
);
let dest = dir.join("jdk.zip");
let outcome = engine
.download(
DownloadRequest::new(ResourceKind::JavaRuntime, url.as_str(), &dest)
.with_sha1(sha1_hex(&payload))
.with_size(payload.len() as u64),
)
.await
.expect("谎报 Content-Range 后应退化单连接成功");
assert_eq!(outcome.bytes, payload.len() as u64);
assert_eq!(std::fs::read(&dest).expect("读回落盘文件失败"), payload);
server.stop();
}
/// 分块作业的两道失速判据(无进度 / 总期限):拆到子模块守 400 行硬线。
#[path = "chunked/timeout.rs"]
mod timeout;
/// 分块路径的续传(失败不重下、旧字节要自证):同族用例单独一份。
#[path = "chunked/resume.rs"]
mod resume;
/// 换源后旧源校验器不许带过去(复审 r1 p2-1):同样单独一份守 400 行硬线。
#[path = "chunked/switch.rs"]
mod switch;