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。
This commit is contained in:
AskaEth committed 2026-11-13 06:59:58 +08:00
1 parent ffca0f4454
commit 1004af81ca
7 files changed
+288 -33

No files matched your search

@@ -0,0 +1,121 @@
//! 弱校验器(`Last-Modified`)驱动 `If-Range` 的那条支路(复审 r2 p3-1)。
//!
//! 分块路径此前只在 **ETag** 上端到端覆盖过条件请求;`Validator::if_range_value()` 的兜底
//! (ETag 缺失 ⇒ 用 Last-Modified)与 `ChunkJournal::record` 写 `last_modified` 这两条路,只有
//! 单连接的纯函数用例覆盖(`resume_test.rs` 的 `missing_validator_restarts`),分块侧零覆盖。
//!
//! 这里补上:服务器**只给** `Last-Modified`(不给 ETag),3/4 段失败后重试必须带这个 HTTP-date
//! 发 `If-Range`,夹具按弱校验器比对相符 ⇒ 206 ⇒ 只补 1 段(41984 = 1.025×),而不是整份重下。
//!
//! 抓得住:分块路径「只有弱校验器时也能续传」这条端到端行为(含 `record` 写 `last_modified`、
//! `validator()` 兜底、夹具的 HTTP-date 比对)。
//! 抓不住:真实服务器对 HTTP-date 型 `If-Range` 的**强/弱比较语义**(RFC 7233 §3.2 规定只能用
//! 强校验器做 If-Range,HTTP-date 允许以 60s 粒度做「没变过」判定)——夹具只做字符串相等,这里
//! 证明的是「我们自己这条链路会把日期原样送进 If-Range 并接受 206」,不是「所有真实服务器都会」。
//! 若服务器判「不符」,按既有路径会回 200 ⇒ `RangeUnsupported` ⇒ 干净重下(正确性不受影响)。
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::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::types::{DownloadRequest, ResourceKind};
use super::support::{sha1_hex, temp_dir, MockResponse, MockServer};
/// 4 段 × 10 KiB。
const SIZE: usize = 40 * 1024;
/// 卡住的那一段吐这么多字节就停。
const STALL_TAIL: usize = 1024;
/// 服务器唯一的校验器(HTTP-date 原文,客户端会原样放进 `If-Range`)。
const LAST_MODIFIED: &str = "Wed, 21 Oct 2015 07:28:00 GMT";
fn payload() -> Vec<u8> {
(0..SIZE).map(|index| (index % 251) as u8).collect()
}
fn engine(dir: &std::path::Path) -> DownloadEngine {
DownloadEngine::new(
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()
},
Arc::new(MirrorTable::builtin()),
Arc::new(RegionProfile::direct(0)),
Arc::new(HealthRegistry::in_memory()),
Arc::new(ContentCache::open(dir.join("cache"))),
)
}
#[tokio::test]
async fn last_modified_alone_can_drive_if_range_on_the_chunked_path() {
let payload = payload();
let size = payload.len() as u64;
// 只给弱校验器:没有 ETag,只有 Last-Modified。
let response = |body: Vec<u8>| {
MockResponse::ok(body)
.without_etag()
.with_last_modified(LAST_MODIFIED)
};
// 4 段里 3 段成功、1 段卡住;重试只该补 1 段;后面 4 条给「退化成整份重下」兜底。
let mut script: Vec<MockResponse> = (0..3).map(|_| response(payload.clone())).collect();
script.push(response(payload.clone()).stalling_after(STALL_TAIL));
script.push(response(payload.clone()));
script.extend((0..4).map(|_| response(payload.clone())));
let server = MockServer::start(script).await;
let url = server.url("/jdk.zip");
let dir = temp_dir("chunked-last-modified");
let engine = engine(&dir);
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(size),
)
.await
.expect("只有弱校验器时也要能续传完成");
assert_eq!(std::fs::read(&dest).expect("读回落盘文件失败"), payload);
// 主判据:重试那一次带的是 **Last-Modified**(HTTP-date),而且只有那一次有条件请求。
let seen = server.if_range_values();
assert_eq!(
seen.len(),
1,
"只该在重试那一次发条件请求(首轮没有可续的前缀):{seen:?}"
);
assert_eq!(
seen[0].to_ascii_lowercase(),
LAST_MODIFIED.to_ascii_lowercase(),
"ETag 缺失时 `If-Range` 必须兜底用 Last-Modified,实得 {:?}",
seen[0]
);
// 3 段 + 卡住的 1KiB + 补 1 段 = 整份 + 1KiB = 41984(1.025×)。
assert_eq!(
server.bytes_sent(),
size + STALL_TAIL as u64,
"弱校验器那一路也该只补 1 段(1.025×),不是整份重下"
);
assert_eq!(server.hits(), 5, "第一次 4 段 + 第二次补 1 段");
assert_eq!(outcome.resumed_from, size * 3 / 4, "复用了 3 段");
server.stop();
}
@@ -285,3 +285,77 @@ async fn changed_size_invalidates_old_chunk_layout() {
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();
}