fix(m162-r1): 叶子成因穿出引擎 + 安装路径切 core 在线源 + 详情迟到结果闸门

P1-1(p1-1items 的根因):
- download/error.rs:`DownloadError` 增 `leaf: Option<DownloadErrorKind>` 与
  `effective_kind()`;`leaf_of()` 汇总规则只此一处:全 404 ⇒ NotFound、有
  Network/Timeout ⇒ Network、全 5xx ⇒ ServerError、混合按「网络错 > 5xx > 404」
  取最可诊断的那个(**不从 suppressed 文本猜状态码**)。
- download/bytes.rs:候选源循环收集逐源 kind,收尾时把叶子挂到 AllSourcesFailed 上。
- online/transport.rs:`From<DownloadError>` 改用 `effective_kind()`,于是
  NotFound/Server/Integrity 在生产路径可达(项目被删/id 拼错/限速不再画成「未联网」)。
- import/fetch.rs:新增 `FetchErrorKind::Offline`(真正的「没联网」)并与 Network 分开;
  `From<DownloadError>`/`From<OnlineError>` 都按叶子/大类映射。
- import/complete.rs:补全只在 `Offline` 时早停;404(真实 mrpack 大多托管在 CF CDN,
  Modrinth 必然 404)与 5xx 只影响那一条,不再掐掉整轮;注记「因未联网提前停止」因此
  只会在真断网时出现。

P2-1:presenter 安装路径不再自己拼 Modrinth URL / 自己解析 JSON——改用
`core::addon::online::Modrinth::versions` + `primary_file()` + `VersionFile::sha1/sha512`;
`HostTask::InstallDone.result` 改成 `Result<String, ContentFailure>`,
`InstallProgressView` 增 `failure_kind`(离线 ≠ 查无此项)。

P2-2:`app/update/content.rs` 把被详情方法抢走的「装入闸门」注释还回
`content_install_request`,详情方法只留自己那一行。

P2-3:`ContentState::apply_detail/fail_detail` 带上 project(事件与 HostTask 都携带请求时的
项目 id),迟到结果一律丢弃;`request_detail` 记下在飞项目,`detail_pending` 不再被迟到
结果清掉。

顺手项:`usable_hash` 让形状非法的那一档摘要被**丢弃**(sha512 仍校验,不再整条跳过;
两档都不可用才拒绝);`digest_file` 的 IO 失败先删落盘文件再返回。i18n 的
ContentOfflineBody 改成不暗示「有缓存」的措辞(golden 同步)。

用例:download/error.rs 叶子四档 + effective_kind;online/tests/classify.rs 5 条
(AllSourcesFailed+leaf 404 ⇒ NotFound、无叶子 ⇒ NotConnected 保守、叶子一一映射、
单源自身 kind、导入侧 404≠Offline);import_online 增「404 不停」「真离线才停」
(共 7 条);download 增「sha1 形状非法仍用 sha512 校验成功」;presenter 增详情迟到闸门。
This commit is contained in:
AskaEth committed 2026-11-07 17:28:36 +08:00
1 parent 81278f55af
commit b394316df4
30 files changed
+658 -172

No files matched your search

