Files
Unlucky-Launcher/crates/core/tests/download/chunked/resume.rs
T
AskaEth 1004af81ca fix(download): 同源换表示时不再沿用上一份的校验器 + 三条 p3 收口(M293 r2)
复审 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。
2026-11-13 06:59:58 +08:00

362 lines
16 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();
}
/// 同一个 URL、**换了一份表示**(复审 r2 p2-1):上一份表示的校验器不许搬进这一轮。
///
/// 链条(全走既有代码):上一轮留下 `url=U, 旧总长/旧段区间, etag="v1"`(`chunk_meta` 固定写
/// `"v1"`)⇒ 本轮因 `HashChanged` 判 `Restart`、`part.discard()`,但 `previous` 还在内存里 ⇒
/// 若把 `"v1"` 写进**新一轮**元数据,而 `record` 又只在缺值时写,那本轮落的 3 段(**新**内容)
/// 配的是上一份表示的校验器 ⇒ 重试拿 `"v1"` 发 `If-Range` ⇒ 此刻服务器是 `"v2"`,按 RFC 7233
/// 回 200 + 完整正文 ⇒ `RangeUnsupported` ⇒ 丢整份 ⇒ 单连接从头下。
/// 实测那是 113664 字节(2.775×),比「干脆不续传」的 72704(1.775×)还多一整份。
///
/// 修法:`carry = if done.is_empty() { None } else { same_source }` —— `done` 为空就没有任何
/// 字节要接着用,旧校验器描述的不是这次要下的东西;本轮响应里的真实校验器由 `record` 填进来。
///
/// 断言:重试带的 `If-Range` 必须是本轮响应给的 `"v2"`(出现 `"v1"` 即串台),且字节数落回
/// 41984(1.025×)而不是 113664。
#[tokio::test]
async fn stale_validator_from_the_previous_representation_is_not_carried() {
/// 服务器此刻那份表示的 ETag(旧元数据里的 `"v1"` 是上一份表示的)。
const ETAG_NEW: &str = "\"v2\"";
let payload = payload();
let size = payload.len() as u64;
// 4 段:3 段成功 + 1 段卡住;重试只该补 1 段;后面 4 条给「退化成单连接整份重下」兜底
// (那种坏结局也要能下完,红的是判据不是脚本用尽)。
let fresh = |body: Vec<u8>| MockResponse::ok(body).with_etag(ETAG_NEW);
let mut script: Vec<MockResponse> = (0..3).map(|_| fresh(payload.clone())).collect();
script.push(fresh(payload.clone()).stalling_after(STALL_TAIL));
script.push(fresh(payload.clone()));
script.extend((0..4).map(|_| fresh(payload.clone())));
let server = MockServer::start(script).await;
let url = server.url("/jdk.zip");
let dir = temp_dir("chunked-stale-validator");
let engine = engine(&dir);
let dest = dir.join("jdk.zip");
// 上一轮留下的中间文件 + 元数据:同一个 URL,但描述的是另一份表示(旧总长 / 旧段区间 /
// 旧 ETag / 旧摘要)。
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("写旧中间文件失败");
chunk_meta(
&url,
old_size as u64,
&sha1_hex(&old_bytes),
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);
assert_eq!(
server.if_range_values(),
vec![ETAG_NEW.to_string()],
"重试只许带本轮响应给的校验器;出现 \"v1\" 说明上一份表示的校验器被搬进了这一轮"
);
// 3 段 + 卡住的 1KiB + 补 1 段 = 整份文件 + 1KiB = 41984(1.025×)。
assert_eq!(
server.bytes_sent(),
size + STALL_TAIL as u64,
"应当是 41984(1.025×);113664(2.775×)说明陈旧校验器把整份顶回来又重下了一遍"
);
assert_eq!(server.hits(), 5, "第一次 4 段 + 第二次补 1 段");
assert_eq!(outcome.resumed_from, size * 3 / 4, "复用了本轮的 3 段");
server.stop();
}