fix(download): 补齐 download_bytes 与等许可两处无界 await,预算耗尽不再重试(M289 r1)
r1 复审 p2-1:`stall.rs` 那句「全仓只有两处无界 await」是假话。真实清单五处, M289 之前只修了两处;这次补齐: * `bytes.rs::fetch_bytes` 的 `send()` 改走 `http::send_bounded`(与文件下载同一个构造点); 整包 `response.bytes()` 换成 `bytes_stream` + `http::read_step` 的空闲判定,并边读边卡 `MAX_IN_MEMORY_BYTES`(`Content-Length` 可缺失/可撒谎)。生产调用点:版本清单、 版本 JSON、Modrinth 元数据。 * `engine::acquire` 的 `acquire_owned()` 加上界(`EngineConfig::permit_timeout`,默认 120min ≥ 单个作业总期限),到点报新阶段 `StallStage::AcquirePermit`(「等许可」), 不冒充「服务器不给数据」。这就是「卡死作业攥着许可把队列堵死」的连坐形态。 * `stall.rs` 模块头改成一张「哪些入口有界、谁托管给谁」的表,并写清五处 await 的真实清单。 r1 复审 p2-2:总期限用完是链路级判决,不再按满预算重试/换源: * `DownloadError::job_budget_exhausted()` + `DownloadError::is_retryable()`(覆盖 `kind.is_retryable()`),引擎与 `download_bytes` 都改用它; * 引擎在预算耗尽时直接结束候选循环,不再换源(同一份字节、同一条链路,换源只会再 烧满一整个预算)。188MB/20 KiB/s 的用户不再从「1.6 小时慢慢下完」变成「几小时反复重下」。 * `stall.rs` 写明「慢链路照样活」的前提 = **平均吞吐 ≥ 32 KiB/s**,以及它挡掉哪类链路; `download.*` schema 接线留给另一个 mission(`setting/keys.rs` 不在 scope)。 顺带(复审 p3): * `transfer.rs` 的读空闲统一走 `EngineConfig::deadlines()`(不再各读字段); * `verify_part` 补一条代价说明:`spawn_blocking` 到点不可取消,可能让同名重建拿到 `Access is denied`(**该假设未验**,注释里已标); * `engine.rs` 因这轮新增又贴到 410 行 ⇒ `finish` / `materialize` / `millis` 逐字节搬到 `engine/finish.rs`(`engine::millis` 路径用 `pub(crate) use` 保持可用)。 新增用例(全离线、<2s):`download_bytes` 静默服务端 / 正文中途卡住、等许可超时、 预算耗尽不重试(`max_attempts_per_source = 3` 下仍只发一轮)、预算耗尽不换源 (官方源 `hits == 0`)。
This commit is contained in:
1 parent
cc0def3415
commit
d6f862a43c
13 files changed
+571
-146
No files matched your search
@@ -10,6 +10,7 @@ use super::engine::{millis, DownloadEngine};
|
||||
use super::error::{leaf_of, DownloadError, DownloadErrorKind};
|
||||
use super::http;
|
||||
use super::progress::ProgressEvent;
|
||||
use super::stall::StallReport;
|
||||
use super::types::{Candidate, DownloadId, DownloadRequest};
|
||||
use super::verify;
|
||||
|
||||
@@ -31,10 +32,12 @@ impl DownloadEngine {
|
||||
));
|
||||
}
|
||||
|
||||
let _permit = self.acquire().await?;
|
||||
let _permit = self.acquire(req.expected_size).await?;
|
||||
let mut suppressed: Vec<String> = Vec::new();
|
||||
// 逐源的叶子分类(出去时汇总成一条,见 `DownloadError::leaf`)。
|
||||
let mut kinds: Vec<DownloadErrorKind> = Vec::new();
|
||||
// 与 `download()` 一致:最后一次尝试的失速现场带出去(含阶段与三个数)。
|
||||
let mut last_stall: Option<StallReport> = None;
|
||||
|
||||
for candidate in &plan {
|
||||
// 与文件下载同一套规则:冷却中/刚出冷却的源只给一次探测机会。
|
||||
@@ -59,13 +62,14 @@ impl DownloadEngine {
|
||||
return Ok(bytes);
|
||||
}
|
||||
Err(err) => {
|
||||
last_stall = err.stall.as_deref().copied();
|
||||
self.health.record_failure(
|
||||
&candidate.source,
|
||||
req.kind,
|
||||
&err.message,
|
||||
now_ms(),
|
||||
);
|
||||
let retry = err.kind.is_retryable() && attempt + 1 < max_attempts;
|
||||
let retry = err.is_retryable() && attempt + 1 < max_attempts;
|
||||
let delay = self.next_delay(attempt);
|
||||
self.emit(ProgressEvent::SourceFailed {
|
||||
id,
|
||||
@@ -90,12 +94,17 @@ impl DownloadEngine {
|
||||
self.flush_health();
|
||||
// 叶子成因要跟着出去:上层(在线内容源的 `Transport`)靠它区分「这个 URL 不存在」
|
||||
// 与「没联网」,只留 `AllSourcesFailed` 的话那套分类在生产路径上永远不可达。
|
||||
let detail = match &last_stall {
|
||||
Some(stall) => format!("(最后一次失速:{stall})"),
|
||||
None => String::new(),
|
||||
};
|
||||
Err(DownloadError::new(
|
||||
DownloadErrorKind::AllSourcesFailed,
|
||||
format!("所有候选源都取不到文本资源:{}", req.url),
|
||||
format!("所有候选源都取不到文本资源:{}{detail}", req.url),
|
||||
)
|
||||
.with_suppressed(suppressed)
|
||||
.with_leaf(leaf_of(kinds)))
|
||||
.with_leaf(leaf_of(kinds))
|
||||
.with_last_stall(last_stall))
|
||||
}
|
||||
|
||||
async fn fetch_bytes(
|
||||
@@ -103,18 +112,25 @@ impl DownloadEngine {
|
||||
req: &DownloadRequest,
|
||||
candidate: &Candidate,
|
||||
) -> Result<Vec<u8>, DownloadError> {
|
||||
let response = self
|
||||
.http
|
||||
.get(&candidate.url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| http::classify_reqwest(&err, &candidate.url))?;
|
||||
// 两处 await 都有界(复审 r1 p2-1):等响应头走 `send_bounded`(与文件下载同一构造点),
|
||||
// 整包读改成流式累加 + `read_step` 的空闲判定。不能再退回 `response.bytes()`:
|
||||
// 它是一次无界的整包读,`MAX_IN_MEMORY_BYTES` 只管大小不管时间。
|
||||
let deadlines = self.cfg.deadlines();
|
||||
let response = http::send_bounded(
|
||||
self.http.get(&candidate.url),
|
||||
&candidate.url,
|
||||
deadlines,
|
||||
req.expected_size,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| err.with_source(candidate.source.as_str()))?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return Err(http::classify_status(status.as_u16(), &candidate.url)
|
||||
.with_source(candidate.source.as_str()));
|
||||
}
|
||||
if let Some(len) = http::content_length(response.headers()) {
|
||||
let declared = http::content_length(response.headers());
|
||||
if let Some(len) = declared {
|
||||
if len > MAX_IN_MEMORY_BYTES {
|
||||
return Err(DownloadError::new(
|
||||
DownloadErrorKind::InvalidRequest,
|
||||
@@ -126,10 +142,42 @@ impl DownloadEngine {
|
||||
.with_source(candidate.source.as_str()));
|
||||
}
|
||||
}
|
||||
let body = response
|
||||
.bytes()
|
||||
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut body: Vec<u8> = Vec::with_capacity(
|
||||
declared
|
||||
.and_then(|len| usize::try_from(len).ok())
|
||||
.unwrap_or(0),
|
||||
);
|
||||
loop {
|
||||
let next = http::read_step(
|
||||
futures_util::StreamExt::next(&mut stream),
|
||||
deadlines.read_idle,
|
||||
body.len() as u64,
|
||||
declared,
|
||||
&candidate.url,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| http::classify_reqwest(&err, &candidate.url))?;
|
||||
.map_err(|err| err.with_source(candidate.source.as_str()))?;
|
||||
let Some(chunk) = next else { break };
|
||||
let chunk = chunk.map_err(|err| {
|
||||
http::classify_reqwest(&err, &candidate.url).with_source(candidate.source.as_str())
|
||||
})?;
|
||||
body.extend_from_slice(&chunk);
|
||||
// `Content-Length` 可以撒谎、也可以缺失(靠关连接分帧):边读边卡,不能只看头。
|
||||
if body.len() as u64 > MAX_IN_MEMORY_BYTES {
|
||||
return Err(DownloadError::new(
|
||||
DownloadErrorKind::InvalidRequest,
|
||||
format!(
|
||||
"文本资源超过内存上限({} 字节),拒绝继续载入:{}",
|
||||
body.len(),
|
||||
candidate.url
|
||||
),
|
||||
)
|
||||
.with_source(candidate.source.as_str()));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(integrity) = &req.integrity {
|
||||
let actual = verify::digest_bytes(integrity.algo, &body);
|
||||
if !verify::matches(&integrity.hex, &actual) {
|
||||
@@ -140,6 +188,6 @@ impl DownloadEngine {
|
||||
.with_source(candidate.source.as_str()));
|
||||
}
|
||||
}
|
||||
Ok(body.to_vec())
|
||||
Ok(body)
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,11 @@ pub struct EngineConfig {
|
||||
///
|
||||
/// 正常盘上 188MB 的 SHA-1 不到 1s;这条只抓「校验被卡住」,不参与慢链路判定。
|
||||
pub verify_timeout: Duration,
|
||||
/// 等并发许可的上界,默认 120min。
|
||||
///
|
||||
/// 必须 ≥ 单个作业的总期限(最坏 ≈100 min 预算 + 校验),否则排队本身会被误杀;
|
||||
/// 它挡的是「许可被永久占住」。取数见 [`super::stall`] 模块头。
|
||||
pub permit_timeout: Duration,
|
||||
/// User-Agent(唯一定义在 `http::USER_AGENT`;两条 HTTP 路径发的都是这一份,别在这里另拼)。
|
||||
pub user_agent: String,
|
||||
/// 进度事件节流间隔,默认 250ms。
|
||||
@@ -90,6 +95,7 @@ impl Default for EngineConfig {
|
||||
chunk_stall_timeout: Duration::from_secs(120),
|
||||
chunk_job_floor: Duration::from_secs(600),
|
||||
verify_timeout: Duration::from_secs(120),
|
||||
permit_timeout: Duration::from_secs(120 * 60),
|
||||
user_agent: super::http::USER_AGENT.to_string(),
|
||||
progress_throttle: Duration::from_millis(250),
|
||||
backoff: BackoffPolicy::default(),
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
//! 中间文件是本会话命名的(`<name>.ullpart-<session>-<seq>`),下次(甚至下次启动)由 `PartFile::for_dest` 认领并改名,
|
||||
//! 续传判定照旧(身份规则见 `atomic.rs` 模块头)。传输细节在 `transfer.rs`,错误分类在 `http.rs`,本文件只负责顺序与策略。
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -30,7 +29,11 @@ use super::http;
|
||||
use super::mirrors::MirrorTable;
|
||||
use super::progress::{ProgressEmitter, ProgressEvent};
|
||||
use super::region::RegionProfile;
|
||||
use super::stall::StallReport;
|
||||
use super::stall::{StallReport, StallStage};
|
||||
mod finish;
|
||||
|
||||
pub(crate) use finish::millis;
|
||||
|
||||
use super::types::{
|
||||
Candidate, DownloadId, DownloadOutcome, DownloadRequest, ResourceKind, ResumePolicy,
|
||||
SOURCE_OFFICIAL,
|
||||
@@ -138,7 +141,7 @@ impl DownloadEngine {
|
||||
|
||||
if let Some(integrity) = req.integrity.clone() {
|
||||
if let Some(cached) = self.cache.lookup(&integrity, now_ms()) {
|
||||
let bytes = materialize(&cached, &req.dest).map_err(|err| {
|
||||
let bytes = finish::materialize(&cached, &req.dest).map_err(|err| {
|
||||
DownloadError::new(
|
||||
DownloadErrorKind::Io,
|
||||
format!("缓存直取失败({}):{err}", req.dest.display()),
|
||||
@@ -172,12 +175,14 @@ impl DownloadEngine {
|
||||
));
|
||||
}
|
||||
|
||||
let _permit = self.acquire().await?;
|
||||
let _permit = self.acquire(req.expected_size).await?;
|
||||
let mut part = PartFile::for_dest(&req.dest);
|
||||
let mut suppressed: Vec<String> = Vec::new();
|
||||
let mut previous: Option<String> = None;
|
||||
// 失速现场只跟**最后一次**尝试:更早的源怎么挂的不能冒充根因。
|
||||
let mut last_stall: Option<StallReport> = None;
|
||||
// 整作业预算用完 ⇒ 不再试剩余候选源(见下面的 `job_budget_exhausted`)。
|
||||
let mut exhausted = false;
|
||||
|
||||
for candidate in &plan {
|
||||
if let Some(from) = previous.take() {
|
||||
@@ -224,7 +229,13 @@ impl DownloadEngine {
|
||||
now_ms(),
|
||||
);
|
||||
}
|
||||
let retry = err.kind.is_retryable() && attempt + 1 < max_attempts;
|
||||
// 整作业总期限用完是**链路级**判决(同一份字节、同一条链路):
|
||||
// ① `err.is_retryable()` 让同源不再重试(每试一次都要再烧满一整个预算);
|
||||
// ② 下面外循环的 `exhausted` 让它连剩余候选源都不试。
|
||||
// 复审 r1 p2-2 的算术:不这么做,188MB/20 KiB/s 的用户会从「1.6 小时
|
||||
// 慢慢下完」变成「每源 3 次 × 每个候选 × 100 min」的反复重下。
|
||||
exhausted = err.job_budget_exhausted();
|
||||
let retry = err.is_retryable() && attempt + 1 < max_attempts;
|
||||
let delay = self.next_delay(attempt);
|
||||
self.emit(ProgressEvent::SourceFailed {
|
||||
id,
|
||||
@@ -244,6 +255,9 @@ impl DownloadEngine {
|
||||
}
|
||||
}
|
||||
previous = Some(candidate.source.clone());
|
||||
if exhausted {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 全部候选失败:保留中间文件与元数据,下次(甚至下次启动)可续传。
|
||||
@@ -263,45 +277,6 @@ impl DownloadEngine {
|
||||
.with_last_stall(last_stall))
|
||||
}
|
||||
|
||||
fn finish(
|
||||
&self,
|
||||
req: &DownloadRequest,
|
||||
part: PartFile,
|
||||
id: DownloadId,
|
||||
candidate: &Candidate,
|
||||
ok: AttemptOk,
|
||||
started: Instant,
|
||||
) -> Result<DownloadOutcome, DownloadError> {
|
||||
if let Some(integrity) = &req.integrity {
|
||||
// 缓存写入失败不影响交付(缓存纯属加速)。
|
||||
let _ = self.cache.insert(part.path(), integrity, now_ms());
|
||||
let _ = self.cache.persist();
|
||||
}
|
||||
part.commit(&req.dest).map_err(|err| {
|
||||
DownloadError::new(
|
||||
DownloadErrorKind::Io,
|
||||
format!("原子落盘失败({}):{err}", req.dest.display()),
|
||||
)
|
||||
})?;
|
||||
let elapsed = started.elapsed();
|
||||
self.emit(ProgressEvent::Completed {
|
||||
id,
|
||||
bytes: ok.bytes,
|
||||
from_cache: false,
|
||||
source: candidate.source.clone(),
|
||||
elapsed_ms: millis(elapsed),
|
||||
});
|
||||
Ok(DownloadOutcome {
|
||||
id,
|
||||
path: req.dest.clone(),
|
||||
bytes: ok.bytes,
|
||||
from_cache: false,
|
||||
source: candidate.source.clone(),
|
||||
resumed_from: ok.resumed_from,
|
||||
elapsed,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn announce(&self, id: &DownloadId, req: &DownloadRequest, candidates: usize) {
|
||||
self.emit(ProgressEvent::Started {
|
||||
id: *id,
|
||||
@@ -313,10 +288,41 @@ impl DownloadEngine {
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) async fn acquire(&self) -> Result<tokio::sync::OwnedSemaphorePermit, DownloadError> {
|
||||
Arc::clone(&self.sem).acquire_owned().await.map_err(|_| {
|
||||
DownloadError::new(DownloadErrorKind::Io, "并发闸门已关闭(引擎正在销毁)")
|
||||
})
|
||||
/// 抢一张并发许可(**有界**:等许可也是一次 `await`)。
|
||||
///
|
||||
/// 为什么必须设界:M289 之前任何一处无界 `await` 都能让一个作业永久攥着许可,同缓存根
|
||||
/// 的其它下载就全堵在这行(复审 r1 p2-1 点名的连坐形态)。上界取 `permit_timeout`
|
||||
/// (必须 ≥ 单个作业的总期限,见 `stall` 模块头),到点报阶段「等许可」——
|
||||
/// 不冒充「服务器不给我数据」。
|
||||
pub(crate) async fn acquire(
|
||||
&self,
|
||||
expected: Option<u64>,
|
||||
) -> Result<tokio::sync::OwnedSemaphorePermit, DownloadError> {
|
||||
match tokio::time::timeout(
|
||||
self.cfg.permit_timeout,
|
||||
Arc::clone(&self.sem).acquire_owned(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(permit)) => Ok(permit),
|
||||
Ok(Err(_)) => Err(DownloadError::new(
|
||||
DownloadErrorKind::Io,
|
||||
"并发闸门已关闭(引擎正在销毁)",
|
||||
)),
|
||||
Err(_) => Err(DownloadError::stalled(
|
||||
StallReport::deadline(
|
||||
StallStage::AcquirePermit,
|
||||
0,
|
||||
expected,
|
||||
self.cfg.permit_timeout,
|
||||
),
|
||||
format!(
|
||||
"等并发许可超过 {}s(此刻可用 {} 张)",
|
||||
self.cfg.permit_timeout.as_secs(),
|
||||
self.sem.available_permits()
|
||||
),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 该源此刻给几次尝试:冷却中 / 刚出冷却未洗白 → 1 次(那就是恢复探测,别让死源每次到期
|
||||
@@ -347,23 +353,3 @@ impl DownloadEngine {
|
||||
self.progress.emit(event);
|
||||
}
|
||||
}
|
||||
|
||||
/// 缓存命中时把文件变成工作副本(硬链接优先,跨卷退化为复制)。
|
||||
fn materialize(source: &Path, dest: &Path) -> std::io::Result<u64> {
|
||||
if let Some(parent) = dest.parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
}
|
||||
if dest.exists() {
|
||||
let _ = std::fs::remove_file(dest);
|
||||
}
|
||||
if std::fs::hard_link(source, dest).is_err() {
|
||||
std::fs::copy(source, dest)?;
|
||||
}
|
||||
Ok(std::fs::metadata(dest)?.len())
|
||||
}
|
||||
|
||||
pub(crate) fn millis(duration: Duration) -> u64 {
|
||||
u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//! 一次尝试成功后的收尾:缓存写入 → 原子落盘 → 完成事件;外加两个小工具。
|
||||
//!
|
||||
//! 从 `engine.rs` 拆出只为守 400 行硬线(M289 r1 复审又给 engine 加了许可上界与
|
||||
//! 预算耗尽短路,贴线了)。搬出来的代码逐字节未改,只有可见性从私有放宽到
|
||||
//! `pub(super)` / `pub(crate)`(跨模块调用需要)。
|
||||
|
||||
use std::path::Path;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::super::atomic::PartFile;
|
||||
use super::super::cache::now_ms;
|
||||
use super::super::error::{DownloadError, DownloadErrorKind};
|
||||
use super::super::progress::ProgressEvent;
|
||||
use super::super::types::{Candidate, DownloadId, DownloadOutcome, DownloadRequest};
|
||||
use super::{AttemptOk, DownloadEngine};
|
||||
|
||||
impl DownloadEngine {
|
||||
pub(super) fn finish(
|
||||
&self,
|
||||
req: &DownloadRequest,
|
||||
part: PartFile,
|
||||
id: DownloadId,
|
||||
candidate: &Candidate,
|
||||
ok: AttemptOk,
|
||||
started: Instant,
|
||||
) -> Result<DownloadOutcome, DownloadError> {
|
||||
if let Some(integrity) = &req.integrity {
|
||||
// 缓存写入失败不影响交付(缓存纯属加速)。
|
||||
let _ = self.cache.insert(part.path(), integrity, now_ms());
|
||||
let _ = self.cache.persist();
|
||||
}
|
||||
part.commit(&req.dest).map_err(|err| {
|
||||
DownloadError::new(
|
||||
DownloadErrorKind::Io,
|
||||
format!("原子落盘失败({}):{err}", req.dest.display()),
|
||||
)
|
||||
})?;
|
||||
let elapsed = started.elapsed();
|
||||
self.emit(ProgressEvent::Completed {
|
||||
id,
|
||||
bytes: ok.bytes,
|
||||
from_cache: false,
|
||||
source: candidate.source.clone(),
|
||||
elapsed_ms: millis(elapsed),
|
||||
});
|
||||
Ok(DownloadOutcome {
|
||||
id,
|
||||
path: req.dest.clone(),
|
||||
bytes: ok.bytes,
|
||||
from_cache: false,
|
||||
source: candidate.source.clone(),
|
||||
resumed_from: ok.resumed_from,
|
||||
elapsed,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 缓存命中时把文件变成工作副本(硬链接优先,跨卷退化为复制)。
|
||||
pub(super) fn materialize(source: &Path, dest: &Path) -> std::io::Result<u64> {
|
||||
if let Some(parent) = dest.parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
}
|
||||
if dest.exists() {
|
||||
let _ = std::fs::remove_file(dest);
|
||||
}
|
||||
if std::fs::hard_link(source, dest).is_err() {
|
||||
std::fs::copy(source, dest)?;
|
||||
}
|
||||
Ok(std::fs::metadata(dest)?.len())
|
||||
}
|
||||
|
||||
pub(crate) fn millis(duration: Duration) -> u64 {
|
||||
u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
|
||||
}
|
||||
@@ -6,7 +6,7 @@ use std::fmt;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::stall::StallReport;
|
||||
use super::stall::{StallReason, StallReport, StallStage};
|
||||
|
||||
/// 错误分类:决定「同源重试」「立刻换源」还是「整体失败」。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
@@ -176,6 +176,27 @@ impl DownloadError {
|
||||
}
|
||||
self.kind
|
||||
}
|
||||
|
||||
/// **整作业总期限用完**:分块作业的平均吞吐掉到 [`super::stall::MIN_AVG_THROUGHPUT`] 以下。
|
||||
///
|
||||
/// 这是一个**链路级**判决(同一份字节、同一条链路),不是「这个源暂时不顺」。
|
||||
#[must_use]
|
||||
pub fn job_budget_exhausted(&self) -> bool {
|
||||
self.stall.as_deref().is_some_and(|stall| {
|
||||
stall.stage == StallStage::AwaitChunkJoin && stall.reason == StallReason::Deadline
|
||||
})
|
||||
}
|
||||
|
||||
/// 这次失败值不值得再试一次。
|
||||
///
|
||||
/// 除了 `kind.is_retryable()`,**整作业总期限用完不再重试**:每个候选源(乃至同一源的
|
||||
/// 每一次重试)都要再烧满一整个预算,而它是链路级判决——复审 r1 p2-2 的算术:188MB 在
|
||||
/// 20 KiB/s 上要 160 min,预算 100.3 min,按默认「每源 3 次 × 多个候选」重试会让用户
|
||||
/// 从「1.6 小时慢慢下完」变成「几小时反复重下,最后全部源失败」。
|
||||
#[must_use]
|
||||
pub fn is_retryable(&self) -> bool {
|
||||
!self.job_budget_exhausted() && self.kind.is_retryable()
|
||||
}
|
||||
}
|
||||
|
||||
/// 从各候选源的失败里汇总出**最可诊断**的叶子成因。
|
||||
|
||||
@@ -1,15 +1,43 @@
|
||||
//! 失速诊断与下载的三段上界(M289 / 台账 P19)。
|
||||
//! 失速诊断与下载的每一处上界(M289 / 台账 P19)。
|
||||
//!
|
||||
//! # 事实:下载引擎曾是全仓唯一没有超时的那条路
|
||||
//! # 事实:下载引擎曾是全仓唯一没有超时的那条路(r1 复审 p2-1 后修正)
|
||||
//!
|
||||
//! 「全仓下载路径只有两处无界 `await`」那句话**是错的**,被复审用一个 `silent()` 探针
|
||||
//! 当场打穿。真实的清单一共有**五处**,M289 修掉了其中五处:
|
||||
//!
|
||||
//! * 单连接请求的 `send()`(`transfer.rs`);
|
||||
//! * 分块请求的 `send()`(`chunked.rs`);
|
||||
//! * `download_bytes` 的 `send()`(`bytes.rs`,生产路径:版本清单 / 版本 JSON /
|
||||
//! Modrinth 元数据)——整包读 `response.bytes()` 同样无界,而且它**只管大小不管时间**
|
||||
//! (`MAX_IN_MEMORY_BYTES` 不是时间上界);
|
||||
//! * `acquire_owned()`(`engine.rs`):等许可也是一次 `await`,卡死的作业攥着许可,
|
||||
//! 同缓存根的 `download()` 就全堵在这里(P19 (c′) 的同族形态)。
|
||||
//!
|
||||
//! 全仓下载路径此前只有两处**无界** `await`:分块请求与单连接请求的 `send()`。
|
||||
//! `build_client` 只设了 `connect_timeout`,reqwest 自己的 `timeout` 默认 `None` ——
|
||||
//! 于是「服务器接了连接、不回一个字节」= 这个 future 永不结束,而下 188MB 的作业
|
||||
//! 就在那里静静挂着(`ull java install` 走的是 `silent()` 引擎:零事件、零日志,
|
||||
//! 用户只能看到一个不动的主界面)。
|
||||
//!
|
||||
//! 同仓两个先例:`region.rs` 给探测的 `send()` 包了 3s,`font.rs` 给整个
|
||||
//! `download_bytes` 包了总预算——下载引擎自己反而是唯一没有界的那条路。
|
||||
//! # 哪些入口有界、谁托管给谁
|
||||
//!
|
||||
//! | 入口 | 上界 | 阶段 |
|
||||
//! |------|------|------|
|
||||
//! | `DownloadEngine::download` 单连接 | `http::send_bounded` + `http::read_step` | 等响应头 / 等字节 |
|
||||
//! | `DownloadEngine::download` 分块 | 同上 + [`StallStage::AwaitChunkJoin`](无进度窗口与总期限) | 等分块回收 |
|
||||
//! | `DownloadEngine::download_bytes` | `http::send_bounded` + `read_step` 流式累加(顺带用 `MAX_IN_MEMORY_BYTES` 卡住无 `Content-Length` 的洪流) | 等响应头 / 等字节 |
|
||||
//! | `DownloadEngine::acquire`(许可) | `EngineConfig::permit_timeout` | [`StallStage::AcquirePermit`] |
|
||||
//! | 校验(`verify_part`) | `EngineConfig::verify_timeout` | [`StallStage::Verify`] |
|
||||
//! | `font::fetch_blocking` | 自带的整作业预算(外加上面全部) | —— |
|
||||
//! | `region::probe_reachable` | 自带的 3s | —— |
|
||||
//!
|
||||
//! 因此**本模块内**裸 `.send()` 只剩两处:本模块的 [`super::http::send_bounded`](唯一构造点)
|
||||
//! 与 `region.rs` 的 3s 探测(自带界)。新加下载入口时**先看这张表**,别再出现第二个
|
||||
//! `download_bytes`。
|
||||
//!
|
||||
//! 模块外还有几处用**同一个** `reqwest::Client`(只设了 `connect_timeout`)的地方,M289 没碰
|
||||
//! (scope 是 `download/**`):`auth/http.rs` 自带 `.timeout(REQUEST_TIMEOUT)`;而
|
||||
//! `game/java/api.rs`、`game/java/net.rs`、`update/github.rs` 是裸 `send()`——**未审**,
|
||||
//! 与这里修掉的是同族形态,已用 TowerFinding 报给塔台另立 mission。
|
||||
//!
|
||||
//! # 取舍:三段可以共存的界,**不是**「整作业必须在 N 秒内下完」
|
||||
//!
|
||||
@@ -34,12 +62,24 @@
|
||||
//! 字节一直在动就一直续期。每秒只挤几 KB 的链路也算「在动」,只有**真的不动**了才判失速;
|
||||
//! 3. [`StallStage::AwaitChunkJoin`](分块作业):一个分块卡住不许 4 条连接陪着挂到天荒地老。
|
||||
//! 这里同时给「无进度窗口」与「总期限」,但总期限按**平均吞吐下限**反推
|
||||
//! ([`job_budget`]),不是拍一个固定秒数:能把 188MB 在期限内下完的慢链路照样活。
|
||||
//! ([`job_budget`]),不是拍一个固定秒数。
|
||||
//!
|
||||
//! **不要**把它改成 `reqwest::ClientBuilder::timeout(...)`:reqwest 的 `timeout` 覆盖
|
||||
//! **整个响应体**读取,那正好就是「慢但有效被判死」的那个坑(它的默认 `None` 也是 P19
|
||||
//! 记的另一半事实)。每个阶段各自的界才分得清「连不上」「不回话」「不回字节」。
|
||||
//!
|
||||
//! # 「慢但有效」的适用边界(r1 复审 p2-2 要求写明)
|
||||
//!
|
||||
//! 第 3 条那句「慢链路照样活」有一个**前提**,等价说法是:**平均吞吐 ≥ 32 KiB/s 才活得下来**。
|
||||
//! 算术:188MB 在 20 KiB/s 上要 160.4 min,而预算只有 100.3 min ⇒ 必被杀。而且一旦被杀,
|
||||
//! `DownloadError::job_budget_exhausted()` 让引擎**不再重试、也不再换源**(同一份字节、
|
||||
//! 同一条链路,候选源不可能更快)——所以在 <32 KiB/s 的链路上,这个文件大小是「下不完」,
|
||||
//! 不是「慢一点」。要让用户自己调这几个数,得接 `download.*` 的 schema(`setting/keys.rs`
|
||||
//! 不在 M289 的 scope,是另一个 mission);在那之前出厂值就是唯一值。
|
||||
//!
|
||||
//! 这条边界的命中面不远:仓内就有 ≈67 KB/s 的真机观测(`font/tests/support.rs` 的文件头)。
|
||||
//! 真实公网慢链路的取值**没有真机验证**(M289 报告列为未验项)。
|
||||
//!
|
||||
//! # 数字从哪来(全仓只写这一处)
|
||||
//!
|
||||
//! * `response_timeout` = 60s:连接已有 10s 的 `connect_timeout` 兜底,这 60s 是**连接
|
||||
@@ -49,16 +89,19 @@
|
||||
//! * `chunk_stall_timeout` = 120s:4 条连接**整体**一个新字节都没有两分钟,只可能是
|
||||
//! 分块任务卡住(写盘卡在杀软/网络盘上时,读超时不会响,只有这个会)。
|
||||
//! * [`MIN_AVG_THROUGHPUT`] = 32 KiB/s:分块作业的总期限 = 总字节 / 32 KiB/s
|
||||
//! (188MB ≈ 100 分钟)。即「整作业的平均速度掉到 32 KiB/s 以下才判死」;比这更快的
|
||||
//! 慢链路仍会下完。**这是平均吞吐门槛,不是慢链路的死刑**:真实公网慢链路的取值
|
||||
//! 没有真机验证过(M289 报告里列为未验项)。
|
||||
//! (188MB ≈ 100 分钟)。适用边界与后果见上一节——**这是「≥32 KiB/s 才活得下来」
|
||||
//! 的等价说法**,不是「慢一点的链路也没事」。
|
||||
//! * `permit_timeout` = 120min:等许可的上界。它必须 **≥ 单个作业的总期限**
|
||||
//! (最坏 ≈ 100 min 预算 + 120s 校验),否则 `download.concurrency = 1` 时正常排队
|
||||
//! 会被误杀;它挡的是「许可被永久占住」(M289 之前每一处无界 `await` 都能造出来)。
|
||||
//! * `verify_timeout` = 120s:188MB 的 SHA-1 在正常盘上不到 1s,120s 只抓「整文件重算
|
||||
//! 摘要被卡住」(网络盘掉线、杀软锁文件),不参与慢链路判定。
|
||||
//!
|
||||
//! # 口径(读这两个数之前先看这里)
|
||||
//!
|
||||
//! `received` / `expected` 是**该阶段自己的口径**:等字节 = 这条连接收到多少 / 该连接
|
||||
//! 被要求收多少(分块就是那一段的长度);等分块回收 = 整个作业收了多少 / 作业总长。
|
||||
//! 被要求收多少(分块就是那一段的长度);等分块回收 = 整个作业收了多少 / 作业总长;
|
||||
//! 等许可 = 都还没有(`received = 0`、`expected` 是这次请求的期望大小)。
|
||||
|
||||
use std::fmt;
|
||||
use std::time::Duration;
|
||||
@@ -75,6 +118,12 @@ pub enum StallStage {
|
||||
AwaitHeaders,
|
||||
/// 响应头已到,等下一块正文字节(读空闲判定的上界)。
|
||||
AwaitBytes,
|
||||
/// 还没开始下载:在等引擎的并发许可(`Semaphore` 队列)。
|
||||
///
|
||||
/// 单独一档而不是塞进 `AwaitBytes`:这里上游根本还没轮到我们,「服务器不给数据」与
|
||||
/// 「引擎不给我们许可」是两种完全不同的故障(复审 r1 p2-1:挂死的作业攥着许可会把
|
||||
/// 同缓存根的 `download()` 全堵死)。
|
||||
AcquirePermit,
|
||||
/// 分块作业在等分块任务回收(整作业的无进度 / 总期限上界)。
|
||||
AwaitChunkJoin,
|
||||
/// 整文件重算摘要(校验阶段)。
|
||||
@@ -89,6 +138,7 @@ impl StallStage {
|
||||
Self::Connect => "connect",
|
||||
Self::AwaitHeaders => "await_headers",
|
||||
Self::AwaitBytes => "await_bytes",
|
||||
Self::AcquirePermit => "acquire_permit",
|
||||
Self::AwaitChunkJoin => "await_chunk_join",
|
||||
Self::Verify => "verify",
|
||||
}
|
||||
@@ -101,6 +151,7 @@ impl StallStage {
|
||||
Self::Connect => "建立连接",
|
||||
Self::AwaitHeaders => "等响应头",
|
||||
Self::AwaitBytes => "等字节",
|
||||
Self::AcquirePermit => "等许可",
|
||||
Self::AwaitChunkJoin => "等分块回收",
|
||||
Self::Verify => "校验",
|
||||
}
|
||||
@@ -293,6 +344,7 @@ mod tests {
|
||||
(StallStage::Connect, "connect", "建立连接"),
|
||||
(StallStage::AwaitHeaders, "await_headers", "等响应头"),
|
||||
(StallStage::AwaitBytes, "await_bytes", "等字节"),
|
||||
(StallStage::AcquirePermit, "acquire_permit", "等许可"),
|
||||
(StallStage::AwaitChunkJoin, "await_chunk_join", "等分块回收"),
|
||||
(StallStage::Verify, "verify", "校验"),
|
||||
];
|
||||
|
||||
@@ -116,6 +116,8 @@ impl DownloadEngine {
|
||||
cut: ResumeCut,
|
||||
) -> Result<AttemptOk, DownloadError> {
|
||||
let integrity = req.integrity.as_ref();
|
||||
// 两段界都从这一处取(`EngineConfig::deadlines`):别在这里各读各的字段。
|
||||
let deadlines = self.cfg.deadlines();
|
||||
let mut offset = cut.offset;
|
||||
let mut resumed_from = cut.resumed_from;
|
||||
let mut request = self.http.get(&candidate.url);
|
||||
@@ -134,14 +136,9 @@ impl DownloadEngine {
|
||||
}
|
||||
}
|
||||
|
||||
let response = http::send_bounded(
|
||||
request,
|
||||
&candidate.url,
|
||||
self.cfg.deadlines(),
|
||||
req.expected_size,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| err.with_source(candidate.source.as_str()))?;
|
||||
let response = http::send_bounded(request, &candidate.url, deadlines, req.expected_size)
|
||||
.await
|
||||
.map_err(|err| err.with_source(candidate.source.as_str()))?;
|
||||
let status = response.status();
|
||||
|
||||
if status.as_u16() == 416 {
|
||||
@@ -235,7 +232,7 @@ impl DownloadEngine {
|
||||
loop {
|
||||
let next = http::read_step(
|
||||
futures_util::StreamExt::next(&mut stream),
|
||||
self.cfg.read_timeout,
|
||||
deadlines.read_idle,
|
||||
written,
|
||||
total,
|
||||
&candidate.url,
|
||||
|
||||
@@ -215,6 +215,12 @@ impl DownloadEngine {
|
||||
// 整文件重算是同步阻塞 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;
|
||||
|
||||
Reference in new issue
Block a user