Files
Unlucky-Launcher/crates/core/tests/download/chunked/resume.rs
T
AskaEth 3ade2d14a8 feat(download): 分块路径写续传元数据并自证旧字节(M293)
台账 P21 第 2 条 + P19(b):分块路径此前进来先 `part.discard()`、失败也 `discard()`,
188MB 的 JDK 任何一次失败都从 0 重下;M289 又给预算耗尽加了「不再重试、不再换源」的
短路,慢链路上一次晚到的失败就变成「这个文件下不来」。

写这一侧:
* `ResumeMeta` 新增 `chunks: Option<Vec<ChunkRecord>>`(段区间 + 该段 SHA-1),
  由新增的 `chunk_resume::ChunkJournal` 每成功回收一段就原子落一次盘;
* `fetch_chunk` 顺带带回段摘要、`Content-Range` 总长与 ETag/Last-Modified,
  所以元数据里同时有 total / 源 URL / 分块清单 / 校验所需信息;
* 分块失败不再丢中间文件,只有 `RangeUnsupported`(单连接接不住这种形状)才丢。

判这一侧:
* `chunk_resume::decide`(纯函数)要求策略允许、URL / 总长 / 摘要 / 校验器全对得上,
  且每条记录精确命中本次分块规划里的一段,否则从头再来;
* `chunk_resume::verify_records` 按记录重读旧段、重算摘要:任何一段对不上就整份丢掉
  (元数据本身已不可信,同批其它记录也不再被信任);
* 续传的段请求带 `If-Range`,让服务器回答「内容变了没有」;
* 整文件摘要照旧在末尾整份重算,覆盖新下 + 复用的全部字节。

单连接路径零改动:`resume::decide` 的判定一字未动,只新增
「元数据里带分块清单 ⇒ 强行判 Restart 交给分块路径」这一条,避免预分配全长文件被
单连接的 `Complete` 误判成「已下满」。

判据(都是「服务端实际吐了多少字节」,不是「最终 hash 对」):
* 3/4 处失败后续传完成:41984 字节 / 40960 文件 = 1.025×(断言 < 1.5×);
* 变异(关掉续传同样夹具):72704 = 1.775×,`< 1.5×` 必红(已实测);
* 变异(去掉旧段摘要自证):坏段被复用 ⇒ 51200 ≠ 40960 必红(已实测);
* 变异(去掉总长比较):旧大小的段被复用 ⇒ 61440 ≠ 40960 必红(已实测)。
2026-11-13 06:26:18 +08:00

