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 必红(已实测)。
This commit is contained in:
AskaEth committed 2026-11-13 06:26:18 +08:00
1 parent 6e1d5fa82e
commit 3ade2d14a8
13 files changed
+1428 -359

No files matched your search

+235
View File
@@ -0,0 +1,235 @@
//! 分块下载的**续传判定与旧字节自证**。
//!
//! 台账 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);
}
}
+55 -4
View File
@@ -11,7 +11,14 @@ use std::sync::atomic::{AtomicU64, Ordering};
use super::error::{DownloadError, DownloadErrorKind};
use super::http;
use super::stall::Deadlines;
use super::types::ResourceKind;
use super::types::{HashAlgo, ResourceKind};
use super::verify;
/// 分块自证用的摘要算法([`super::resume::ChunkRecord`] 里记的就是它)。
///
/// 固定 SHA-1 而不是跟随请求的校验算法:这条摘要是「这段字节是我们写下去的」的**归属证据**,
/// 不对抗攻击者;整文件的期望摘要仍按请求里的 `algo` 校验。固定算法让记录不必再存一个算法字段。
pub const CHUNK_DIGEST: HashAlgo = HashAlgo::Sha1;
/// 默认分块连接数(设计文档 Q19 定案 N=4)。
pub const DEFAULT_CONNECTIONS: usize = 4;
@@ -205,11 +212,35 @@ pub fn content_range_matches(requested: ByteRange, value: Option<&str>) -> bool
.is_some_and(|parsed| parsed.start == requested.start && parsed.end == requested.end)
}
/// 一次成功分块抓取的产物:落盘的字节数 + **它的身份信息**。
///
/// 为什么把这些一起带回来:分块路径的续传元数据(`ResumeMeta::chunks`)要记「这一段是什么」
/// ——段区间、这段字节的摘要([`Self::sha1`])、服务器的校验器与总长。它们只有响应头/读流这一处
/// 拿得到,带回来由编排层决定怎么落盘,比在这里再读一遍盘便宜。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChunkFetch {
/// 本段区间。
pub range: ByteRange,
/// 实际落盘字节数(校验通过时恒等于 `range.len()`)。
pub written: u64,
/// 本段字节的摘要(边写边算,算法见 [`CHUNK_DIGEST`])。
pub sha1: String,
/// 响应 `Content-Range` 里的总长(服务器没给就是 `None`)。
pub total: Option<u64>,
/// 响应 `ETag`。
pub etag: Option<String>,
/// 响应 `Last-Modified`。
pub last_modified: Option<String>,
}
/// 抓一个段,写到预分配文件的 `range.start` 偏移处,并把字节数累加进 `progress`。
///
/// 服务器若对带 `Range` 的请求返回 200(无视 Range),立刻返回 `RangeUnsupported`
/// —— 引擎据此退化单连接,而不是把整份文件重复写 N 遍。
///
/// `if_range` 是续传时的校验器(ETag 优先 / Last-Modified 兜底):服务器照办回 206,
/// 内容变过回 200 —— 于是「远端换了内容」由协议层回答,不必等整文件摘要才发现。
///
/// 两处 `await` 都有界(M289):等响应头用 [`http::send_bounded`],等正文用
/// [`http::read_step`],上界来自 [`Deadlines`]。失速时错误里带阶段与本段口径的字节数。
pub async fn fetch_chunk(
@@ -219,12 +250,16 @@ pub async fn fetch_chunk(
part: &Path,
deadlines: Deadlines,
progress: &AtomicU64,
) -> Result<u64, DownloadError> {
if_range: Option<&str>,
) -> Result<ChunkFetch, DownloadError> {
use tokio::io::{AsyncSeekExt, AsyncWriteExt};
let request = client
let mut request = client
.get(url)
.header(reqwest::header::RANGE, range.header_value());
if let Some(value) = if_range {
request = request.header(reqwest::header::IF_RANGE, value);
}
let response = http::send_bounded(request, url, deadlines, Some(range.len())).await?;
let status = response.status().as_u16();
@@ -252,6 +287,13 @@ pub async fn fetch_chunk(
));
}
// 顺手取走总长:编排层要拿它比对「这个源给的还是不是同一个文件」。
let total = content_range
.and_then(parse_content_range)
.and_then(|parsed| parsed.total);
let etag = http::etag(response.headers());
let last_modified = http::last_modified(response.headers());
let mut file = tokio::fs::OpenOptions::new()
.write(true)
.open(part)
@@ -262,6 +304,7 @@ pub async fn fetch_chunk(
.map_err(|err| io_error(&err, "定位分块偏移失败"))?;
let mut stream = response.bytes_stream();
let mut hasher = verify::Hasher::new(CHUNK_DIGEST);
let mut written = 0_u64;
loop {
let next = http::read_step(
@@ -277,6 +320,7 @@ pub async fn fetch_chunk(
file.write_all(&chunk)
.await
.map_err(|err| io_error(&err, "写入分块失败"))?;
hasher.update(&chunk);
let len = chunk.len() as u64;
written += len;
progress.fetch_add(len, Ordering::Relaxed);
@@ -291,7 +335,14 @@ pub async fn fetch_chunk(
format!("分块长度不符:期望 {} 实得 {written}({url})", range.len()),
));
}
Ok(written)
Ok(ChunkFetch {
range,
written,
sha1: hasher.finish(),
total,
etag,
last_modified,
})
}
fn io_error(err: &std::io::Error, context: &str) -> DownloadError {
+4 -1
View File
@@ -29,6 +29,7 @@
//! health 源 × 资源类型的健康度记忆
//! cache 内容寻址缓存(记录信任) atomic 落盘守卫 / 原子写
//! resume 保守续传判定 verify 流式摘要
//! chunk_resume 分块续传判定 + 旧字节自证
//! progress 事件流 error 错误分类
//! shared 进程级共享:一个数据根一个引擎(跨请求复用连接池/限流/健康度)
//! ```
@@ -49,6 +50,7 @@ pub mod adoptium;
pub mod backoff;
pub mod blocking;
pub mod cache;
pub mod chunk_resume;
pub mod chunked;
pub mod config;
pub mod engine;
@@ -79,6 +81,7 @@ pub use self::atomic::{PartFile, ADOPT_MIN_AGE, META_SUFFIX, ORPHAN_MAX_AGE, PAR
pub use self::backoff::{BackoffPolicy, Jitter};
pub use self::blocking::block_on;
pub use self::cache::{CacheRecord, CacheStats, ContentCache};
pub use self::chunk_resume::ChunkDecision;
pub use self::chunked::{ByteRange, ChunkedConfig, RangeSupport};
pub use self::config::EngineConfig;
pub use self::engine::DownloadEngine;
@@ -92,7 +95,7 @@ pub use self::mirrors::{direct_source_id, AdoptiumMirror, GhProxy, MirrorTable,
pub use self::progress::{ProgressEmitter, ProgressEvent, ProgressThrottle};
pub use self::region::{RegionMode, RegionProfile, RegionTrigger, Signals};
pub use self::region_system::detect_signals;
pub use self::resume::{RestartReason, ResumeDecision, ResumeMeta};
pub use self::resume::{ChunkRecord, RestartReason, ResumeDecision, ResumeMeta};
pub use self::stall::{Deadlines, StallReason, StallReport, StallStage, MIN_AVG_THROUGHPUT};
pub use self::types::{
Candidate, DownloadId, DownloadOutcome, DownloadRequest, HashAlgo, Integrity, ResourceKind,
+60
View File
@@ -13,6 +13,14 @@
//! 任何一条不满足就**从头下**——重下 100MB 也比交付一个坏文件便宜。
//! 另外单独处理 416(`Requested Range Not Satisfiable`):已下满则直接进入校验,
//! 否则丢弃中间文件重来。
//!
//! # 分块路径(`chunks` 字段)
//!
//! 多连接分块写的是**乱序的段**,单连接那套「长度 = 偏移」的续传判定完全不适用:文件被
//! `set_len(total)` 预分配过,长度永远等于总长,`Complete` 判定会被它骗到。所以分块路径把
//! 「已落盘的分块清单」写进同一份元数据的 [`ResumeMeta::chunks`],判定逻辑在
//! [`super::chunk_resume`]:那边要求 URL / 总长 / 期望摘要 / 校验器全部对得上,**而且**
//! 每条记录都要能重算出与记录一致的摘要,才允许复用旧字节。
use std::io;
use std::path::Path;
@@ -39,6 +47,49 @@ pub struct ResumeMeta {
pub hash: String,
/// 保存时间(unix 毫秒)。
pub saved_at_ms: u64,
/// 分块路径已落盘的分块清单(单连接路径不写也不读,恒为 `None`)。
///
/// `Some([])` 与 `None` 是两件事:前者表示「这个中间文件是分块路径预分配的、此刻一段都还没
/// 落完」,后者表示「这份元数据不是分块路径写的」。单连接路径要靠这个区别躲开
/// 「预分配全长 = 下满了」的误判(见 [`super::chunk_resume`])。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub chunks: Option<Vec<ChunkRecord>>,
}
/// 一个已落盘分块的**自证记录**:段区间 + 写盘时顺手算出的摘要。
///
/// 恢复时按这条记录重读那段字节、重算摘要并比对——这是「磁盘上这堆旧字节确实是我们上次
/// 写下去的那一段」的唯一证据。只凭长度/URL/ETag 无法证明:同一个 URL 的响应可能换过内容,
/// 进程被强杀也可能留下「元数据说写完了、盘上只写了一半」的段。
///
/// 摘要算法固定 SHA-1(见 [`super::chunked::CHUNK_DIGEST`]):它在这里是**字节归属**的证据,
/// 不是对抗性安全边界;整文件的期望摘要仍按请求里的算法校验。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChunkRecord {
/// 段起始偏移(含)。
pub start: u64,
/// 段结束偏移(**含**,与 [`super::chunked::ByteRange`] 同口径)。
pub end: u64,
/// 该段字节的小写十六进制摘要。
pub sha1: String,
}
impl ChunkRecord {
/// 段长度(字节;`end < start` 时为 0)。
#[must_use]
pub const fn len(&self) -> u64 {
if self.end < self.start {
0
} else {
self.end - self.start + 1
}
}
/// 空段。
#[must_use]
pub const fn is_empty(&self) -> bool {
self.end < self.start
}
}
impl ResumeMeta {
@@ -96,12 +147,18 @@ pub enum RestartReason {
NoPart,
/// 中间文件是 0 字节。
EmptyPart,
/// 中间文件是分块路径预分配的全长文件:单连接的「长度 = 已下」不成立,交给分块路径判。
ChunkedPart,
/// 没有元数据(无法证明中间文件是同一次下载的产物)。
NoMeta,
/// URL 变了(换源后偏移不可信)。
UrlChanged,
/// 期望摘要变了(目标文件已经不是同一个东西)。
HashChanged,
/// 记录的总长与本次规划的总长不一致(远端换了文件,或换了个大小的目标)。
SizeChanged,
/// 已落盘分块的段区间与本次分块规划对不上(连接数/规划变了,旧段无法对号入座)。
ChunkLayout,
/// 总长未知(无法判断是否已下满)。
TotalUnknown,
/// 已下字节超过总长(中间文件被污染)。
@@ -120,9 +177,12 @@ impl RestartReason {
Self::PolicyNever => "policy_never",
Self::NoPart => "no_part",
Self::EmptyPart => "empty_part",
Self::ChunkedPart => "chunked_part",
Self::NoMeta => "no_meta",
Self::UrlChanged => "url_changed",
Self::HashChanged => "hash_changed",
Self::SizeChanged => "size_changed",
Self::ChunkLayout => "chunk_layout",
Self::TotalUnknown => "total_unknown",
Self::Oversize => "oversize",
Self::NoValidator => "no_validator",
+14 -1
View File
@@ -50,8 +50,19 @@ impl DownloadEngine {
let integrity = req.integrity.as_ref();
let meta_path = part.meta_path();
let previous = ResumeMeta::load(&meta_path);
// 分块路径的中间文件是**预分配整长**的:长度等于总长不代表下满了。元数据里带分块清单
// (`chunks`)就说明这份文件是分块路径写的,单连接的 `Complete` 判定在这里必然判错
// (它会把「3/4 块完成」当成「已下满」,然后整文件校验白读一遍再判死)。
// 这种文件一律交给分块路径:它能按段对号入座,且逐段重算摘要后才敢复用。
let chunked_part = previous
.as_ref()
.is_some_and(|meta| meta.chunks.is_some() && part.exists());
// 「没有中间文件」和「有但 0 字节」是两种不同的原因,分开报出来便于诊断。
let decision = if !part.exists() {
let decision = if chunked_part {
ResumeDecision::Restart {
reason: RestartReason::ChunkedPart,
}
} else if !part.exists() {
ResumeDecision::Restart {
reason: RestartReason::NoPart,
}
@@ -207,6 +218,8 @@ impl DownloadEngine {
algo: integrity.map_or(HashAlgo::Sha1, |item| item.algo),
hash: integrity.map_or_else(String::new, |item| item.hex.clone()),
saved_at_ms: now_ms(),
// 单连接路径不写分块清单:这份文件是「从头顺序追加」的,长度就是已下字节。
chunks: None,
};
let _ = meta.save(&part.meta_path());
+147 -22
View File
@@ -1,19 +1,37 @@
//! 大文件多连接分块的编排与校验。
//!
//! 分块本身由 `chunked.rs` 负责(纯规划 + 单块抓取),这里负责并发、进度上报、
//! 失败清理与「整文件重算一次摘要」(分块是乱序写,只能整文件校验)。
//! 续传元数据的读写与「整文件重算一次摘要」(分块是乱序写,只能整文件校验)。
//!
//! # 续传(M293:台账 P21 第 2 条 + P19(b))
//!
//! 此前这里开头无条件 `part.discard()`、失败也 `discard()`,于是 188MB 的 JDK 任何一次失败
//! 都从 0 重下;M289 又给「预算耗尽」加了「不再重试、不再换源」的短路,慢链路上一次晚到的
//! 失败就变成「这个文件下不来」。现在:
//!
//! * 每落完一段就把它连同**这段字节的摘要**写进 `ResumeMeta::chunks`(`ChunkJournal`);
//! * 失败**不再**丢中间文件——已完成的段是几十分钟的字节,元数据里带着它们的自证记录;
//! * 下次进本函数先过 [`super::chunk_resume::decide`](身份)与 [`Self::verify_chunks`]
//! (旧字节自证),两道门都过才复用;任何一道不过就整份丢掉从头再来;
//! * 整文件摘要照旧在传输结束后整份重算,覆盖新下 + 复用的全部字节。
//!
//! 唯一仍然主动 `discard()` 的失败是 `RangeUnsupported`:服务器不支持 Range 时单连接路径
//! 接不住「预分配 + 乱序段」这种形状的文件(它只会截断重写),留着没意义。
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use super::atomic::PartFile;
use super::chunked;
use super::cache::now_ms;
use super::chunk_resume::{self, ChunkDecision, ChunkJournal};
use super::chunked::{self, ChunkFetch};
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::types::{Candidate, DownloadId, DownloadRequest, Integrity};
use super::types::{Candidate, DownloadId, DownloadRequest, HashAlgo, Integrity};
use super::verify;
impl DownloadEngine {
@@ -38,53 +56,124 @@ impl DownloadEngine {
"可切分段不足,退化为单连接",
));
}
let integrity = req.integrity.as_ref();
// ① 身份:旧分块是不是**这次**下载的产物(纯判定,不碰盘)。
let meta_path = part.meta_path();
let previous = ResumeMeta::load(&meta_path);
let decision = chunk_resume::decide(
req.resume,
part.len(),
previous.as_ref(),
&candidate.url,
integrity.map(|item| (item.algo, item.hex.as_str())),
total,
&ranges,
);
// ② 自证:按记录重读那些字节、重算摘要。任何一段对不上就整份丢掉从头再来
// (理由见 `chunk_resume` 模块头:对不上说明这份元数据本身已经不可信)。
let mut done: Vec<ChunkRecord> = Vec::new();
match decision {
ChunkDecision::Reuse { done: records } => {
if self.verify_chunks(part, &records).await {
done = records;
} else {
part.discard();
}
}
ChunkDecision::Restart { .. } => part.discard(),
}
let reused: u64 = done.iter().map(ChunkRecord::len).sum();
// 续传时把旧校验器带上:这是 `If-Range` 的取值来源,也是「远端内容没变」的协议层证据。
let if_range = if done.is_empty() {
None
} else {
previous
.as_ref()
.and_then(|meta| meta.validator().if_range_value().map(str::to_string))
};
part.discard();
part.ensure_parent_async()
.await
.map_err(|err| io_err("创建下载目录失败", &err))?;
{
let file = tokio::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
let mut options = tokio::fs::OpenOptions::new();
options.create(true).write(true);
if done.is_empty() {
// 从头:先把可能存在的旧内容截掉,再预分配整长。
options.truncate(true);
}
let file = options
.open(part.path())
.await
.map_err(|err| io_err("预分配中间文件失败", &err))?;
// 续传时这一行是「归一化」:分块写是随机偏移,长度本该已经是 total;
// 若被外部截短/加长过,先摆正,缺的段下面的补抓会填满。
file.set_len(total)
.await
.map_err(|err| io_err("预分配长度失败", &err))?;
}
let counter = Arc::new(AtomicU64::new(0));
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()),
total: Some(total),
algo: integrity.map_or(HashAlgo::Sha1, |item| item.algo),
hash: integrity.map_or_else(String::new, |item| item.hex.clone()),
saved_at_ms: now_ms(),
chunks: Some(done),
},
meta_path,
);
let counter = Arc::new(AtomicU64::new(reused));
let reporter = self.spawn_reporter(id, Arc::clone(&counter), total);
let deadlines = self.cfg.deadlines();
let todo = chunk_resume::remaining(&ranges, journal.done());
let mut set = tokio::task::JoinSet::new();
for range in ranges {
for range in todo {
let client = self.http.clone();
let url = candidate.url.clone();
let path = part.path().to_path_buf();
let progress = Arc::clone(&counter);
let if_range = if_range.clone();
set.spawn(async move {
chunked::fetch_chunk(&client, &url, range, &path, deadlines, &progress).await
chunked::fetch_chunk(
&client,
&url,
range,
&path,
deadlines,
&progress,
if_range.as_deref(),
)
.await
});
}
let failure = self
.await_chunks(&mut set, &counter, total, &candidate.url)
.await
.map(|err| err.with_source(candidate.source.as_str()));
.await_chunks(&mut set, &counter, total, &candidate.url, &mut journal)
.await;
reporter.abort();
if let Some(err) = failure {
part.discard();
return Err(err);
// 已完成的段连同自证摘要都在元数据里(`ChunkJournal` 每段落一次盘),中间文件留着,
// 下一次(甚至下次启动)接着下。只有「这个源不支持 Range」才把文件丢掉:
// 单连接路径接不住预分配 + 乱序段的形状。
if err.kind == DownloadErrorKind::RangeUnsupported {
part.discard();
}
return Err(err.with_source(candidate.source.as_str()));
}
// 分块是乱序写,只能整文件重算一次摘要。
self.verify_part(part, req.integrity.as_ref(), Some(total), id)
.await?;
// 分块是乱序写,只能整文件重算一次摘要——这一步覆盖新下的字节**与**复用的旧字节。
self.verify_part(part, integrity, Some(total), id).await?;
self.emit(ProgressEvent::Progress {
id,
downloaded: total,
@@ -92,7 +181,7 @@ impl DownloadEngine {
});
Ok(AttemptOk {
bytes: total,
resumed_from: 0,
resumed_from: reused,
})
}
@@ -105,12 +194,16 @@ impl DownloadEngine {
/// * **总期限**:`chunk_job_budget`(按平均吞吐下限反推,见 `stall` 模块头)用完。
///
/// 两者都归到阶段 [`StallStage::AwaitChunkJoin`],靠 [`StallReason`] 区分。
///
/// 成功的段在这里**顺手记账**([`ChunkJournal::record`]):这是唯一同时握着「段结果」与
/// 「段区间」的地方,落到元数据之后,本作业后面怎么挂都不影响这几段的续传价值。
async fn await_chunks(
&self,
set: &mut tokio::task::JoinSet<Result<u64, DownloadError>>,
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;
@@ -124,7 +217,22 @@ impl DownloadEngine {
}
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(_)))) => {}
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);
@@ -175,6 +283,23 @@ impl DownloadEngine {
}
}
/// 旧分块自证:按记录逐段重读、重算摘要。
///
/// `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,