fix(addon): 复审 r2 —— 下载阶段抽出可测边界并补守门
- 阻塞项:下载循环从 `install::run` 抽到新文件 `addon/downloads.rs`
(`download_failure()` + `download_stage()`),假下载器就能驱动它;三条用例分别钉
①失败→Unreachable+叶子成因、②第 2 个失败不中断且已装不回滚、③解析完就取消一份都不下。
实测删掉 `record_failure` 那行会红(failures 0 vs 1),把 `cause` 丢掉也会红;
- nit① 补 core 用例:夹具里 `{"version_id":"vid-only","dependency_type":"required"}` ⇒
`plan.unresolved_required == [UnresolvedRequired{parent:"MIXED001",…}]`,同版本的
`project_id` required 仍被跟;删掉收集那段即红(left: [] vs 一条);
- nit② 取消检查加回解析之后(`download_stage` 入口),并用例钉「解析后取消 ⇒ 不下任何文件」;
- nit③ `fail()` 文档写明去重按**请求键**而非规范 id 的边界(先 slug 后 project_id 仍会两条),
不消掉但不再让人误以为全覆盖;
- `install.rs` 只留解析 + 收口(275 行),模块表登记 `downloads`。
This commit is contained in:
1 parent
aa33210bb6
commit
ff2f229b80
5 files changed
+383
-56
No files matched your search
@@ -196,6 +196,14 @@ pub async fn resolve<T: Transport + ?Sized>(
|
||||
/// 标键这一步是**失败侧的去重网**:成功路径靠 `alias` 短路,失败路径以前直接 `continue`,
|
||||
/// 于是同一个项目被两条路径要求时会各取一次版本、各记一条失败,重试名单里出现两次。
|
||||
/// 标记之后,后续同键请求在①处短路,走现成的 [`fold_parents`] 把父边折进那条失败。
|
||||
///
|
||||
/// # 这道去重按**请求键**,不是规范 id(已知边界,别当成全覆盖)
|
||||
///
|
||||
/// 规范 id(`project_id`)要拿到响应才知道;失败时没有响应,能用的只有请求键。所以同一个
|
||||
/// 失败项目如果**先以 slug、后以 project_id** 被两个不同的父项目要求,仍会被取两次、名单里
|
||||
/// 出现两个名字。真实载荷里 `dependencies[].project_id` 与用户点的 slug 混用的情况没有证据,
|
||||
/// 且这一档本来就是「取不到」,重复一条的代价是名单多一行、不是多装一个东西——故本轮不消掉,
|
||||
/// 只把边界写在这里(复审 r2 nit③)。
|
||||
fn fail(
|
||||
plan: &mut DependencyPlan,
|
||||
alias: &mut BTreeMap<String, String>,
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
use crate::addon::online::dependency::{present_key, DependencyPlan, ResolveRequest};
|
||||
use crate::addon::online::model::parse_versions;
|
||||
use crate::addon::online::{resolve, DependencyType, Modrinth, PresentFile};
|
||||
use crate::addon::online::{resolve, DependencyType, Modrinth, PresentFile, UnresolvedRequired};
|
||||
use crate::download::blocking::block_on;
|
||||
|
||||
use super::fixtures_deps as deps;
|
||||
@@ -187,3 +187,46 @@ fn required_project_ids_are_sorted_and_deduplicated_on_their_own() {
|
||||
"既要去重(非相邻)也要排序"
|
||||
);
|
||||
}
|
||||
|
||||
/// 只有 `version_id` 的必备前置:**core 自己**要把它们收进 `unresolved_required`(r2 nit①)。
|
||||
///
|
||||
/// presenter 那条用例是直接赋值 `plan.unresolved_required`,绕过了 core;这里从夹具走一遍解析,
|
||||
/// 顺带钉住「别为了收集把正常的 required 也吞掉」——同一版本里带 `project_id` 的那条仍要被跟。
|
||||
#[test]
|
||||
fn a_required_dependency_with_only_a_version_id_is_collected_by_the_resolver() {
|
||||
let body = r#"[{"id": "ver-mixed", "project_id": "MIXED001", "version_number": "1.0",
|
||||
"files": [{"url": "https://cdn.modrinth.com/data/MIXED001/1.0/mixed.jar",
|
||||
"filename": "mixed.jar", "size": 16, "primary": true}],
|
||||
"dependencies": [
|
||||
{"version_id": "vid-only", "dependency_type": "required"},
|
||||
{"project_id": "FOLLOW01", "dependency_type": "required"}
|
||||
]}]"#;
|
||||
let transport = FixtureTransport::new()
|
||||
.with(&format!("{API}/mixed/version"), body)
|
||||
.with(
|
||||
&format!("{API}/FOLLOW01/version"),
|
||||
&version_json("FOLLOW01", "follow.jar", &[]),
|
||||
);
|
||||
|
||||
let plan = resolve_with(&transport, &request("mixed"));
|
||||
|
||||
assert_eq!(
|
||||
plan.unresolved_required,
|
||||
[UnresolvedRequired {
|
||||
parent: "MIXED001".to_string(),
|
||||
version_id: Some("vid-only".to_string()),
|
||||
file_name: None,
|
||||
}],
|
||||
"只给版本 id 的 required 必须被记下来"
|
||||
);
|
||||
let projects: Vec<&str> = plan
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|node| node.project.as_str())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
projects,
|
||||
["MIXED001", "FOLLOW01"],
|
||||
"同一版本里带 project_id 的 required 仍要被跟"
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,7 @@
|
||||
//! | [`sources`] | 两个站点的 JSON 方言 → [`crate::dto::ContentHit`] |
|
||||
//! | `http` | 取文本:无 header 走引擎、带 header 走 client + 退避、百分号编码 |
|
||||
//! | [`install`] | 一键装入:解析文件(含 `required` 前置)→ 下载到实例目录 → 进度回流 |
|
||||
//! | [`downloads`] | 下载阶段(可测):逐个下、失败进名单不中断、两处取消点 |
|
||||
//! | [`plan`] | 依赖计划的宿主侧:扫目录、两档失败 → DTO 大类、人读文案 |
|
||||
//!
|
||||
//! ## 整合包与 CF 的边界(都写进报告)
|
||||
@@ -33,6 +34,7 @@
|
||||
//! host 侧再挡一次(就算绕过 UI 发指令也只回一条 `Failed`)。真流程是 M111 的导入管线。
|
||||
//! - CurseForge 本轮只做**检索**(需要用户配 `ULL_CF_API_KEY`),文件解析未接通。
|
||||
|
||||
mod downloads;
|
||||
mod http;
|
||||
mod install;
|
||||
mod plan;
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
//! 下载阶段的**可测那一段**:把计划里的文件逐个下下来,失败进名单而不中断。
|
||||
//!
|
||||
//! # 为什么单独一个文件
|
||||
//!
|
||||
//! 这段逻辑以前整块躺在 `install::run` 里,而 `run` 需要实例目录 + 真引擎 ⇒ 没有用例能驱动它,
|
||||
//! 于是「下载失败记进两档名单」这条改动**整段删掉也不会红一条**(M186 r2 复审实测:
|
||||
//! 整段包进 `if false` 后 `cargo test -p unlucky-presenter --lib host::addon` 仍 36 passed)。
|
||||
//! 抽成只依赖 [`ArtifactFetcher`] 的函数之后,假下载器就能驱动它。
|
||||
//!
|
||||
//! # 三条不变量(每条都有用例)
|
||||
//!
|
||||
//! 1. **失败不中断**:某个文件下载失败 ⇒ 记进 [`DependencyPlan::record_failure`],继续下一个;
|
||||
//! 2. **已装的不回滚**:成功的文件留在盘上,并出现在返回的「已装」名单里;
|
||||
//! 3. **两处取消点**:进入本阶段先查一次(`run` 解析完就调它 ⇒ 解析阶段的取消生效,
|
||||
//! 全部文件都「已存在」而 `nodes` 为空时也不会漏掉取消),循环每次迭代再查一次。
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use unlucky_core::addon::online::{
|
||||
download_verified, ArtifactFetcher, DependencyFailure, DependencyPlan, FailureKind,
|
||||
OnlineError, PlannedNode,
|
||||
};
|
||||
use unlucky_core::download::blocking::block_on;
|
||||
|
||||
use crate::dto::{ContentFailure, InstallProgressView};
|
||||
|
||||
use super::super::job::{HostTask, JobSender};
|
||||
use super::install::{local, node_label, safe_file_name};
|
||||
|
||||
/// 下载失败 → 两档失败里的一条(`Unreachable` + 叶子成因 `cause`)。
|
||||
///
|
||||
/// 为什么归 `Unreachable`:`download_verified` 的失败就是「取不到这份字节」——没网 / 站点错 /
|
||||
/// 内容与声明不符 / 本地 IO,叶子成因留在 `cause` 上,上层照 `sources::failure_of` 映射成
|
||||
/// DTO 大类;**不是** `Incompatible`(换版本/加载器也救不了一次断网)。
|
||||
#[must_use]
|
||||
pub(super) fn download_failure(node: &PlannedNode, error: &OnlineError) -> DependencyFailure {
|
||||
DependencyFailure {
|
||||
requested_as: node.requested_as.clone(),
|
||||
required_by: node.required_by.clone(),
|
||||
depth: node.depth,
|
||||
kind: FailureKind::Unreachable,
|
||||
cause: Some(error.kind()),
|
||||
message: format!(
|
||||
"下载 {} 失败({}):{}",
|
||||
node.file.filename,
|
||||
error.kind().as_str(),
|
||||
error.message()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// 下载阶段:先查一次取消,再逐个下(失败进名单、不中断)。
|
||||
///
|
||||
/// 返回**真落盘**的节点(失败的不在里面),调用方据此拼「装了 N 个」。
|
||||
///
|
||||
/// # Errors
|
||||
/// 用户取消时返回一条 `ContentFailure`(detail 以「已取消」开头,`install()` 据此画 `Cancelled`)。
|
||||
pub(super) fn download_stage(
|
||||
plan: &mut DependencyPlan,
|
||||
target_dir: &Path,
|
||||
fetcher: &dyn ArtifactFetcher,
|
||||
cancel: &AtomicBool,
|
||||
progress: &mut InstallProgressView,
|
||||
tx: &JobSender,
|
||||
epoch: u64,
|
||||
) -> Result<Vec<PlannedNode>, ContentFailure> {
|
||||
// 解析完立刻查一次:解析最多要跑 6 层版本请求,此时取消不该先下一份文件再说
|
||||
// (main 的 `resolve_file` 之后就有这条检查,r2 复审指出我们把它丢了)。
|
||||
if cancel.load(Ordering::SeqCst) {
|
||||
return Err(local("已取消(下载开始前)"));
|
||||
}
|
||||
let mut installed: Vec<PlannedNode> = Vec::new();
|
||||
for index in 0..plan.nodes.len() {
|
||||
if cancel.load(Ordering::SeqCst) {
|
||||
return Err(local("已取消(下载中途)"));
|
||||
}
|
||||
let node = plan.nodes[index].clone();
|
||||
progress.message = node_label(&node);
|
||||
let _ = tx.send(HostTask::Install {
|
||||
progress: Box::new(progress.clone()),
|
||||
epoch,
|
||||
});
|
||||
// M162:走 `online::download_verified`——sha1 交给引擎流式校验、sha512 落盘后复核,
|
||||
// 失败重试且不留半截文件(内容库安装与整合包导入用**同一段**校验,不各写一份)。
|
||||
let dest = target_dir.join(safe_file_name(&node.file.filename));
|
||||
let outcome = block_on(download_verified(
|
||||
fetcher,
|
||||
&node.file.url,
|
||||
node.file.sha1(),
|
||||
node.file.sha512(),
|
||||
(node.file.size > 0).then_some(node.file.size),
|
||||
&dest,
|
||||
));
|
||||
match outcome {
|
||||
Ok(_) => installed.push(node),
|
||||
Err(error) => {
|
||||
// 下载阶段的失败**也记进同一套两档结构**(Unreachable + 叶子成因):
|
||||
// 已下好的不回滚、失败项进「可只重试」,整份计划的上下文(缺的是谁的前置)不丢。
|
||||
plan.record_failure(download_failure(&node, &error));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
progress.files_done += 1;
|
||||
let _ = tx.send(HostTask::Install {
|
||||
progress: Box::new(progress.clone()),
|
||||
epoch,
|
||||
});
|
||||
}
|
||||
Ok(installed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use unlucky_core::addon::online::{
|
||||
ArtifactFetcher, DependencyPlan, FailureKind, FetchArgs, OnlineError, OnlineErrorKind,
|
||||
OnlineFuture, PlannedNode, ResolveRequest, VersionFile,
|
||||
};
|
||||
|
||||
use crate::dto::InstallProgressView;
|
||||
|
||||
use super::{download_failure, download_stage};
|
||||
|
||||
/// 假下载器:`fail_on` 里的文件名一律「没联网」,其余写一行字节(**不校验摘要**)。
|
||||
struct FakeFetcher {
|
||||
fail_on: Vec<&'static str>,
|
||||
attempts: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl FakeFetcher {
|
||||
fn new(fail_on: &[&'static str]) -> Self {
|
||||
Self {
|
||||
fail_on: fail_on.to_vec(),
|
||||
attempts: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn attempts(&self) -> Vec<String> {
|
||||
self.attempts.lock().expect("锁没中毒").clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl ArtifactFetcher for FakeFetcher {
|
||||
fn fetch<'a>(&'a self, args: FetchArgs<'a>) -> OnlineFuture<'a, Result<u64, OnlineError>> {
|
||||
Box::pin(async move {
|
||||
let name = args
|
||||
.dest
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
self.attempts.lock().expect("锁没中毒").push(name.clone());
|
||||
if self.fail_on.contains(&name.as_str()) {
|
||||
return Err(OnlineError::offline("假装的断网"));
|
||||
}
|
||||
std::fs::write(args.dest, b"jar").map_err(|error| {
|
||||
OnlineError::new(OnlineErrorKind::Io, format!("写盘失败:{error}"))
|
||||
})?;
|
||||
Ok(4)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn node(project: &str, file: &str, required_by: &[&str]) -> PlannedNode {
|
||||
PlannedNode {
|
||||
project: project.to_string(),
|
||||
requested_as: project.to_string(),
|
||||
version_id: format!("ver-{project}"),
|
||||
version_name: project.to_string(),
|
||||
version_number: "1.0".to_string(),
|
||||
file: VersionFile {
|
||||
url: format!("https://cdn.modrinth.com/data/{project}/1.0/{file}"),
|
||||
filename: file.to_string(),
|
||||
size: 0,
|
||||
..VersionFile::default()
|
||||
},
|
||||
depth: u8::from(!required_by.is_empty()),
|
||||
required_by: required_by
|
||||
.iter()
|
||||
.map(|parent| (*parent).to_string())
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn plan(nodes: Vec<PlannedNode>) -> DependencyPlan {
|
||||
DependencyPlan {
|
||||
request: ResolveRequest::single("AANobbMI"),
|
||||
nodes,
|
||||
skipped: Vec::new(),
|
||||
failures: Vec::new(),
|
||||
truncated: Vec::new(),
|
||||
unresolved_required: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn temp_dir(tag: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("ull-m186-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("建临时目录");
|
||||
dir
|
||||
}
|
||||
|
||||
/// 失败的那一条**进名单**、父边与叶子成因都带着(守的是 [`download_failure`] 的映射)。
|
||||
#[test]
|
||||
fn a_download_error_becomes_an_unreachable_failure_with_its_leaf_cause() {
|
||||
let node = node("P7dR8mSH", "fabric-api.jar", &["AANobbMI"]);
|
||||
let failure = download_failure(&node, &OnlineError::offline("连不上 cdn.modrinth.com"));
|
||||
|
||||
assert_eq!(failure.kind, FailureKind::Unreachable);
|
||||
assert_eq!(failure.cause, Some(OnlineErrorKind::NotConnected));
|
||||
assert_eq!(failure.requested_as, "P7dR8mSH");
|
||||
assert_eq!(failure.required_by, ["AANobbMI".to_string()]);
|
||||
assert_eq!(failure.depth, 1);
|
||||
assert!(
|
||||
failure.message.contains("fabric-api.jar"),
|
||||
"{}",
|
||||
failure.message
|
||||
);
|
||||
assert!(
|
||||
failure.message.contains("online_not_connected"),
|
||||
"成因要带 code:{}",
|
||||
failure.message
|
||||
);
|
||||
}
|
||||
|
||||
/// 第 2 个文件失败:**不中断**、已装的不回滚、失败进名单、第 3 个仍会被尝试。
|
||||
#[test]
|
||||
fn a_failed_file_does_not_stop_the_rest_and_keeps_what_already_landed() {
|
||||
let dir = temp_dir("download-partial");
|
||||
let mut plan = plan(vec![
|
||||
node("AANobbMI", "sodium.jar", &[]),
|
||||
node("P7dR8mSH", "fabric-api.jar", &["AANobbMI"]),
|
||||
node("1eAoo2KR", "yacl.jar", &["P7dR8mSH"]),
|
||||
]);
|
||||
let fetcher = FakeFetcher::new(&["fabric-api.jar"]);
|
||||
let (tx, _rx) = std::sync::mpsc::channel();
|
||||
let mut progress = InstallProgressView {
|
||||
files_total: 3,
|
||||
..InstallProgressView::new()
|
||||
};
|
||||
|
||||
let installed = download_stage(
|
||||
&mut plan,
|
||||
&dir,
|
||||
&fetcher,
|
||||
&AtomicBool::new(false),
|
||||
&mut progress,
|
||||
&tx,
|
||||
7,
|
||||
)
|
||||
.expect("没取消就不该回 Err");
|
||||
|
||||
let names: Vec<&str> = installed
|
||||
.iter()
|
||||
.map(|node| node.file.filename.as_str())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
["sodium.jar", "yacl.jar"],
|
||||
"成功的不回滚、后面的照下"
|
||||
);
|
||||
// 失败的那个会被 `download_verified` 重试 `RETRY_ATTEMPTS` 次(这里 3 次),
|
||||
// 关键是**它之后**还会去试第 3 个文件。
|
||||
let attempts = fetcher.attempts();
|
||||
assert_eq!(attempts.first().map(String::as_str), Some("sodium.jar"));
|
||||
assert_eq!(attempts.last().map(String::as_str), Some("yacl.jar"));
|
||||
assert_eq!(
|
||||
attempts
|
||||
.iter()
|
||||
.filter(|name| *name == "fabric-api.jar")
|
||||
.count(),
|
||||
3,
|
||||
"失败的尝试 + 重试都发生在第 3 个文件之前:{attempts:?}"
|
||||
);
|
||||
assert_eq!(plan.failures.len(), 1, "{:?}", plan.failures);
|
||||
assert_eq!(plan.failures[0].requested_as, "P7dR8mSH");
|
||||
assert!(plan.failures[0].message.contains("fabric-api.jar"));
|
||||
assert_eq!(progress.files_done, 2, "进度只数真落盘的");
|
||||
assert!(dir.join("sodium.jar").exists(), "已下好的文件不许删");
|
||||
assert!(!dir.join("fabric-api.jar").exists());
|
||||
assert!(dir.join("yacl.jar").exists());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// 解析完就取消 ⇒ 一条文件都不下(r2 nit②:main 有这条检查,我们曾把它丢了)。
|
||||
#[test]
|
||||
fn cancelling_right_after_resolution_touches_nothing() {
|
||||
let dir = temp_dir("download-cancelled");
|
||||
let mut plan = plan(vec![
|
||||
node("AANobbMI", "sodium.jar", &[]),
|
||||
node("P7dR8mSH", "fabric-api.jar", &["AANobbMI"]),
|
||||
]);
|
||||
let fetcher = FakeFetcher::new(&[]);
|
||||
let (tx, _rx) = std::sync::mpsc::channel();
|
||||
let mut progress = InstallProgressView::new();
|
||||
|
||||
let error = download_stage(
|
||||
&mut plan,
|
||||
&dir,
|
||||
&fetcher,
|
||||
&AtomicBool::new(true),
|
||||
&mut progress,
|
||||
&tx,
|
||||
7,
|
||||
)
|
||||
.expect_err("取消必须回 Err");
|
||||
|
||||
assert!(error.detail.contains("已取消"), "{}", error.detail);
|
||||
assert!(fetcher.attempts().is_empty(), "一份都不许下");
|
||||
assert!(plan.failures.is_empty(), "取消不是失败,不进名单");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -44,8 +44,7 @@ use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use unlucky_core::addon::online::{
|
||||
download_verified, resolve, DependencyFailure, EngineArtifactFetcher, EngineTransport,
|
||||
FailureKind, Modrinth, PlannedNode,
|
||||
resolve, EngineArtifactFetcher, EngineTransport, Modrinth, PlannedNode,
|
||||
};
|
||||
use unlucky_core::download::blocking::block_on;
|
||||
use unlucky_core::game::install::instance::read_instance_config;
|
||||
@@ -58,7 +57,7 @@ use crate::dto::{
|
||||
|
||||
use super::super::env::HostEnv;
|
||||
use super::super::job::{HostTask, JobSender};
|
||||
use super::plan;
|
||||
use super::{downloads, plan};
|
||||
|
||||
/// 跑一次装入(同步;由 [`super::spawn_install`] 放进后台线程)。
|
||||
pub(super) fn install(
|
||||
@@ -194,56 +193,16 @@ fn run(
|
||||
});
|
||||
|
||||
let fetcher = EngineArtifactFetcher::new(&core_engine);
|
||||
let mut installed: Vec<PlannedNode> = Vec::new();
|
||||
for index in 0..dependency_plan.nodes.len() {
|
||||
if cancel.load(Ordering::SeqCst) {
|
||||
return Err(local("已取消(下载中途)"));
|
||||
}
|
||||
let node = dependency_plan.nodes[index].clone();
|
||||
progress.message = node_label(&node);
|
||||
let _ = tx.send(HostTask::Install {
|
||||
progress: Box::new(progress.clone()),
|
||||
epoch,
|
||||
});
|
||||
// M162:走 `online::download_verified`——sha1 交给引擎流式校验、sha512 落盘后复核,
|
||||
// 失败重试且不留半截文件(内容库安装与整合包导入用**同一段**校验,不各写一份)。
|
||||
let dest = target_dir.join(safe_file_name(&node.file.filename));
|
||||
let outcome = block_on(download_verified(
|
||||
&fetcher,
|
||||
&node.file.url,
|
||||
node.file.sha1(),
|
||||
node.file.sha512(),
|
||||
(node.file.size > 0).then_some(node.file.size),
|
||||
&dest,
|
||||
));
|
||||
match outcome {
|
||||
Ok(_) => installed.push(node),
|
||||
Err(error) => {
|
||||
// 下载阶段的失败**也记进同一套两档结构**(Unreachable + 叶子成因):
|
||||
// 已下好的不回滚、失败项进「可只重试」,整份计划的上下文(缺的是谁的前置)不丢。
|
||||
// 以前这里 `.map_err(...)?` 直接中断,用户看不到「主文件已装、缺 fabric-api」。
|
||||
dependency_plan.record_failure(DependencyFailure {
|
||||
requested_as: node.requested_as.clone(),
|
||||
required_by: node.required_by.clone(),
|
||||
depth: node.depth,
|
||||
kind: FailureKind::Unreachable,
|
||||
cause: Some(error.kind()),
|
||||
message: format!(
|
||||
"下载 {} 失败({}):{}",
|
||||
node.file.filename,
|
||||
error.kind().as_str(),
|
||||
error.message()
|
||||
),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
progress.files_done += 1;
|
||||
let _ = tx.send(HostTask::Install {
|
||||
progress: Box::new(progress.clone()),
|
||||
epoch,
|
||||
});
|
||||
}
|
||||
// 下载阶段在 `downloads.rs`:可测(假下载器能驱动),失败进名单而不中断。
|
||||
let installed = downloads::download_stage(
|
||||
&mut dependency_plan,
|
||||
&target_dir,
|
||||
&fetcher,
|
||||
cancel,
|
||||
progress,
|
||||
tx,
|
||||
epoch,
|
||||
)?;
|
||||
|
||||
// 解析与下载两段的失败在这里一起收口:两档分成两句话(不合并成「下载失败」),
|
||||
// 并列出「可只重试这 N 个」。
|
||||
@@ -263,7 +222,7 @@ pub(super) fn finish(progress: &mut InstallProgressView) {
|
||||
}
|
||||
|
||||
/// 进度面板上的那一行文件名(前置标出来,用户才知道多出来的文件是什么)。
|
||||
fn node_label(node: &PlannedNode) -> String {
|
||||
pub(super) fn node_label(node: &PlannedNode) -> String {
|
||||
if node.is_root() {
|
||||
node.file.filename.clone()
|
||||
} else {
|
||||
@@ -272,7 +231,7 @@ fn node_label(node: &PlannedNode) -> String {
|
||||
}
|
||||
|
||||
/// 本地失败(找实例 / 建目录 / 取消)——不是网络问题,归到 `Other`。
|
||||
fn local(reason: impl Into<String>) -> ContentFailure {
|
||||
pub(super) fn local(reason: impl Into<String>) -> ContentFailure {
|
||||
ContentFailure::new(ContentFailureKind::Other, reason)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user