From 1004af81caff67171d1c8a914f4ef1fa012f15f4 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Fri, 13 Nov 2026 06:59:58 +0800 Subject: [PATCH] =?UTF-8?q?fix(download):=20=E5=90=8C=E6=BA=90=E6=8D=A2?= =?UTF-8?q?=E8=A1=A8=E7=A4=BA=E6=97=B6=E4=B8=8D=E5=86=8D=E6=B2=BF=E7=94=A8?= =?UTF-8?q?=E4=B8=8A=E4=B8=80=E4=BB=BD=E7=9A=84=E6=A0=A1=E9=AA=8C=E5=99=A8?= =?UTF-8?q?=20+=20=E4=B8=89=E6=9D=A1=20p3=20=E6=94=B6=E5=8F=A3=EF=BC=88M29?= =?UTF-8?q?3=20r2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审 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。 --- crates/core/src/download/chunk_resume.rs | 50 ++++++-- crates/core/src/download/transfer_chunk.rs | 52 ++++---- .../tests/download/chunked/last_modified.rs | 121 ++++++++++++++++++ crates/core/tests/download/chunked/resume.rs | 74 +++++++++++ crates/core/tests/download/chunked_test.rs | 4 + .../core/tests/download/support/response.rs | 12 +- crates/core/tests/download/support/server.rs | 8 ++ 7 files changed, 288 insertions(+), 33 deletions(-) create mode 100644 crates/core/tests/download/chunked/last_modified.rs diff --git a/crates/core/src/download/chunk_resume.rs b/crates/core/src/download/chunk_resume.rs index 34dbada..ee5bae1 100644 --- a/crates/core/src/download/chunk_resume.rs +++ b/crates/core/src/download/chunk_resume.rs @@ -29,14 +29,20 @@ //! 写的这一侧是本文末的 [`ChunkJournal`]:每成功回收一段就落一次盘(原子写),失败时元数据 //! 里已经有「哪几段、各自摘要是什么」,于是下一次尝试能接着下而不是从 0。 //! -//! # 校验器跟着**源**走(复审 r1 p2-1) +//! # 校验器跟着**源**与**表示**走(复审 r1 + r2 的 p2-1) //! -//! 校验器(ETag / Last-Modified)是「某个 URL 上那个表示」的属性,不是这个文件的属性:镜像 / -//! CDN / 对象存储各自生成,跨源几乎必然不同。把它当成文件的属性(例如换源后原样沿用)会让 -//! 身份门的「有校验器」被一个不相干的值满足,于是拿 A 的 ETag 去问 B —— 按 RFC 7233 服务器回 -//! 200 + 完整正文,分块路径拿不到 Range,整份丢掉退化单连接从头下(本 mission 要消灭的形态)。 -//! 所以沿用旧校验器的唯一条件是 `meta.url == 当前源 URL`,判定在口径上只有一处 -//! (`transfer_chunk` 的 `same_source`)。 +//! 校验器(ETag / Last-Modified)是「某个 URL 上**某一份表示**」的属性,不是这个文件的属性。 +//! 它有两个独立的失效维度,缺一不可: +//! +//! * **源**:镜像 / CDN / 对象存储各自生成,跨源几乎必然不同。把 A 的校验器带进源 B 的元数据, +//! 身份门的「有校验器」就被一个不相干的值满足,于是拿 A 的 ETag 去问 B; +//! * **表示**:同一个 URL 也可能换了一份表示(重新发布、CDN 回源到新对象)。`done` 为空表示这 +//! 一轮没有任何字节要接着用,此时上一份元数据里的校验器描述的不是这次要下的东西。 +//! +//! 两条路的终点相同:按 RFC 7233 §3.2,服务器对不上的 `If-Range` 会回 200 + 完整正文,分块路径 +//! 拿不到 Range ⇒ `RangeUnsupported` ⇒ 整份丢掉退化单连接从头下(本 mission 要消灭的形态;r2 实测 +//! 同源换表示那条是 2.775×,比不续传的 1.775× 还费)。所以沿用旧校验器的条件只有一处判定 +//! (`transfer_chunk` 的 `carry` = 「同源 **且** `done` 非空」),写元数据与发 `If-Range` 都走它。 //! //! # 已知代价(复审 r1 p3-3) //! @@ -46,6 +52,15 @@ //! 「进度从复用字节开始」之前,所以界面会看起来停在原地(未做专门进度事件,见 mission 报告 //! 的跟进项)。要省掉这次读只有一条路:相信元数据里的摘要而不复核——那等于把「旧字节可信」 //! 当前提,与上面的自证要求直接冲突,**明确不做**。 +//! * **请求没带期望摘要时,末尾那道整文件摘要空转**(`verify_part` 在 `integrity: None` 时直接 +//! `Ok`)。此时「续传后重新校验」只剩两道:旧段自证(只能证明「这些字节是我们写下去的」)与 +//! `If-Range`(只能证明「服务器那份表示没变」,而服务器忽略该头时就失效)。边界很清楚: +//! **长度相同、内容换了一份**的文件在这种配置下无法察觉(末尾没有摘要可比)。这不是本 mission +//! 引入的(单连接路径一直如此,`transfer.rs` 零改动),但分块路径继承了它。现实入口是 +//! `ModFile` 上游没给合法 sha1 的时候(`addon/online/download.rs` 只在 sha1 合法时才设 +//! `integrity`,而 mod / 整合包正会走分块);`Library` 元数据、`download.bin` 缓存也有无摘要 +//! 调用点。要收紧就得要求「分块路径必须带 integrity」,那会让这类下载退回单连接或直接失败—— +//! 属于产品决策,不在这里单方面改。 //! * **超时后的读盘线程不可取消**:[`verify_records`] 跑在 `spawn_blocking` 上,调用方到达 //! `verify_timeout` 后会丢弃中间文件并重写同一路径,而那个线程可能还在读、还持着句柄 //! (与 `transfer_chunk::verify_part` 里记的那条同族)。假设未验:Windows 上可能让随后同名 @@ -236,9 +251,9 @@ impl ChunkJournal { /// 校验器**只在还没有值时写入**,这是刻意的(复审 r1 问过):同一个 URL 的续传里如果服务器 /// 换了 ETag,保留旧值反而是保守选择——下一次带着旧 ETag 发 `If-Range` 会被服务器判「变了」 /// 而回 200,于是干净重下;若改成覆盖成新值,下一次会把「旧字节 + 新表示」当成同一份续下去, - /// 只能等末尾整文件摘要才发现(那已经是白下)。跨源的情况根本不走到这里:换源时 - /// `transfer_chunk` 的 `same_source` 过滤已经让 `etag` 从 `None` 起步,这里填的是**本源的** - /// 校验器。 + /// 只能等末尾整文件摘要才发现(那已经是白下)。跨源、跨表示的情况根本不走到这里: + /// `transfer_chunk` 的 `carry`(同源 **且** `done` 非空)已经让 `etag` 从 `None` 起步, + /// 这里填的只可能是**本源、本轮响应**带来的校验器。 pub fn record(&mut self, fetched: ChunkFetch) { if self.meta.etag.is_none() { self.meta.etag = fetched.etag; @@ -258,6 +273,21 @@ impl ChunkJournal { } /// 落盘(元数据写失败不影响下载本身:续传只是加速,不是正确性前提)。 + /// + /// **同步写**(`write_atomic`:建 tmp → 写 → `sync_all` → rename)跑在 async 任务里。判断是 + /// 「可以接受」,量级与理由(复审 r2 p3-2): + /// * 一个作业最多 `MAX_CONNECTIONS`(16) 段 + 建账那次 = **≤17 次**,每次 1–3 KiB(一条记录 + /// 约 80 字节)。本地 SSD 每次约 1–2 ms ⇒ 整作业 ≤ ~30 ms;网络盘上一次 rename 可能几十 + /// ms ⇒ ≤ ~1 s,摊在「几分钟到几小时」的作业上(落完一段本来就要几十秒到几分钟),占比 + /// 1e-4 量级; + /// * 顺序有意义:必须**先**把「这一段已落盘 + 摘要」写到盘上,才谈得上「记账可信」 + /// (record-then-trust)。挪进 `spawn_blocking` 就得让 journal 跨 await 被拥有或加锁, + /// 为几毫秒的 executor 占用引入一处顺序风险,不划算; + /// * 与单连接路径同口径:那边也是在 async 里同步 `meta.save`(`transfer.rs`)。只改分块侧会 + /// 让两条路径的行为分叉。 + /// + /// 退路:真出现 executor 饥饿(并发下载很多 + 网络盘)时,把 `record()` 整体挪进 + /// `spawn_blocking`、journal 变成 `Arc>` 是可行方向,那时再收敛。 pub fn save(&mut self) { self.meta.saved_at_ms = now_ms(); let _ = self.meta.save(&self.path); diff --git a/crates/core/src/download/transfer_chunk.rs b/crates/core/src/download/transfer_chunk.rs index 6a2c105..a5eaa1b 100644 --- a/crates/core/src/download/transfer_chunk.rs +++ b/crates/core/src/download/transfer_chunk.rs @@ -90,28 +90,29 @@ impl DownloadEngine { } ChunkDecision::Restart { .. } => part.discard(), } - // 校验器是**源**的属性(镜像/CDN/对象存储各自生成 ETag),不是这个文件的属性:只有 - // 「记下它的那个 URL 就是现在这个源」时才能沿用,否则当「没有校验器」。 + // 校验器既是**源**的属性(镜像/CDN/对象存储各自生成 ETag),也是**一份表示**的属性—— + // 两件事缺一不可,所以「谁能当校验器」只有 `carry` 这一个判定点: // - // 为什么必须挡(复审 r1 p2-1):换源时 `decide` 判 `UrlChanged`、删盘重来,但 `previous` - // 还在内存里。把 A 的校验器写进 `url=B` 的新元数据之后,身份门的 `NoValidator` 会被一个 - // 不相干的校验器满足,于是拿 **A 的 ETag** 去对 B 发 `If-Range`:按 RFC 7233 §3.2, - // 服务器对不上就回 200 + 完整正文 ⇒ `fetch_chunk` 判 `RangeUnsupported` ⇒ 丢掉整份 ⇒ - // 单连接从头下——正是本 mission 要消灭的「从 0 重下」,只是换了条路径进来。 + // * **源**(复审 r1 p2-1):`previous.url` 不是当前源就当没有。把 A 的校验器写进 `url=B` + // 的新元数据之后,身份门的 `NoValidator` 会被一个不相干的值满足,于是拿 **A 的 ETag** + // 去对 B 发 `If-Range`:按 RFC 7233 §3.2,服务器对不上就回 200 + 完整正文 ⇒ + // `fetch_chunk` 判 `RangeUnsupported` ⇒ 丢掉整份 ⇒ 单连接从头下。 + // * **表示**(复审 r2 p2-1):`done` 为空表示这一轮没有任何字节要接着用(`Restart`,或 + // 第一次跑),那上一份元数据里的校验器描述的是**另一份**表示。留着它这一轮看不出问题 + // (`if_range` 本来就不发),但它会被原样写进新元数据,而 `record` 只在缺值时写 ⇒ 本轮 + // 响应的真实校验器永远盖不上它 ⇒ 下一次续传拿一个已知可能不匹配的值去问服务器 ⇒ 同上 + // 那条 200/丢整份的路。实测那是 2.775×,比「干脆不续传」的 1.775× 还费一整份。 + // 清掉之后,本轮响应里的真实校验器由 `ChunkJournal::record` 填进来;最坏也只是 + // `NoValidator` ⇒ 整份重下(1.775× 那条基线),不会更坏。 // - // 三种修法里选「校验器与 URL 绑定」(复审的选项 A)而不是「换源时清空」(B)或「多源各记 - // 一份」(C):`ResumeMeta` 本来就有 `url` 字段,绑定语义只需一个 `filter` 就能表达,且 - // 「这个校验器属于哪个源」在读取处即可判定,不必再加一个可能与 `url` 漂移的来源字段; - // 而 B 只是 A 在「URL 变了」这一特例上的写法,条件写在读取处比分散到写入处更难漏。 + // 为什么不选复审给的另两种修法:「换源时清空」(B)只是「源」这一维的特例写法,条件放在 + // 读取处比散到写入处更难漏;「校验器加来源字段」(C)会在 `ResumeMeta` 里多一份可与 + // `url` 漂移的真相,而当前模型是「一个中间文件 = 一个目标」,没有并存多源的形态。 let same_source = previous.as_ref().filter(|meta| meta.url == candidate.url); - // 续传时把**同源**的旧校验器带上:这是 `If-Range` 的取值来源,也是「远端内容没变」的 - // 协议层证据。(`done` 非空 ⇒ `decide` 已保证 `previous.url == candidate.url`, - // 这里仍走同一个 `same_source`,让「谁能当校验器」只有一个判定点。) - let if_range = if done.is_empty() { - None - } else { - same_source.and_then(|meta| meta.validator().if_range_value().map(str::to_string)) - }; + let carry = if done.is_empty() { None } else { same_source }; + // 续传时把**同源、同一份表示**的旧校验器带上:这是 `If-Range` 的取值来源,也是「远端 + // 内容没变」的协议层证据。 + let if_range = carry.and_then(|meta| meta.validator().if_range_value().map(str::to_string)); part.ensure_parent_async() .await @@ -137,9 +138,10 @@ impl DownloadEngine { let mut journal = ChunkJournal::begin( ResumeMeta { url: candidate.url.clone(), - // 只沿用**同一个源**的旧校验器;换了源就当没有,让下面每一段的响应自己去填。 - etag: same_source.and_then(|meta| meta.etag.clone()), - last_modified: same_source.and_then(|meta| meta.last_modified.clone()), + // 只沿用「同一个源 + 同一份表示」的旧校验器(见上面的 `carry`);其余情况当没有, + // 让下面每一段的响应自己去填。 + etag: carry.and_then(|meta| meta.etag.clone()), + last_modified: carry.and_then(|meta| meta.last_modified.clone()), total: Some(total), algo: integrity.map_or(HashAlgo::Sha1, |item| item.algo), hash: integrity.map_or_else(String::new, |item| item.hex.clone()), @@ -244,6 +246,12 @@ impl DownloadEngine { } } + /// 整文件摘要(有界、分类见下)。 + /// + /// `integrity: None` 时**直接返回 `Ok`**:这类请求没有任何端到端的摘要可比,末尾这一步 + /// 空转。两条路径都如此(单连接一直如此,本 mission 未改)。此时「续传后重新校验」只剩 + /// 旧段自证 + `If-Range` 两道,`chunk_resume` 模块头的「已知代价」写了这个一致性边界 + /// (长度相同、内容换了一份的文件不可察觉;现实入口是没有上游 sha1 的 `ModFile`)。 pub(crate) async fn verify_part( &self, part: &mut PartFile, diff --git a/crates/core/tests/download/chunked/last_modified.rs b/crates/core/tests/download/chunked/last_modified.rs new file mode 100644 index 0000000..1b3c5ae --- /dev/null +++ b/crates/core/tests/download/chunked/last_modified.rs @@ -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 { + (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| { + MockResponse::ok(body) + .without_etag() + .with_last_modified(LAST_MODIFIED) + }; + // 4 段里 3 段成功、1 段卡住;重试只该补 1 段;后面 4 条给「退化成整份重下」兜底。 + let mut script: Vec = (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(); +} diff --git a/crates/core/tests/download/chunked/resume.rs b/crates/core/tests/download/chunked/resume.rs index be6ec87..746ca44 100644 --- a/crates/core/tests/download/chunked/resume.rs +++ b/crates/core/tests/download/chunked/resume.rs @@ -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| MockResponse::ok(body).with_etag(ETAG_NEW); + let mut script: Vec = (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(); +} diff --git a/crates/core/tests/download/chunked_test.rs b/crates/core/tests/download/chunked_test.rs index c38221b..8b4f293 100644 --- a/crates/core/tests/download/chunked_test.rs +++ b/crates/core/tests/download/chunked_test.rs @@ -385,3 +385,7 @@ 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; diff --git a/crates/core/tests/download/support/response.rs b/crates/core/tests/download/support/response.rs index 028c8e8..6c5b136 100644 --- a/crates/core/tests/download/support/response.rs +++ b/crates/core/tests/download/support/response.rs @@ -15,6 +15,8 @@ pub struct MockResponse { pub range_support: bool, /// `ETag`(续传测试用)。 pub etag: Option, + /// `Last-Modified`(`ETag` 缺失时的弱校验器;HTTP-date 字样原样发出)。 + pub last_modified: Option, /// 返回 206 时故意把 `Content-Range` 写成从 0 开始(模拟段错位的坏服务器)。 pub lie_content_range: bool, /// 读完请求头后**一个字节都不写**(服务端接了连接但永不回话)。 @@ -39,6 +41,7 @@ impl MockResponse { body: body.into(), range_support: true, etag: Some("\"v1\"".to_string()), + last_modified: None, lie_content_range: false, silent: false, stall_at: None, @@ -55,6 +58,7 @@ impl MockResponse { body: Vec::new(), range_support: false, etag: None, + last_modified: None, lie_content_range: false, silent: false, stall_at: None, @@ -108,12 +112,18 @@ impl MockResponse { self } - /// 完全不发 `ETag`(模拟「自己没有任何校验器」的源)。 + /// 完全不发 `ETag`(模拟「自己没有强校验器」的源)。 pub fn without_etag(mut self) -> Self { self.etag = None; self } + /// 指定 `Last-Modified`(`ETag` 缺失时 `If-Range` 的兜底取值)。 + pub fn with_last_modified(mut self, value: impl Into) -> Self { + self.last_modified = Some(value.into()); + self + } + /// 206 但 `Content-Range` 谎报(模拟段错位的坏 CDN)。 pub fn with_wrong_content_range(mut self) -> Self { self.lie_content_range = true; diff --git a/crates/core/tests/download/support/server.rs b/crates/core/tests/download/support/server.rs index 2891d72..0e8a0f1 100644 --- a/crates/core/tests/download/support/server.rs +++ b/crates/core/tests/download/support/server.rs @@ -162,6 +162,11 @@ async fn serve( .etag .as_deref() .is_some_and(|etag| sent_value.eq_ignore_ascii_case(etag)) + // ETag 缺失时按弱校验器(HTTP-date)比:这是 `If-Range` 的兜底支路。 + || response + .last_modified + .as_deref() + .is_some_and(|date| sent_value.eq_ignore_ascii_case(date)) }); let range = if response.range_support && if_range.is_some() && !validator_ok { None @@ -222,6 +227,9 @@ async fn serve( if let Some(etag) = &response.etag { head.push_str(&format!("ETag: {etag}\r\n")); } + if let Some(date) = &response.last_modified { + head.push_str(&format!("Last-Modified: {date}\r\n")); + } for header in headers { head.push_str(&header); head.push_str("\r\n");