288 lines
12 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.
//! 分块路径的续传(M293 / 台账 P21 第 2 条 + P19(b)):真跑两次尝试,**数服务端吐了多少字节**。
//!
//! 判据刻意不用「最终 hash 对」:hash 对只说明拼出来的文件没错,抓不住「其实整份又下了一遍」——
//! 那正是 P21 那条债的形态(188MB 的 JDK 一次失败就从 0 重下)。所以每个用例都用夹具统计
//! 服务端实际发出的正文字节数([`MockServer::bytes_sent`]):
//!
//! * `resumes_the_finished_chunks_after_a_partial_failure`:第一次在 3/4 处失败、第二次续传完成
//! ⇒ 两次总字节必须 **< 1.5×文件大小**。变异:把 `attempt_chunked` 改回「进来先 `discard()`、
//! 失败也 `discard()`」⇒ 第二次必然整份重下,该断言红(1.75×)。
//! * `without_resume_the_same_failure_downloads_again`:同一份夹具、同一个失败点,只把续传策略
//! 换成 `Never` ⇒ 服务端确实吐出 ~1.75×。这条是上一条的**可执行变异证明**:`< 1.5×` 这个阈值
//! 真的把「续传」和「全重下」分开了,不是随便定的一个宽阈值。
//! * `corrupted_old_chunks_are_never_stitched_in`:盘上的旧段被改过一个字节 ⇒ 一段都不许复用
//! (摘要自证不过就整份丢掉从头再来),服务端要重新吐全部字节。
//! * `changed_size_invalidates_old_chunk_layout`:总长换了(同一个目标文件,大小变了)⇒ 旧段一段
//! 都不许用。这条钉的是「旧元数据不得被误用」。
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::{plan, ByteRange, ChunkedConfig};
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::resume::{ChunkRecord, ResumeMeta};
use unlucky_core::download::types::{DownloadRequest, HashAlgo, ResourceKind, ResumePolicy};
use unlucky_core::download::verify::digest_bytes;
use super::support::{sha1_hex, temp_dir, MockResponse, MockServer};
/// 4 段 × 10 KiB。
const SIZE: usize = 40 * 1024;
/// 卡住的那一段吐这么多字节就停(这部分字节落盘了但不会被记账)。
const STALL_TAIL: usize = 1024;
fn payload() -> Vec<u8> {
(0..SIZE).map(|index| (index % 251) as u8).collect()
}
/// 一次尝试允许 2 次(第一次故意失败、第二次续传);读空闲 300ms:卡住的那一段自己报错,
/// 那时其余三段早就落完并被回收了(作业级的无进度窗口是 10s,不会抢在它前面)。
fn config() -> EngineConfig {
EngineConfig {
max_attempts_per_source: 2,
backoff: BackoffPolicy::none(),
connect_timeout: Duration::from_secs(2),
response_timeout: Duration::from_secs(2),
read_timeout: Duration::from_millis(300),
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 engine(dir: &std::path::Path) -> DownloadEngine {
DownloadEngine::new(
config(),
Arc::new(MirrorTable::builtin()),
Arc::new(RegionProfile::direct(0)),
Arc::new(HealthRegistry::in_memory()),
Arc::new(ContentCache::open(dir.join("cache"))),
)
}
fn request(url: &str, dest: &std::path::Path, payload: &[u8]) -> DownloadRequest {
DownloadRequest::new(ResourceKind::JavaRuntime, url, dest)
.with_sha1(sha1_hex(payload))
.with_size(payload.len() as u64)
}
/// 第一次尝试的脚本:4 段里 3 段正常落完,第 4 个连接拿到「吐一点就卡住」的那份。
fn partial_script(payload: &[u8]) -> Vec<MockResponse> {
let mut script: Vec<MockResponse> =
(0..3).map(|_| MockResponse::ok(payload.to_vec())).collect();
script.push(MockResponse::ok(payload.to_vec()).stalling_after(STALL_TAIL));
script
}
fn record_of(payload: &[u8], range: ByteRange) -> ChunkRecord {
ChunkRecord {
start: range.start,
end: range.end,
sha1: digest_bytes(
HashAlgo::Sha1,
&payload[range.start as usize..=range.end as usize],
),
}
}
/// 分块路径的元数据(`chunks: Some(..)` 是「这个中间文件属于分块路径」的标记)。
fn chunk_meta(url: &str, total: u64, hash: &str, chunks: Vec<ChunkRecord>) -> ResumeMeta {
ResumeMeta {
url: url.to_string(),
etag: Some("\"v1\"".to_string()),
last_modified: None,
total: Some(total),
algo: HashAlgo::Sha1,
hash: hash.to_string(),
saved_at_ms: 1,
chunks: Some(chunks),
}
}
/// 3/4 段落完就失败,第二次续传完成 ⇒ 服务端总共只该吐 ≈1.0×,而不是 ≈1.75×。
#[tokio::test]
async fn resumes_the_finished_chunks_after_a_partial_failure() {
let payload = payload();
let size = payload.len() as u64;
let mut script = partial_script(&payload);
// 第二次尝试只该有 1 段要补。脚本仍给足 4 条:万一实现退化成「从头下」,
// 那一次也要能下完——这样红的就是**字节判据**本身,而不是「脚本用尽 ⇒ 500」。
script.extend((0..4).map(|_| MockResponse::ok(payload.clone())));
let server = MockServer::start(script).await;
let url = server.url("/jdk.zip");
let dir = temp_dir("chunked-resume");
let engine = engine(&dir);
let dest = dir.join("jdk.zip");
let outcome = engine
.download(request(&url, &dest, &payload))
.await
.expect("续传后应当成功");
assert_eq!(std::fs::read(&dest).expect("读回落盘文件失败"), payload);
// 主判据:服务端总共吐了多少字节(「最终 hash 对」抓不到「其实全重下了」)。
let sent = server.bytes_sent();
assert!(
sent < size * 3 / 2,
"两次尝试总共只该吐 ≈1.0× 字节,实得 {sent}(1.5×{size} = {}):\
超过它说明第二次又把整份下了一遍",
size * 3 / 2
);
assert_eq!(server.hits(), 5, "第一次 4 段 + 第二次补 1 段");
assert_eq!(
outcome.resumed_from,
size * 3 / 4,
"3/4 的段应当从磁盘复用(剩下 1 段重抓)"
);
server.stop();
}
/// 上一条的变异证明:同一个失败点,只把续传关掉 ⇒ 两次尝试一共吐 ~1.75×。
///
/// 把实现改回「失败就 discard、下次从头下」时,上一条的 `< 1.5×` 会落到本用例的区间 ⇒ 必红。
#[tokio::test]
async fn without_resume_the_same_failure_downloads_again() {
let payload = payload();
let size = payload.len() as u64;
let mut script = partial_script(&payload);
// 第二次尝试要是从头下:4 段全部重抓。
script.extend((0..4).map(|_| MockResponse::ok(payload.clone())));
let server = MockServer::start(script).await;
let url = server.url("/jdk.zip");
let dir = temp_dir("chunked-noresume");
let engine = engine(&dir);
let dest = dir.join("jdk.zip");
let outcome = engine
.download(request(&url, &dest, &payload).with_resume(ResumePolicy::Never))
.await
.expect("不续传也要能下完");
assert_eq!(std::fs::read(&dest).expect("读回落盘文件失败"), payload);
assert_eq!(outcome.resumed_from, 0, "策略 Never ⇒ 一个字节都不复用");
let sent = server.bytes_sent();
assert!(
sent > size * 3 / 2,
"不续传时服务端要吐出两遍的字节(实测 {sent},应当 > {})——\
这正是 P21 那条债的形态",
size * 3 / 2
);
assert_eq!(server.hits(), 8, "第一次 4 段 + 第二次整份 4 段");
server.stop();
}
/// 盘上的旧段被改过:不许把「记录里说好的字节」当成「盘上现在就是好的」直接拼进结果。
#[tokio::test]
async fn corrupted_old_chunks_are_never_stitched_in() {
let payload = payload();
let size = payload.len() as u64;
let mut script: Vec<MockResponse> = (0..4).map(|_| MockResponse::ok(payload.clone())).collect();
// 万一实现真去复用坏段,它还要再整份重下一遍才有机会成功(8 条够两种世界都下完)。
script.extend((0..4).map(|_| MockResponse::ok(payload.clone())));
let server = MockServer::start(script).await;
let url = server.url("/jdk.zip");
let dir = temp_dir("chunked-broken-part");
let engine = engine(&dir);
let dest = dir.join("jdk.zip");
let ranges = plan(size, 4);
// 旧版无主命名的中间文件(`PartFile::for_dest` 会收养它)。
let part = dir.join("jdk.zip.ullpart");
let meta_path = dir.join("jdk.zip.ullpart.ullmeta.json");
let mut on_disk = payload.clone();
on_disk[ranges[1].start as usize + 5] ^= 0xff;
std::fs::write(&part, &on_disk).expect("写旧中间文件失败");
// 元数据声称前 3 段都落完了(记的是**原始**字节的摘要),只有第 4 段没记。
chunk_meta(
&url,
size,
&sha1_hex(&payload),
ranges[..3]
.iter()
.map(|range| record_of(&payload, *range))
.collect(),
)
.save(&meta_path)
.expect("写旧元数据失败");
let outcome = engine
.download(request(&url, &dest, &payload))
.await
.expect("坏旧段被丢掉后应当重下成功");
assert_eq!(std::fs::read(&dest).expect("读回落盘文件失败"), payload);
// 主判据:坏段自证不过 ⇒ 整份丢掉从头来,服务端只该吐这一份文件的字节。
assert_eq!(
server.bytes_sent(),
size,
"全部字节都要重新抓一遍(复用坏字节 = 交付坏文件)"
);
assert_eq!(server.hits(), 4, "从头下 = 4 个分块请求");
assert_eq!(
outcome.resumed_from, 0,
"任何一段对不上就整份丢掉,一段都不复用"
);
server.stop();
}
/// 同一个目标文件换了大小:旧的 2 段元数据**恰好**对得上新规划的前两段,只有「总长变了」能拦住它。
#[tokio::test]
async fn changed_size_invalidates_old_chunk_layout() {
let payload = payload();
let size = payload.len() as u64;
let mut script: Vec<MockResponse> = (0..4).map(|_| MockResponse::ok(payload.clone())).collect();
// 真去复用旧段时,它还要再整份重下一遍才有机会成功(8 条够两种世界都下完)。
script.extend((0..4).map(|_| MockResponse::ok(payload.clone())));
let server = MockServer::start(script).await;
let url = server.url("/jdk.zip");
let dir = temp_dir("chunked-size-change");
let engine = engine(&dir);
let dest = dir.join("jdk.zip");
// 旧中间文件 = 上一次「同一个目标、一半大小」的下载残留(内容是**另一个版本**的字节)。
let old_size = SIZE / 2;
let old_bytes = vec![0xEE_u8; old_size];
let part = dir.join("jdk.zip.ullpart");
let meta_path = dir.join("jdk.zip.ullpart.ullmeta.json");
std::fs::write(&part, &old_bytes).expect("写旧中间文件失败");
// 2 段 × 10KiB:段区间与新规划的 [0-10239] / [10240-20479] 完全重合,所以
// 「段对不上号」这条拦不住它,只有总长比较能拦(变异:删掉总长比较 ⇒ 本用例红:
// 复用 20480 字节 + 整文件校验失败 ⇒ 白下一次 20480 字节)。
chunk_meta(
&url,
old_size as u64,
&sha1_hex(&payload),
plan(old_size as u64, 2)
.iter()
.map(|range| record_of(&old_bytes, *range))
.collect(),
)
.save(&meta_path)
.expect("写旧元数据失败");
let outcome = engine
.download(request(&url, &dest, &payload))
.await
.expect("大小变了以后必须从头重下成功");
assert_eq!(std::fs::read(&dest).expect("读回落盘文件失败"), payload);
// 主判据:旧文件的那 20480 字节一个都不许用(误用 ⇒ 这次会吐出 1.5×)。
assert_eq!(
server.bytes_sent(),
size,
"旧的那 20480 字节一个都不许用:只允许下这次的 {size} 字节"
);
assert_eq!(outcome.resumed_from, 0, "总长变了 ⇒ 旧段偏移全部作废");
server.stop();
}