feat(m195): 导入管线取消令牌与原子回滚(取消不是失败)

M190 复核 §3 A-2:导入管线没有取消令牌,「取消导入」只改界面状态,后台仍跑完并写盘
(半包 / 覆盖风险,丢数据档)。本轮把取消做成管线级:

- core 新增 `import/cancel.rs`:`ImportCancel` 令牌(可 `from_flag` 接管既有
  `Arc<AtomicBool>`)+ 内部 `Stopped::{Cancelled, Failed}` 两条停止路线;
- 结果类型单列 `ImportStatus::Cancelled`(`error` 恒为 None、退出码 130、文案 key 独立),
  与 `Failed` 是两条路——UI 不弹红、脚本不会把「改主意」记成「装坏了」;
- 七个检查点:入口 → 解析后 → 补全后 → 开暂存目录前 → 提交前 → 合并态逐文件 → 登记前;
  解压 / 远端下载各逐条检查;单条下载在飞不可中断(fetcher 无取消参数)已在文档里说准;
- 取消后不留半包 / 不覆盖已有实例:暂存目录整个删、合并态 journal 逆序回滚(备份改回原名)、
  落盘后取消连内容一起回滚;`files / conflicts / missing / bytes_written` 清零,不留假账;
- presenter:同一个取消标记现在真交给 core(`with_cancel`),取消后不再「后台跑完并登记」;
  CLI 增 `cancelled → 130` 一档与三档人读名。

守门三条各带变异证明(见 mission 报告):删回滚 → 红、删登记前检查 → 红、把取消并进
`fail()` → 红。

没验:超大整合包的真实 IO 下取消响应延迟(最坏 = 当前一条文件读完/下完 + 一次回滚)、
单条下载在飞不可中断、macOS / Linux 未跑。
This commit is contained in:
AskaEth committed 2026-11-09 05:06:52 +08:00
1 parent 3116f43c9f
commit 1441a240fe
18 files changed
+1135 -180

No files matched your search

