复审 r2(p2-1):r1 的 `same_source` 只挡了「源不同」,没挡「同一 URL、换了一份表示」。
`done` 为空(Restart:`SizeChanged` / `HashChanged` / `ChunkLayout` …)时没有任何字节要接着用,
但 `journal.begin` 仍把上一份表示的 ETag 写进**新一轮**元数据;`record` 又只在缺值时写 ⇒ 本轮
3 段(新内容)配的是旧校验器 ⇒ 重试拿它发 `If-Range` ⇒ 服务器按 RFC 7233 回 200 + 完整正文 ⇒
`RangeUnsupported` ⇒ 丢整份 ⇒ 单连接从头下。实测 113664 字节(2.775×),比「干脆不续传」的
72704(1.775×)还多一整份。
修法:「谁能当校验器」收成一个 `carry`,两个维度缺一不可 ——
let carry = if done.is_empty() { None } else { same_source };
`if_range`、`etag`、`last_modified` 三处全部走它。清掉之后本轮响应里的真实校验器由
`ChunkJournal::record` 填进来;最坏也只是 `NoValidator` ⇒ 整份重下(1.775× 基线),不会更坏。
文档(`chunk_resume` 模块头的「校验器跟着源与表示走」、`record`、`carry` 处)同步改成两维口径。
判据(新用例 `chunked/resume.rs::stale_validator_from_the_previous_representation_is_not_carried`):
同一 URL、盘上是上一份表示的元数据(旧总长/旧段区间/旧 ETag `"v1"`/旧摘要)⇒ 本轮因
`HashChanged` 走 Restart、服务器此刻发 `"v2"`。断言:重试的 `If-Range` 只许是 `"v2"`、字节数
41984(1.025×,而不是 113664)、hits 5、resumed_from 30720、终态文件逐字节正确。
变异(两种,均实测红):
* 只把元数据那两行退回 `same_source`(`if_range` 仍按 `done` 门控)⇒ 复现复审的探针数字:
PROBE stale_validator if_range=["\"v1\""] hits=6 bytes=113664 resumed_from=0,
断言红在 `left: ["\"v1\""] right: ["\"v2\""]`;
* 把 `carry` 整体退回 `same_source`(连带首轮也发 `If-Range`)⇒ 红在
`left: ["\"v1\"","\"v1\"","\"v1\"","\"v1\""]`。
三条 p3:
* p3-1 `Last-Modified` 支路端到端补上了:夹具新增 `last_modified` 字段 / `with_last_modified()`
/ 发出该响应头,`validator_ok` 扩展成「ETag 或 HTTP-date 比得上」(RFC 7233 的弱校验器兜底)。
新用例 `chunked/last_modified.rs`:服务器**只给** Last-Modified,重试必须带这个日期发
`If-Range`、只补 1 段(41984 = 1.025×)。变异(`record` 不写 last_modified)⇒ 红在
「只该在重试那一次发条件请求:[]」(变成 NoValidator 干净重下)。用例同时写明它抓不住真实
服务器的 HTTP-date 比较语义。
* p3-2 `ChunkJournal::save` 的同步原子写:判断「可以接受」并给了量级 —— 一个作业 ≤17 次
(16 段 + 建账)、每次 1–3 KiB,SSD ≈ ≤30 ms / 网络盘 ≈ ≤1 s,摊在分钟到小时的作业上占比
1e-4 量级;且顺序有意义(record-then-trust),挪 `spawn_blocking` 要为几毫秒引入跨 await
加锁的顺序风险;与单连接路径同口径。退路写在文档里。
* p3-3 `integrity=None` 时末尾摘要空转的一致性边界写进 `chunk_resume` 模块头「已知代价」+
`verify_part` 文档:只剩旧段自证与 `If-Range` 两道,「同长度换内容」不可察觉;现实入口是
上游没给合法 sha1 的 `ModFile`(`addon/online/download.rs` 只在 sha1 合法时才设 integrity),
以及 `Library` 元数据 / `download.bin` 缓存等无摘要调用点;要收紧属于产品决策。
字节判据复测(三条探针形态):
healthy bytes=41984 size=40960 ratio=1.025 hits=5 resumed_from=30720
nofeature bytes=72704 size=40960 ratio=1.775 hits=8 resumed_from=0
staleval bytes=41984 size=40960 ratio=1.025 hits=5 resumed_from=30720 if_range=["\"v2\""]
即 stale-validator 那条从 2.775× 回到 1.025×,healthy 档未变差。
门禁:lib 903 / engine_mock 17 / chunked 22 / resume 12 / partfile 7 / cache 6 全绿;
clippy -D warnings 0、fmt --check 0。改动的单文件最大 391 行(chunked_test.rs),无超 400。
392 lines
14 KiB
Rust
392 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;
|
|
|
|
/// 弱校验器(Last-Modified)驱动 `If-Range` 的支路(复审 r2 p3-1):单独一份。
|
|
#[path = "chunked/last_modified.rs"]
|
|
mod last_modified;
|