From ff2f229b802175b65d1bcc30a26608d95e70bdff Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 14:26:50 +0800 Subject: [PATCH] =?UTF-8?q?fix(addon):=20=E5=A4=8D=E5=AE=A1=20r2=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E4=B8=8B=E8=BD=BD=E9=98=B6=E6=AE=B5?= =?UTF-8?q?=E6=8A=BD=E5=87=BA=E5=8F=AF=E6=B5=8B=E8=BE=B9=E7=95=8C=E5=B9=B6?= =?UTF-8?q?=E8=A1=A5=E5=AE=88=E9=97=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 阻塞项:下载循环从 `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`。 --- crates/core/src/addon/online/resolve.rs | 8 + .../src/addon/online/tests/regressions.rs | 45 ++- crates/presenter/src/host/addon.rs | 2 + crates/presenter/src/host/addon/downloads.rs | 315 ++++++++++++++++++ crates/presenter/src/host/addon/install.rs | 69 +--- 5 files changed, 383 insertions(+), 56 deletions(-) create mode 100644 crates/presenter/src/host/addon/downloads.rs diff --git a/crates/core/src/addon/online/resolve.rs b/crates/core/src/addon/online/resolve.rs index 30a0e9d..16966c7 100644 --- a/crates/core/src/addon/online/resolve.rs +++ b/crates/core/src/addon/online/resolve.rs @@ -196,6 +196,14 @@ pub async fn resolve( /// 标键这一步是**失败侧的去重网**:成功路径靠 `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, diff --git a/crates/core/src/addon/online/tests/regressions.rs b/crates/core/src/addon/online/tests/regressions.rs index 00b5419..605c2fc 100644 --- a/crates/core/src/addon/online/tests/regressions.rs +++ b/crates/core/src/addon/online/tests/regressions.rs @@ -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 仍要被跟" + ); +} diff --git a/crates/presenter/src/host/addon.rs b/crates/presenter/src/host/addon.rs index 24246ab..92e6073 100644 --- a/crates/presenter/src/host/addon.rs +++ b/crates/presenter/src/host/addon.rs @@ -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; diff --git a/crates/presenter/src/host/addon/downloads.rs b/crates/presenter/src/host/addon/downloads.rs new file mode 100644 index 0000000..3d91061 --- /dev/null +++ b/crates/presenter/src/host/addon/downloads.rs @@ -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, ContentFailure> { + // 解析完立刻查一次:解析最多要跑 6 层版本请求,此时取消不该先下一份文件再说 + // (main 的 `resolve_file` 之后就有这条检查,r2 复审指出我们把它丢了)。 + if cancel.load(Ordering::SeqCst) { + return Err(local("已取消(下载开始前)")); + } + let mut installed: Vec = 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>, + } + + 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 { + self.attempts.lock().expect("锁没中毒").clone() + } + } + + impl ArtifactFetcher for FakeFetcher { + fn fetch<'a>(&'a self, args: FetchArgs<'a>) -> OnlineFuture<'a, Result> { + 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) -> 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); + } +} diff --git a/crates/presenter/src/host/addon/install.rs b/crates/presenter/src/host/addon/install.rs index d549611..b3d2a4b 100644 --- a/crates/presenter/src/host/addon/install.rs +++ b/crates/presenter/src/host/addon/install.rs @@ -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 = 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) -> ContentFailure { +pub(super) fn local(reason: impl Into) -> ContentFailure { ContentFailure::new(ContentFailureKind::Other, reason) }