+14
View File
@@ -11,6 +11,11 @@
//! | `success` | `0` | 全部落地、无冲突 | //! | `success` | `0` | 全部落地、无冲突 |
//! | `partial` | `3` | 有缺文件或有保留用户文件的冲突(实例可用但不完整) | //! | `partial` | `3` | 有缺文件或有保留用户文件的冲突(实例可用但不完整) |
//! | `failed` | `1` | 致命失败,**已整包回滚** | //! | `failed` | `1` | 致命失败,**已整包回滚** |
//! | `cancelled` | `130` | 用户取消(管线在检查点停手并回滚,**不是失败**) |
//!
//! `130`(= `128 + SIGINT`)是"被用户中断"的惯例码:CLI 这一轮没有取消入口(一次性命令,
//! Ctrl-C 直接杀进程),但 `ImportStatus::Cancelled` 是 core 的公开结果,脚本读 `--json` 时
//! 必须能把它与 `failed` 分开——所以退出码与文案在这里就位,接中断信号是下一步的事。
//! //!
//! `--json` 的信封在三种状态下**都是** `{"ok":true,"data":{…}}`(命令跑完了),分流看 //! `--json` 的信封在三种状态下**都是** `{"ok":true,"data":{…}}`(命令跑完了),分流看
//! `data.status` / `data.error.code` 或退出码;`ok:false` 只用于"命令没跑起来"(参数错、 //! `data.status` / `data.error.code` 或退出码;`ok:false` 只用于"命令没跑起来"(参数错、
@@ -208,6 +213,15 @@ fn status_label(report: &ImportReport) -> &'static str {
Some(RollbackStage::Incomplete) => "失败(回滚不完整)", Some(RollbackStage::Incomplete) => "失败(回滚不完整)",
None => match report.status { None => match report.status {
ImportStatus::Success => "成功", ImportStatus::Success => "成功",
// 取消**不是失败**:不说"失败",也不说"部分成功"(那会让人以为装了一半)。
// 但回滚不完整时也不许说"与导入前一致"——与失败路径同一套诚实口径。
ImportStatus::Cancelled => {
if report.rolled_back {
"已取消(目标目录与导入前一致)"
} else {
"已取消(回滚不完整,目标目录可能与导入前不同)"
}
}
_ => "部分成功", _ => "部分成功",
}, },
} }
+39 -3
View File
@@ -29,6 +29,14 @@
//! | 两阶段补全 + **失败分级**(仅"缺文件"可容忍) | [`pipeline`] 的顺序 + [`report::MissingFile`] 与 [`error::ImportError`] 的分工 | //! | 两阶段补全 + **失败分级**(仅"缺文件"可容忍) | [`pipeline`] 的顺序 + [`report::MissingFile`] 与 [`error::ImportError`] 的分工 |
//! | 路径穿越**双检** | 开包时 M110 [`crate::addon::pack::PackArchive::open`],落地时 [`stage`] 的 `normalize_file` + `starts_with` 复核 | //! | 路径穿越**双检** | 开包时 M110 [`crate::addon::pack::PackArchive::open`],落地时 [`stage`] 的 `normalize_file` + `starts_with` 复核 |
//! //!
//! # 取消(M195):每条停下路线都回滚,且**取消不是失败**
//!
//! 用户点「取消」之后管线真的停手:每个阶段边界与长循环内都有检查点,取消后目标目录与导入前
//! 一致(先写临时目录、最后 `rename` / journal 合并),**不登记实例**。结果类型单列一档
//! [`ImportStatus::Cancelled`](`error` 为空、CLI 退出码 `130`、UI 不弹红),与
//! [`ImportStatus::Failed`] 是两条路。逐段检查点表(含"哪几段没有检查点、为什么")在
//! [`cancel`] 模块文档里。
//!
//! # 三种格式的语义(tower 定案,与 HMCL 相反的两条要记住) //! # 三种格式的语义(tower 定案,与 HMCL 相反的两条要记住)
//! //!
//! - **mrpack**:`overrides/` 与 `client-overrides/` 都解压,**后者胜**(Modrinth 官方客户端语义; //! - **mrpack**:`overrides/` 与 `client-overrides/` 都解压,**后者胜**(Modrinth 官方客户端语义;
@@ -55,11 +63,13 @@
//! ```ignore //! ```ignore
//! let engine = /* core::download::DownloadEngine */; //! let engine = /* core::download::DownloadEngine */;
//! let fetcher = DownloadEngineFetcher::new(&engine); //! let fetcher = DownloadEngineFetcher::new(&engine);
//! let cancel = ImportCancel::new(); // 交给「取消」按钮:cancel.cancel()
//! let request = ImportRequest::new(pack_path, instance_dir) //! let request = ImportRequest::new(pack_path, instance_dir)
//! .with_fetcher(&fetcher) //! .with_fetcher(&fetcher)
//! .with_cancel(&cancel)
//! .with_registration(&launcher_layout, None); //! .with_registration(&launcher_layout, None);
//! let report = download::blocking::block_on(import_pack(&request)); //! let report = download::blocking::block_on(import_pack(&request));
//! match report.status { /* Success / Partial / Failed */ } //! match report.status { /* Success / Partial / Failed / Cancelled */ }
//! ``` //! ```
//! //!
//! # 刻意不做的事(分工边界) //! # 刻意不做的事(分工边界)
@@ -76,8 +86,13 @@
//! //!
//! 真实 CF / MultiMC 导出物(夹具是手造的)、超大包(>2G)、Windows 之外的平台、断网中途、 //! 真实 CF / MultiMC 导出物(夹具是手造的)、超大包(>2G)、Windows 之外的平台、断网中途、
//! 磁盘满、真实 CurseForge `files[]` 补全(缺 CF 客户端,见 [`fetch`] 的说明)。 //! 磁盘满、真实 CurseForge `files[]` 补全(缺 CF 客户端,见 [`fetch`] 的说明)。
//!
//! 取消这一条另有三个欠账(M195 报告同样登记):①超大整合包(几千文件 / 数 GB)在真实磁盘
//! 上的**取消响应延迟**没测——最坏要等"当前这一条文件读完/下完 + 一次回滚";②**单条下载在飞
//! 不可中断**([`fetch::PackFetcher`] 没有取消参数),只能等它返回;③macOS / Linux 未验。
pub mod baseline; pub mod baseline;
pub mod cancel;
pub(crate) mod complete; pub(crate) mod complete;
pub mod decision; pub mod decision;
pub mod error; pub mod error;
@@ -86,11 +101,13 @@ pub mod merge;
pub mod phase; pub mod phase;
pub mod pipeline; pub mod pipeline;
pub mod plan; pub mod plan;
pub(crate) mod prepare;
pub mod report; pub mod report;
pub mod scan; pub mod scan;
pub mod stage; pub mod stage;
pub use baseline::{PackBaseline, BASELINE_FILE}; pub use baseline::{PackBaseline, BASELINE_FILE};
pub use cancel::ImportCancel;
pub use decision::ConflictDecisions; pub use decision::ConflictDecisions;
pub use error::{ImportError, ImportErrorKind}; pub use error::{ImportError, ImportErrorKind};
pub use fetch::{ pub use fetch::{
@@ -98,8 +115,8 @@ pub use fetch::{
FetchedFile, ModrinthFileMeta, PackFetcher, FetchedFile, ModrinthFileMeta, PackFetcher,
}; };
pub use merge::{MergeAction, MergeDecision}; pub use merge::{MergeAction, MergeDecision};
pub use pipeline::baseline_relative;
pub use plan::{ImportPlan, PlanOverride, PlanRemote, Shadowed, SuffixVariant}; pub use plan::{ImportPlan, PlanOverride, PlanRemote, Shadowed, SuffixVariant};
pub use prepare::baseline_relative;
pub use report::{ pub use report::{
keys, ConflictChoice, ConflictFile, ConflictReason, FileAction, FileSource, ImportIssue, keys, ConflictChoice, ConflictFile, ConflictReason, FileAction, FileSource, ImportIssue,
ImportProgress, ImportProgressSink, ImportReport, ImportStage, ImportStatus, ImportedFile, ImportProgress, ImportProgressSink, ImportReport, ImportStage, ImportStatus, ImportedFile,
@@ -156,6 +173,11 @@ pub struct ImportRequest<'a> {
pub instance_name: Option<&'a str>, pub instance_name: Option<&'a str>,
/// 逐文件冲突决策(含整批缺省档,**缺省 = 全部保留**)。 /// 逐文件冲突决策(含整批缺省档,**缺省 = 全部保留**)。
pub decisions: ConflictDecisions, pub decisions: ConflictDecisions,
/// 取消令牌;缺省是永不取消的独立令牌(没挂令牌的调用方不必写 `Option`)。
///
/// 语义与检查点位置见 [`cancel`] 模块文档:取消后**不得**留下半包 / 覆盖已有实例,也不许
/// 登记实例;判据是报告里的 [`report::ImportStatus::Cancelled`](**不是**失败)。
pub cancel: ImportCancel,
/// 包读取限额(条数 / 单条体积 / 解压总量)。 /// 包读取限额(条数 / 单条体积 / 解压总量)。
pub limits: PackLimits, pub limits: PackLimits,
/// 进度回调。 /// 进度回调。
@@ -173,6 +195,7 @@ impl<'a> ImportRequest<'a> {
register: None, register: None,
instance_name: None, instance_name: None,
decisions: ConflictDecisions::default(), decisions: ConflictDecisions::default(),
cancel: ImportCancel::default(),
limits: PackLimits::default(), limits: PackLimits::default(),
progress: None, progress: None,
} }
@@ -227,9 +250,22 @@ impl<'a> ImportRequest<'a> {
self.progress = Some(sink); self.progress = Some(sink);
self self
} }
/// 挂取消令牌(与调用方手里的那份**共享同一个开关**)。
///
/// 取消的结果是 [`ImportStatus`] 里的 `Cancelled`(不是失败);停在哪一段、哪些段落没有
/// 检查点,见 [`cancel`] 模块文档的逐段表。
#[must_use]
pub fn with_cancel(mut self, token: &ImportCancel) -> Self {
self.cancel = token.clone();
self
}
} }
/// 导入一个整合包。**永不返回 `Err`**:三态与逐项原因都在 [`ImportReport`] 里。 /// 导入一个整合包。**永不返回 `Err`**:三态 + 取消 + 逐项原因都在 [`ImportReport`] 里。
pub async fn import_pack(request: &ImportRequest<'_>) -> ImportReport { pub async fn import_pack(request: &ImportRequest<'_>) -> ImportReport {
pipeline::run(request).await pipeline::run(request).await
} }
#[cfg(test)]
mod cancel_tests;
+152
View File
@@ -0,0 +1,152 @@
//! 导入的**取消令牌**(M195):用户点「取消」之后,后台真的停手并回滚,而不是跑完再假装停了。
//!
//! # 取消不是失败(这是本模块存在的唯一理由)
//!
//! | 现象 | 取消 | 失败 |
//! |------|------|------|
//! | 结果类型 | [`super::report::ImportStatus::Cancelled`] | [`super::report::ImportStatus::Failed`] |
//! | `ImportReport::error` | `None` | 必有 |
//! | CLI 退出码 | `130` | `1` |
//! | UI | 面板收回 / 「已取消」,**不弹红** | 红色结果块 |
//!
//! 管线内部因此有**两条**停止路线:[`Stopped`]。它故意不是 [`super::error::ImportError`] 的
//! 第 N 个大类——把两者并成一个变体,调用方就再也分不清"用户改主意"与"这包装坏了",
//! 而这两件事的收尾文案、退出码、重试语义全都不同。
//!
//! # 取消到底停在哪一段(逐段点名,没有检查点的段落要说明为什么)
//!
//! | 段 | 代码 | 检查点 | 为什么是这个粒度 |
//! |----|------|--------|------------------|
//! | 读包 | `PackArchive::open` → `PackRegistry::read` → `ImportPlan::build` | 入口前、**解析后**各一次 | 一次 `open` 是**不可分割**的(zip 目录 + 四检 + 限额一次扫完),取消等它返回;解析完立刻有一次,所以"用户刚点取消时包已经读完"也不会白写盘 |
//! | 校验 / 补全 | `pipeline::load_baseline`、`complete::complete` | 补全循环**每条目**一次 | 补全是逐条网络请求(按 sha512 反查 sha1),条目之间是天然的取消点 |
//! | 解压 / 写入(暂存目录) | `phase::extract_overrides` | **每条**一次 | 只写临时目录,取消 = 临时目录整个删掉,目标目录一个字节都没动 |
//! | 远端下载(暂存目录) | `phase::fetch_remote` | **每条**一次 | 单条 `PackFetcher::fetch_file` **在飞不可中断**(fetcher 接口没有取消参数),取消在它返回后的下一次检查生效 |
//! | 落盘 | `Staging::commit` | 提交前一次;合并态**逐文件**一次 | 全新导入是一次 `rename`(单步原子,之后无可取消);合并态是逐文件 `rename` + journal,中途取消**逆序回滚**(新文件删掉、旧备份改回原名) |
//! | 登记实例 | `game::install::register` | 落盘后、登记前一次 | 内容已进目标目录,取消必须**连内容一起回滚**,否则留下"有目录没档案"的半个实例 |
//! | 提交前清中间产物 | `Staging::purge_engine_artifacts` | 与"提交前"共用一次 | 它只是暂存目录里的一次本地遍历,与 `commit` 同一条边界 |
//!
//! **预检([`super::scan`])不接取消**:它只读盘、不写一个字节,取消了也不会留下任何东西;
//! 给它加令牌只是让"用户点完取消还得等预检跑完"这件事更难看。
//!
//! # 与 M186 的下载取消不是一套(不许照搬)
//!
//! M186 的口径是"解析后查一次 + 下载循环每次迭代前查一次",因为那边是**一批独立文件**的下载
//! 队列,随时可以在文件之间停。导入是**单包解压 + 两阶段落盘**:真正的风险不是"少下几个文件",
//! 而是"临时目录里的半包被提交进目标目录"。所以这里多了两处 M186 没有的检查点——**落盘逐文件**
//! 与**登记前**——它们对应两种只能靠回滚才能收场的状态(合并态已搬了一半 / 内容已落盘但档案
//! 还没建)。照搬 M186 的两处检查会漏掉这两段,正是"取消后仍写盘"的原始 finding 形态。
//!
//! # 目标的形态(取消后的机器位)
//!
//! 报告的收尾在 [`super::report::ImportReport::cancel`](三档注记与 `rolled_back` 都在那里):
//!
//! - 取消发生在**落盘之前** ⇒ 临时目录已删,`rolled_back = true`、`rollback` 为 `None`;
//! - 取消发生在**落盘之中/之后** ⇒ 回滚已做,`rollback` 里有结果,`rolled_back` 只在**完整**
//! 回滚时为 `true`(回滚本身失败时如实为 `false` 并逐项列出,与失败路径同一套口径)。
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use super::error::ImportError;
/// 取消令牌:一个可跨线程共享的布尔开关。
///
/// [`Default`] 造出的是**永不取消**的独立令牌(没挂令牌的调用方零成本,不必到处写 `Option`)。
/// 要用既有 `Arc<AtomicBool>`(presenter 的 `cancel_pack` 就是这么存的)时走
/// [`ImportCancel::from_flag`],两边共享同一个开关。
#[derive(Debug, Clone, Default)]
pub struct ImportCancel {
flag: Arc<AtomicBool>,
}
impl ImportCancel {
/// 一个新的、未取消的令牌。
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// 接管一个既有开关(共享同一份状态:外面置位,这里立刻看得见)。
#[must_use]
pub fn from_flag(flag: Arc<AtomicBool>) -> Self {
Self { flag }
}
/// 底层开关(要交给别的线程持有时用)。
#[must_use]
pub fn flag(&self) -> Arc<AtomicBool> {
Arc::clone(&self.flag)
}
/// 置位(幂等)。
pub fn cancel(&self) {
self.flag.store(true, Ordering::SeqCst);
}
/// 是否已被取消。
#[must_use]
pub fn is_cancelled(&self) -> bool {
self.flag.load(Ordering::SeqCst)
}
/// 一个检查点:已取消 ⇒ [`Stopped::Cancelled`](由 `?` 冒泡到编排层)。
pub(crate) fn stopped(&self) -> Result<(), Stopped> {
if self.is_cancelled() {
Err(Stopped::Cancelled)
} else {
Ok(())
}
}
}
/// 管线内部的两条停止路线:**取消**与**失败**。
///
/// 分开的理由见模块文档:"用户改主意"不是"这包装坏了"。`From<ImportError>` 让所有既有的
/// `?`(IO / 穿越 / 摘要不符 / 限额)继续直接冒泡成 [`Stopped::Failed`],不用把每个调用点
/// 都改成手写映射。
#[derive(Debug)]
pub(crate) enum Stopped {
/// 用户在检查点之前点了取消。
Cancelled,
/// 一次必须回滚的失败。
Failed(ImportError),
}
impl From<ImportError> for Stopped {
fn from(error: ImportError) -> Self {
Self::Failed(error)
}
}
impl From<crate::addon::pack::PackError> for Stopped {
fn from(error: crate::addon::pack::PackError) -> Self {
Self::Failed(error.into())
}
}
impl From<super::fetch::FetchError> for Stopped {
fn from(error: super::fetch::FetchError) -> Self {
Self::Failed(error.into())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_token_is_shared_only_with_the_flags_it_was_built_from() {
let token = ImportCancel::new();
assert!(!token.is_cancelled());
token.cancel();
assert!(token.is_cancelled(), "自己置位自己要看得见");
let foreign = ImportCancel::new();
assert!(!foreign.is_cancelled(), "默认令牌之间不共享状态");
let flag = Arc::new(AtomicBool::new(false));
let shared = ImportCancel::from_flag(Arc::clone(&flag));
flag.store(true, Ordering::SeqCst);
assert!(shared.is_cancelled(), "外部置位要被看见");
}
}
@@ -0,0 +1,365 @@
//! 导入取消(M195)的守门用例:**取消不是失败**、**取消不留半包 / 不覆盖已有实例**、
//! **取消后不登记实例**。
//!
//! # 每条用例挡哪种回归(变异证明见 mission 报告)
//!
//! | 用例 | 删掉哪一处就红 |
//! |------|----------------|
//! | `a_cancel_before_the_first_write_touches_nothing` | 入口检查点 |
//! | `a_cancel_inside_the_extract_loop_leaves_no_half_unpacked_pack` | 解压循环里的检查点 |
//! | `a_cancel_in_the_middle_of_the_merge_restores_the_users_file` | 取消时的 `rollback_merge` |
//! | `a_cancel_right_before_registering_rolls_the_whole_instance_back` | 登记前的检查点 |
//! | `cancel_and_failure_are_two_different_result_types` | 把取消并进 `fail()` 那条路 |
//!
//! 取消的触发点全部走**进度回调**(回调里置位 = 用户此刻点了取消,没有 sleep、没有竞态)。
//! 全部离线:手造 mrpack(`files` 为空,内容全在 `overrides/`)。
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use super::cancel::ImportCancel;
use super::report::{ImportProgress, ImportProgressSink, ImportStage, ImportStatus};
use super::{import_pack, ImportRequest};
use crate::addon::temp::TempRoot;
use crate::download::blocking::block_on;
/// 一份只含覆盖内容的 mrpack 清单(`files` 为空 ⇒ 全部内容来自 `overrides/`,无远端条目)。
const INDEX: &str = r#"{
"game": "minecraft",
"formatVersion": 1,
"versionId": "1.0.0",
"name": "Cancelable",
"files": [],
"dependencies": {"minecraft": "1.20.1", "fabric-loader": "0.15.11"}
}
"#;
/// 手造一个 mrpack(`entries` 是 `overrides/` 下的实例内路径与字节)。
fn mrpack(tag: &str, entries: &[(&str, &[u8])]) -> (TempRoot, PathBuf) {
let root = TempRoot::new(tag);
let source = root.join("src");
write_file(&source.join("modrinth.index.json"), INDEX.as_bytes());
for (path, bytes) in entries {
write_file(&source.join("overrides").join(path), bytes);
}
let pack = root.join("pack.mrpack");
zip_dir(&source, &pack);
(root, pack)
}
/// 三个文件的包。
fn three_files(tag: &str) -> (TempRoot, PathBuf) {
mrpack(
tag,
&[
("mods/a.jar", b"pack a"),
("mods/b.jar", b"pack b"),
("mods/c.jar", b"pack c"),
],
)
}
fn write_file(path: &Path, bytes: &[u8]) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("建目录");
}
std::fs::write(path, bytes).expect("写文件");
}
fn zip_dir(source: &Path, target: &Path) {
let file = std::fs::File::create(target).expect("建 zip");
let mut writer = zip::ZipWriter::new(file);
let options =
zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
let mut pending = vec![PathBuf::new()];
while let Some(relative) = pending.pop() {
let dir = source.join(&relative);
let mut children: Vec<_> = std::fs::read_dir(&dir)
.expect("读目录")
.collect::<Result<Vec<_>, _>>()
.expect("目录项");
children.sort_by_key(std::fs::DirEntry::file_name);
for child in children {
let relative = relative.join(child.file_name());
if child.file_type().expect("类型").is_dir() {
pending.push(relative);
continue;
}
let bytes = std::fs::read(child.path()).expect("读夹具文件");
let entry = relative.to_string_lossy().replace('\\', "/");
writer.start_file(entry, options).expect("写 zip 条目");
writer.write_all(&bytes).expect("写 zip 内容");
}
}
writer.finish().expect("收尾 zip");
}
/// 在**指定阶段的第一条进度事件**上置位取消(= 用户此刻点了取消)。
fn cancel_on(token: &ImportCancel, stage: ImportStage) -> ImportProgressSink {
let token = token.clone();
Arc::new(move |progress: &ImportProgress| {
if progress.stage == stage {
token.cancel();
}
})
}
/// 在**合并态第二条逐文件进度**上置位取消。
///
/// 合并是**排序后**逐文件搬:第 0 条是基线(`.ull/pack-baseline.json`,`.` 排在最前),第 1 条
/// 才是 `mods/a.jar`。所以 `done == 1` 正好是"用户那个被覆盖的文件已经搬进去、后面还有"的
/// 窗口——journal 回滚要接住的正是这一刻(`total > 0` 顺带把阶段开始那条 0/0 排除掉)。
fn cancel_on_second_merged_file(token: &ImportCancel) -> ImportProgressSink {
let token = token.clone();
Arc::new(move |progress: &ImportProgress| {
if progress.stage == ImportStage::Commit && progress.done == 1 {
token.cancel();
}
})
}
/// 目标目录里**全部文件**的实例内相对路径(排序后),用来钉"取消后与导入前逐文件相同"。
fn files_under(root: &Path) -> Vec<String> {
let mut out = Vec::new();
let mut pending = vec![root.to_path_buf()];
while let Some(dir) = pending.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.filter_map(Result::ok) {
let path = entry.path();
if entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false) {
pending.push(path);
continue;
}
let relative = path
.strip_prefix(root)
.map(|tail| tail.to_string_lossy().replace('\\', "/"))
.unwrap_or_default();
out.push(relative);
}
}
out.sort();
out
}
/// 父目录里有没有遗留的暂存目录(`.<name>.ull-import-<pid>-<seq>`)。
fn stray_staging(parent: &Path) -> Vec<String> {
std::fs::read_dir(parent)
.map(|entries| {
entries
.filter_map(Result::ok)
.map(|entry| entry.file_name().to_string_lossy().to_string())
.filter(|name| name.contains(".ull-import-"))
.collect()
})
.unwrap_or_default()
}
#[test]
fn a_cancel_before_the_first_write_touches_nothing() {
// 入口检查点:包还没读,目标目录还没建。
let (root, pack) = three_files("cancel-entry");
let target = root.join("instance");
let token = ImportCancel::new();
token.cancel();
let report = block_on(import_pack(
&ImportRequest::new(&pack, &target).with_cancel(&token),
));
assert_eq!(report.status, ImportStatus::Cancelled);
assert!(
report.error.is_none(),
"取消不是失败:{}",
report.status.as_str()
);
assert!(report.pack_name.is_none(), "停在入口 = 包都没读");
assert!(report.files.is_empty());
assert!(report.rolled_back, "一个字节都没写,目标目录与导入前一致");
assert!(!target.exists(), "目标目录不许出现");
assert!(
stray_staging(root.path()).is_empty(),
"{:?}",
stray_staging(root.path())
);
}
#[test]
fn a_cancel_inside_the_extract_loop_leaves_no_half_unpacked_pack() {
// 解压循环里的检查点:第一条进度事件上取消 ⇒ 第一条写进临时目录,第二条之前停下,
// 临时目录整个删掉 ⇒ 目标目录**不出现半成品**,报告里也不许有"已落地 N 条"的假账。
let (root, pack) = three_files("cancel-extract");
let target = root.join("instance");
let token = ImportCancel::new();
let report = block_on(import_pack(
&ImportRequest::new(&pack, &target)
.with_cancel(&token)
.with_progress(cancel_on(&token, ImportStage::Extract)),
));
assert_eq!(report.status, ImportStatus::Cancelled, "{:?}", report.error);
assert!(report.error.is_none(), "取消不许带错误");
assert!(!target.exists(), "不许留下半包:{}", target.display());
assert!(
report.files.is_empty(),
"取消前的处理记录一条都没落地,报告不许冒充已装:{:?}",
report.files
);
assert_eq!(report.bytes_written, 0, "写入量要归零");
assert!(report.registered.is_none());
assert!(report.rolled_back);
assert!(
stray_staging(root.path()).is_empty(),
"{:?}",
stray_staging(root.path())
);
}
#[test]
fn a_cancel_in_the_middle_of_the_merge_restores_the_users_file() {
// 合并态(目标目录已有内容):提交是逐文件 rename + journal。取消落在两条搬动之间 ⇒
// **逆序回滚**必须把用户被覆盖的文件改回来,且不留备份、不留暂存目录。
let (root, pack) = three_files("cancel-merge");
let target = root.join("instance");
write_file(&target.join("mods/a.jar"), b"user a");
write_file(&target.join("mods/user.jar"), b"user only");
let token = ImportCancel::new();
let report = block_on(import_pack(
&ImportRequest::new(&pack, &target)
.with_cancel(&token)
// 覆盖档:让 a.jar 真的走"备份 + 覆盖"那条路,取消后必须恢复用户内容。
.with_conflict_policy(super::ConflictPolicy::Overwrite)
.with_progress(cancel_on_second_merged_file(&token)),
));
assert_eq!(report.status, ImportStatus::Cancelled, "{:?}", report.error);
assert!(report.error.is_none());
assert_eq!(
std::fs::read(target.join("mods/a.jar")).expect("用户文件要还在"),
b"user a",
"取消后必须恢复被覆盖的用户文件(回滚被删掉就会读到包内字节)"
);
assert_eq!(
std::fs::read(target.join("mods/user.jar")).expect("用户文件要还在"),
b"user only"
);
assert!(!target.join("mods/b.jar").exists(), "取消后不许有半包");
assert!(!target.join("mods/c.jar").exists(), "取消后不许有半包");
assert_eq!(
files_under(&target),
vec!["mods/a.jar".to_string(), "mods/user.jar".to_string()],
"目标目录必须与导入前**逐文件相同**(多出基线 / 少掉用户文件都是错)"
);
assert!(
std::fs::read_dir(target.join("mods"))
.expect("读 mods")
.filter_map(Result::ok)
.all(|entry| !entry.file_name().to_string_lossy().contains(".ullbak-")),
"备份必须被清掉,不许在实例里留 .ullbak-*"
);
assert!(
stray_staging(root.path()).is_empty(),
"{:?}",
stray_staging(root.path())
);
// 回滚做过,且完整 ⇒ 机器位与文案位都说"与导入前一致"。
assert!(report.rolled_back, "{:?}", report.rollback);
assert_eq!(
report
.rollback
.as_ref()
.map(super::stage::RollbackOutcome::failure_count),
Some(0)
);
}
#[test]
fn a_cancel_right_before_registering_rolls_the_whole_instance_back() {
// 登记前的检查点(**取消后不得登记实例**):取消在"内容已落盘、档案还没建"的窗口里落下
// ⇒ 连内容一起回滚。删掉这条检查,实例就会被登记上(本用例立刻红)。
let (root, pack) = three_files("cancel-register");
let target = root.join("instance");
let home = root.join("data");
let layout = crate::platform::layout::LauncherLayout::new(&home);
let token = ImportCancel::new();
let report = block_on(import_pack(
&ImportRequest::new(&pack, &target)
.with_cancel(&token)
.with_registration(&layout, Some("cancelled pack"))
.with_progress(cancel_on(&token, ImportStage::Register)),
));
assert_eq!(report.status, ImportStatus::Cancelled, "{:?}", report.error);
assert!(report.error.is_none());
assert!(report.registered.is_none(), "取消后不许登记实例");
let profiles = crate::game::install::instance::read_profiles(&layout).expect("读档案");
assert!(
profiles.profiles.is_empty(),
"profiles.json 里不许出现被取消的实例:{:?}",
profiles.profiles
);
assert!(!target.exists(), "内容已提交也要连内容一起回滚");
assert!(report.rolled_back);
assert!(
stray_staging(root.path()).is_empty(),
"{:?}",
stray_staging(root.path())
);
}
#[test]
fn cancel_and_failure_are_two_different_result_types() {
// 条款③:取消与真失败必须产生**不同的结果类型**(状态变体、错误位、退出码、文案 key、
// JSON 的 status 全不同)。把两条路合并成同一个错误,这里就红。
let (root, pack) = three_files("cancel-vs-fail");
let token = ImportCancel::new();
token.cancel();
let cancelled = block_on(import_pack(
&ImportRequest::new(&pack, &root.join("cancelled")).with_cancel(&token),
));
// 真失败:清单坏掉的包(开包成功、读清单失败)。
let broken = root.join("broken.zip");
{
let file = std::fs::File::create(&broken).expect("建 zip");
let mut writer = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Stored);
writer.start_file("manifest.json", options).expect("写条目");
writer.write_all(b"{\"manifestType\":").expect("写内容");
writer.finish().expect("收尾");
}
let failed = block_on(import_pack(&ImportRequest::new(
&broken,
&root.join("failed"),
)));
assert_eq!(cancelled.status, ImportStatus::Cancelled);
assert_eq!(failed.status, ImportStatus::Failed);
assert!(cancelled.error.is_none(), "取消不是错误");
assert!(failed.error.is_some(), "失败必须带错误");
assert_ne!(
cancelled.status.exit_code(),
failed.status.exit_code(),
"退出码必须能区分「用户取消」与「装坏了」"
);
assert_eq!(cancelled.status.exit_code(), 130);
assert_eq!(failed.status.exit_code(), 1);
assert_ne!(cancelled.status.message_key(), failed.status.message_key());
let cancelled_json = serde_json::to_value(&cancelled).expect("序列化");
let failed_json = serde_json::to_value(&failed).expect("序列化");
assert_eq!(cancelled_json["status"], serde_json::json!("cancelled"));
assert_eq!(failed_json["status"], serde_json::json!("failed"));
assert!(
cancelled_json["error"].is_null(),
"取消的 JSON 里不许有 error:{}",
cancelled_json["error"]
);
}
mod report_shaping;
@@ -0,0 +1,111 @@
//! 取消报告的**三档诚实口径**:`ImportReport::cancel` 的直接用例(不需要跑管线)。
//!
//! 从 `cancel_tests.rs` 拆出来只为行数:那边装下五个端到端用例就够满了。
use crate::addon::import::report::{FileAction, FileSource, ImportReport, ImportedFile};
use crate::addon::import::stage::RollbackOutcome;
use crate::addon::import::ImportStatus;
fn base() -> ImportReport {
ImportReport::empty(std::path::PathBuf::from("instance"))
}
fn notes(report: &ImportReport) -> Vec<&str> {
report
.warnings
.iter()
.map(|note| note.message.as_str())
.collect()
}
#[test]
fn a_cancel_before_writing_never_claims_the_target_was_touched() {
let mut report = base();
report.cancel(RollbackOutcome::default());
assert_eq!(report.status, ImportStatus::Cancelled);
assert!(report.error.is_none(), "取消不许带错误");
assert!(report.rolled_back);
assert!(report.rollback.is_none(), "没做过回滚就不该有回滚记录");
let notes = notes(&report);
assert!(
notes.iter().any(|line| line.contains("未被写入")),
"{notes:?}"
);
assert!(
!notes.iter().any(|line| line.contains("恢复为导入前")),
"{notes:?}"
);
}
#[test]
fn a_cancel_after_commit_says_it_rolled_back_exactly_once() {
let mut report = base();
report.cancel(RollbackOutcome {
attempted: 2,
failed: Vec::new(),
});
assert_eq!(report.status, ImportStatus::Cancelled);
assert!(report.rolled_back);
assert_eq!(
report.rollback.as_ref().map(RollbackOutcome::failure_count),
Some(0)
);
let notes = notes(&report);
assert!(
notes.iter().any(|line| line.contains("恢复为导入前")),
"{notes:?}"
);
assert!(
!notes.iter().any(|line| line.contains("未被写入")),
"{notes:?}"
);
}
#[test]
fn an_incomplete_cancel_rollback_never_claims_consistency() {
let mut report = base();
report.cancel(RollbackOutcome {
attempted: 3,
failed: vec!["删除 config/a.toml 失败:拒绝访问".to_string()],
});
assert_eq!(report.status, ImportStatus::Cancelled);
assert!(!report.rolled_back, "回滚不完整时不许宣称一致");
let notes = notes(&report);
assert!(
notes
.iter()
.any(|line| line.contains("未能回滚") || line.contains("未完成")),
"{notes:?}"
);
assert!(
!notes
.iter()
.any(|line| line.contains("恢复为导入前的样子") || line.contains("未被写入")),
"{notes:?}"
);
}
#[test]
fn cancelling_clears_the_per_file_ledger_it_never_landed() {
// 取消前的 `files` 是"处理过"而不是"落地过":留着就是假账(UI 会照它画"已装 N 个")。
let mut report = base();
report.bytes_written = 4096;
report.files.push(ImportedFile {
path: "mods/a.jar".to_string(),
source: FileSource::Overrides,
sha1: None,
bytes: 4096,
disabled: false,
action: FileAction::Installed,
note: None,
});
report.cancel(RollbackOutcome::default());
assert!(report.files.is_empty(), "取消后不许留下「已落地」的账");
assert_eq!(report.bytes_written, 0);
assert_eq!(report.installed(), 0);
}
+11 -1
View File
@@ -77,7 +77,13 @@ pub(crate) async fn complete_and_note(
plan: &mut ImportPlan, plan: &mut ImportPlan,
report: &mut ImportReport, report: &mut ImportReport,
) { ) {
if let Some(note) = complete(request, plan).await.note("") { let completion = complete(request, plan).await;
// 取消时这条注记会撒谎(剩下的条目**没试过**,不是"补不出"):取消收尾的注记已经说清
// 停在哪一步,这里不再补一句计数。
if request.cancel.is_cancelled() {
return;
}
if let Some(note) = completion.note("") {
report.warnings.push(note); report.warnings.push(note);
} }
} }
@@ -94,6 +100,10 @@ pub(crate) async fn complete(request: &ImportRequest<'_>, plan: &mut ImportPlan)
return state; return state;
}; };
for entry in plan.remote.iter_mut() { for entry in plan.remote.iter_mut() {
// 取消检查点:补全是**逐条网络请求**,条目之间是天然的落点;已补的只改内存,不写盘。
if request.cancel.is_cancelled() {
break;
}
if entry.file.sha1().is_some() { if entry.file.sha1().is_some() {
continue; continue;
} }
+27 -63
View File
@@ -7,26 +7,33 @@ use std::path::Path;
use super::super::pack::{PackArchive, PackError}; use super::super::pack::{PackArchive, PackError};
use super::baseline::PackBaseline; use super::baseline::PackBaseline;
use super::cancel::Stopped;
use super::error::ImportError; use super::error::ImportError;
use super::fetch::{FetchError, FetchRequest}; use super::fetch::{FetchError, FetchRequest};
use super::merge::{self, MergeAction}; use super::merge;
use super::pipeline::tell;
use super::plan::{curse_class_dir, ImportPlan, PlanRemote}; use super::plan::{curse_class_dir, ImportPlan, PlanRemote};
use super::report::{ use super::report::tell;
ConflictChoice, ConflictFile, ConflictReason, FileAction, ImportReport, ImportStage, use super::report::{ImportReport, ImportStage, ImportedFile, MissingFile, MissingReason};
ImportedFile, MissingFile, MissingReason,
};
use super::stage::Staging; use super::stage::Staging;
use super::ImportRequest; use super::ImportRequest;
use crate::download::verify::{self, digest_bytes}; use crate::download::verify::{self, digest_bytes};
use crate::download::HashAlgo; use crate::download::HashAlgo;
mod settle;
use settle::{conflict, settle};
/// 阶段一:覆盖条目落暂存 + 三方合并。 /// 阶段一:覆盖条目落暂存 + 三方合并。
/// ///
/// 被同包另一条声明遮蔽的条目([`super::plan::PlanOverride::shadowed`])**不读字节、不落地**: /// 被同包另一条声明遮蔽的条目([`super::plan::PlanOverride::shadowed`])**不读字节、不落地**:
/// 它的结论已经由 `plan::layer::resolve` 算成一条 `NOTE_OVERRIDE_SHADOWED` 注记 /// 它的结论已经由 `plan::layer::resolve` 算成一条 `NOTE_OVERRIDE_SHADOWED` 注记
/// (进 `report.warnings`),而这条路径的行由真正的胜者给出——一路径一行,不再出现 /// (进 `report.warnings`),而这条路径的行由真正的胜者给出——一路径一行,不再出现
/// "两条 Installed、前一条的哈希其实没落地"。 /// "两条 Installed、前一条的哈希其实没落地"。
///
/// # 取消检查点
///
/// 解压阶段**每条一次**(循环开头):只写临时目录,取消 = 临时目录整个删掉,目标目录一个
/// 字节都没动。被 `?` 冒泡出去的 [`Stopped::Cancelled`] 与失败**不是同一条路**。
pub(crate) async fn extract_overrides( pub(crate) async fn extract_overrides(
request: &ImportRequest<'_>, request: &ImportRequest<'_>,
archive: &PackArchive, archive: &PackArchive,
@@ -35,7 +42,7 @@ pub(crate) async fn extract_overrides(
staging: &Staging, staging: &Staging,
report: &mut ImportReport, report: &mut ImportReport,
next: &mut PackBaseline, next: &mut PackBaseline,
) -> Result<(), ImportError> { ) -> Result<(), Stopped> {
let live: Vec<&super::plan::PlanOverride> = plan let live: Vec<&super::plan::PlanOverride> = plan
.overrides .overrides
.iter() .iter()
@@ -44,6 +51,8 @@ pub(crate) async fn extract_overrides(
let total = live.len(); let total = live.len();
let target = staging.target().to_path_buf(); let target = staging.target().to_path_buf();
for (index, entry) in live.iter().enumerate() { for (index, entry) in live.iter().enumerate() {
// 取消检查点(每条一次):只写临时目录,停下就是"什么都没发生"。
request.cancel.stopped()?;
tell( tell(
&request.progress, &request.progress,
ImportStage::Extract, ImportStage::Extract,
@@ -94,6 +103,12 @@ pub(crate) async fn extract_overrides(
} }
/// 阶段二:远端条目补全 + 下载 + 同一张合并表。 /// 阶段二:远端条目补全 + 下载 + 同一张合并表。
///
/// # 取消检查点
///
/// 每条远端条目一次(循环开头)。**单条下载在飞不可中断**([`super::fetch::PackFetcher`] 没有
/// 取消参数),所以取消在最坏情况下要等"当前这一条下载返回";返回后下一次检查立刻生效,
/// 已下到临时目录的字节随临时目录一起删掉。
pub(crate) async fn fetch_remote( pub(crate) async fn fetch_remote(
request: &ImportRequest<'_>, request: &ImportRequest<'_>,
plan: &ImportPlan, plan: &ImportPlan,
@@ -101,10 +116,12 @@ pub(crate) async fn fetch_remote(
staging: &Staging, staging: &Staging,
report: &mut ImportReport, report: &mut ImportReport,
next: &mut PackBaseline, next: &mut PackBaseline,
) -> Result<(), ImportError> { ) -> Result<(), Stopped> {
let total = plan.remote.len(); let total = plan.remote.len();
let target = staging.target().to_path_buf(); let target = staging.target().to_path_buf();
for (index, entry) in plan.remote.iter().enumerate() { for (index, entry) in plan.remote.iter().enumerate() {
// 取消检查点(每条一次):上一条下载返回之后立刻生效。
request.cancel.stopped()?;
let display = entry.path.clone().unwrap_or_else(|| entry.file.key()); let display = entry.path.clone().unwrap_or_else(|| entry.file.key());
tell( tell(
&request.progress, &request.progress,
@@ -185,7 +202,8 @@ pub(crate) async fn fetch_remote(
return Err(ImportError::integrity(format!( return Err(ImportError::integrity(format!(
"{} 的摘要与清单声明不符(声明 {declared},实际 {pack_sha1})", "{} 的摘要与清单声明不符(声明 {declared},实际 {pack_sha1})",
resolved.path resolved.path
))); ))
.into());
} }
} }
let decision = merge::decide(&pack_sha1, baseline.sha1(&resolved.path), disk.as_deref()); let decision = merge::decide(&pack_sha1, baseline.sha1(&resolved.path), disk.as_deref());
@@ -284,60 +302,6 @@ async fn resolve(
})) }))
} }
/// 合并判定 + 决策 → 实际动作。
///
/// `picked` 是这一次的处置(逐项决策或整批档,见 [`super::decision::ConflictDecisions`]):
/// 它**只对冲突生效**——`Install` / `SkipIdentical` 不受它影响,所以"用户点错一条保留"不会漏装。
fn settle(
decision: merge::MergeDecision,
disk_exists: bool,
picked: ConflictChoice,
) -> (FileAction, ConflictChoice) {
let overwrite = picked == ConflictChoice::Overwrite;
match decision.action {
MergeAction::Install => (
if disk_exists {
FileAction::Replaced
} else {
FileAction::Installed
},
ConflictChoice::Overwrite,
),
MergeAction::SkipIdentical => (FileAction::SkippedIdentical, ConflictChoice::KeepUser),
MergeAction::KeepUser if overwrite => (
if disk_exists {
FileAction::Replaced
} else {
FileAction::Installed
},
ConflictChoice::Overwrite,
),
MergeAction::KeepUser => (FileAction::KeptUser, ConflictChoice::KeepUser),
}
}
/// 组装一条冲突记录。
fn conflict(
path: String,
reason: ConflictReason,
choice: ConflictChoice,
pack_sha1: &str,
pack_size: u64,
base_sha1: Option<String>,
disk_sha1: Option<String>,
) -> ConflictFile {
ConflictFile {
path,
reason,
choice,
message_key: reason.message_key(),
pack_sha1: pack_sha1.to_string(),
pack_size,
base_sha1,
disk_sha1,
}
}
/// 组装一条缺失记录(条目本身补不出来)。 /// 组装一条缺失记录(条目本身补不出来)。
fn missing_for(entry: &PlanRemote, fetcher_present: bool) -> MissingFile { fn missing_for(entry: &PlanRemote, fetcher_present: bool) -> MissingFile {
let reason = if fetcher_present { let reason = if fetcher_present {
@@ -0,0 +1,61 @@
//! 合并判定 + 决策 → 实际动作,以及冲突记录的组装。
//!
//! 从 [`super`] 拆出来的唯一原因是行数上限(400 行),行为逐字未变:这两个函数是**纯函数**,
//! 不碰盘、不看时钟,所以搬动它们不影响任何判据。
use super::super::merge::{self, MergeAction};
use super::super::report::{ConflictChoice, ConflictFile, ConflictReason, FileAction};
/// 合并判定 + 决策 → 实际动作。
///
/// `picked` 是这一次的处置(逐项决策或整批档,见 [`crate::addon::import::decision::ConflictDecisions`]):
/// 它**只对冲突生效**——`Install` / `SkipIdentical` 不受它影响,所以"用户点错一条保留"不会漏装。
pub(super) fn settle(
decision: merge::MergeDecision,
disk_exists: bool,
picked: ConflictChoice,
) -> (FileAction, ConflictChoice) {
let overwrite = picked == ConflictChoice::Overwrite;
match decision.action {
MergeAction::Install => (
if disk_exists {
FileAction::Replaced
} else {
FileAction::Installed
},
ConflictChoice::Overwrite,
),
MergeAction::SkipIdentical => (FileAction::SkippedIdentical, ConflictChoice::KeepUser),
MergeAction::KeepUser if overwrite => (
if disk_exists {
FileAction::Replaced
} else {
FileAction::Installed
},
ConflictChoice::Overwrite,
),
MergeAction::KeepUser => (FileAction::KeptUser, ConflictChoice::KeepUser),
}
}
/// 组装一条冲突记录。
pub(super) fn conflict(
path: String,
reason: ConflictReason,
choice: ConflictChoice,
pack_sha1: &str,
pack_size: u64,
base_sha1: Option<String>,
disk_sha1: Option<String>,
) -> ConflictFile {
ConflictFile {
path,
reason,
choice,
message_key: reason.message_key(),
pack_sha1: pack_sha1.to_string(),
pack_size,
base_sha1,
disk_sha1,
}
}
+65 -76
View File
@@ -26,35 +26,58 @@
//! //!
//! 失败后目标目录与导入前一致(或明确告诉你哪几项没回去),重跑同一个包是安全的;成功后再跑 //! 失败后目标目录与导入前一致(或明确告诉你哪几项没回去),重跑同一个包是安全的;成功后再跑
//! 一遍会靠基线里的哈希走"跳过 / 已一致"路径,不会重复写盘。 //! 一遍会靠基线里的哈希走"跳过 / 已一致"路径,不会重复写盘。
//!
//! # 取消(M195):七个检查点
//!
//! 编排层负责**阶段边界**的检查点(入口 → 解析后 → 补全后 → 开暂存目录前 → 提交前 → 登记前),
//! 长循环里的**逐条**检查在各自实现里(解压/下载在 [`super::phase`],合并落盘在
//! [`super::stage`])。逐段表(含"哪几段没有检查点、为什么")见 [`super::cancel`]。
//! 每次停下都按"回滚做过什么"收尾:没写盘 ⇒ 删暂存目录;写过 ⇒ 回滚结果如实进报告。
//! **取消不走 [`failed`]**:它走 [`ImportReport::cancel`],结果类型是 `Cancelled`
//! 而不是 `Failed`。
use super::super::pack::{PackArchive, PackError, PackRegistry};
use super::baseline::PackBaseline; use super::baseline::PackBaseline;
use super::cancel::Stopped;
use super::error::ImportError; use super::error::ImportError;
use super::phase; use super::phase;
use super::plan::ImportPlan; use super::plan::ImportPlan;
use super::report::{ use super::prepare::{account_plan, probe_and_plan, write_baseline};
keys, ImportIssue, ImportProgress, ImportReport, ImportStage, RegisteredInstance, use super::report::{keys, tell, ImportIssue, ImportReport, ImportStage, RegisteredInstance};
}; use super::stage::{CommitStop, RollbackOutcome, Staging};
use super::stage::{RollbackOutcome, Staging};
use super::ImportRequest; use super::ImportRequest;
/// 跑一次导入。**永不返回 `Err`**:所有失败都在报告的三态里。 /// 跑一次导入。**永不返回 `Err`**:成功 / 部分成功 / 失败 / 取消全在报告里。
pub async fn run(request: &ImportRequest<'_>) -> ImportReport { pub async fn run(request: &ImportRequest<'_>) -> ImportReport {
let mut report = ImportReport::empty(request.target.to_path_buf()); let mut report = ImportReport::empty(request.target.to_path_buf());
// 检查点①:入口(用户可能在上一次点开面板之后、线程起来之前就取消了)。
if request.cancel.is_cancelled() {
return cancelled(report, RollbackOutcome::default());
}
tell(&request.progress, ImportStage::Probe, 0, 0, None); tell(&request.progress, ImportStage::Probe, 0, 0, None);
let (archive, mut plan) = match probe_and_plan(request) { let (archive, mut plan) = match probe_and_plan(request) {
Ok(pair) => pair, Ok(pair) => pair,
Err(error) => return abandoned(report, error), Err(error) => return abandoned(report, error),
}; };
account_plan(&mut report, &plan); account_plan(&mut report, &plan);
// 检查点②:解析后(包读完了,目标目录还一个字节都没写——停下最便宜)。
if request.cancel.is_cancelled() {
return cancelled(report, RollbackOutcome::default());
}
super::complete::complete_and_note(request, &mut plan, &mut report).await; super::complete::complete_and_note(request, &mut plan, &mut report).await;
// 检查点③:补全后(补全是逐条网络请求,条目之间已经在 `complete` 里停过一次)。
if request.cancel.is_cancelled() {
return cancelled(report, RollbackOutcome::default());
}
let baseline = { let baseline = {
let (baseline, warnings) = load_baseline(request.target, &plan); let (baseline, warnings) = load_baseline(request.target, &plan);
report.warnings.extend(warnings); report.warnings.extend(warnings);
baseline baseline
}; };
// 检查点④:开暂存目录前(此后磁盘上就有临时目录了)。
if request.cancel.is_cancelled() {
return cancelled(report, RollbackOutcome::default());
}
let staging = match Staging::begin(request.target) { let staging = match Staging::begin(request.target) {
Ok(staging) => staging, Ok(staging) => staging,
Err(error) => return abandoned(report, error), Err(error) => return abandoned(report, error),
@@ -73,13 +96,28 @@ pub async fn run(request: &ImportRequest<'_>) -> ImportReport {
) )
.await?; .await?;
phase::fetch_remote(request, &plan, &baseline, &staging, &mut report, &mut next).await?; phase::fetch_remote(request, &plan, &baseline, &staging, &mut report, &mut next).await?;
write_baseline(&staging, &plan, &next) write_baseline(&staging, &plan, &next)?;
Ok::<(), Stopped>(())
} }
.await; .await;
if let Err(error) = staged { match staged {
Ok(()) => {}
// 停在暂存阶段:临时目录整个删掉,目标目录与导入前一致(一个字节都没写过)。
Err(Stopped::Cancelled) => {
staging.abort();
return cancelled(report, RollbackOutcome::default());
}
Err(Stopped::Failed(error)) => {
staging.abort(); staging.abort();
return abandoned(report, error); return abandoned(report, error);
} }
}
// 检查点⑤:提交前(与"清中间产物"同一条边界)。
if request.cancel.is_cancelled() {
staging.abort();
return cancelled(report, RollbackOutcome::default());
}
// 引擎的续传残片(`.ullpart-*`)与 meta 不是整合包内容,提交前剔掉。 // 引擎的续传残片(`.ullpart-*`)与 meta 不是整合包内容,提交前剔掉。
match staging.purge_engine_artifacts() { match staging.purge_engine_artifacts() {
@@ -95,13 +133,24 @@ pub async fn run(request: &ImportRequest<'_>) -> ImportReport {
} }
tell(&request.progress, ImportStage::Commit, 0, 0, None); tell(&request.progress, ImportStage::Commit, 0, 0, None);
let guard = match staging.commit() { // 检查点⑥:落盘(全新导入只在 rename 之前;合并态在 `apply_merge` 里逐文件)。
let token = request.cancel.clone();
let guard = match staging.commit(&token, &request.progress) {
Ok(guard) => guard, Ok(guard) => guard,
Err(failure) => return record_rollback(failed(report, failure.error), failure.rollback), Err(CommitStop::Cancelled(outcome)) => return cancelled(report, outcome),
Err(CommitStop::Failed(failure)) => {
return record_rollback(failed(report, failure.error), failure.rollback);
}
}; };
if let Some(layout) = request.register { if let Some(layout) = request.register {
tell(&request.progress, ImportStage::Register, 0, 0, None); tell(&request.progress, ImportStage::Register, 0, 0, None);
// 检查点⑦:登记前。内容已经落进目标目录,所以取消要**连内容一起回滚**,否则留下
// "有目录没档案"的半个实例——这正是"取消后不得登记实例"的判据。
if request.cancel.is_cancelled() {
let outcome = guard.rollback();
return cancelled(report, outcome);
}
let name = request let name = request
.instance_name .instance_name
.filter(|name| !name.trim().is_empty()) .filter(|name| !name.trim().is_empty())
@@ -138,31 +187,12 @@ pub async fn run(request: &ImportRequest<'_>) -> ImportReport {
report report
} }
/// 开包 + 探测 + 组计划(任何失败都是 `Failed`)。 /// 取消收尾:把「用户取消」写进报告(**不是失败**),返回收好尾的报告。
/// ///
/// 预检([`super::scan`])走的是**同一段代码**:两边对"这包是什么、要装什么"必须完全一致, /// 三档注记与 `rolled_back` 的判据在 [`ImportReport::cancel`](那里也是失败路径的同形三档)。
/// 否则"预检说没冲突、导入说有冲突"这种事就会发生。 fn cancelled(mut report: ImportReport, outcome: RollbackOutcome) -> ImportReport {
pub(super) fn probe_and_plan( report.cancel(outcome);
request: &ImportRequest<'_>, report
) -> Result<(PackArchive, ImportPlan), ImportError> {
// 路径穿越双检 + 符号链接 + 条数/体积限额都在 `open` 里一次做完(M110 的 `archive`)。
let archive = PackArchive::open(request.pack, request.limits)?;
let (format, manifest) = PackRegistry::builtin().read(&archive)?;
let plan = ImportPlan::build(&archive, format.id(), manifest)?;
Ok((archive, plan))
}
/// 把计划里的静态信息抄进报告。
fn account_plan(report: &mut ImportReport, plan: &ImportPlan) {
report.format = Some(plan.format.clone());
report.pack_name = Some(plan.manifest.name.clone());
report.pack_version = plan.manifest.version.clone();
report.game_version = Some(plan.mc_version.clone());
report.loader = plan.loader.clone();
report.version_id = Some(plan.version_id.clone());
report.max_memory_mb = plan.max_memory_mb;
report.rejected_components.clone_from(&plan.rejected);
report.warnings.extend(plan.notes.iter().cloned());
} }
/// 读三方合并的 base 侧:没有 / 读不出来 / **属于别的包** 三条路都不致命。 /// 读三方合并的 base 侧:没有 / 读不出来 / **属于别的包** 三条路都不致命。
@@ -233,47 +263,6 @@ fn record_rollback(mut report: ImportReport, outcome: RollbackOutcome) -> Import
report report
} }
/// 基线作为**最后一个暂存文件**写进去:它跟同一个事务一起提交 / 回滚。
fn write_baseline(
staging: &Staging,
plan: &ImportPlan,
next: &PackBaseline,
) -> Result<(), ImportError> {
let mut baseline = next.clone();
baseline.pack_name.clone_from(&plan.manifest.name);
baseline.format.clone_from(&plan.format);
let bytes = baseline.to_bytes().map_err(PackError::io)?;
staging.write(&baseline_relative(), &bytes)?;
Ok(())
}
/// 基线相对实例根的路径(`<根>/.ull/pack-baseline.json`;与读侧同源,见
/// [`PackBaseline::relative_path`])。
#[must_use]
pub fn baseline_relative() -> String {
PackBaseline::relative_path()
.to_string_lossy()
.replace('\\', "/")
}
/// 发一条进度事件(`sink` 为 `None` 时什么都不做)。
pub(super) fn tell(
sink: &Option<super::report::ImportProgressSink>,
stage: ImportStage,
done: usize,
total: usize,
current: Option<String>,
) {
if let Some(sink) = sink {
sink(&ImportProgress {
stage,
done,
total,
current,
});
}
}
/// 失败收尾:**只落错误,不宣称目标目录状态**。 /// 失败收尾:**只落错误,不宣称目标目录状态**。
/// ///
/// 口径由调用方补:从未写过目标目录 ⇒ [`abandoned`];已经写过 ⇒ [`record_rollback`]。 /// 口径由调用方补:从未写过目标目录 ⇒ [`abandoned`];已经写过 ⇒ [`record_rollback`]。
+65
View File
@@ -0,0 +1,65 @@
//! 导入管线的**准备段**:读包 → 组计划 → 抄进报告 → 写基线(一个字节都还没进目标目录)。
//!
//! 从 [`super::pipeline`] 拆出来的是"碰盘之前"的那一段,行为逐字未变:编排文件(`pipeline.rs`)
//! 要装下取消的七个检查点与回滚收尾,把这一段移到同级模块是为了让它仍在一屏之内。
//!
//! 这里的函数**都不查取消令牌**:整段落在"解析后一次检查"之前/之后的两侧,而 `open` 是
//! 不可分割的(zip 目录 + 四检 + 限额一次扫完)——见 [`super::cancel`] 的逐段表。
use super::baseline::PackBaseline;
use super::error::ImportError;
use super::plan::ImportPlan;
use super::report::ImportReport;
use super::stage::Staging;
use super::ImportRequest;
use crate::addon::pack::{PackArchive, PackError, PackRegistry};
/// 开包 + 探测 + 组计划(任何失败都是 `Failed`)。
///
/// 预检([`super::scan`])走的是**同一段代码**:两边对"这包是什么、要装什么"必须完全一致,
/// 否则"预检说没冲突、导入说有冲突"这种事就会发生。
pub(super) fn probe_and_plan(
request: &ImportRequest<'_>,
) -> Result<(PackArchive, ImportPlan), ImportError> {
// 路径穿越双检 + 符号链接 + 条数/体积限额都在 `open` 里一次做完(M110 的 `archive`)。
let archive = PackArchive::open(request.pack, request.limits)?;
let (format, manifest) = PackRegistry::builtin().read(&archive)?;
let plan = ImportPlan::build(&archive, format.id(), manifest)?;
Ok((archive, plan))
}
/// 把计划里的静态信息抄进报告。
pub(super) fn account_plan(report: &mut ImportReport, plan: &ImportPlan) {
report.format = Some(plan.format.clone());
report.pack_name = Some(plan.manifest.name.clone());
report.pack_version = plan.manifest.version.clone();
report.game_version = Some(plan.mc_version.clone());
report.loader = plan.loader.clone();
report.version_id = Some(plan.version_id.clone());
report.max_memory_mb = plan.max_memory_mb;
report.rejected_components.clone_from(&plan.rejected);
report.warnings.extend(plan.notes.iter().cloned());
}
/// 基线作为**最后一个暂存文件**写进去:它跟同一个事务一起提交 / 回滚。
pub(super) fn write_baseline(
staging: &Staging,
plan: &ImportPlan,
next: &PackBaseline,
) -> Result<(), ImportError> {
let mut baseline = next.clone();
baseline.pack_name.clone_from(&plan.manifest.name);
baseline.format.clone_from(&plan.format);
let bytes = baseline.to_bytes().map_err(PackError::io)?;
staging.write(&baseline_relative(), &bytes)?;
Ok(())
}
/// 基线相对实例根的路径(`<根>/.ull/pack-baseline.json`;与读侧同源,见
/// [`PackBaseline::relative_path`])。
#[must_use]
pub fn baseline_relative() -> String {
PackBaseline::relative_path()
.to_string_lossy()
.replace('\\', "/")
}
+96 -2
View File
@@ -32,7 +32,11 @@ use serde::Serialize;
use super::error::ImportError; use super::error::ImportError;
use super::stage::RollbackOutcome; use super::stage::RollbackOutcome;
/// 导入三态。 /// 导入结果(三态 + 一档"用户取消")。
///
/// 取消**单列一档、不是第四种失败**(M195):它有自己的变体、自己的文案 key、自己的退出码,
/// 且 `ImportReport::error` 恒为 `None`。把两者并成一个错误变体,UI 就会把"用户自己点的"
/// 画成红的,脚本也会把"我改主意了"当成"装坏了"。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum ImportStatus { pub enum ImportStatus {
@@ -42,6 +46,8 @@ pub enum ImportStatus {
Partial, Partial,
/// 致命失败,已整包回滚。 /// 致命失败,已整包回滚。
Failed, Failed,
/// 用户主动取消:管线在检查点停下,目标目录与导入前一致(`error` 为 `None`)。
Cancelled,
} }
impl ImportStatus { impl ImportStatus {
@@ -52,6 +58,7 @@ impl ImportStatus {
Self::Success => "success", Self::Success => "success",
Self::Partial => "partial", Self::Partial => "partial",
Self::Failed => "failed", Self::Failed => "failed",
Self::Cancelled => "cancelled",
} }
} }
@@ -62,16 +69,21 @@ impl ImportStatus {
Self::Success => keys::STATUS_SUCCESS, Self::Success => keys::STATUS_SUCCESS,
Self::Partial => keys::STATUS_PARTIAL, Self::Partial => keys::STATUS_PARTIAL,
Self::Failed => keys::STATUS_FAILED, Self::Failed => keys::STATUS_FAILED,
Self::Cancelled => keys::STATUS_CANCELLED,
} }
} }
/// CLI 退出码:`0` / `3` / `1`(`2` 留给内部错误,`1` 与用法错误同码但 JSON 里可区分)。 /// CLI 退出码:`0` / `3` / `1` / `130`(`2` 留给内部错误,`1` 与用法错误同码但 JSON 里可区分)。
///
/// `130` 是「被用户中断」的惯例码(`128 + SIGINT`):**与失败不同码**,脚本不会把
/// 「用户改主意」记成「装坏了」,也不会误判成成功。
#[must_use] #[must_use]
pub const fn exit_code(self) -> i32 { pub const fn exit_code(self) -> i32 {
match self { match self {
Self::Success => 0, Self::Success => 0,
Self::Partial => 3, Self::Partial => 3,
Self::Failed => 1, Self::Failed => 1,
Self::Cancelled => 130,
} }
} }
} }
@@ -134,6 +146,27 @@ pub struct ImportProgress {
/// 进度回调。 /// 进度回调。
pub type ImportProgressSink = std::sync::Arc<dyn Fn(&ImportProgress) + Send + Sync>; pub type ImportProgressSink = std::sync::Arc<dyn Fn(&ImportProgress) + Send + Sync>;
/// 发一条进度事件(`sink` 为 `None` 时什么都不做)。
///
/// 放在这里是因为它是 [`ImportProgress`] 的唯一出口:编排、阶段实现、提交事务都走它,
/// 谁都不许自己拼一条进度事件。
pub(crate) fn tell(
sink: &Option<ImportProgressSink>,
stage: ImportStage,
done: usize,
total: usize,
current: Option<String>,
) {
if let Some(sink) = sink {
sink(&ImportProgress {
stage,
done,
total,
current,
});
}
}
/// 一次导入的完整结果。 /// 一次导入的完整结果。
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct ImportReport { pub struct ImportReport {
@@ -240,4 +273,65 @@ impl ImportReport {
self.error = Some(error); self.error = Some(error);
self.status = ImportStatus::Failed; self.status = ImportStatus::Failed;
} }
/// 取消收尾:状态落到 [`ImportStatus::Cancelled`],并说准**目标目录现在的样子**。
///
/// 与 [`ImportReport::fail`] 是**两条路**(M195 的守门条款③):取消不是失败,`error` 保持
/// `None`,UI 不弹红、CLI 退出码 `130`。把两者并成一个错误变体,这一条就守不住。
///
/// `outcome` 是取消之前已经做过的回滚动作(落盘之前取消 ⇒ `attempted == 0`,即"没写过")。
/// 三档文案与失败路径**同形**:没写过 / 回滚干净 / 回滚没做完——回滚不完整时同样不许宣称
/// "与导入前一致"。
pub fn cancel(&mut self, outcome: RollbackOutcome) {
// 取消前"处理过"的条目一条都没进目标目录(暂存目录已删 / 已回滚):留着它们会让
// `installed()` / `bytes_written` 谎报"装了多少",所以清空并把这笔账写进注记。
let handled = self.files.len();
self.files.clear();
self.conflicts.clear();
self.missing.clear();
self.bytes_written = 0;
self.error = None;
self.status = ImportStatus::Cancelled;
if outcome.attempted == 0 {
// 落盘之前就停了:目标目录一个字节都没动。
self.rolled_back = true;
self.warnings.push(ImportIssue::new(
keys::NOTE_CANCELLED,
format!(
"已取消:目标目录未被写入({});已处理的 {handled} 条文件未落地",
self.target.display()
),
));
return;
}
if outcome.is_complete() {
self.rolled_back = true;
self.warnings.push(ImportIssue::new(
keys::NOTE_CANCELLED,
format!(
"已取消:已回滚 {} 项,目标目录恢复为导入前的样子({})",
outcome.attempted,
self.target.display()
),
));
} else {
// 回滚本身失败:与失败路径同一口径——不许在"可能没回干净"时宣称一致。
self.rolled_back = false;
self.warnings.push(ImportIssue::new(
keys::NOTE_ROLLBACK_INCOMPLETE,
format!(
"取消时回滚有 {} 项未完成(目标目录可能与导入前不同):{}",
outcome.failure_count(),
outcome.failed.join(";")
),
));
self.warnings.push(ImportIssue::new(
keys::NOTE_CANCELLED,
format!("已取消:已处理 {handled} 条文件、回滚未完整(详见 rollback.failed)"),
));
}
self.rollback = Some(outcome);
}
} }
@@ -24,6 +24,8 @@ pub const STATUS_SUCCESS: &str = "pack.import.status.success";
pub const STATUS_PARTIAL: &str = "pack.import.status.partial"; pub const STATUS_PARTIAL: &str = "pack.import.status.partial";
/// 状态:失败(是否回滚干净看 `ImportReport::rolled_back`)。 /// 状态:失败(是否回滚干净看 `ImportReport::rolled_back`)。
pub const STATUS_FAILED: &str = "pack.import.status.failed"; pub const STATUS_FAILED: &str = "pack.import.status.failed";
/// 状态:**用户取消**(不是失败,`error` 为空)。
pub const STATUS_CANCELLED: &str = "pack.import.status.cancelled";
/// 注记:`server-overrides/` 客户端不解压。 /// 注记:`server-overrides/` 客户端不解压。
pub const NOTE_SERVER_OVERRIDES_SKIPPED: &str = "pack.import.note.server_overrides_skipped"; pub const NOTE_SERVER_OVERRIDES_SKIPPED: &str = "pack.import.note.server_overrides_skipped";
@@ -51,6 +53,8 @@ pub const NOTE_BASELINE_PACK_MISMATCH: &str = "pack.import.note.baseline_pack_mi
pub const NOTE_ENGINE_ARTIFACTS_DROPPED: &str = "pack.import.note.engine_artifacts_dropped"; pub const NOTE_ENGINE_ARTIFACTS_DROPPED: &str = "pack.import.note.engine_artifacts_dropped";
/// 注记:回滚**未完整完成**(逐项列失败,`rolled_back` 为 false)。 /// 注记:回滚**未完整完成**(逐项列失败,`rolled_back` 为 false)。
pub const NOTE_ROLLBACK_INCOMPLETE: &str = "pack.import.note.rollback_incomplete"; pub const NOTE_ROLLBACK_INCOMPLETE: &str = "pack.import.note.rollback_incomplete";
/// 注记:用户取消,管线在检查点停下(说明停在哪一步、已处理的条目未落地)。
pub const NOTE_CANCELLED: &str = "pack.import.note.cancelled";
/// 注记:同包另一条声明遮蔽了这个路径(说清谁赢、两份内容是否相同)。 /// 注记:同包另一条声明遮蔽了这个路径(说清谁赢、两份内容是否相同)。
pub const NOTE_OVERRIDE_SHADOWED: &str = "pack.import.note.override_shadowed"; pub const NOTE_OVERRIDE_SHADOWED: &str = "pack.import.note.override_shadowed";
/// 注记:这些远端条目预检判不了(补全前没有路径 / 有路径但缺 sha1 声明),无法预判冲突。 /// 注记:这些远端条目预检判不了(补全前没有路径 / 有路径但缺 sha1 声明),无法预判冲突。
@@ -87,6 +91,7 @@ pub const ALL: &[&str] = &[
STATUS_SUCCESS, STATUS_SUCCESS,
STATUS_PARTIAL, STATUS_PARTIAL,
STATUS_FAILED, STATUS_FAILED,
STATUS_CANCELLED,
NOTE_SERVER_OVERRIDES_SKIPPED, NOTE_SERVER_OVERRIDES_SKIPPED,
NOTE_UNKNOWN_DEPENDENCY, NOTE_UNKNOWN_DEPENDENCY,
NOTE_ENV_UNSUPPORTED, NOTE_ENV_UNSUPPORTED,
@@ -100,6 +105,7 @@ pub const ALL: &[&str] = &[
NOTE_BASELINE_PACK_MISMATCH, NOTE_BASELINE_PACK_MISMATCH,
NOTE_ENGINE_ARTIFACTS_DROPPED, NOTE_ENGINE_ARTIFACTS_DROPPED,
NOTE_ROLLBACK_INCOMPLETE, NOTE_ROLLBACK_INCOMPLETE,
NOTE_CANCELLED,
NOTE_OVERRIDE_SHADOWED, NOTE_OVERRIDE_SHADOWED,
NOTE_REMOTE_PATH_UNKNOWN, NOTE_REMOTE_PATH_UNKNOWN,
NOTE_REMOTE_COMPLETED, NOTE_REMOTE_COMPLETED,
+2 -1
View File
@@ -42,8 +42,9 @@ use super::baseline::PackBaseline;
use super::decision::ConflictDecisions; use super::decision::ConflictDecisions;
use super::error::ImportError; use super::error::ImportError;
use super::merge; use super::merge;
use super::pipeline::{load_baseline, probe_and_plan}; use super::pipeline::load_baseline;
use super::plan::ImportPlan; use super::plan::ImportPlan;
use super::prepare::probe_and_plan;
use super::report::{keys, ConflictReason, FileSource, ImportIssue, LoaderTarget}; use super::report::{keys, ConflictReason, FileSource, ImportIssue, LoaderTarget};
use super::ImportRequest; use super::ImportRequest;
use crate::download::verify; use crate::download::verify;
+66 -13
View File
@@ -10,6 +10,15 @@
//! 为什么全新导入不逐文件搬:逐文件搬意味着"搬到一半失败"会留下一个**看起来存在**的 //! 为什么全新导入不逐文件搬:逐文件搬意味着"搬到一半失败"会留下一个**看起来存在**的
//! 半个实例,而 `rename` 一个目录是单步原子操作——失败就只有"没有"这一种结果。 //! 半个实例,而 `rename` 一个目录是单步原子操作——失败就只有"没有"这一种结果。
//! //!
//! # 取消落在这层怎么办(M195)
//!
//! | 起点 | 取消点 | 收场 |
//! |------|--------|------|
//! | 全新导入 | **只在 `rename` 之前** | 删暂存目录,目标目录从未出现 |
//! | 合并态 | 每条搬动之前 | journal **逆序**撤回(新文件删掉、旧备份改回原名) |
//!
//! 合并态逐文件报进度(`ImportStage::Commit`):既是界面该有的动静,也是取消能落下的地方。
//!
//! # 两道路径检查都在这层再走一遍 //! # 两道路径检查都在这层再走一遍
//! //!
//! 第一道是 M110 的 [`normalize_file`](七条写死规则:空名 / `\0` / `\` / `:` / 前导 `/` / //! 第一道是 M110 的 [`normalize_file`](七条写死规则:空名 / `\0` / `\` / `:` / 前导 `/` /
@@ -19,14 +28,17 @@
pub mod commit; pub mod commit;
pub use commit::{CommitFailure, CommitGuard, RollbackOutcome}; pub use commit::{CommitFailure, CommitGuard, CommitStop, RollbackOutcome};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use super::super::pack::path::normalize_file; use super::super::pack::path::normalize_file;
use super::super::pack::PackError; use super::super::pack::PackError;
use super::cancel::{ImportCancel, Stopped};
use super::error::ImportError; use super::error::ImportError;
use super::report::tell;
use super::report::{ImportProgressSink, ImportStage};
use crate::platform::atomic::write_atomic; use crate::platform::atomic::write_atomic;
use crate::download::atomic::{META_SUFFIX, PART_SUFFIX}; use crate::download::atomic::{META_SUFFIX, PART_SUFFIX};
@@ -177,38 +189,62 @@ impl Staging {
/// 提交事务:返回一个**必须显式 `finish`** 的守卫(析构即回滚)。 /// 提交事务:返回一个**必须显式 `finish`** 的守卫(析构即回滚)。
/// ///
/// `cancel` 是取消令牌,`progress` 是进度回调(合并态**逐文件**报一条:几千文件的合并本来
/// 就该看得见动静,而"取消"也正需要文件之间的落点)。
///
/// # Errors /// # Errors
/// ///
/// `rename` 失败、合并态逐文件搬动失败。**回滚已经在这条路径上做过**,其结果连同错误 /// - [`CommitStop::Cancelled`]:取消落在提交前 / 合并中途。**回滚已经在这条路径上做过**,
/// 一起返回([`CommitFailure::rollback`]):调用方必须把它写进报告,不许假设"失败 = 已回滚干净"。 /// 结果一并返回(调用方把它写进报告的 `rollback` 机器位);
pub fn commit(self) -> Result<CommitGuard, CommitFailure> { /// - [`CommitStop::Failed`]:`rename` 失败、合并态逐文件搬动失败。回滚同样已经做过,其结果
/// 随错误返回([`CommitFailure::rollback`]):调用方不许假设"失败 = 已回滚干净"。
///
/// 全新导入只有一次 `rename`:那是单步原子操作,**取消点只在它之前**——之后不存在"半个
/// 实例"这种中间态,也就没有"取消"这一说。
pub fn commit(
self,
cancel: &ImportCancel,
progress: &Option<ImportProgressSink>,
) -> Result<CommitGuard, CommitStop> {
match self.mode { match self.mode {
Mode::Fresh => match std::fs::rename(&self.temp, &self.target) { Mode::Fresh => {
if cancel.is_cancelled() {
// 还没 rename:目标目录一个字节都没动,删掉暂存目录就是零残留。
self.abort_inner();
return Err(CommitStop::Cancelled(RollbackOutcome::default()));
}
match std::fs::rename(&self.temp, &self.target) {
Ok(()) => Ok(CommitGuard::fresh(self.target.clone(), self.temp.clone())), Ok(()) => Ok(CommitGuard::fresh(self.target.clone(), self.temp.clone())),
Err(error) => { Err(error) => {
self.abort_inner(); self.abort_inner();
Err(CommitFailure::io( Err(CommitStop::Failed(CommitFailure::io(
format!( format!(
"把 {} 改名成 {} 失败:{error}", "把 {} 改名成 {} 失败:{error}",
self.temp.display(), self.temp.display(),
self.target.display() self.target.display()
), ),
RollbackOutcome::default(), RollbackOutcome::default(),
)) )))
}
}
} }
},
Mode::Merge => { Mode::Merge => {
let mut applied: Vec<Applied> = Vec::new(); let mut applied: Vec<Applied> = Vec::new();
match self.apply_merge(&mut applied) { match self.apply_merge(&mut applied, cancel, progress) {
Ok(()) => Ok(CommitGuard::merge( Ok(()) => Ok(CommitGuard::merge(
self.target.clone(), self.target.clone(),
self.temp.clone(), self.temp.clone(),
applied, applied,
)), )),
Err(error) => { Err(Stopped::Cancelled) => {
let rollback = rollback_merge(&mut applied); let rollback = rollback_merge(&mut applied);
self.abort_inner(); self.abort_inner();
Err(CommitFailure::new(error, rollback)) Err(CommitStop::Cancelled(rollback))
}
Err(Stopped::Failed(error)) => {
let rollback = rollback_merge(&mut applied);
self.abort_inner();
Err(CommitStop::Failed(CommitFailure::new(error, rollback)))
} }
} }
} }
@@ -244,15 +280,32 @@ impl Staging {
} }
/// 合并态:把临时目录里的文件逐个搬进目标目录(每个都带备份)。 /// 合并态:把临时目录里的文件逐个搬进目标目录(每个都带备份)。
fn apply_merge(&self, applied: &mut Vec<Applied>) -> Result<(), ImportError> { ///
/// **逐文件取消点**:每条搬动之前查一次令牌,已取消 ⇒ `Err(Stopped::Cancelled)`,调用方
/// 按 journal 逆序回滚(新文件删掉、旧备份改回原名)。
fn apply_merge(
&self,
applied: &mut Vec<Applied>,
cancel: &ImportCancel,
progress: &Option<ImportProgressSink>,
) -> Result<(), Stopped> {
let mut staged: Vec<PathBuf> = Vec::new(); let mut staged: Vec<PathBuf> = Vec::new();
collect_files(&self.temp, &mut staged)?; collect_files(&self.temp, &mut staged)?;
staged.sort(); staged.sort();
for source in staged { let total = staged.len();
for (index, source) in staged.into_iter().enumerate() {
cancel.stopped()?;
let relative = source let relative = source
.strip_prefix(&self.temp) .strip_prefix(&self.temp)
.map_err(|_| io_error(format!("暂存路径不在临时目录里:{}", source.display())))?; .map_err(|_| io_error(format!("暂存路径不在临时目录里:{}", source.display())))?;
let relative_text = relative.to_string_lossy().replace('\\', "/"); let relative_text = relative.to_string_lossy().replace('\\', "/");
tell(
progress,
ImportStage::Commit,
index,
total,
Some(relative_text.clone()),
);
// 搬动前再规整一次(第三道,防"临时目录里被塞了奇怪的名字")。 // 搬动前再规整一次(第三道,防"临时目录里被塞了奇怪的名字")。
let normalized = normalize_file(&relative_text)?; let normalized = normalize_file(&relative_text)?;
let dest = self.target.join(&normalized); let dest = self.target.join(&normalized);
@@ -207,6 +207,18 @@ pub(super) fn backup_path(dest: &Path) -> PathBuf {
dest.with_file_name(name) dest.with_file_name(name)
} }
/// 提交阶段的两条停止路线(M195):**取消不是失败**,所以不共用 [`CommitFailure`]。
///
/// 两条路都**已经把回滚做过**(全新导入是 `abort`,合并态是 journal 逆序撤回):
/// 调用方拿到它们只管写报告,不必再自己清一遍。
#[derive(Debug)]
pub enum CommitStop {
/// 用户取消(回滚结果一并交回,可能不完整)。
Cancelled(RollbackOutcome),
/// 真失败(回滚结果连同错误一起交回)。
Failed(CommitFailure),
}
/// 一次**失败的回滚**(提交阶段失败时连同错误一起交回调用方)。 /// 一次**失败的回滚**(提交阶段失败时连同错误一起交回调用方)。
/// ///
/// `io_error` 让"把一个 IO 原因包成 `pack_io`"只在一处定义。 /// `io_error` 让"把一个 IO 原因包成 `pack_io`"只在一处定义。
+11 -10
View File
@@ -24,19 +24,20 @@
//! 所以每个可扫文件都证明不了可被远程替代 ⇒ 内联;`SkippedLocal` 就是「无法导出」。 //! 所以每个可扫文件都证明不了可被远程替代 ⇒ 内联;`SkippedLocal` 就是「无法导出」。
//! `tests::preview_counts_match_the_written_report` 用真管线钉住这个等式。 //! `tests::preview_counts_match_the_written_report` 用真管线钉住这个等式。
//! //!
//! ## 取消是**界面级**的 //! ## 取消是**管线级**的(M195 起)
//! //!
//! [`cancel_pack`] 置模块级标记 + `dispatch` 里的 `CancelInstall` 把代次 +1: //! [`cancel_pack`] 置模块级标记 + `dispatch` 里的 `CancelInstall` 把代次 +1,而**同一个标记**
//! 从 M195 起就是 core 的取消令牌(`ImportRequest::with_cancel`):
//! //!
//! 1. 面板立刻收回(不是等后台回一句话); //! 1. 面板立刻收回(不是等后台回一句话);
//! 2. 这一轮之后回来的事件因代次不符被 `handle` 丢掉; //! 2. 这一轮之后回来的事件因代次不符被 `handle` 丢掉;
//! 3. **后台那次导入会继续跑到结束**——core 的 `ImportRequest` 没有取消令牌,进度回调也拿不到 //! 3. **后台那次导入真的停手**:core 在阶段边界与长循环内检查令牌,停下就回滚(暂存目录删掉、
//! 控制流。所以「取消」的真实含义是「不再显示、结果作废」,不是「把它停手」。要真停必须给 //! 落盘过的撤回、实例不登记),结果是 `ImportStatus::Cancelled` 而**不是**失败。响应延迟的
//! core 加 cancel token(已登记 finding,报告里同样划出来); //! 底线是「当前这一条文件读完/下完」,见 `core::addon::import::cancel` 的逐段表;
//! 4. **提示语不能借用安装那条**(复审 r1 P1-2):下载页的取消 toast 写着「实例未创建」, //! 4. **提示语不能借用安装那条**(复审 r1 P1-2):`cancel_pack()` 回一个「刚才有没有导入在跑」,
//! 而导入会把实例**登记上**。所以 `cancel_pack()` 回一个「刚才有没有导入在跑」, //! `dispatch` 据此改发 [`crate::dto::PackEvent::Cancelled`]。取消**通常**意味着实例没建
//! `dispatch` 据此改发 [`crate::dto::PackEvent::Cancelled`],界面上换成 //! (管线回滚了),但取消也可能恰好在两个检查点之间落下(那时已经装完并登记)——所以文案
//! 「导入会在后台继续跑完 —— 本次结果作废」这句。 //! 只承诺「本次结果作废 + 目标目录已回滚到导入前」,不承诺「一条命令都没执行」。
mod convert; mod convert;
mod diagnose; mod diagnose;
@@ -151,7 +152,7 @@ impl Drop for InFlight {
/// 取消这个 host 正在跑的导入(`CancelInstall` 与「关面板」都走它;没在跑时是 no-op)。 /// 取消这个 host 正在跑的导入(`CancelInstall` 与「关面板」都走它;没在跑时是 no-op)。
/// ///
/// 返回值 = 「刚才确实有一次导入在跑」。调用方据此决定提示语:**导入的取消不是 install 的取消** /// 返回值 = 「刚才确实有一次导入在跑」。调用方据此决定提示语:**导入的取消不是 install 的取消**
/// (后台会跑完并登记实例,见模块文档),所以不能复用下载页那条「安装已取消、实例未创建」。 /// (M195 起 core 真的停手并回滚,见模块文档),所以不能复用下载页那条「安装已取消、实例未创建」。
pub(in crate::host) fn cancel_pack(env: &HostEnv) -> bool { pub(in crate::host) fn cancel_pack(env: &HostEnv) -> bool {
let key = host_key(env); let key = host_key(env);
{ {
@@ -100,10 +100,21 @@ const fn reason_of(reason: ConflictReason) -> ConflictReasonView {
} }
/// 一次导入结果 → DTO。 /// 一次导入结果 → DTO。
///
/// # `Cancelled` 为什么在这里没有对应档(M195)
///
/// DTO 的 `ImportStatusView` 只有三档,取消走的是 [`crate::dto::PackEvent::Cancelled`]
/// (`import::run` 拿到 `ImportStatus::Cancelled` 就不发报告事件了)。所以本函数只会在
/// "取消恰好落在两个检查点之间、报告仍然是成功/部分成功"时被调用。真收到 `Cancelled`
/// 时按 `Partial` 落地:**绝不是** `Failed`——取消不是失败,不许画成红的。
pub(super) fn report_view(report: &ImportReport, target: &Path) -> ImportReportView { pub(super) fn report_view(report: &ImportReport, target: &Path) -> ImportReportView {
ImportReportView { ImportReportView {
status: ImportStatusView::from_slug(report.status.as_str()) status: match report.status {
.unwrap_or(ImportStatusView::Failed), ImportStatus::Cancelled => ImportStatusView::Partial,
other => {
ImportStatusView::from_slug(other.as_str()).unwrap_or(ImportStatusView::Failed)
}
},
format: report.format.as_deref().and_then(PackFormatView::from_slug), format: report.format.as_deref().and_then(PackFormatView::from_slug),
pack_name: report.pack_name.clone(), pack_name: report.pack_name.clone(),
pack_version: report.pack_version.clone(), pack_version: report.pack_version.clone(),
+19 -9
View File
@@ -7,15 +7,17 @@
//! 往下导(不打扰用户);有冲突就回 [`PackEvent::Scan`] 让页面画清单,等用户点完再导; //! 往下导(不打扰用户);有冲突就回 [`PackEvent::Scan`] 让页面画清单,等用户点完再导;
//! 3. **缺省档永远 `KeepAll`**:决策从 DTO 逐字段搬([`convert::decisions_of`]),页面没点过的 //! 3. **缺省档永远 `KeepAll`**:决策从 DTO 逐字段搬([`convert::decisions_of`]),页面没点过的
//! 路径在 core 侧落到"保留"; //! 路径在 core 侧落到"保留";
//! 4. **取消后不再打扰界面**:进度回调与最终报告都先看取消标记。 //! 4. **取消是真取消**(M195):`cancel` 标记既挡界面事件、又**交给 core 当取消令牌**——管线
//! 在检查点停手并回滚,不再"后台跑完并登记"(逐段检查点见
//! `core::addon::import::cancel`)。取消后不再打扰界面:进度回调与最终报告都先看取消标记。
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc; use std::sync::Arc;
use unlucky_core::addon::import::{ use unlucky_core::addon::import::{
import_pack, scan_pack_online, DownloadEngineFetcher, ImportProgress, ImportProgressSink, import_pack, scan_pack_online, DownloadEngineFetcher, ImportCancel, ImportProgress,
ImportRequest, ImportProgressSink, ImportRequest,
}; };
use unlucky_core::download::blocking::block_on; use unlucky_core::download::blocking::block_on;
@@ -181,8 +183,11 @@ pub(super) fn run(
let engine = super::super::super::engine::engine(env); let engine = super::super::super::engine::engine(env);
let fetcher = DownloadEngineFetcher::new(&engine); let fetcher = DownloadEngineFetcher::new(&engine);
// 这个开关从 M195 起是**真令牌**:core 的每个检查点都看它(不只是挡界面事件)。
let token = ImportCancel::from_flag(Arc::clone(&cancel));
let mut core_request = ImportRequest::new(&request.pack, &target) let mut core_request = ImportRequest::new(&request.pack, &target)
.with_fetcher(&fetcher) .with_fetcher(&fetcher)
.with_cancel(&token)
.with_progress(sink) .with_progress(sink)
// 决策逐字段搬过来:`KeepAll`(缺省)/ `OverwriteAll` / 逐项,语义在 core 那一侧落地。 // 决策逐字段搬过来:`KeepAll`(缺省)/ `OverwriteAll` / 逐项,语义在 core 那一侧落地。
.with_decisions(convert::decisions_of(&request.decisions)); .with_decisions(convert::decisions_of(&request.decisions));
@@ -191,15 +196,20 @@ pub(super) fn run(
} }
let report = block_on(import_pack(&core_request)); let report = block_on(import_pack(&core_request));
if report.status == ImportStatus::Failed && register { if (report.status == ImportStatus::Failed || report.status == ImportStatus::Cancelled)
// 失败已回滚,但**目录是我们建的**、core 只清文件不清目录 ⇒ 不清掉的话 && register
// 下一次重试会撞「directory already exists」(复审 r1 P2-9)。 {
// 失败 / 取消都已回滚,但**目录是我们建的**(`prepare_target`)、core 只清文件不清
// 目录 ⇒ 不清掉的话下一次重试会撞「directory already exists」(复审 r1 P2-9)。
remove_if_empty(&target); remove_if_empty(&target);
} }
if cancel.load(Ordering::SeqCst) { if report.status == ImportStatus::Cancelled || cancel.load(Ordering::SeqCst) {
// 取消之后不再打扰界面(面板已经收回,代次也已经作废)。 // 取消之后不再打扰界面(面板已经收回,代次也已经作废)。
// 注意:后台这一步**已经跑完**(core 没有取消令牌),实例可能已被登记 —— //
// 所以取消的提示语必须说「还在后台跑」,不能说「实例未创建」。 // M195 起这条是**真的停下来了**:core 在检查点回滚(暂存目录已删,落盘过的已撤回,
// 实例没有登记)。所以提示语可以照实说「已取消:目标目录未改动」;
// `cancel.load(..)` 单独留一条是因为取消也可能**恰好落在两个检查点之间**(管线已经
// 跑完并登记),那时同样不再打扰界面,但别对用户说"没装"。
return; return;
} }
send( send(