Files
Unlucky-Launcher/crates/core/src/download/chunk_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

236 lines
10 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.
//! 分块下载的**续传判定与旧字节自证**。
//!
//! 台账 P21 第 2 条 + P19(b):分块路径此前**不写**续传元数据、每次进 `attempt_chunked` 先
//! `part.discard()`,于是 188MB 的 JDK 任何一次失败都从 0 重下。这里补上「记住落了哪些段」,
//! 但**不是**把旧字节当可信——分块路径的旧字节比单连接的更可疑,原因有三条:
//!
//! 1. 中间文件被 `set_len(total)` 预分配过,长度恒等于总长:`resume::decide` 的「长度 = 已下」
//! 这条前提在这里根本不成立(它会判成 `Complete`,然后整文件校验把一份没下完的文件判死);
//! 2. 段是**乱序**写的,磁盘上「有字节」不等于「这一段是完整的」——进程被强杀时正写到一半的段
//! 与写完了的段在文件里长得一模一样;
//! 3. 同一个 URL 的响应可能在两次尝试之间换了内容(镜像回源、滚动发布)。
//!
//! 所以复用旧段要过两道门,缺一不可:
//!
//! * **身份**([`decide`],纯函数):策略允许续传、元数据在、URL / 总长 / 摘要算法 / 期望摘要
//! 全部与本次请求一致、有 ETag 或 Last-Modified 作校验器(续传请求会带 `If-Range`,让服务器
//! 自己回答「内容变了没有」),并且每条记录都能**精确对上**本次分块规划里的某一段;
//! * **自证**([`verify_records`]):按记录重读那段字节、重算摘要,与记录比对。
//!
//! 两道门任一不过 ⇒ 丢掉整份中间文件**从头再来**(不是「只补坏的那一段」):
//! 摘要对不上意味着这份元数据本身已经不可信(盘上数据在腐坏,或者元数据是崩溃前写的、
//! 盘上并没有落那样),同一份元数据里的**其它**记录同样没有理由被信任。这条与
//! [`super::resume`] 的保守立场一致——重下 100MB 也比交付一个坏文件便宜。
//!
//! 最后还有第三道保险:整文件摘要在传输结束后**整份重算**(`transfer_chunk::verify_part`),
//! 覆盖新下的字节与复用的字节。前两道门只保证「旧字节是我们写的」,第三道门才保证
//! 「我们写的是对的那份」。
//!
//! 写的这一侧是本文末的 [`ChunkJournal`]:每成功回收一段就落一次盘(原子写),失败时元数据
//! 里已经有「哪几段、各自摘要是什么」,于是下一次尝试能接着下而不是从 0。
use std::fs::File;
use std::io::{self, Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use super::cache::now_ms;
use super::chunked::{self, ByteRange, ChunkFetch};
use super::resume::{ChunkRecord, RestartReason, ResumeMeta};
use super::types::{HashAlgo, ResumePolicy};
use super::verify;
/// 复用旧分块的判定结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ChunkDecision {
/// 这些段可以复用——**但调用方仍须先跑 [`verify_records`]**,本判定只看不碰盘。
Reuse {
/// 已落盘且对得上本次规划的分块记录。
done: Vec<ChunkRecord>,
},
/// 从头下载(调用方要丢掉整份中间文件)。
Restart {
/// 原因(诊断用;与单连接路径共用同一个枚举)。
reason: RestartReason,
},
}
/// 判定能否复用旧分块(纯函数:不读盘、不看时钟)。
///
/// `part_len` 为 0 表示没有中间文件(或它是 0 字节)。`planned` 是本次要用的分块规划,
/// 记录必须**逐一精确命中**其中的段:连接数变了、总长变了、或记录本身畸形,都判从头再来。
#[must_use]
pub fn decide(
policy: ResumePolicy,
part_len: u64,
meta: Option<&ResumeMeta>,
url: &str,
integrity: Option<(HashAlgo, &str)>,
total: u64,
planned: &[ByteRange],
) -> ChunkDecision {
let restart = |reason| ChunkDecision::Restart { reason };
if policy == ResumePolicy::Never {
return restart(RestartReason::PolicyNever);
}
if part_len == 0 {
return restart(RestartReason::NoPart);
}
let Some(meta) = meta else {
return restart(RestartReason::NoMeta);
};
// 没有分块清单的元数据(单连接路径写的)证明不了「乱序段各自到哪」,一律从头来。
let Some(records) = meta.chunks.as_ref() else {
return restart(RestartReason::NoMeta);
};
if meta.url != url {
return restart(RestartReason::UrlChanged);
}
if let Some((algo, hash)) = integrity {
if meta.algo != algo || !meta.hash.eq_ignore_ascii_case(hash) {
return restart(RestartReason::HashChanged);
}
}
// 总长变了就是另一个文件:旧段的偏移全部作废(只比 URL 抓不住这个)。
if meta.total != Some(total) {
return restart(RestartReason::SizeChanged);
}
// 没有校验器就无法让服务器回答「内容变了没有」,只能从头下(与单连接同一立场)。
if meta.validator().if_range_value().is_none() {
return restart(RestartReason::NoValidator);
}
// 每条记录必须是规划里**原样**的一段,且不许重复:段边界一旦漂移,
// 「已落盘多少」就说不清了(长度恰好但位置错的文件会被整文件校验兜住,但那已经是白下)。
let mut done: Vec<ChunkRecord> = Vec::with_capacity(records.len());
for record in records {
if record.is_empty() || record.end >= total {
return restart(RestartReason::ChunkLayout);
}
if !planned
.iter()
.any(|range| range.start == record.start && range.end == record.end)
{
return restart(RestartReason::ChunkLayout);
}
if done
.iter()
.any(|seen| seen.start == record.start && seen.end == record.end)
{
return restart(RestartReason::ChunkLayout);
}
done.push(record.clone());
}
ChunkDecision::Reuse { done }
}
/// 本次还要抓的段 = 规划 − 已落盘([`decide`] 已保证 `done ⊆ planned` 且边界精确)。
#[must_use]
pub fn remaining(planned: &[ByteRange], done: &[ChunkRecord]) -> Vec<ByteRange> {
planned
.iter()
.copied()
.filter(|range| {
!done
.iter()
.any(|record| record.start == range.start && record.end == range.end)
})
.collect()
}
/// 按记录逐段重读、重算摘要:返回**可复用的字节数**。
///
/// 任一记录对不上(摘要不符 / 读不满一段 / 读盘失败)就返回 `Err`,调用方必须整份丢掉。
/// 同步阻塞 IO:异步路径要把它放进 `spawn_blocking` 并套上 `verify_timeout`
/// (与 `transfer_chunk::verify_part` 同一套做法)。
pub fn verify_records(path: &Path, records: &[ChunkRecord]) -> io::Result<u64> {
let mut file = File::open(path)?;
let mut buffer = vec![0_u8; 64 * 1024];
let mut verified = 0_u64;
for record in records {
let mut hasher = verify::Hasher::new(chunked::CHUNK_DIGEST);
file.seek(SeekFrom::Start(record.start))?;
let mut left = record.len();
while left > 0 {
let want = usize::try_from(left.min(buffer.len() as u64)).unwrap_or(buffer.len());
let read = file.read(&mut buffer[..want])?;
if read == 0 {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
format!(
"分块 [{}..={}] 在盘上不够长(缺 {left} 字节)",
record.start, record.end
),
));
}
hasher.update(&buffer[..read]);
left -= read as u64;
}
let actual = hasher.finish();
if !verify::matches(&record.sha1, &actual) {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"分块 [{}..={}] 摘要不符:记录 {} 实得 {actual}",
record.start, record.end, record.sha1
),
));
}
verified += record.len();
}
Ok(verified)
}
/// 续传元数据的增量写手:每落完一段就把「这一段已落盘、摘要是它」写进元数据。
///
/// 为什么不收工再写:进程被强杀 / 断电时收工那次写根本不会发生,而已经落盘的段是好几分钟到
/// 几十分钟的字节。代价是每段一次小文件原子写(`plan` 最多切 `MAX_CONNECTIONS` = 16 段,
/// 单个作业最多 16 次),而且**只有成功回收的段才记**——写到一半被 abort 的段不会被记,
/// 下次整段重抓(于是「记录里的段」不会出现半截字节)。
pub struct ChunkJournal {
/// 元数据本体(`chunks` 随进度增长)。
meta: ResumeMeta,
/// 落盘路径。
path: PathBuf,
}
impl ChunkJournal {
/// 建账并立刻落一次盘。
///
/// 这一次写还担着第二个作用:元数据里 `chunks: Some(..)` 是「这个中间文件属于分块路径」的
/// 标记,下次尝试靠它躲开单连接路径「长度 = 已下满」的误判(预分配文件长度恒等于总长)。
pub fn begin(meta: ResumeMeta, path: PathBuf) -> Self {
let mut journal = Self { meta, path };
journal.save();
journal
}
/// 已落盘的段。
pub fn done(&self) -> &[ChunkRecord] {
self.meta.chunks.as_deref().unwrap_or_default()
}
/// 记一个已落盘段(顺带补上服务器校验器:只有第一次拿到的算数,续传沿用旧的那个)。
pub fn record(&mut self, fetched: ChunkFetch) {
if self.meta.etag.is_none() {
self.meta.etag = fetched.etag;
}
if self.meta.last_modified.is_none() {
self.meta.last_modified = fetched.last_modified;
}
self.meta
.chunks
.get_or_insert_with(Vec::new)
.push(ChunkRecord {
start: fetched.range.start,
end: fetched.range.end,
sha1: fetched.sha1,
});
self.save();
}
/// 落盘(元数据写失败不影响下载本身:续传只是加速,不是正确性前提)。
pub fn save(&mut self) {
self.meta.saved_at_ms = now_ms();
let _ = self.meta.save(&self.path);
}
}