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

389 lines
17 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.
//! 大文件多连接分块的编排与校验。
//!
//! 分块本身由 `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::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, HashAlgo, Integrity};
use super::verify;
impl DownloadEngine {
pub(crate) async fn attempt_chunked(
&self,
id: DownloadId,
req: &DownloadRequest,
candidate: &Candidate,
part: &mut PartFile,
total: Option<u64>,
) -> Result<AttemptOk, DownloadError> {
let Some(total) = total.filter(|total| *total > 0) else {
return Err(DownloadError::new(
DownloadErrorKind::RangeUnsupported,
"总长未知,无法分块",
));
};
let ranges = chunked::plan(total, self.cfg.chunked.effective_connections());
if ranges.len() < 2 {
return Err(DownloadError::new(
DownloadErrorKind::RangeUnsupported,
"可切分段不足,退化为单连接",
));
}
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.ensure_parent_async()
.await
.map_err(|err| io_err("创建下载目录失败", &err))?;
{
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 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 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,
if_range.as_deref(),
)
.await
});
}
let failure = self
.await_chunks(&mut set, &counter, total, &candidate.url, &mut journal)
.await;
reporter.abort();
if let Some(err) = failure {
// 已完成的段连同自证摘要都在元数据里(`ChunkJournal` 每段落一次盘),中间文件留着,
// 下一次(甚至下次启动)接着下。只有「这个源不支持 Range」才把文件丢掉:
// 单连接路径接不住预分配 + 乱序段的形状。
if err.kind == DownloadErrorKind::RangeUnsupported {
part.discard();
}
return Err(err.with_source(candidate.source.as_str()));
}
// 分块是乱序写,只能整文件重算一次摘要——这一步覆盖新下的字节**与**复用的旧字节。
self.verify_part(part, integrity, Some(total), id).await?;
self.emit(ProgressEvent::Progress {
id,
downloaded: total,
total_bytes: Some(total),
});
Ok(AttemptOk {
bytes: total,
resumed_from: reused,
})
}
/// 回收分块任务,同时盯着「整个作业有没有在动」。
///
/// 两道判据必须分得开(另一处是单块自己的读空闲超时,阶段 = 等字节):
/// * **无进度**:`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,
counter: Arc<AtomicU64>,
total: u64,
) -> tokio::task::JoinHandle<()> {
let emitter = self.progress.clone();
let interval = self.cfg.progress_throttle.max(Duration::from_millis(50));
tokio::spawn(async move {
let mut ticker = tokio::time::interval(interval);
loop {
ticker.tick().await;
let downloaded = counter.load(Ordering::Relaxed).min(total);
emitter.emit(ProgressEvent::Progress {
id,
downloaded,
total_bytes: Some(total),
});
}
})
}
pub(crate) async fn verify_part(
&self,
part: &mut PartFile,
integrity: Option<&Integrity>,
expected: Option<u64>,
id: DownloadId,
) -> Result<(), DownloadError> {
let Some(integrity) = integrity else {
return Ok(());
};
self.emit(ProgressEvent::Verifying {
id,
algo: integrity.algo,
expected_prefix: integrity.short_prefix().to_string(),
});
// 整文件重算是同步阻塞 IO:挪到 blocking 池并套上界,否则「校验阶段卡住」
// (网络盘掉线、杀软锁文件)照样是永久挂住,且它发生在字节全部收完之后,
// 从进度上看与「下载完成」一模一样。
//
// 代价(复审 r1 p3-1,未验):`spawn_blocking` 的线程**不可取消**——到点后它还在
// 读文件、还持着句柄,重试会再堆一个(每源最多 3 个)。**假设未验**:Windows 上
// 那个句柄可能让随后的同名重建(`attempt_chunked` 的 `OpenOptions::create(true)`)
// 拿到 `Access is denied`,表现为「预分配中间文件失败」而不是干净重试
// (`PartFile::discard` 忽略错误)。仍然有界、有分类,只是诊断会拐一道。
let on_disk = part.len();
let path = part.path().to_path_buf();
let algo = integrity.algo;
let digest = tokio::time::timeout(
self.cfg.verify_timeout,
tokio::task::spawn_blocking(move || verify::digest_file(&path, algo)),
)
.await
.map_err(|_| {
DownloadError::stalled(
StallReport::deadline(
StallStage::Verify,
on_disk,
expected,
self.cfg.verify_timeout,
),
format!(
"整文件重算摘要超过 {}ms",
self.cfg.verify_timeout.as_millis()
),
)
})?
.map_err(|err| {
DownloadError::new(DownloadErrorKind::Io, format!("校验任务异常退出:{err}"))
})?
.map_err(|err| io_err("读取中间文件失败", &err))?;
if verify::matches(&integrity.hex, &digest) {
return Ok(());
}
part.discard();
Err(DownloadError::new(
DownloadErrorKind::Integrity,
format!("校验不符:期望 {} 实得 {digest}", integrity.hex),
))
}
}
fn io_err(context: &str, err: &std::io::Error) -> DownloadError {
DownloadError::new(DownloadErrorKind::Io, format!("{context}: {err}"))
}