fix(download): 换源后不再沿用旧源校验器 + 三条 p3 收口(M293 r1)

复审 r1(p2-1)指出的链条:源 A 落 3/4 段失败 ⇒ 元数据 url=A + A 的 ETag;换源 B 时
`decide` 判 UrlChanged、删盘重来,但 `ChunkJournal::begin` 把 A 的校验器原样写进 url=B 的
新元数据,而 `record` 只在 `etag.is_none()` 时写入 ⇒ B 的真实校验器永远盖不上。于是拿 **A 的
ETag** 对 B 发 `If-Range`:按 RFC 7233 §3.2 服务器回 200 + 完整正文 ⇒ `fetch_chunk` 判
RangeUnsupported ⇒ 丢掉整份 ⇒ 单连接从头下 —— 债务以另一条路径复现。

修法(复审给的选项 A):校验器与**记下它的 URL** 绑定,`same_source = previous.filter(|m|
m.url == candidate.url)`,`etag` / `last_modified` / `If-Range` 三处全部走它,让「谁能当校验器」
只有一个判定点。不选 B(换源时清空)是因为 B 只是 A 在「URL 变了」这一特例上的写法,条件放在
读取处比散到写入处更难漏;不选 C(多源各记一份)是因为 `ResumeMeta` 本来就有 url,再加一个来源
字段会多一处可与 url 漂移的状态。

夹具此前不解析 `If-Range`,这一跳只能读代码推。现在 `support/` 记录每个请求的 `If-Range` 取值,
并按 RFC 7233 §3.2 处置(对不上就忽略 Range、回 200 + 完整正文),于是「真实服务器上的后果」也
进了用例。

判据(新用例 `chunked/switch.rs`,两个夹具服务器当一个官方源 + 一个镜像源):
* 源 B 收到的 `If-Range` 只许是它自己的 `"src-b"`(源 A 同源续传照旧带 `"src-a"` 作对照);
* 源 B 的字节数仍是 3 段 + 卡住的 1KiB + 补 1 段 = 41984(1.025×),换源没把同源续传打回整份重下;
* 最终文件逐字节正确;
* 第二条用例走「新源自己不发 ETag」那一支:`NoValidator` ⇒ 新源重试整份重下(`resumed_from=0`、
  8 个请求、72704 字节),一个条件请求都不许发 —— 这是「无法证明就不复用」的明确取舍。
变异(去掉 URL 过滤)⇒ 两条都红,实测原文:
  assertion `left == right` failed: 源 B 收到了不属于它的校验器
  left: ["\"src-a\""]  right: ["\"src-b\""]
  新源没有任何校验器,就不许发条件请求(带 A 的值更是串源):["\"src-a\""]
同一变异下还实测到了后果:`b_bytes=113664 b_hits=6 resumed_from=0`(200 白下一份 + 单连接整份
重下 = 2.77×),即复审预测的那一跳确实存在。

三条 p3 一并收口:
* p3-1:`abort_all()` 之前用 `try_join_next` 把「已落完但还没被收走」的段记进账
  (`absorb_completed`,总长判据与正常回收同一道)。这是尽力减少浪费、不是正确性前提,且
  它是「谁先完成」的竞态 ⇒ 没有确定性用例能钉住,已在文档里写明边界。
* p3-2:`verify_chunks` 改为返回 `Option<u64>`(可复用字节数),`AttemptOk.resumed_from`
  直接取它,删掉原来「记录另算一遍」的双份口径。
* p3-3:`chunk_resume` 模块头新增「已知代价」:重读量正比于**复用的字节**(不是整份文件)、
  为什么不能用「相信摘要」来省掉、以及超时后 `spawn_blocking` 线程不可取消在 Windows 上的
  句柄风险。

拆文件守 400 行硬线:`await_chunks` + `absorb_completed` 移到 `transfer_chunk/join.rs`
(逐字节搬移),`MockResponse` 移到 `support/response.rs`(逐字节搬移,只加 `with_etag` /
`without_etag` 两个构造器)。

门禁:lib 903 / engine_mock 17 / chunked 20 / resume 12 / partfile 7 / cache 6 全绿,
clippy -D warnings 0、fmt --check 0;字节判据复测未变差(resume 41984 = 1.025×,
noresume 72704 = 1.775×)。
This commit is contained in:
AskaEth committed 2026-11-13 06:45:55 +08:00
1 parent 3ade2d14a8
commit ffca0f4454
8 files changed
+628 -238

No files matched your search