+3 -1
View File
@@ -82,7 +82,9 @@ pub(crate) async fn complete(request: &ImportRequest<'_>, plan: &mut ImportPlan)
}
}
Err(error) => {
if error.kind() == FetchErrorKind::Network {
// 只有**真正的「没联网」**才提前收工:404(这条不在 Modrinth 上,或托管在
// CF CDN)与 5xx 都只影响这一条,后面那些仍然可能补得出来。
if error.kind() == FetchErrorKind::Offline {
state.offline = true;
}
state.unfilled += 1;
+4 -3
View File
@@ -176,9 +176,10 @@ impl From<FetchError> for ImportError {
let kind = match error.kind() {
FetchErrorKind::Integrity => ImportErrorKind::Integrity,
FetchErrorKind::Io => ImportErrorKind::Pack(PackErrorKind::Io),
FetchErrorKind::NotFound | FetchErrorKind::Network | FetchErrorKind::Unsupported => {
ImportErrorKind::Fetch
}
FetchErrorKind::NotFound
| FetchErrorKind::Network
| FetchErrorKind::Offline
| FetchErrorKind::Unsupported => ImportErrorKind::Fetch,
};
Self {
kind,
+16 -6
View File
@@ -44,8 +44,11 @@ use crate::download::{DownloadEngine, DownloadError, DownloadErrorKind};
pub enum FetchErrorKind {
/// 源上确实没有(404 / 文件被删)。
NotFound,
/// 网络侧失败(超时、连不上、所有候选源都失败)。
/// 网络侧失败(超时、连不上、站点 5xx 退避后仍失败)。
Network,
/// **没联网**:连不上任何候选源(与 `Network` 分开,是因为「补全」见到它要立刻停,
/// 而见到 404 / 5xx 不该停 —— 下一条可能补得出来)。
Offline,
/// 本地 IO(写盘失败、磁盘满)。
Io,
/// 内容与声明的摘要不符。
@@ -61,6 +64,7 @@ impl FetchErrorKind {
match self {
Self::NotFound => "fetch_not_found",
Self::Network => "fetch_network",
Self::Offline => "fetch_offline",
Self::Io => "fetch_io",
Self::Integrity => "fetch_integrity",
Self::Unsupported => "fetch_unsupported",
@@ -70,7 +74,10 @@ impl FetchErrorKind {
/// 是否属于"某个文件搞不到"(可容忍,实例照装)。
#[must_use]
pub const fn is_tolerable(self) -> bool {
matches!(self, Self::NotFound | Self::Network | Self::Unsupported)
matches!(
self,
Self::NotFound | Self::Network | Self::Offline | Self::Unsupported
)
}
}
@@ -126,14 +133,15 @@ impl std::error::Error for FetchError {}
impl From<DownloadError> for FetchError {
fn from(error: DownloadError) -> Self {
let kind = match error.kind {
// 用**叶子成因**(`effective_kind`):候选源循环结束时只有一条 `AllSourcesFailed`,
// 直接按它判定会把「这个文件不存在」也算成「网络失败」。
let kind = match error.effective_kind() {
DownloadErrorKind::NotFound => FetchErrorKind::NotFound,
DownloadErrorKind::Integrity => FetchErrorKind::Integrity,
DownloadErrorKind::Io => FetchErrorKind::Io,
DownloadErrorKind::InvalidRequest => FetchErrorKind::Unsupported,
DownloadErrorKind::Timeout | DownloadErrorKind::Network => FetchErrorKind::Offline,
DownloadErrorKind::ServerError
| DownloadErrorKind::Timeout
| DownloadErrorKind::Network
| DownloadErrorKind::RangeUnsupported
| DownloadErrorKind::AllSourcesFailed => FetchErrorKind::Network,
};
@@ -148,7 +156,9 @@ impl From<OnlineError> for FetchError {
OnlineErrorKind::Integrity | OnlineErrorKind::Malformed => FetchErrorKind::Integrity,
OnlineErrorKind::Io => FetchErrorKind::Io,
OnlineErrorKind::Unsupported => FetchErrorKind::Unsupported,
OnlineErrorKind::NotConnected | OnlineErrorKind::Server => FetchErrorKind::Network,
// 「没联网」要能被「补全立刻停」认出来;站点 5xx 只是这一条补不出。
OnlineErrorKind::NotConnected => FetchErrorKind::Offline,
OnlineErrorKind::Server => FetchErrorKind::Network,
};
Self::new(kind, error.message().to_string())
}
+28 -16
View File
@@ -122,8 +122,17 @@ pub async fn download_verified(
if url.trim().is_empty() {
return Err(OnlineError::unsupported("没有可用下载地址"));
}
let sha1 = check_hash(sha1, HashAlgorithm::Sha1)?;
let sha512 = check_hash(sha512, HashAlgorithm::Sha512)?;
// 两档摘要**各自**过形状检查:坏掉的那一档丢掉,用另一档校验(mrpack 的权威摘要是
// sha512,CLI/整合包都可能给出一个人写坏的 sha1——为此整条不装是过分惩罚)。
// 但如果**声明过**摘要却一档都用不上,就必须失败:那是「没有任何校验」的伪装。
let declared = sha1.is_some() || sha512.is_some();
let sha1 = usable_hash(sha1, HashAlgorithm::Sha1);
let sha512 = usable_hash(sha512, HashAlgorithm::Sha512);
if declared && sha1.is_none() && sha512.is_none() {
return Err(OnlineError::unsupported(
"声明的 sha1/sha512 都不是合法摘要,无法校验(拒绝在「没有校验」的情况下落盘)",
));
}
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent).map_err(|error| {
OnlineError::new(
@@ -165,7 +174,14 @@ pub async fn download_verified(
continue;
}
}
let digest = digest_file(dest)?;
let digest = match digest_file(dest) {
Ok(digest) => digest,
Err(error) => {
// 与其它失败路径一致:读不出来就不许把文件留在盘上。
remove_quiet(dest);
return Err(error);
}
};
if let Some(expected) = sha1 {
if !equals_hex(expected, &digest.sha1) {
last = Some(OnlineError::integrity(format!(
@@ -239,19 +255,15 @@ pub fn digest_file(path: &Path) -> Result<Digest, OnlineError> {
/// 摘要形状合法吗(长度 + 十六进制)。
///
/// 返回的是调用方原来那段文本(不复制、不归一),大小写不敏感的比较交给 [`equals_hex`]。
fn check_hash(raw: Option<&str>, algorithm: HashAlgorithm) -> Result<Option<&str>, OnlineError> {
match raw.map(str::trim) {
None => Ok(None),
Some("") => Ok(None),
Some(hex) if algorithm.accepts(hex) => Ok(Some(hex)),
Some(hex) => Err(OnlineError::unsupported(format!(
"声明的 {} 不是合法摘要:{:?}(要求 {} 位十六进制)",
algorithm.as_str(),
hex,
algorithm.hex_len()
))),
}
/// 这一档摘要能用吗:形状合法就原样返回(不复制、不归一,大小写不敏感比较交给
/// [`equals_hex`]),形状不合法就当「没给」。
///
/// 为什么丢掉而不是报错:`sha1` 与 `sha512` 是同一个文件的两档校验,**任一**都能证明内容;
/// 一档写坏不该让整条装不上(r1 顺手项)。两档都不可用时由 `download_verified` 统一拒绝。
fn usable_hash(raw: Option<&str>, algorithm: HashAlgorithm) -> Option<&str> {
raw.map(str::trim)
.filter(|hex| !hex.is_empty())
.filter(|hex| algorithm.accepts(hex))
}
/// 十六进制比较(大小写不敏感:声明侧可能大写)。
@@ -0,0 +1,89 @@
//! 引擎错误 → 在线内容源错误分类(r1 P1-1 的守卫)。
//!
//! 这一族的价值全在「经过真的 `From<DownloadError>`」:直接给 `OnlineError` 造一个
//! `NotFound` 什么也证明不了——本轮修的是**引擎把叶子成因折叠掉**之后上层的映射,
//! 所以输入必须是引擎真会产出的那种错误(`AllSourcesFailed` + `leaf`)。
use crate::addon::online::{OnlineError, OnlineErrorKind};
use crate::download::{DownloadError, DownloadErrorKind};
#[test]
fn an_all_sources_failed_with_a_404_leaf_is_not_found() {
// 引擎在「所有候选源都回 404」时的真实形状(`download::bytes` 的收尾)。
let error = DownloadError::new(
DownloadErrorKind::AllSourcesFailed,
"所有候选源都取不到文本资源:https://api.modrinth.com/v2/version_file/...",
)
.with_suppressed(vec!["official: HTTP 404".to_string()])
.with_leaf(DownloadErrorKind::NotFound);
let online: OnlineError = error.into();
assert_eq!(
online.kind(),
OnlineErrorKind::NotFound,
"项目不存在 / id 拼错必须报 NotFound,不能画成「未联网」"
);
assert!(!online.is_offline());
}
#[test]
fn an_all_sources_failed_without_a_leaf_stays_conservatively_offline() {
let online: OnlineError =
DownloadError::new(DownloadErrorKind::AllSourcesFailed, "全部候选源都失败").into();
assert_eq!(online.kind(), OnlineErrorKind::NotConnected);
assert!(online.is_offline());
}
#[test]
fn leaf_kinds_map_one_to_one() {
let cases = [
(DownloadErrorKind::NotFound, OnlineErrorKind::NotFound),
(DownloadErrorKind::ServerError, OnlineErrorKind::Server),
(DownloadErrorKind::Timeout, OnlineErrorKind::NotConnected),
(DownloadErrorKind::Network, OnlineErrorKind::NotConnected),
(DownloadErrorKind::Integrity, OnlineErrorKind::Integrity),
(DownloadErrorKind::Io, OnlineErrorKind::Io),
(
DownloadErrorKind::InvalidRequest,
OnlineErrorKind::Unsupported,
),
(
DownloadErrorKind::RangeUnsupported,
OnlineErrorKind::Malformed,
),
];
for (leaf, expected) in cases {
let error =
DownloadError::new(DownloadErrorKind::AllSourcesFailed, "全部失败").with_leaf(leaf);
let online: OnlineError = error.into();
assert_eq!(online.kind(), expected, "叶子 {} 映射错了", leaf.as_str());
}
}
#[test]
fn a_single_source_failure_uses_its_own_kind() {
// 单源直失败(没有 AllSourcesFailed 这一层):不该看 leaf,本来就没有。
let online: OnlineError = DownloadError::new(DownloadErrorKind::NotFound, "HTTP 404").into();
assert_eq!(online.kind(), OnlineErrorKind::NotFound);
}
#[test]
fn a_404_leaf_is_not_treated_as_offline_by_the_importer_too() {
// 导入侧(补全)用的是另一套分类:这里只钉「404 与没联网不同类」。
use crate::addon::import::FetchErrorKind;
let not_found: crate::addon::import::FetchError =
DownloadError::new(DownloadErrorKind::AllSourcesFailed, "全部失败")
.with_leaf(DownloadErrorKind::NotFound)
.into();
assert_eq!(not_found.kind(), FetchErrorKind::NotFound);
assert!(not_found.kind().is_tolerable());
let offline: crate::addon::import::FetchError =
DownloadError::new(DownloadErrorKind::AllSourcesFailed, "全部失败")
.with_leaf(DownloadErrorKind::Network)
.into();
assert_eq!(offline.kind(), FetchErrorKind::Offline);
assert!(
offline.kind().is_tolerable(),
"没联网仍然是「这条文件搞不到」,实例照装"
);
}
@@ -155,6 +155,26 @@ fn a_malformed_declared_hash_never_touches_the_disk_or_the_network() {
assert!(!dest.exists());
}
#[test]
fn a_malformed_sha1_is_dropped_and_the_sha512_still_guards_the_file() {
// r1 顺手项:sha1 形状写坏不该让整条装不上——sha512 才是 mrpack 的权威摘要。
let root = TempRoot::new("online-bad-sha1-good-sha512");
let dest = root.join("sodium.jar");
let fetcher = FakeArtifact::new(BYTES);
let (_, sha512) = spec();
let done = block_on(download_verified(
&fetcher,
"https://cdn.modrinth.com/data/AANobbMI/sodium.jar",
Some("not-a-sha1-at-all"),
Some(&sha512),
Some(BYTES.len() as u64),
&dest,
))
.expect("sha512 合法就该能校验并装下");
assert_eq!(done.digest.sha512, sha512);
assert!(dest.exists());
}
#[test]
fn an_empty_url_is_unsupported() {
let root = TempRoot::new("online-no-url");
@@ -7,6 +7,7 @@
//! M162 报告的「没验的部分」里。
mod api;
mod classify;
mod complete;
mod download;
mod fixtures;
+28 -9
View File
@@ -139,21 +139,40 @@ impl std::error::Error for OnlineError {}
/// `DownloadEngine` 的错误 → 本模块的错误分类。
///
/// **分类口径**:连接类/超时/所有候选源失败都是「没联网」(用户能做的只有检查网络);
/// 5xx/429 是「站点那边的问题」,与没网不同,但都值得重试一次。
/// # 为什么读 `effective_kind()` 而不是 `kind`
///
/// 引擎的候选源循环结束时只留下一条 [`DownloadErrorKind::AllSourcesFailed`],404 / 429 / 5xx /
/// 超时这些**叶子**成因会被折叠掉(`crates/core/src/download/bytes.rs` 的收尾)。
/// 如果直接按 `kind` 判定,`NotFound` / `Server` / `Integrity` 三档在本模块的生产路径上
/// **永远不可达**:项目被删、id 拼错、被限速都会画成「未联网」(r1 P1-1 后果 A)。
///
/// 引擎现在会把汇总出来的叶子 kind 挂在 `DownloadError::leaf` 上,这里按叶子映射;
/// 没有叶子(单源直失败 / 手工构造)就按自身 kind。**不从 `suppressed` 文本里猜状态码。**
///
/// # 分类口径
///
/// | 引擎侧 | 本模块 | 上层该怎么说 |
/// |--------|--------|--------------|
/// | `NotFound`(404/410/403,全源都是) | [`OnlineErrorKind::NotFound`] | 「查无此项 / 换个关键词」 |
/// | `ServerError`(5xx / 429) | [`OnlineErrorKind::Server`] | 「站点那边出错了」 |
/// | `Network` / `Timeout` | [`OnlineErrorKind::NotConnected`] | 「未联网,检查网络」 |
/// | `AllSourcesFailed`(没有叶子) | [`OnlineErrorKind::NotConnected`] | 同上(保守) |
/// | `Integrity` | [`OnlineErrorKind::Integrity`] | 「内容不对」 |
impl From<DownloadError> for OnlineError {
fn from(error: DownloadError) -> Self {
let kind = match error.kind {
let kind = match error.effective_kind() {
DownloadErrorKind::NotFound => OnlineErrorKind::NotFound,
DownloadErrorKind::ServerError => OnlineErrorKind::Server,
DownloadErrorKind::Timeout
| DownloadErrorKind::Network
| DownloadErrorKind::AllSourcesFailed => OnlineErrorKind::NotConnected,
DownloadErrorKind::Io => OnlineErrorKind::Io,
DownloadErrorKind::Integrity | DownloadErrorKind::RangeUnsupported => {
OnlineErrorKind::Malformed
DownloadErrorKind::Timeout | DownloadErrorKind::Network => {
OnlineErrorKind::NotConnected
}
DownloadErrorKind::Io => OnlineErrorKind::Io,
DownloadErrorKind::Integrity => OnlineErrorKind::Integrity,
DownloadErrorKind::RangeUnsupported => OnlineErrorKind::Malformed,
DownloadErrorKind::InvalidRequest => OnlineErrorKind::Unsupported,
// 没有叶子 ⇒ 分不清「没网」与「站点挂了」,按可重试的「没联网」保守处理:
// 上层至少会提示「检查网络后重试」,而不是宣称「没有匹配的内容」。
DownloadErrorKind::AllSourcesFailed => OnlineErrorKind::NotConnected,
};
Self::new(kind, error.message)
}
@@ -143,3 +143,80 @@ fn a_wrong_completed_sha1_fails_the_import_instead_of_installing() {
);
assert!(!target.join(PATH).exists(), "失败的导入不许留文件");
}
// ---------------------------------------------------------------- r1 P1-1 后果 B 的守卫
const CF_SHA512: &str = "c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1\
c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1";
/// 两条远端条目:第一条**站点不认得**(等价于托管在 CurseForge CDN),第二条认得。
///
/// 真实 mrpack 大多是这个形状——所以「第一条补不出就掐掉整轮」是 r1 抓到的后果 B。
fn pack_with_one_unknown_sha512(tag: &str) -> (TempRoot, std::path::PathBuf) {
let root = TempRoot::new(tag);
let dir = root.join("src");
let index = format!(
r#"{{"game":"minecraft","formatVersion":1,"versionId":"1.0.0","name":"混托管",
"files":[
{{"path":"mods/hosted-on-cf.jar","hashes":{{"sha512":"{CF_SHA512}"}},
"downloads":["stub://mods/hosted-on-cf.jar"],"fileSize":10}},
{{"path":"{PATH}","hashes":{{"sha512":"{SHA512}"}},
"downloads":["{DOWNLOAD}"],"fileSize":{size}}}
],
"dependencies":{{"minecraft":"1.20.1"}}}}"#,
size = PACK_BYTES.len()
);
write_file(&dir.join("modrinth.index.json"), index.as_bytes());
let pack = root.join("pack.mrpack");
zip_dir(&dir, &pack);
(root, pack)
}
#[test]
fn a_404_on_one_entry_does_not_stop_the_completion_round() {
// 第一次查(CF 那一条)站点回 404 ⇒ **不许**停:第二条仍然要补出来并参与判定。
let (root, pack) = pack_with_one_unknown_sha512("online-mixed-hosting");
let target = root.join("instance");
write_file(&target.join(PATH), DISK_BYTES);
write_file(&target.join("mods/hosted-on-cf.jar"), b"user's own copy");
let fetcher = StubFetcher::new().with_modrinth(SHA512, meta(&sha1(PACK_BYTES)));
let scan = block_on(scan_pack_online(
&ImportRequest::new(&pack, &target).with_fetcher(&fetcher),
));
assert!(scan.is_ok(), "{:?}", scan.error);
let paths: Vec<&str> = scan.conflicts.iter().map(|c| c.path.as_str()).collect();
assert_eq!(
paths,
vec![PATH],
"第二条(托管在 Modrinth 的那条)必须补出来并判出冲突:{:?}",
paths
);
assert_eq!(scan.unknown_remote, 1, "补不出的那一条仍如实计数");
assert!(
!scan
.notes
.iter()
.any(|note| note.message.contains("未联网")),
"404 不是「没联网」,注记不许这么说:{:?}",
scan.notes.iter().map(|n| &n.message).collect::<Vec<_>>()
);
}
#[test]
fn only_a_real_offline_stops_the_completion_round() {
let (root, pack) = pack_with_one_unknown_sha512("online-mixed-offline");
let target = root.join("instance");
write_file(&target.join(PATH), DISK_BYTES);
write_file(&target.join("mods/hosted-on-cf.jar"), b"user's own copy");
let fetcher = StubFetcher::new()
.with_modrinth(SHA512, meta(&sha1(PACK_BYTES)))
.offline_modrinth();
let scan = block_on(scan_pack_online(
&ImportRequest::new(&pack, &target).with_fetcher(&fetcher),
));
assert!(scan.is_ok(), "{:?}", scan.error);
assert_eq!(scan.unknown_remote, 2, "没联网 ⇒ 两条都判不了");
assert!(scan.conflicts.is_empty());
}
+15
View File
@@ -55,6 +55,8 @@ pub(crate) struct StubFetcher {
curse: BTreeMap<(i64, i64), CurseFileMeta>,
/// M162:按 sha512 查到的 Modrinth 文件元数据(模拟「站点认得这个文件」)。
modrinth: BTreeMap<String, ModrinthFileMeta>,
/// M162:补全时假装「没联网」(与「站点不认得这个摘要」区分开)。
modrinth_offline: bool,
/// 被请求过的 URL(断言"没白下载"用)。
calls: Mutex<Vec<String>>,
/// 是否像真引擎那样在目标旁边留下续传残片(`.ullpart-*` / `.ullmeta.json`)。
@@ -68,6 +70,7 @@ impl StubFetcher {
files: BTreeMap::new(),
curse: BTreeMap::new(),
modrinth: BTreeMap::new(),
modrinth_offline: false,
calls: Mutex::new(Vec::new()),
artifacts: false,
}
@@ -91,6 +94,12 @@ impl StubFetcher {
self
}
/// 让补全路径假装「没联网」(`FetchErrorKind::Offline`)。
pub(crate) fn offline_modrinth(mut self) -> Self {
self.modrinth_offline = true;
self
}
/// 像真引擎那样在每次"下载"后留下续传残片与 meta(用来钉"中间产物不许进实例")。
pub(crate) fn with_engine_artifacts(mut self) -> Self {
self.artifacts = true;
@@ -127,6 +136,12 @@ impl PackFetcher for StubFetcher {
sha512: &'a str,
) -> FetchFuture<'a, Result<ModrinthFileMeta, super::super::import::FetchError>> {
Box::pin(async move {
if self.modrinth_offline {
return Err(super::super::import::FetchError::new(
super::super::import::FetchErrorKind::Offline,
"连不上 api.modrinth.com",
));
}
self.modrinth.get(sha512).cloned().ok_or_else(|| {
super::super::import::FetchError::new(
super::super::import::FetchErrorKind::NotFound,
+8 -2
View File
@@ -7,7 +7,7 @@ use std::time::Instant;
use super::cache::now_ms;
use super::engine::{millis, DownloadEngine};
use super::error::{DownloadError, DownloadErrorKind};
use super::error::{leaf_of, DownloadError, DownloadErrorKind};
use super::http;
use super::progress::ProgressEvent;
use super::types::{Candidate, DownloadId, DownloadRequest};
@@ -33,6 +33,8 @@ impl DownloadEngine {
let _permit = self.acquire().await?;
let mut suppressed: Vec<String> = Vec::new();
// 逐源的叶子分类(出去时汇总成一条,见 `DownloadError::leaf`)。
let mut kinds: Vec<DownloadErrorKind> = Vec::new();
for candidate in &plan {
// 与文件下载同一套规则:冷却中/刚出冷却的源只给一次探测机会。
@@ -74,6 +76,7 @@ impl DownloadEngine {
retry_in_ms: if retry { millis(delay) } else { 0 },
});
if !retry {
kinds.push(err.kind);
suppressed.push(format!("{}: {}", candidate.source, err.message));
break;
}
@@ -84,11 +87,14 @@ impl DownloadEngine {
}
self.flush_health();
// 叶子成因要跟着出去:上层(在线内容源的 `Transport`)靠它区分「这个 URL 不存在」
// 与「没联网」,只留 `AllSourcesFailed` 的话那套分类在生产路径上永远不可达。
Err(DownloadError::new(
DownloadErrorKind::AllSourcesFailed,
format!("所有候选源都取不到文本资源:{}", req.url),
)
.with_suppressed(suppressed))
.with_suppressed(suppressed)
.with_leaf(leaf_of(kinds)))
}
async fn fetch_bytes(
+125
View File
@@ -70,6 +70,18 @@ pub struct DownloadError {
pub attempts: usize,
/// 其它候选源的失败摘要,便于诊断「到底哪一层挂了」。
pub suppressed: Vec<String>,
/// **叶子成因**:整体失败([`DownloadErrorKind::AllSourcesFailed`])之前,各候选源到底
/// 是怎么挂的。
///
/// 为什么需要它:候选源循环结束后只剩一条 `AllSourcesFailed`,上层(在线内容源、
/// 导入补全、自更新)拿到它就无法区分「这个项目不存在(404)」与「没联网」——
/// 前者要提示换关键词,后者要提示检查网络。`suppressed` 里虽然有文字,但从错误串里
/// 反解状态码是靠文案猜,改一句提示就失效。
///
/// 汇总规则([`leaf_of`]):全 404 ⇒ `NotFound`;出现 `Network`/`Timeout` ⇒ `Network`;
/// 全 5xx ⇒ `ServerError`;混合按「网络错 > 5xx > 404」取最可诊断的那个。
/// `None` = 没有汇总过(单源直接失败,或调用方自己构造的错误)。
pub leaf: Option<DownloadErrorKind>,
}
impl DownloadError {
@@ -82,6 +94,7 @@ impl DownloadError {
message: message.into(),
attempts: 0,
suppressed: Vec::new(),
leaf: None,
}
}
@@ -105,6 +118,62 @@ impl DownloadError {
self.suppressed = suppressed;
self
}
/// 标注整体失败之前的叶子成因。
#[must_use]
pub const fn with_leaf(mut self, leaf: DownloadErrorKind) -> Self {
self.leaf = Some(leaf);
self
}
/// 最可诊断的成因:有叶子就用叶子,否则用自身的 kind。
///
/// 上层(`addon::online::transport` / `addon::import::fetch`)靠这一处判定,不各自
/// 再写一遍「AllSourcesFailed 到底算哪一类」。
#[must_use]
pub fn effective_kind(&self) -> DownloadErrorKind {
if self.kind == DownloadErrorKind::AllSourcesFailed {
return self.leaf.unwrap_or(self.kind);
}
self.kind
}
}
/// 从各候选源的失败里汇总出**最可诊断**的叶子成因。
///
/// 规则(与 `online::transport` 的映射表配套,判据只此一处):
///
/// | 各源的情况 | 叶子 |
/// |------------|------|
/// | 全部 404/410/403(`NotFound`) | `NotFound` |
/// | 出现 `Network` / `Timeout` | `Network` |
/// | 全部 5xx(`ServerError`) | `ServerError` |
/// | 其它(`Integrity` / `Io` / `InvalidRequest`) | 该条自己的 kind(后出现的赢) |
/// | 混合了 404 与 5xx | `ServerError`(比「不存在」更可诊断) |
///
/// `kinds` 为空时返回 `AllSourcesFailed`(调用方据此保留原 kind)。
#[must_use]
pub fn leaf_of(kinds: impl IntoIterator<Item = DownloadErrorKind>) -> DownloadErrorKind {
let mut leaf = DownloadErrorKind::AllSourcesFailed;
let mut rank = 0u8;
for kind in kinds {
let (candidate, weight) = match kind {
DownloadErrorKind::Network | DownloadErrorKind::Timeout => {
(DownloadErrorKind::Network, 4)
}
DownloadErrorKind::ServerError => (DownloadErrorKind::ServerError, 3),
DownloadErrorKind::Integrity => (DownloadErrorKind::Integrity, 3),
DownloadErrorKind::Io => (DownloadErrorKind::Io, 3),
DownloadErrorKind::InvalidRequest => (DownloadErrorKind::InvalidRequest, 3),
DownloadErrorKind::NotFound => (DownloadErrorKind::NotFound, 1),
DownloadErrorKind::RangeUnsupported | DownloadErrorKind::AllSourcesFailed => (kind, 2),
};
if weight >= rank {
rank = weight;
leaf = candidate;
}
}
leaf
}
impl fmt::Display for DownloadError {
@@ -123,3 +192,59 @@ impl fmt::Display for DownloadError {
}
impl std::error::Error for DownloadError {}
#[cfg(test)]
mod tests {
use super::{leaf_of, DownloadError, DownloadErrorKind};
/// 四档叶子成因(tower 定案的门禁):全 404 / 有网络错 / 全 5xx / 混合。
#[test]
fn leaf_keeps_the_most_diagnosable_cause() {
use DownloadErrorKind::{Network, NotFound, ServerError, Timeout};
assert_eq!(leaf_of([NotFound, NotFound]), NotFound, "全 404 ⇒ NotFound");
assert_eq!(
leaf_of([Network, NotFound]),
Network,
"有网络错 ⇒ Network(哪怕另一源是 404)"
);
assert_eq!(
leaf_of([ServerError, ServerError]),
ServerError,
"全 5xx ⇒ ServerError"
);
assert_eq!(
leaf_of([NotFound, ServerError]),
ServerError,
"混合 404 与 5xx ⇒ 取更可诊断的那个"
);
assert_eq!(leaf_of([Timeout, NotFound]), Network, "超时归到 Network");
assert_eq!(
leaf_of([]),
DownloadErrorKind::AllSourcesFailed,
"一条都没有 ⇒ 保留原 kind"
);
}
/// 整体失败时上层靠 `effective_kind()` 判定;单源失败时就是它自己的 kind。
#[test]
fn effective_kind_prefers_the_leaf_only_for_all_sources_failed() {
let leaf = DownloadError::new(DownloadErrorKind::AllSourcesFailed, "全部失败")
.with_leaf(DownloadErrorKind::NotFound);
assert_eq!(leaf.effective_kind(), DownloadErrorKind::NotFound);
assert_eq!(
leaf.kind,
DownloadErrorKind::AllSourcesFailed,
"kind 本身不改"
);
let bare = DownloadError::new(DownloadErrorKind::AllSourcesFailed, "全部失败");
assert_eq!(
bare.effective_kind(),
DownloadErrorKind::AllSourcesFailed,
"没有叶子 ⇒ 原样返回(上层再决定当哪一类)"
);
let single = DownloadError::new(DownloadErrorKind::NotFound, "404");
assert_eq!(single.effective_kind(), DownloadErrorKind::NotFound);
}
}