+31 -1
View File
@@ -28,6 +28,29 @@
//!
//! 写的这一侧是本文末的 [`ChunkJournal`]:每成功回收一段就落一次盘(原子写),失败时元数据
//! 里已经有「哪几段、各自摘要是什么」,于是下一次尝试能接着下而不是从 0。
//!
//! # 校验器跟着**源**走(复审 r1 p2-1)
//!
//! 校验器(ETag / Last-Modified)是「某个 URL 上那个表示」的属性,不是这个文件的属性:镜像 /
//! CDN / 对象存储各自生成,跨源几乎必然不同。把它当成文件的属性(例如换源后原样沿用)会让
//! 身份门的「有校验器」被一个不相干的值满足,于是拿 A 的 ETag 去问 B —— 按 RFC 7233 服务器回
//! 200 + 完整正文,分块路径拿不到 Range,整份丢掉退化单连接从头下(本 mission 要消灭的形态)。
//! 所以沿用旧校验器的唯一条件是 `meta.url == 当前源 URL`,判定在口径上只有一处
//! (`transfer_chunk` 的 `same_source`)。
//!
//! # 已知代价(复审 r1 p3-3)
//!
//! * **每次续传都要重读「即将复用」的那些字节**(不是整份文件:复用 3/4 才读 3/4,1 段就只读
//! 1 段)。这是本模块存在的意义本身——不复读就无法证明盘上旧字节还是当初那一段。188MB 文件
//! 落完 3/4 时约 141MB 顺序读,本地 SSD 是毫秒级,网络盘上可能到几秒;它发生在
//! 「进度从复用字节开始」之前,所以界面会看起来停在原地(未做专门进度事件,见 mission 报告
//! 的跟进项)。要省掉这次读只有一条路:相信元数据里的摘要而不复核——那等于把「旧字节可信」
//! 当前提,与上面的自证要求直接冲突,**明确不做**。
//! * **超时后的读盘线程不可取消**:[`verify_records`] 跑在 `spawn_blocking` 上,调用方到达
//! `verify_timeout` 后会丢弃中间文件并重写同一路径,而那个线程可能还在读、还持着句柄
//! (与 `transfer_chunk::verify_part` 里记的那条同族)。假设未验:Windows 上可能让随后同名
//! 重建拿到 `Access is denied`,表现为「预分配中间文件失败」而不是干净重试。有界、有分类,
//! 但诊断会拐一道。
use std::fs::File;
use std::io::{self, Read, Seek, SeekFrom};
@@ -208,7 +231,14 @@ impl ChunkJournal {
self.meta.chunks.as_deref().unwrap_or_default()
}
/// 记一个已落盘段(顺带补上服务器校验器:只有第一次拿到的算数,续传沿用旧的那个)。
/// 记一个已落盘段(顺带补上服务器校验器)。
///
/// 校验器**只在还没有值时写入**,这是刻意的(复审 r1 问过):同一个 URL 的续传里如果服务器
/// 换了 ETag,保留旧值反而是保守选择——下一次带着旧 ETag 发 `If-Range` 会被服务器判「变了」
/// 而回 200,于是干净重下;若改成覆盖成新值,下一次会把「旧字节 + 新表示」当成同一份续下去,
/// 只能等末尾整文件摘要才发现(那已经是白下)。跨源的情况根本不走到这里:换源时
/// `transfer_chunk` 的 `same_source` 过滤已经让 `etag` 从 `None` 起步,这里填的是**本源的**
/// 校验器。
pub fn record(&mut self, fetched: ChunkFetch) {
if self.meta.etag.is_none() {
self.meta.etag = fetched.etag;
+53 -131
View File
@@ -25,15 +25,18 @@ use std::time::Duration;
use super::atomic::PartFile;
use super::cache::now_ms;
use super::chunk_resume::{self, ChunkDecision, ChunkJournal};
use super::chunked::{self, ChunkFetch};
use super::chunked;
use super::engine::{AttemptOk, DownloadEngine};
use super::error::{DownloadError, DownloadErrorKind};
use super::progress::ProgressEvent;
use super::resume::{ChunkRecord, ResumeMeta};
use super::stall::{StallReason, StallReport, StallStage};
use super::stall::{StallReport, StallStage};
use super::types::{Candidate, DownloadId, DownloadRequest, HashAlgo, Integrity};
use super::verify;
/// 分块任务的回收(失速判据 + 已完成段记账):拆出守 400 行硬线。
mod join;
impl DownloadEngine {
pub(crate) async fn attempt_chunked(
&self,
@@ -73,24 +76,41 @@ impl DownloadEngine {
// ② 自证:按记录重读那些字节、重算摘要。任何一段对不上就整份丢掉从头再来
// (理由见 `chunk_resume` 模块头:对不上说明这份元数据本身已经不可信)。
let mut done: Vec<ChunkRecord> = Vec::new();
let mut reused = 0_u64;
match decision {
ChunkDecision::Reuse { done: records } => {
if self.verify_chunks(part, &records).await {
done = records;
} else {
part.discard();
// 复用字节数直接取自证的结果(唯一真相源:少读一段就少算一段)。
match self.verify_chunks(part, &records).await {
Some(bytes) => {
done = records;
reused = bytes;
}
None => part.discard(),
}
}
ChunkDecision::Restart { .. } => part.discard(),
}
let reused: u64 = done.iter().map(ChunkRecord::len).sum();
// 续传时把旧校验器带上:这是 `If-Range` 的取值来源,也是「远端内容没变」的协议层证据。
// 校验器是**源**的属性(镜像/CDN/对象存储各自生成 ETag),不是这个文件的属性:只有
// 「记下它的那个 URL 就是现在这个源」时才能沿用,否则当「没有校验器」。
//
// 为什么必须挡(复审 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 重下」,只是换了条路径进来。
//
// 三种修法里选「校验器与 URL 绑定」(复审的选项 A)而不是「换源时清空」(B)或「多源各记
// 一份」(C):`ResumeMeta` 本来就有 `url` 字段,绑定语义只需一个 `filter` 就能表达,且
// 「这个校验器属于哪个源」在读取处即可判定,不必再加一个可能与 `url` 漂移的来源字段;
// 而 B 只是 A 在「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 {
previous
.as_ref()
.and_then(|meta| meta.validator().if_range_value().map(str::to_string))
same_source.and_then(|meta| meta.validator().if_range_value().map(str::to_string))
};
part.ensure_parent_async()
@@ -117,11 +137,9 @@ impl DownloadEngine {
let mut journal = ChunkJournal::begin(
ResumeMeta {
url: candidate.url.clone(),
// 续传沿用旧校验器:新响应可能不给 ETag,而 `If-Range` 要靠它。
etag: previous.as_ref().and_then(|meta| meta.etag.clone()),
last_modified: previous
.as_ref()
.and_then(|meta| meta.last_modified.clone()),
// 只沿用**同一个源**的旧校验器;换了源就当没有,让下面每一段的响应自己去填。
etag: same_source.and_then(|meta| meta.etag.clone()),
last_modified: same_source.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()),
@@ -185,121 +203,6 @@ impl DownloadEngine {
})
}
/// 回收分块任务,同时盯着「整个作业有没有在动」。
///
/// 两道判据必须分得开(另一处是单块自己的读空闲超时,阶段 = 等字节):
/// * **无进度**:`counter` 连续 `chunk_stall_timeout` 没有新字节。分块任务卡在写盘上
/// (杀软/网络盘)时单块读超时不会响,只有这道抓得住——现场留给 `join_next` 的正是
/// 「188MB 落完 99% 之后永久挂住」那个形态;
/// * **总期限**:`chunk_job_budget`(按平均吞吐下限反推,见 `stall` 模块头)用完。
///
/// 两者都归到阶段 [`StallStage::AwaitChunkJoin`],靠 [`StallReason`] 区分。
///
/// 成功的段在这里**顺手记账**([`ChunkJournal::record`]):这是唯一同时握着「段结果」与
/// 「段区间」的地方,落到元数据之后,本作业后面怎么挂都不影响这几段的续传价值。
async fn await_chunks(
&self,
set: &mut tokio::task::JoinSet<Result<ChunkFetch, DownloadError>>,
counter: &AtomicU64,
total: u64,
url: &str,
journal: &mut ChunkJournal,
) -> Option<DownloadError> {
let budget = self.cfg.chunk_job_budget(Some(total));
let hard_deadline = tokio::time::Instant::now() + budget;
let mut last_seen = 0_u64;
let mut last_progress = tokio::time::Instant::now();
loop {
let seen = counter.load(Ordering::Relaxed);
if seen > last_seen {
last_seen = seen;
last_progress = tokio::time::Instant::now();
}
let wake = (last_progress + self.cfg.chunk_stall_timeout).min(hard_deadline);
match tokio::time::timeout_at(wake, set.join_next()).await {
Ok(Some(Ok(Ok(fetched)))) => {
// 「这个源给的还是不是同一个文件」:`Content-Range` 的总长必须与我们规划的
// 一致,否则写下去的段根本拼不成目标文件(单连接路径同一处判据是收尾的
// `written != total`)。
if fetched.total.is_some_and(|server| server != total) {
set.abort_all();
return Some(DownloadError::new(
DownloadErrorKind::Network,
format!(
"分块总长不符:响应声明 {},本次规划 {total}({url})",
fetched.total.unwrap_or_default()
),
));
}
journal.record(fetched);
}
Ok(Some(Ok(Err(err)))) => {
set.abort_all();
return Some(err);
}
Ok(Some(Err(err))) => {
set.abort_all();
return Some(DownloadError::new(
DownloadErrorKind::Network,
format!("分块任务异常:{err}"),
));
}
// 全部回收(成功)。
Ok(None) => return None,
Err(_) => {
let now = tokio::time::Instant::now();
// 窗口内确实有字节进来 ⇒ 续期(「慢但有效」不被误杀的机制就在这一行)。
let seen = counter.load(Ordering::Relaxed);
if now < hard_deadline && seen > last_seen {
last_seen = seen;
last_progress = now;
continue;
}
let report = if now >= hard_deadline {
StallReport::deadline(
StallStage::AwaitChunkJoin,
seen,
Some(total),
now - last_progress,
)
} else {
StallReport::no_progress(
StallStage::AwaitChunkJoin,
seen,
Some(total),
now - last_progress,
)
};
set.abort_all();
let context = match report.reason {
StallReason::Deadline => {
format!("分块作业超过总期限 {}s({url})", budget.as_secs())
}
StallReason::NoProgress => format!("分块作业长时间没有新字节({url})"),
};
return Some(DownloadError::stalled(report, context));
}
}
}
}
/// 旧分块自证:按记录逐段重读、重算摘要。
///
/// `true` = 每一段都对得上,可以复用;`false` = 证明不了(摘要不符 / 盘上不够长 / 读盘失败 /
/// 超时),调用方必须整份丢掉。超时也归到「证明不了」:`spawn_blocking` 的线程不可取消,
/// 拿不到结果就不能拿旧字节去拼文件——宁可再生一遍字节。
async fn verify_chunks(&self, part: &PartFile, records: &[ChunkRecord]) -> bool {
let path = part.path().to_path_buf();
let records = records.to_vec();
let job = tokio::task::spawn_blocking(move || {
chunk_resume::verify_records(&path, &records).is_ok()
});
matches!(
tokio::time::timeout(self.cfg.verify_timeout, job).await,
Ok(Ok(true))
)
}
fn spawn_reporter(
&self,
id: DownloadId,
@@ -322,6 +225,25 @@ impl DownloadEngine {
})
}
/// 旧分块自证:按记录逐段重读、重算摘要。
///
/// `Some(bytes)` = 每一段都对得上,返回值就是**可复用的字节数**(调用方直接拿它当
/// `resumed_from`,别再从记录另算一遍——两份口径会静默漂移);`None` = 证明不了
/// (摘要不符 / 盘上不够长 / 读盘失败 / 超时),调用方必须整份丢掉。
///
/// 超时也归到「证明不了」:`spawn_blocking` 的线程不可取消,到点后它还在读文件、还持着
/// 句柄(与 `verify_part` 里那段注释同族,代价见 `chunk_resume` 模块头「已知代价」)。
async fn verify_chunks(&self, part: &PartFile, records: &[ChunkRecord]) -> Option<u64> {
let path = part.path().to_path_buf();
let records = records.to_vec();
let job =
tokio::task::spawn_blocking(move || chunk_resume::verify_records(&path, &records));
match tokio::time::timeout(self.cfg.verify_timeout, job).await {
Ok(Ok(Ok(bytes))) => Some(bytes),
_ => None,
}
}
pub(crate) async fn verify_part(
&self,
part: &mut PartFile,
@@ -0,0 +1,139 @@
//! 分块任务的回收:两道失速判据 + 已完成段的记账。
//!
//! 从 `transfer_chunk.rs` 拆出只为守 400 行硬线(M293 r1:换源校验器修复 + 两条 p3 收口之后)。
//! 搬出来的代码逐字节未改,仍挂在同一个 `impl DownloadEngine` 上。
use std::sync::atomic::{AtomicU64, Ordering};
use super::super::chunk_resume::ChunkJournal;
use super::super::chunked::ChunkFetch;
use super::super::engine::DownloadEngine;
use super::super::error::{DownloadError, DownloadErrorKind};
use super::super::stall::{StallReason, StallReport, StallStage};
impl DownloadEngine {
/// 回收分块任务,同时盯着「整个作业有没有在动」。
///
/// 两道判据必须分得开(另一处是单块自己的读空闲超时,阶段 = 等字节):
/// * **无进度**:`counter` 连续 `chunk_stall_timeout` 没有新字节。分块任务卡在写盘上
/// (杀软/网络盘)时单块读超时不会响,只有这道抓得住——现场留给 `join_next` 的正是
/// 「188MB 落完 99% 之后永久挂住」那个形态;
/// * **总期限**:`chunk_job_budget`(按平均吞吐下限反推,见 `stall` 模块头)用完。
///
/// 两者都归到阶段 [`StallStage::AwaitChunkJoin`],靠 [`StallReason`] 区分。
///
/// 成功的段在这里**顺手记账**([`ChunkJournal::record`]):这是唯一同时握着「段结果」与
/// 「段区间」的地方,落到元数据之后,本作业后面怎么挂都不影响这几段的续传价值。
pub(super) async fn await_chunks(
&self,
set: &mut tokio::task::JoinSet<Result<ChunkFetch, DownloadError>>,
counter: &AtomicU64,
total: u64,
url: &str,
journal: &mut ChunkJournal,
) -> Option<DownloadError> {
let budget = self.cfg.chunk_job_budget(Some(total));
let hard_deadline = tokio::time::Instant::now() + budget;
let mut last_seen = 0_u64;
let mut last_progress = tokio::time::Instant::now();
loop {
let seen = counter.load(Ordering::Relaxed);
if seen > last_seen {
last_seen = seen;
last_progress = tokio::time::Instant::now();
}
let wake = (last_progress + self.cfg.chunk_stall_timeout).min(hard_deadline);
match tokio::time::timeout_at(wake, set.join_next()).await {
Ok(Some(Ok(Ok(fetched)))) => {
// 「这个源给的还是不是同一个文件」:`Content-Range` 的总长必须与我们规划的
// 一致,否则写下去的段根本拼不成目标文件(单连接路径同一处判据是收尾的
// `written != total`)。
if fetched.total.is_some_and(|server| server != total) {
absorb_completed(journal, set, total);
set.abort_all();
return Some(DownloadError::new(
DownloadErrorKind::Network,
format!(
"分块总长不符:响应声明 {},本次规划 {total}({url})",
fetched.total.unwrap_or_default()
),
));
}
journal.record(fetched);
}
Ok(Some(Ok(Err(err)))) => {
absorb_completed(journal, set, total);
set.abort_all();
return Some(err);
}
Ok(Some(Err(err))) => {
absorb_completed(journal, set, total);
set.abort_all();
return Some(DownloadError::new(
DownloadErrorKind::Network,
format!("分块任务异常:{err}"),
));
}
// 全部回收(成功)。
Ok(None) => return None,
Err(_) => {
let now = tokio::time::Instant::now();
// 窗口内确实有字节进来 ⇒ 续期(「慢但有效」不被误杀的机制就在这一行)。
let seen = counter.load(Ordering::Relaxed);
if now < hard_deadline && seen > last_seen {
last_seen = seen;
last_progress = now;
continue;
}
let report = if now >= hard_deadline {
StallReport::deadline(
StallStage::AwaitChunkJoin,
seen,
Some(total),
now - last_progress,
)
} else {
StallReport::no_progress(
StallStage::AwaitChunkJoin,
seen,
Some(total),
now - last_progress,
)
};
absorb_completed(journal, set, total);
set.abort_all();
let context = match report.reason {
StallReason::Deadline => {
format!("分块作业超过总期限 {}s({url})", budget.as_secs())
}
StallReason::NoProgress => format!("分块作业长时间没有新字节({url})"),
};
return Some(DownloadError::stalled(report, context));
}
}
}
}
}
/// 把「已经落完、但还没被 `join_next` 收走」的段也记进账(复审 r1 p3-1)。
///
/// `abort_all()` 只中止任务、不替我们保存已完成的结果;失败分支直接 `return` 等于把那些**字节
/// 已经完整落盘**的段扔掉,下次整段重抓(4 连接时最多白丢 3 段)。这里在 abort 之前用
/// `try_join_next` 把就绪结果收干净,总长判据与正常回收同一道。
///
/// 边界:这是**尽力减少浪费**,不是正确性前提——没收走的段只会让下次多下,不会被误用
/// (记录带段摘要自证)。也正因为它是「谁先完成」的竞态,没有确定性的用例能钉住它:
/// 用例只能断言「失败后已完成的段被记账」,而失败与完成的先后顺序由调度器决定。
fn absorb_completed(
journal: &mut ChunkJournal,
set: &mut tokio::task::JoinSet<Result<ChunkFetch, DownloadError>>,
total: u64,
) {
while let Some(joined) = set.try_join_next() {
if let Ok(Ok(fetched)) = joined {
if fetched.total.is_none_or(|server| server == total) {
journal.record(fetched);
}
}
}
}