Merge branch 'feat/m195-importcancel-import-pipeline-cancel'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
This commit is contained in:
commit
0e6d21ba89
40 files changed
+1958
-290
No files matched your search
@@ -19,9 +19,10 @@ use std::path::PathBuf;
|
||||
use unlucky_i18n::Lang;
|
||||
use unlucky_presenter::dto::{
|
||||
ConflictChoiceView, ConflictFileView, ConflictReasonView, ExportClassView, ExportFileView,
|
||||
ExportPreviewView, ExportReasonView, ExportState, FileActionView, ImportErrorView,
|
||||
ImportFailureStageView, ImportProgressView, ImportReportView, ImportStageView, ImportState,
|
||||
ImportStatusView, InstanceId, LoaderKind, MissingFileView, MissingReasonView, PackFormatView,
|
||||
ExportPreviewView, ExportReasonView, ExportState, FileActionView, ImportCancelView,
|
||||
ImportErrorView, ImportFailureStageView, ImportProgressView, ImportReportView, ImportStageView,
|
||||
ImportState, ImportStatusView, InstanceId, LoaderKind, MissingFileView, MissingReasonView,
|
||||
PackFormatView,
|
||||
};
|
||||
|
||||
use crate::pages::content::Target;
|
||||
@@ -212,6 +213,7 @@ fn report(status: ImportStatusView, conflicts: bool) -> ImportReportView {
|
||||
bytes_written: 3_400_000,
|
||||
rolled_back: true,
|
||||
failure_stage: None,
|
||||
cancel: ImportCancelView::NotRequested,
|
||||
};
|
||||
if conflicts {
|
||||
report.conflicts = vec![
|
||||
|
||||
@@ -196,8 +196,8 @@ pub struct Pack {
|
||||
pub running: Option<Running>,
|
||||
/// 探测失败诊断
|
||||
pub probe: Option<Probe>,
|
||||
/// 用户取消过这次导入:面板收回但**后台还在跑**(core 没有取消令牌),
|
||||
/// 所以要把这句话留在界面上,别让用户以为「没建成」(复审 r1 P1-2)。
|
||||
/// 用户点过取消、**结果还没回来**(面板已收回)。它只作占位:真实结局在
|
||||
/// `report.cancel` 里(已回滚 / 回滚没做完 / 取消太晚),所以文案不许在这里替结局下结论。
|
||||
pub cancelled: bool,
|
||||
/// 取消提示的正文
|
||||
pub cancelled_note: &'static str,
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
use unlucky_presenter::dto::{
|
||||
ConflictChoiceView, ConflictFileView, ConflictReasonView, ImportErrorView,
|
||||
ConflictChoiceView, ConflictFileView, ConflictReasonView, ImportCancelView, ImportErrorView,
|
||||
ImportFailureStageView, ImportReportView, ImportStatusView, MissingFileView, MissingReasonView,
|
||||
};
|
||||
|
||||
@@ -124,6 +124,11 @@ pub struct Report {
|
||||
pub can_overwrite: bool,
|
||||
/// 「两者都留改名」为何不支持(有冲突时如实写)
|
||||
pub overwrite_note: Option<String>,
|
||||
/// 这次运行与取消的关系(M195 r3:标题与图标按它分档,不许一句打天下)
|
||||
pub cancel: ImportCancelView,
|
||||
/// 取消的**补充说明**:只有「取消太晚」有——那种情况标题照常是"已导入/部分成功",
|
||||
/// 必须另起一句说清"你的取消没赶上,实例会保留、同名文件可能已被覆盖"。
|
||||
pub cancel_note: Option<String>,
|
||||
}
|
||||
|
||||
impl Report {
|
||||
@@ -202,6 +207,16 @@ pub(super) fn report_of(
|
||||
&& page.instance.is_some(),
|
||||
overwrite_note: (!report.conflicts.is_empty())
|
||||
.then(|| catalog.text(Key::PackOverwriteNote).to_owned()),
|
||||
cancel: report.cancel,
|
||||
// 「取消太晚」那句只说给**真的跑完**的导入(复审 r3 P2-1):推导那边已经保证
|
||||
// `Failed` 不会落到 `TooLate`,这里再看一眼状态是双保险——万一推导回归,也不会让
|
||||
// 红卡("零残留")与"已经跑完、文件可能被覆盖"同时出现在一屏上。
|
||||
cancel_note: (report.cancel == ImportCancelView::TooLate
|
||||
&& matches!(
|
||||
report.status,
|
||||
ImportStatusView::Success | ImportStatusView::Partial
|
||||
))
|
||||
.then(|| catalog.text(Key::PackCancelTooLate).to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,6 +230,12 @@ fn status_title(report: &ImportReportView, catalog: Catalog) -> &'static str {
|
||||
match report.status {
|
||||
ImportStatusView::Success => catalog.text(Key::PackStatusSuccess),
|
||||
ImportStatusView::Partial => catalog.text(Key::PackStatusPartial),
|
||||
// 三档取消各有各的话:已回滚(什么都没留下)/ 回滚没做完(要对一眼目录)/
|
||||
// 取消太晚(标题是真实状态,另有一句 cancel_note 说明"没赶上")。
|
||||
ImportStatusView::Cancelled => match report.cancel {
|
||||
ImportCancelView::RollbackIncomplete => catalog.text(Key::PackCancelIncomplete),
|
||||
_ => catalog.text(Key::PackStatusCancelled),
|
||||
},
|
||||
ImportStatusView::Failed => match report.failure_stage {
|
||||
Some(ImportFailureStageView::NotStarted) => {
|
||||
catalog.text(Key::PackStatusFailedNotStarted)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! 这里**不测画法**(画法靠出图),测的是「决策」:哪些数字进了哪一行、哪些字段被截短、
|
||||
//! 哪一态该出现哪一块。所有断言都能在没有图形环境的情况下跑。
|
||||
|
||||
mod cancel;
|
||||
mod pick;
|
||||
mod stage;
|
||||
|
||||
@@ -11,8 +12,9 @@ use std::path::PathBuf;
|
||||
use unlucky_i18n::Lang;
|
||||
use unlucky_presenter::dto::{
|
||||
ConflictChoiceView, ConflictFileView, ConflictReasonView, ExportClassView, ExportFileView,
|
||||
ExportPreviewView, ImportErrorView, ImportProgressView, ImportReportView, ImportStageView,
|
||||
ImportStatusView, InstallStage, MissingFileView, MissingReasonView, PackFormatView,
|
||||
ExportPreviewView, ImportCancelView, ImportErrorView, ImportProgressView, ImportReportView,
|
||||
ImportStageView, ImportStatusView, InstallStage, MissingFileView, MissingReasonView,
|
||||
PackFormatView,
|
||||
};
|
||||
|
||||
use super::progress::{panel_progress, panel_stage, stage_line};
|
||||
@@ -228,6 +230,7 @@ fn report_keeps_tolerable_and_fatal_apart() {
|
||||
bytes_written: 0,
|
||||
rolled_back: true,
|
||||
failure_stage: None,
|
||||
cancel: ImportCancelView::NotRequested,
|
||||
};
|
||||
let block = report_of(&report, &PackPageState::default(), catalog());
|
||||
assert!(block.fatal.is_some(), "致命块必须单独存在");
|
||||
@@ -265,6 +268,7 @@ fn conflicts_default_to_keeping_the_user_copy() {
|
||||
bytes_written: 0,
|
||||
rolled_back: true,
|
||||
failure_stage: None,
|
||||
cancel: ImportCancelView::NotRequested,
|
||||
};
|
||||
// 「以包为准」只在导入到**已有实例**时给(复审 r1 P2-2),所以这里选一个实例
|
||||
let page = PackPageState {
|
||||
@@ -363,6 +367,7 @@ fn overwrite_is_offered_only_when_importing_into_an_existing_instance() {
|
||||
bytes_written: 0,
|
||||
rolled_back: true,
|
||||
failure_stage: None,
|
||||
cancel: ImportCancelView::NotRequested,
|
||||
};
|
||||
|
||||
let new_instance = PackPageState::default();
|
||||
@@ -380,21 +385,3 @@ fn overwrite_is_offered_only_when_importing_into_an_existing_instance() {
|
||||
"导入到已有实例时才给翻档动作"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_state_is_visible_and_says_the_import_keeps_running() {
|
||||
// 复审 r1 P1-2:面板收回之后,界面上必须留一句「后台还在跑」
|
||||
let import = unlucky_presenter::dto::ImportState {
|
||||
cancelled: true,
|
||||
..unlucky_presenter::dto::ImportState::default()
|
||||
};
|
||||
let export = unlucky_presenter::dto::ExportState::default();
|
||||
let model = Pack::of(&import, &export, &PackPageState::default(), &[], catalog());
|
||||
assert!(model.cancelled);
|
||||
assert!(
|
||||
model.cancelled_note.to_lowercase().contains("background"),
|
||||
"取消提示必须说清后台还在跑:{}",
|
||||
model.cancelled_note
|
||||
);
|
||||
assert!(!model.is_idle(), "取消态不能被当成「什么都没有」");
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
//! 取消的**三档真实结局**在界面上的分支(M195 r3)。
|
||||
//!
|
||||
//! 口径:一句「已停手并回滚」打天下是假话——取消可能停在提交前(已回滚)、可能停在回滚没收尾
|
||||
//! (目标目录可能变了)、也可能**太晚**(管线已经跑完,实例保留、同名文件可能已被覆盖)。
|
||||
//! 这一族用例钉的就是"三档各说各的话",以及"占位文案不再替结局下结论"。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use unlucky_i18n::{Catalog, Key, Lang};
|
||||
use unlucky_presenter::dto::{
|
||||
ImportCancelView, ImportReportView, ImportStatusView, PackFormatView,
|
||||
};
|
||||
|
||||
use super::super::report::report_of;
|
||||
use super::super::PackPageState;
|
||||
|
||||
fn catalog() -> Catalog {
|
||||
Lang::En.catalog()
|
||||
}
|
||||
|
||||
fn report(
|
||||
status: ImportStatusView,
|
||||
cancel: ImportCancelView,
|
||||
rolled_back: bool,
|
||||
) -> ImportReportView {
|
||||
ImportReportView {
|
||||
status,
|
||||
format: Some(PackFormatView::Multimc),
|
||||
pack_name: Some("Better MC".to_string()),
|
||||
pack_version: None,
|
||||
game_version: Some("1.20.1".to_string()),
|
||||
loader: None,
|
||||
version_id: Some("1.20.1".to_string()),
|
||||
target: PathBuf::from("C:/tmp/instance"),
|
||||
files: Vec::new(),
|
||||
conflicts: Vec::new(),
|
||||
missing: Vec::new(),
|
||||
rejected: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
error: None,
|
||||
registered: None,
|
||||
bytes_written: 0,
|
||||
rolled_back,
|
||||
failure_stage: None,
|
||||
cancel,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rolled_back_cancel_says_it_is_cancelled_not_partially_installed() {
|
||||
// 取消停在提交前:已回滚 ⇒ 标题是「已取消 —— 已回滚」,**不是**「部分成功」(那等于说装了一半),
|
||||
// 也不能出现「取消太晚」那句补充说明。
|
||||
let view = report(
|
||||
ImportStatusView::Cancelled,
|
||||
ImportCancelView::RolledBack,
|
||||
true,
|
||||
);
|
||||
let block = report_of(&view, &PackPageState::default(), catalog());
|
||||
|
||||
assert_eq!(block.title, catalog().text(Key::PackStatusCancelled));
|
||||
assert_ne!(
|
||||
block.title,
|
||||
catalog().text(Key::PackStatusPartial),
|
||||
"已回滚的取消不许显示成「部分成功」"
|
||||
);
|
||||
assert!(!block.installed(), "取消不是「装上了」");
|
||||
assert!(block.cancel_note.is_none(), "已回滚不需要「太晚」那句");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_incomplete_cancel_rollback_says_the_directory_may_differ() {
|
||||
let view = report(
|
||||
ImportStatusView::Cancelled,
|
||||
ImportCancelView::RollbackIncomplete,
|
||||
false,
|
||||
);
|
||||
let block = report_of(&view, &PackPageState::default(), catalog());
|
||||
|
||||
assert_eq!(block.title, catalog().text(Key::PackCancelIncomplete));
|
||||
assert_ne!(
|
||||
block.title,
|
||||
catalog().text(Key::PackStatusCancelled),
|
||||
"回滚没做完 ≠ 已回滚(这两句话不能混)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_too_late_cancel_says_the_import_already_finished() {
|
||||
// 取消来得太晚:状态是真实的 `Success`,另加一句「实例会保留、同名文件可能已被覆盖」。
|
||||
let view = report(ImportStatusView::Success, ImportCancelView::TooLate, false);
|
||||
let block = report_of(&view, &PackPageState::default(), catalog());
|
||||
|
||||
assert_eq!(block.title, catalog().text(Key::PackStatusSuccess));
|
||||
assert_eq!(block.status, ImportStatusView::Success);
|
||||
assert_ne!(
|
||||
block.status,
|
||||
ImportStatusView::Cancelled,
|
||||
"太晚 ≠ 已回滚(状态是真的 Success/Partial)"
|
||||
);
|
||||
assert_eq!(
|
||||
block.cancel_note,
|
||||
Some(catalog().text(Key::PackCancelTooLate).to_owned()),
|
||||
"必须另外说清「取消没赶上」"
|
||||
);
|
||||
}
|
||||
|
||||
/// 三档的标题**互不相同**(把任意两档并成一句,这里就红)。
|
||||
#[test]
|
||||
fn the_three_cancel_outcomes_have_three_different_titles() {
|
||||
let titles = [
|
||||
report_of(
|
||||
&report(
|
||||
ImportStatusView::Cancelled,
|
||||
ImportCancelView::RolledBack,
|
||||
true,
|
||||
),
|
||||
&PackPageState::default(),
|
||||
catalog(),
|
||||
)
|
||||
.title,
|
||||
report_of(
|
||||
&report(
|
||||
ImportStatusView::Cancelled,
|
||||
ImportCancelView::RollbackIncomplete,
|
||||
false,
|
||||
),
|
||||
&PackPageState::default(),
|
||||
catalog(),
|
||||
)
|
||||
.title,
|
||||
report_of(
|
||||
&report(ImportStatusView::Success, ImportCancelView::TooLate, false),
|
||||
&PackPageState::default(),
|
||||
catalog(),
|
||||
)
|
||||
.title,
|
||||
];
|
||||
let unique: std::collections::BTreeSet<&String> = titles.iter().collect();
|
||||
assert_eq!(unique.len(), 3, "三档必须各说各的话:{titles:?}");
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
//! 从 `tests.rs` 拆出(那边贴着 400 行硬线):本文件只守「预检没过(一个字节都没写)」与
|
||||
//! 「已回滚」的措辞/图标分野。夹具在父模块(`use super::*`)。
|
||||
|
||||
use unlucky_presenter::dto::ImportFailureStageView;
|
||||
use unlucky_presenter::dto::{ImportCancelView, ImportFailureStageView};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -39,6 +39,7 @@ fn the_two_failure_stages_never_share_one_sentence() {
|
||||
bytes_written: 0,
|
||||
rolled_back: true,
|
||||
failure_stage: stage,
|
||||
cancel: ImportCancelView::NotRequested,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -98,7 +98,8 @@ pub(crate) fn section<'a>(
|
||||
} else if let Some(report) = &model.report {
|
||||
block = block.push(report_block(ui, catalog, report));
|
||||
} else if model.cancelled {
|
||||
// 取消过、还没有新结果:把「后台还在跑」这句话留在屏上(复审 r1 P1-2)
|
||||
// 取消过、**结果还没回来**:先留一句"正在停手"的占位(真实结局随后的报告给出,
|
||||
// 已回滚 / 回滚没做完 / 取消太晚三档见 `model::report`)
|
||||
block = block.push(
|
||||
column![
|
||||
frame::text_role(
|
||||
|
||||
@@ -57,6 +57,14 @@ pub(super) fn report_block<'a>(
|
||||
report.written_line.as_str(),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
// 「取消太晚」的那句补充说明(标题仍是真实的 `已导入 / 部分成功`)。
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
report.cancel_note.as_deref().unwrap_or(""),
|
||||
ui.color(Role::Tertiary),
|
||||
),
|
||||
]
|
||||
.spacing(ui.space(Space::X1))
|
||||
.width(Length::Fill);
|
||||
@@ -292,17 +300,31 @@ pub(super) fn conflict_row<'a>(
|
||||
}
|
||||
|
||||
pub(super) fn status_icon(report: &Report) -> IconKind {
|
||||
use unlucky_presenter::dto::{ImportCancelView, ImportStatusView};
|
||||
match report.status {
|
||||
unlucky_presenter::dto::ImportStatusView::Success => IconKind::Check,
|
||||
unlucky_presenter::dto::ImportStatusView::Partial => IconKind::Warn,
|
||||
unlucky_presenter::dto::ImportStatusView::Failed => IconKind::Error,
|
||||
ImportStatusView::Success => IconKind::Check,
|
||||
ImportStatusView::Partial => IconKind::Warn,
|
||||
ImportStatusView::Failed => IconKind::Error,
|
||||
// 取消**不是错误**(M195):停手用「关闭(叉)」这个描边字形(页面骨架只允许描边,
|
||||
// 令牌层的 Play/Stop/More 是填充字形,见 `tests/no_hardcoded_tokens.rs` 的守门),
|
||||
// 回滚没做完才升级成 Warn,都不上红。
|
||||
ImportStatusView::Cancelled => match report.cancel {
|
||||
ImportCancelView::RollbackIncomplete => IconKind::Warn,
|
||||
_ => IconKind::Close,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn status_role(report: &Report) -> Role {
|
||||
use unlucky_presenter::dto::{ImportCancelView, ImportStatusView};
|
||||
match report.status {
|
||||
unlucky_presenter::dto::ImportStatusView::Success => Role::Primary,
|
||||
unlucky_presenter::dto::ImportStatusView::Partial => Role::Tertiary,
|
||||
unlucky_presenter::dto::ImportStatusView::Failed => Role::Error,
|
||||
ImportStatusView::Success => Role::Primary,
|
||||
ImportStatusView::Partial => Role::Tertiary,
|
||||
ImportStatusView::Failed => Role::Error,
|
||||
// 取消一档也不做成 `Role::Error`:用户自己点的,不该看起来像装坏了。
|
||||
ImportStatusView::Cancelled => match report.cancel {
|
||||
ImportCancelView::RollbackIncomplete => Role::Tertiary,
|
||||
_ => Role::OnSurfaceVariant,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,11 @@
|
||||
//! | `success` | `0` | 全部落地、无冲突 |
|
||||
//! | `partial` | `3` | 有缺文件或有保留用户文件的冲突(实例可用但不完整) |
|
||||
//! | `failed` | `1` | 致命失败,**已整包回滚** |
|
||||
//! | `cancelled` | `130` | 用户取消(管线在检查点停手并回滚,**不是失败**) |
|
||||
//!
|
||||
//! `130`(= `128 + SIGINT`)是"被用户中断"的惯例码:CLI 这一轮没有取消入口(一次性命令,
|
||||
//! Ctrl-C 直接杀进程),但 `ImportStatus::Cancelled` 是 core 的公开结果,脚本读 `--json` 时
|
||||
//! 必须能把它与 `failed` 分开——所以退出码与文案在这里就位,接中断信号是下一步的事。
|
||||
//!
|
||||
//! `--json` 的信封在三种状态下**都是** `{"ok":true,"data":{…}}`(命令跑完了),分流看
|
||||
//! `data.status` / `data.error.code` 或退出码;`ok:false` 只用于"命令没跑起来"(参数错、
|
||||
@@ -208,6 +213,15 @@ fn status_label(report: &ImportReport) -> &'static str {
|
||||
Some(RollbackStage::Incomplete) => "失败(回滚不完整)",
|
||||
None => match report.status {
|
||||
ImportStatus::Success => "成功",
|
||||
// 取消**不是失败**:不说"失败",也不说"部分成功"(那会让人以为装了一半)。
|
||||
// 但回滚不完整时也不许说"与导入前一致"——与失败路径同一套诚实口径。
|
||||
ImportStatus::Cancelled => {
|
||||
if report.rolled_back {
|
||||
"已取消(目标目录与导入前一致)"
|
||||
} else {
|
||||
"已取消(回滚不完整,目标目录可能与导入前不同)"
|
||||
}
|
||||
}
|
||||
_ => "部分成功",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -29,6 +29,14 @@
|
||||
//! | 两阶段补全 + **失败分级**(仅"缺文件"可容忍) | [`pipeline`] 的顺序 + [`report::MissingFile`] 与 [`error::ImportError`] 的分工 |
|
||||
//! | 路径穿越**双检** | 开包时 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 相反的两条要记住)
|
||||
//!
|
||||
//! - **mrpack**:`overrides/` 与 `client-overrides/` 都解压,**后者胜**(Modrinth 官方客户端语义;
|
||||
@@ -55,11 +63,13 @@
|
||||
//! ```ignore
|
||||
//! let engine = /* core::download::DownloadEngine */;
|
||||
//! let fetcher = DownloadEngineFetcher::new(&engine);
|
||||
//! let cancel = ImportCancel::new(); // 交给「取消」按钮:cancel.cancel()
|
||||
//! let request = ImportRequest::new(pack_path, instance_dir)
|
||||
//! .with_fetcher(&fetcher)
|
||||
//! .with_cancel(&cancel)
|
||||
//! .with_registration(&launcher_layout, None);
|
||||
//! 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 之外的平台、断网中途、
|
||||
//! 磁盘满、真实 CurseForge `files[]` 补全(缺 CF 客户端,见 [`fetch`] 的说明)。
|
||||
//!
|
||||
//! 取消这一条另有三个欠账(M195 报告同样登记):①超大整合包(几千文件 / 数 GB)在真实磁盘
|
||||
//! 上的**取消响应延迟**没测——最坏要等"当前这一条文件读完/下完 + 一次回滚";②**单条下载在飞
|
||||
//! 不可中断**([`fetch::PackFetcher`] 没有取消参数),只能等它返回;③macOS / Linux 未验。
|
||||
|
||||
pub mod baseline;
|
||||
pub mod cancel;
|
||||
pub(crate) mod complete;
|
||||
pub mod decision;
|
||||
pub mod error;
|
||||
@@ -86,11 +101,13 @@ pub mod merge;
|
||||
pub mod phase;
|
||||
pub mod pipeline;
|
||||
pub mod plan;
|
||||
pub(crate) mod prepare;
|
||||
pub mod report;
|
||||
pub mod scan;
|
||||
pub mod stage;
|
||||
|
||||
pub use baseline::{PackBaseline, BASELINE_FILE};
|
||||
pub use cancel::ImportCancel;
|
||||
pub use decision::ConflictDecisions;
|
||||
pub use error::{ImportError, ImportErrorKind};
|
||||
pub use fetch::{
|
||||
@@ -98,8 +115,8 @@ pub use fetch::{
|
||||
FetchedFile, ModrinthFileMeta, PackFetcher,
|
||||
};
|
||||
pub use merge::{MergeAction, MergeDecision};
|
||||
pub use pipeline::baseline_relative;
|
||||
pub use plan::{ImportPlan, PlanOverride, PlanRemote, Shadowed, SuffixVariant};
|
||||
pub use prepare::baseline_relative;
|
||||
pub use report::{
|
||||
keys, ConflictChoice, ConflictFile, ConflictReason, FileAction, FileSource, ImportIssue,
|
||||
ImportProgress, ImportProgressSink, ImportReport, ImportStage, ImportStatus, ImportedFile,
|
||||
@@ -156,6 +173,11 @@ pub struct ImportRequest<'a> {
|
||||
pub instance_name: Option<&'a str>,
|
||||
/// 逐文件冲突决策(含整批缺省档,**缺省 = 全部保留**)。
|
||||
pub decisions: ConflictDecisions,
|
||||
/// 取消令牌;缺省是永不取消的独立令牌(没挂令牌的调用方不必写 `Option`)。
|
||||
///
|
||||
/// 语义与检查点位置见 [`cancel`] 模块文档:取消后**不得**留下半包 / 覆盖已有实例,也不许
|
||||
/// 登记实例;判据是报告里的 [`report::ImportStatus::Cancelled`](**不是**失败)。
|
||||
pub cancel: ImportCancel,
|
||||
/// 包读取限额(条数 / 单条体积 / 解压总量)。
|
||||
pub limits: PackLimits,
|
||||
/// 进度回调。
|
||||
@@ -173,6 +195,7 @@ impl<'a> ImportRequest<'a> {
|
||||
register: None,
|
||||
instance_name: None,
|
||||
decisions: ConflictDecisions::default(),
|
||||
cancel: ImportCancel::default(),
|
||||
limits: PackLimits::default(),
|
||||
progress: None,
|
||||
}
|
||||
@@ -227,9 +250,22 @@ impl<'a> ImportRequest<'a> {
|
||||
self.progress = Some(sink);
|
||||
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 {
|
||||
pipeline::run(request).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cancel_tests;
|
||||
@@ -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,400 @@
|
||||
//! 导入取消(M195)的守门用例:**取消不是失败**、**取消不留半包 / 不覆盖已有实例**、
|
||||
//! **取消后不登记实例**。
|
||||
//!
|
||||
//! # 每条用例挡哪种回归(变异证明见 mission 报告)
|
||||
//!
|
||||
//! | 用例 | 删掉哪一处就红 |
|
||||
//! |------|----------------|
|
||||
//! | `a_cancel_before_the_first_write_touches_nothing` | 入口检查点 |
|
||||
//! | `a_cancel_inside_the_extract_loop_leaves_no_half_unpacked_pack` | 解压循环检查点(判据 = 解压进度条数:有 = 1、删掉 = 3) |
|
||||
//! | `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()` 那条路 |
|
||||
//!
|
||||
//! # 解压那条必须数事件,其余没有真守卫的检查点
|
||||
//!
|
||||
//! 解压只写**暂存目录**,所以"status == Cancelled / 目标目录不存在 / `files` 为空 / 无残留暂存"
|
||||
//! 在删掉循环检查点后**依然成立**(提交前检查点会替它停一次)——只有"解了几条"能区分两者。
|
||||
//!
|
||||
//! 有变异证明的只有上表五处。**没有**:检查点②解析后 / ③补全后 / ④开暂存目录前 / ⑤提交前(删掉
|
||||
//! 任一处,别的检查点会接住同一次取消,`pipeline.rs` 的断言仍绿)、全新导入 `commit` 的取消分支、
|
||||
//! [`super::phase`] 远端下载逐条检查点、[`super::complete`] 补全循环的 `break`——补它们要两段式
|
||||
//! 夹具或更多计数探针,本轮没做。
|
||||
//!
|
||||
//! 触发点全部走**进度回调**(置位 = 用户此刻点了取消,无 sleep、无竞态);全部离线。
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
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();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 数**解压阶段**的进度条数,并在第一条上置位取消(这是解压循环检查点唯一的可观测判据:
|
||||
/// 有它 ⇒ 第一条之后立刻停 = 1;删掉 ⇒ 一路解完 = 文件条数)。
|
||||
fn cancel_on_first_extract_counting(
|
||||
token: &ImportCancel,
|
||||
) -> (ImportProgressSink, Arc<AtomicUsize>) {
|
||||
let token = token.clone();
|
||||
let count = Arc::new(AtomicUsize::new(0));
|
||||
let seen = Arc::clone(&count);
|
||||
let sink: ImportProgressSink = Arc::new(move |progress: &ImportProgress| {
|
||||
if progress.stage != ImportStage::Extract {
|
||||
return;
|
||||
}
|
||||
if seen.fetch_add(1, Ordering::SeqCst) == 0 {
|
||||
token.cancel();
|
||||
}
|
||||
});
|
||||
(sink, count)
|
||||
}
|
||||
|
||||
/// 在**合并态第二条逐文件进度**上置位取消。合并是排序后逐文件搬:第 0 条是基线
|
||||
/// (`.ull/pack-baseline.json`,`.` 排最前),第 1 条才是 `mods/a.jar`——`done == 1` 正好是
|
||||
/// "用户那个被覆盖的文件已经搬进去、后面还有"的窗口,journal 回滚要接住的正是这一刻。
|
||||
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 (sink, extract_events) = cancel_on_first_extract_counting(&token);
|
||||
|
||||
let report = block_on(import_pack(
|
||||
&ImportRequest::new(&pack, &target)
|
||||
.with_cancel(&token)
|
||||
.with_progress(sink),
|
||||
));
|
||||
|
||||
assert_eq!(report.status, ImportStatus::Cancelled, "{:?}", report.error);
|
||||
// **这一条才是解压循环检查点的判据**:有它 ⇒ 第一条之后立刻停(3 个文件的包里只报了 1 条
|
||||
// 解压进度);删掉它 ⇒ 一路解完(3 条)。其余断言在删掉检查点后仍然成立,挡不住回归。
|
||||
assert_eq!(
|
||||
extract_events.load(Ordering::SeqCst),
|
||||
1,
|
||||
"取消必须在第一条解压之后立刻生效(包里有 3 个文件,解完再停 = 解压段没有检查点)"
|
||||
);
|
||||
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);
|
||||
}
|
||||
@@ -77,7 +77,13 @@ pub(crate) async fn complete_and_note(
|
||||
plan: &mut ImportPlan,
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -94,6 +100,10 @@ pub(crate) async fn complete(request: &ImportRequest<'_>, plan: &mut ImportPlan)
|
||||
return state;
|
||||
};
|
||||
for entry in plan.remote.iter_mut() {
|
||||
// 取消检查点:补全是**逐条网络请求**,条目之间是天然的落点;已补的只改内存,不写盘。
|
||||
if request.cancel.is_cancelled() {
|
||||
break;
|
||||
}
|
||||
if entry.file.sha1().is_some() {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -7,26 +7,33 @@ use std::path::Path;
|
||||
|
||||
use super::super::pack::{PackArchive, PackError};
|
||||
use super::baseline::PackBaseline;
|
||||
use super::cancel::Stopped;
|
||||
use super::error::ImportError;
|
||||
use super::fetch::{FetchError, FetchRequest};
|
||||
use super::merge::{self, MergeAction};
|
||||
use super::pipeline::tell;
|
||||
use super::merge;
|
||||
use super::plan::{curse_class_dir, ImportPlan, PlanRemote};
|
||||
use super::report::{
|
||||
ConflictChoice, ConflictFile, ConflictReason, FileAction, ImportReport, ImportStage,
|
||||
ImportedFile, MissingFile, MissingReason,
|
||||
};
|
||||
use super::report::tell;
|
||||
use super::report::{ImportReport, ImportStage, ImportedFile, MissingFile, MissingReason};
|
||||
use super::stage::Staging;
|
||||
use super::ImportRequest;
|
||||
use crate::download::verify::{self, digest_bytes};
|
||||
use crate::download::HashAlgo;
|
||||
|
||||
mod settle;
|
||||
|
||||
use settle::{conflict, settle};
|
||||
|
||||
/// 阶段一:覆盖条目落暂存 + 三方合并。
|
||||
///
|
||||
/// 被同包另一条声明遮蔽的条目([`super::plan::PlanOverride::shadowed`])**不读字节、不落地**:
|
||||
/// 它的结论已经由 `plan::layer::resolve` 算成一条 `NOTE_OVERRIDE_SHADOWED` 注记
|
||||
/// (进 `report.warnings`),而这条路径的行由真正的胜者给出——一路径一行,不再出现
|
||||
/// "两条 Installed、前一条的哈希其实没落地"。
|
||||
///
|
||||
/// # 取消检查点
|
||||
///
|
||||
/// 解压阶段**每条一次**(循环开头):只写临时目录,取消 = 临时目录整个删掉,目标目录一个
|
||||
/// 字节都没动。被 `?` 冒泡出去的 [`Stopped::Cancelled`] 与失败**不是同一条路**。
|
||||
pub(crate) async fn extract_overrides(
|
||||
request: &ImportRequest<'_>,
|
||||
archive: &PackArchive,
|
||||
@@ -35,7 +42,7 @@ pub(crate) async fn extract_overrides(
|
||||
staging: &Staging,
|
||||
report: &mut ImportReport,
|
||||
next: &mut PackBaseline,
|
||||
) -> Result<(), ImportError> {
|
||||
) -> Result<(), Stopped> {
|
||||
let live: Vec<&super::plan::PlanOverride> = plan
|
||||
.overrides
|
||||
.iter()
|
||||
@@ -44,6 +51,8 @@ pub(crate) async fn extract_overrides(
|
||||
let total = live.len();
|
||||
let target = staging.target().to_path_buf();
|
||||
for (index, entry) in live.iter().enumerate() {
|
||||
// 取消检查点(每条一次):只写临时目录,停下就是"什么都没发生"。
|
||||
request.cancel.stopped()?;
|
||||
tell(
|
||||
&request.progress,
|
||||
ImportStage::Extract,
|
||||
@@ -94,6 +103,12 @@ pub(crate) async fn extract_overrides(
|
||||
}
|
||||
|
||||
/// 阶段二:远端条目补全 + 下载 + 同一张合并表。
|
||||
///
|
||||
/// # 取消检查点
|
||||
///
|
||||
/// 每条远端条目一次(循环开头)。**单条下载在飞不可中断**([`super::fetch::PackFetcher`] 没有
|
||||
/// 取消参数),所以取消在最坏情况下要等"当前这一条下载返回";返回后下一次检查立刻生效,
|
||||
/// 已下到临时目录的字节随临时目录一起删掉。
|
||||
pub(crate) async fn fetch_remote(
|
||||
request: &ImportRequest<'_>,
|
||||
plan: &ImportPlan,
|
||||
@@ -101,10 +116,12 @@ pub(crate) async fn fetch_remote(
|
||||
staging: &Staging,
|
||||
report: &mut ImportReport,
|
||||
next: &mut PackBaseline,
|
||||
) -> Result<(), ImportError> {
|
||||
) -> Result<(), Stopped> {
|
||||
let total = plan.remote.len();
|
||||
let target = staging.target().to_path_buf();
|
||||
for (index, entry) in plan.remote.iter().enumerate() {
|
||||
// 取消检查点(每条一次):上一条下载返回之后立刻生效。
|
||||
request.cancel.stopped()?;
|
||||
let display = entry.path.clone().unwrap_or_else(|| entry.file.key());
|
||||
tell(
|
||||
&request.progress,
|
||||
@@ -185,7 +202,8 @@ pub(crate) async fn fetch_remote(
|
||||
return Err(ImportError::integrity(format!(
|
||||
"{} 的摘要与清单声明不符(声明 {declared},实际 {pack_sha1})",
|
||||
resolved.path
|
||||
)));
|
||||
))
|
||||
.into());
|
||||
}
|
||||
}
|
||||
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 {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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::cancel::Stopped;
|
||||
use super::error::ImportError;
|
||||
use super::phase;
|
||||
use super::plan::ImportPlan;
|
||||
use super::report::{
|
||||
keys, ImportIssue, ImportProgress, ImportReport, ImportStage, RegisteredInstance,
|
||||
};
|
||||
use super::stage::{RollbackOutcome, Staging};
|
||||
use super::prepare::{account_plan, probe_and_plan, write_baseline};
|
||||
use super::report::{keys, tell, ImportIssue, ImportReport, ImportStage, RegisteredInstance};
|
||||
use super::stage::{CommitStop, RollbackOutcome, Staging};
|
||||
use super::ImportRequest;
|
||||
|
||||
/// 跑一次导入。**永不返回 `Err`**:所有失败都在报告的三态里。
|
||||
/// 跑一次导入。**永不返回 `Err`**:成功 / 部分成功 / 失败 / 取消全在报告里。
|
||||
pub async fn run(request: &ImportRequest<'_>) -> ImportReport {
|
||||
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);
|
||||
let (archive, mut plan) = match probe_and_plan(request) {
|
||||
Ok(pair) => pair,
|
||||
Err(error) => return abandoned(report, error),
|
||||
};
|
||||
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;
|
||||
// 检查点③:补全后(补全是逐条网络请求,条目之间已经在 `complete` 里停过一次)。
|
||||
if request.cancel.is_cancelled() {
|
||||
return cancelled(report, RollbackOutcome::default());
|
||||
}
|
||||
|
||||
let baseline = {
|
||||
let (baseline, warnings) = load_baseline(request.target, &plan);
|
||||
report.warnings.extend(warnings);
|
||||
baseline
|
||||
};
|
||||
// 检查点④:开暂存目录前(此后磁盘上就有临时目录了)。
|
||||
if request.cancel.is_cancelled() {
|
||||
return cancelled(report, RollbackOutcome::default());
|
||||
}
|
||||
let staging = match Staging::begin(request.target) {
|
||||
Ok(staging) => staging,
|
||||
Err(error) => return abandoned(report, error),
|
||||
@@ -73,12 +96,27 @@ pub async fn run(request: &ImportRequest<'_>) -> ImportReport {
|
||||
)
|
||||
.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;
|
||||
if let Err(error) = staged {
|
||||
match staged {
|
||||
Ok(()) => {}
|
||||
// 停在暂存阶段:临时目录整个删掉,目标目录与导入前一致(一个字节都没写过)。
|
||||
Err(Stopped::Cancelled) => {
|
||||
staging.abort();
|
||||
return cancelled(report, RollbackOutcome::default());
|
||||
}
|
||||
Err(Stopped::Failed(error)) => {
|
||||
staging.abort();
|
||||
return abandoned(report, error);
|
||||
}
|
||||
}
|
||||
|
||||
// 检查点⑤:提交前(与"清中间产物"同一条边界)。
|
||||
if request.cancel.is_cancelled() {
|
||||
staging.abort();
|
||||
return abandoned(report, error);
|
||||
return cancelled(report, RollbackOutcome::default());
|
||||
}
|
||||
|
||||
// 引擎的续传残片(`.ullpart-*`)与 meta 不是整合包内容,提交前剔掉。
|
||||
@@ -95,13 +133,24 @@ pub async fn run(request: &ImportRequest<'_>) -> ImportReport {
|
||||
}
|
||||
|
||||
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,
|
||||
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 {
|
||||
tell(&request.progress, ImportStage::Register, 0, 0, None);
|
||||
// 检查点⑦:登记前。内容已经落进目标目录,所以取消要**连内容一起回滚**,否则留下
|
||||
// "有目录没档案"的半个实例——这正是"取消后不得登记实例"的判据。
|
||||
if request.cancel.is_cancelled() {
|
||||
let outcome = guard.rollback();
|
||||
return cancelled(report, outcome);
|
||||
}
|
||||
let name = request
|
||||
.instance_name
|
||||
.filter(|name| !name.trim().is_empty())
|
||||
@@ -138,31 +187,12 @@ pub async fn run(request: &ImportRequest<'_>) -> ImportReport {
|
||||
report
|
||||
}
|
||||
|
||||
/// 开包 + 探测 + 组计划(任何失败都是 `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))
|
||||
}
|
||||
|
||||
/// 把计划里的静态信息抄进报告。
|
||||
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());
|
||||
/// 三档注记与 `rolled_back` 的判据在 [`ImportReport::cancel`](那里也是失败路径的同形三档)。
|
||||
fn cancelled(mut report: ImportReport, outcome: RollbackOutcome) -> ImportReport {
|
||||
report.cancel(outcome);
|
||||
report
|
||||
}
|
||||
|
||||
/// 读三方合并的 base 侧:没有 / 读不出来 / **属于别的包** 三条路都不致命。
|
||||
@@ -233,47 +263,6 @@ fn record_rollback(mut report: ImportReport, outcome: RollbackOutcome) -> Import
|
||||
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`]。
|
||||
|
||||
@@ -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('\\', "/")
|
||||
}
|
||||
@@ -32,7 +32,11 @@ use serde::Serialize;
|
||||
use super::error::ImportError;
|
||||
use super::stage::RollbackOutcome;
|
||||
|
||||
/// 导入三态。
|
||||
/// 导入结果(三态 + 一档"用户取消")。
|
||||
///
|
||||
/// 取消**单列一档、不是第四种失败**(M195):它有自己的变体、自己的文案 key、自己的退出码,
|
||||
/// 且 `ImportReport::error` 恒为 `None`。把两者并成一个错误变体,UI 就会把"用户自己点的"
|
||||
/// 画成红的,脚本也会把"我改主意了"当成"装坏了"。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ImportStatus {
|
||||
@@ -42,6 +46,8 @@ pub enum ImportStatus {
|
||||
Partial,
|
||||
/// 致命失败,已整包回滚。
|
||||
Failed,
|
||||
/// 用户主动取消:管线在检查点停下,目标目录与导入前一致(`error` 为 `None`)。
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl ImportStatus {
|
||||
@@ -52,6 +58,7 @@ impl ImportStatus {
|
||||
Self::Success => "success",
|
||||
Self::Partial => "partial",
|
||||
Self::Failed => "failed",
|
||||
Self::Cancelled => "cancelled",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,16 +69,21 @@ impl ImportStatus {
|
||||
Self::Success => keys::STATUS_SUCCESS,
|
||||
Self::Partial => keys::STATUS_PARTIAL,
|
||||
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]
|
||||
pub const fn exit_code(self) -> i32 {
|
||||
match self {
|
||||
Self::Success => 0,
|
||||
Self::Partial => 3,
|
||||
Self::Failed => 1,
|
||||
Self::Cancelled => 130,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -134,6 +146,27 @@ pub struct ImportProgress {
|
||||
/// 进度回调。
|
||||
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)]
|
||||
pub struct ImportReport {
|
||||
@@ -240,4 +273,65 @@ impl ImportReport {
|
||||
self.error = Some(error);
|
||||
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";
|
||||
/// 状态:失败(是否回滚干净看 `ImportReport::rolled_back`)。
|
||||
pub const STATUS_FAILED: &str = "pack.import.status.failed";
|
||||
/// 状态:**用户取消**(不是失败,`error` 为空)。
|
||||
pub const STATUS_CANCELLED: &str = "pack.import.status.cancelled";
|
||||
|
||||
/// 注记:`server-overrides/` 客户端不解压。
|
||||
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";
|
||||
/// 注记:回滚**未完整完成**(逐项列失败,`rolled_back` 为 false)。
|
||||
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";
|
||||
/// 注记:这些远端条目预检判不了(补全前没有路径 / 有路径但缺 sha1 声明),无法预判冲突。
|
||||
@@ -87,6 +91,7 @@ pub const ALL: &[&str] = &[
|
||||
STATUS_SUCCESS,
|
||||
STATUS_PARTIAL,
|
||||
STATUS_FAILED,
|
||||
STATUS_CANCELLED,
|
||||
NOTE_SERVER_OVERRIDES_SKIPPED,
|
||||
NOTE_UNKNOWN_DEPENDENCY,
|
||||
NOTE_ENV_UNSUPPORTED,
|
||||
@@ -100,6 +105,7 @@ pub const ALL: &[&str] = &[
|
||||
NOTE_BASELINE_PACK_MISMATCH,
|
||||
NOTE_ENGINE_ARTIFACTS_DROPPED,
|
||||
NOTE_ROLLBACK_INCOMPLETE,
|
||||
NOTE_CANCELLED,
|
||||
NOTE_OVERRIDE_SHADOWED,
|
||||
NOTE_REMOTE_PATH_UNKNOWN,
|
||||
NOTE_REMOTE_COMPLETED,
|
||||
|
||||
@@ -42,8 +42,9 @@ use super::baseline::PackBaseline;
|
||||
use super::decision::ConflictDecisions;
|
||||
use super::error::ImportError;
|
||||
use super::merge;
|
||||
use super::pipeline::{load_baseline, probe_and_plan};
|
||||
use super::pipeline::load_baseline;
|
||||
use super::plan::ImportPlan;
|
||||
use super::prepare::probe_and_plan;
|
||||
use super::report::{keys, ConflictReason, FileSource, ImportIssue, LoaderTarget};
|
||||
use super::ImportRequest;
|
||||
use crate::download::verify;
|
||||
|
||||
@@ -10,6 +10,15 @@
|
||||
//! 为什么全新导入不逐文件搬:逐文件搬意味着"搬到一半失败"会留下一个**看起来存在**的
|
||||
//! 半个实例,而 `rename` 一个目录是单步原子操作——失败就只有"没有"这一种结果。
|
||||
//!
|
||||
//! # 取消落在这层怎么办(M195)
|
||||
//!
|
||||
//! | 起点 | 取消点 | 收场 |
|
||||
//! |------|--------|------|
|
||||
//! | 全新导入 | **只在 `rename` 之前** | 删暂存目录,目标目录从未出现 |
|
||||
//! | 合并态 | 每条搬动之前 | journal **逆序**撤回(新文件删掉、旧备份改回原名) |
|
||||
//!
|
||||
//! 合并态逐文件报进度(`ImportStage::Commit`):既是界面该有的动静,也是取消能落下的地方。
|
||||
//!
|
||||
//! # 两道路径检查都在这层再走一遍
|
||||
//!
|
||||
//! 第一道是 M110 的 [`normalize_file`](七条写死规则:空名 / `\0` / `\` / `:` / 前导 `/` /
|
||||
@@ -19,14 +28,17 @@
|
||||
|
||||
pub mod commit;
|
||||
|
||||
pub use commit::{CommitFailure, CommitGuard, RollbackOutcome};
|
||||
pub use commit::{CommitFailure, CommitGuard, CommitStop, RollbackOutcome};
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use super::super::pack::path::normalize_file;
|
||||
use super::super::pack::PackError;
|
||||
use super::cancel::{ImportCancel, Stopped};
|
||||
use super::error::ImportError;
|
||||
use super::report::tell;
|
||||
use super::report::{ImportProgressSink, ImportStage};
|
||||
use crate::platform::atomic::write_atomic;
|
||||
|
||||
use crate::download::atomic::{META_SUFFIX, PART_SUFFIX};
|
||||
@@ -177,38 +189,62 @@ impl Staging {
|
||||
|
||||
/// 提交事务:返回一个**必须显式 `finish`** 的守卫(析构即回滚)。
|
||||
///
|
||||
/// `cancel` 是取消令牌,`progress` 是进度回调(合并态**逐文件**报一条:几千文件的合并本来
|
||||
/// 就该看得见动静,而"取消"也正需要文件之间的落点)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// `rename` 失败、合并态逐文件搬动失败。**回滚已经在这条路径上做过**,其结果连同错误
|
||||
/// 一起返回([`CommitFailure::rollback`]):调用方必须把它写进报告,不许假设"失败 = 已回滚干净"。
|
||||
pub fn commit(self) -> Result<CommitGuard, CommitFailure> {
|
||||
/// - [`CommitStop::Cancelled`]:取消落在提交前 / 合并中途。**回滚已经在这条路径上做过**,
|
||||
/// 结果一并返回(调用方把它写进报告的 `rollback` 机器位);
|
||||
/// - [`CommitStop::Failed`]:`rename` 失败、合并态逐文件搬动失败。回滚同样已经做过,其结果
|
||||
/// 随错误返回([`CommitFailure::rollback`]):调用方不许假设"失败 = 已回滚干净"。
|
||||
///
|
||||
/// 全新导入只有一次 `rename`:那是单步原子操作,**取消点只在它之前**——之后不存在"半个
|
||||
/// 实例"这种中间态,也就没有"取消"这一说。
|
||||
pub fn commit(
|
||||
self,
|
||||
cancel: &ImportCancel,
|
||||
progress: &Option<ImportProgressSink>,
|
||||
) -> Result<CommitGuard, CommitStop> {
|
||||
match self.mode {
|
||||
Mode::Fresh => match std::fs::rename(&self.temp, &self.target) {
|
||||
Ok(()) => Ok(CommitGuard::fresh(self.target.clone(), self.temp.clone())),
|
||||
Err(error) => {
|
||||
Mode::Fresh => {
|
||||
if cancel.is_cancelled() {
|
||||
// 还没 rename:目标目录一个字节都没动,删掉暂存目录就是零残留。
|
||||
self.abort_inner();
|
||||
Err(CommitFailure::io(
|
||||
format!(
|
||||
"把 {} 改名成 {} 失败:{error}",
|
||||
self.temp.display(),
|
||||
self.target.display()
|
||||
),
|
||||
RollbackOutcome::default(),
|
||||
))
|
||||
return Err(CommitStop::Cancelled(RollbackOutcome::default()));
|
||||
}
|
||||
},
|
||||
match std::fs::rename(&self.temp, &self.target) {
|
||||
Ok(()) => Ok(CommitGuard::fresh(self.target.clone(), self.temp.clone())),
|
||||
Err(error) => {
|
||||
self.abort_inner();
|
||||
Err(CommitStop::Failed(CommitFailure::io(
|
||||
format!(
|
||||
"把 {} 改名成 {} 失败:{error}",
|
||||
self.temp.display(),
|
||||
self.target.display()
|
||||
),
|
||||
RollbackOutcome::default(),
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
Mode::Merge => {
|
||||
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(
|
||||
self.target.clone(),
|
||||
self.temp.clone(),
|
||||
applied,
|
||||
)),
|
||||
Err(error) => {
|
||||
Err(Stopped::Cancelled) => {
|
||||
let rollback = rollback_merge(&mut applied);
|
||||
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();
|
||||
collect_files(&self.temp, &mut staged)?;
|
||||
staged.sort();
|
||||
for source in staged {
|
||||
let total = staged.len();
|
||||
for (index, source) in staged.into_iter().enumerate() {
|
||||
cancel.stopped()?;
|
||||
let relative = source
|
||||
.strip_prefix(&self.temp)
|
||||
.map_err(|_| io_error(format!("暂存路径不在临时目录里:{}", source.display())))?;
|
||||
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 dest = self.target.join(&normalized);
|
||||
|
||||
@@ -207,6 +207,18 @@ pub(super) fn backup_path(dest: &Path) -> PathBuf {
|
||||
dest.with_file_name(name)
|
||||
}
|
||||
|
||||
/// 提交阶段的两条停止路线(M195):**取消不是失败**,所以不共用 [`CommitFailure`]。
|
||||
///
|
||||
/// 两条路都**已经把回滚做过**(全新导入是 `abort`,合并态是 journal 逆序撤回):
|
||||
/// 调用方拿到它们只管写报告,不必再自己清一遍。
|
||||
#[derive(Debug)]
|
||||
pub enum CommitStop {
|
||||
/// 用户取消(回滚结果一并交回,可能不完整)。
|
||||
Cancelled(RollbackOutcome),
|
||||
/// 真失败(回滚结果连同错误一起交回)。
|
||||
Failed(CommitFailure),
|
||||
}
|
||||
|
||||
/// 一次**失败的回滚**(提交阶段失败时连同错误一起交回调用方)。
|
||||
///
|
||||
/// `io_error` 让"把一个 IO 原因包成 `pack_io`"只在一处定义。
|
||||
|
||||
@@ -179,8 +179,17 @@ macro_rules! content_entries {
|
||||
),
|
||||
PackReportRejectedNote => ("not supported by the client", "客户端不支持"),
|
||||
PackCancelled => (
|
||||
"Import keeps running in the background — this result is discarded. Files already written stay.",
|
||||
"导入会在后台继续跑完 —— 本次结果作废;已经写下的文件会留下。"
|
||||
"Cancel requested — the pipeline is stopping now; the real outcome follows.",
|
||||
"已请求取消 —— 后台正在停手,真实结果随后给出。"
|
||||
),
|
||||
PackStatusCancelled => ("Cancelled — rolled back", "已取消 —— 已回滚"),
|
||||
PackCancelIncomplete => (
|
||||
"Cancelled — rollback incomplete: the target directory may differ from before the import.",
|
||||
"已取消 —— 回滚没做完:目标目录可能与导入前不同。"
|
||||
),
|
||||
PackCancelTooLate => (
|
||||
"The cancel arrived too late: this import already finished. The instance is kept; same-name files may already be overwritten.",
|
||||
"取消来得太晚:这次导入已经跑完,实例会保留,同名文件可能已被覆盖。"
|
||||
),
|
||||
PackOverwriteAction => ("Re-import, the pack wins", "以包为准重新导入"),
|
||||
PackOverwriteNote => (
|
||||
|
||||
@@ -29,7 +29,10 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[
|
||||
("PackReportConflicts", "Conflicts: {count}", "冲突:{count} 个"),
|
||||
("PackReportRejected", "The client does not support these: {count}", "这些组件客户端不支持:{count} 个"),
|
||||
("PackReportRejectedNote", "not supported by the client", "客户端不支持"),
|
||||
("PackCancelled", "Import keeps running in the background — this result is discarded. Files already written stay.", "导入会在后台继续跑完 —— 本次结果作废;已经写下的文件会留下。"),
|
||||
("PackCancelled", "Cancel requested — the pipeline is stopping now; the real outcome follows.", "已请求取消 —— 后台正在停手,真实结果随后给出。"),
|
||||
("PackStatusCancelled", "Cancelled — rolled back", "已取消 —— 已回滚"),
|
||||
("PackCancelIncomplete", "Cancelled — rollback incomplete: the target directory may differ from before the import.", "已取消 —— 回滚没做完:目标目录可能与导入前不同。"),
|
||||
("PackCancelTooLate", "The cancel arrived too late: this import already finished. The instance is kept; same-name files may already be overwritten.", "取消来得太晚:这次导入已经跑完,实例会保留,同名文件可能已被覆盖。"),
|
||||
("PackOverwriteAction", "Re-import, the pack wins", "以包为准重新导入"),
|
||||
("PackOverwriteNote", "Keeping both (renamed) is not supported yet: for each existing file you pick either “keep” or “overwrite”.", "「两者都留(改名)」暂不支持:每个已存在的文件只能选「保留」或「覆盖」。"),
|
||||
("PackMissingOffline", "offline import", "离线导入"),
|
||||
|
||||
@@ -62,10 +62,10 @@ pub use pack::{
|
||||
ConflictChoiceView, ConflictDecisionView, ConflictDecisionsView, ConflictFileView,
|
||||
ConflictReasonView, ConflictSourceView, ExportClassView, ExportDoneView, ExportFileView,
|
||||
ExportPreviewView, ExportReasonView, ExportRequestView, ExportState, FileActionView,
|
||||
ImportConflictView, ImportErrorView, ImportFailureStageView, ImportProgressView,
|
||||
ImportReportView, ImportRequestView, ImportScanView, ImportStageView, ImportState,
|
||||
ImportStatusView, ImportedFileView, MissingFileView, MissingReasonView, PackCommand, PackEvent,
|
||||
PackFormatView, PackState, ProbeView,
|
||||
ImportCancelView, ImportConflictView, ImportErrorView, ImportFailureStageView,
|
||||
ImportProgressView, ImportReportView, ImportRequestView, ImportScanView, ImportStageView,
|
||||
ImportState, ImportStatusView, ImportedFileView, MissingFileView, MissingReasonView,
|
||||
PackCommand, PackEvent, PackFormatView, PackState, ProbeView,
|
||||
};
|
||||
pub use settings::{SettingControl, SettingGroupView, SettingRowView};
|
||||
// M159: 自更新
|
||||
|
||||
@@ -57,7 +57,7 @@ pub use export::{
|
||||
};
|
||||
pub use format::PackFormatView;
|
||||
pub use import::{
|
||||
FileActionView, ImportErrorView, ImportFailureStageView, ImportProgressView, ImportReportView,
|
||||
ImportRequestView, ImportStageView, ImportState, ImportStatusView, ImportedFileView,
|
||||
MissingFileView, MissingReasonView, ProbeView,
|
||||
FileActionView, ImportCancelView, ImportErrorView, ImportFailureStageView, ImportProgressView,
|
||||
ImportReportView, ImportRequestView, ImportStageView, ImportState, ImportStatusView,
|
||||
ImportedFileView, MissingFileView, MissingReasonView, ProbeView,
|
||||
};
|
||||
@@ -64,8 +64,11 @@ pub enum PackEvent {
|
||||
Exported(Box<ExportDoneView>),
|
||||
/// 导出/预检在 host 侧失败(实例找不到、目录派生失败……一句人读原因)
|
||||
ExportFailed(String),
|
||||
/// 用户取消了导入:面板收回,但后台那次导入会跑完(core 没有取消令牌),
|
||||
/// 所以留一位给界面说清「还在跑、结果作废」。
|
||||
/// 用户取消了导入(面板立刻收回)。
|
||||
///
|
||||
/// 它只表示"取消后还没有结果":M195 起 core 有取消令牌,管线会在检查点停手并回滚,**真实
|
||||
/// 结局随后的 [`PackEvent::Report`] 才是准话**(`ImportReportView::cancel` 三档:已回滚 /
|
||||
/// 回滚没做完 / 取消太晚),所以 `Report` 一到就清掉这一位。
|
||||
Cancelled,
|
||||
/// 关掉所有面板
|
||||
Cleared,
|
||||
@@ -99,6 +102,9 @@ impl PackState {
|
||||
self.import.progress = None;
|
||||
self.import.probe = None;
|
||||
self.import.scan = None;
|
||||
// M195 r3:取消的**真实结局**在 `report.cancel` 里,`cancelled` 只作"取消后
|
||||
// 还没有结果"时的占位;结果一到就清掉,免得它跨到下一次导入上撒谎。
|
||||
self.import.cancelled = false;
|
||||
self.import.report = Some(*report);
|
||||
}
|
||||
PackEvent::Probe(probe) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! 导入侧 DTO:请求、进度、三态、逐项原因、探测诊断。
|
||||
//!
|
||||
//! 三态(`Success` / `Partial` / `Failed`)与 core 一一对应,**`Partial` 不是 `Success`**;
|
||||
//! 容忍域([`MissingFileView`])与致命域([`ImportErrorView`])分属不同类型,UI 不可能混成一个列表。
|
||||
//! 状态档(`Success` / `Partial` / `Failed` / `Cancelled`)与 core 一一对应,**`Partial` 不是
|
||||
//! `Success`**、**取消不是失败**;容忍域与致命域分属不同类型,UI 不可能混成一个列表。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -12,6 +12,12 @@ use crate::dto::InstanceId;
|
||||
use super::conflict::{ConflictDecisionsView, ConflictFileView, ImportScanView};
|
||||
use super::format::PackFormatView;
|
||||
|
||||
mod cancel;
|
||||
mod failure_stage;
|
||||
|
||||
pub use cancel::ImportCancelView;
|
||||
pub use failure_stage::ImportFailureStageView;
|
||||
|
||||
/// 导入阶段(与 core 的 `ImportStage` 一一对应)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub enum ImportStageView {
|
||||
@@ -62,43 +68,6 @@ impl ImportStageView {
|
||||
}
|
||||
}
|
||||
|
||||
/// 致命失败的**阶段**(M161:`rolled_back` 一个人说不了两件事)。
|
||||
///
|
||||
/// core 的 `ImportReport::rolled_back` 语义是「目标目录已确认与导入前一致」——它同时覆盖
|
||||
/// 「回滚完整完成」和「**从未写过**目标目录」两种情形(见 `core::addon::import::pipeline` 的
|
||||
/// `record_rollback` / `abandoned`)。UI 拿一个 bool 判不出该说哪一句,于是预检失败也会被
|
||||
/// 说成「已回滚」,而实际上一个字节都没写。这个枚举把两种情形重新分开,并补上第三种
|
||||
/// (回滚有失败项,目标目录可能与导入前不同)。
|
||||
///
|
||||
/// 只在 `status == Failed` 时有值:成功/部分成功的报告没有「失败阶段」可言。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ImportFailureStageView {
|
||||
/// 预检/装配阶段就失败了:**目标目录一个字节都没写**(用户该去修包/修配置再试)。
|
||||
NotStarted,
|
||||
/// 已经写过目标目录,回滚**完整**完成(零残留)。
|
||||
RolledBack,
|
||||
/// 写过了,但回滚有失败项:目标目录可能与导入前不同(`warnings` 里逐条列了)。
|
||||
RollbackIncomplete,
|
||||
}
|
||||
|
||||
impl ImportFailureStageView {
|
||||
/// 稳定 slug(日志 / 测试用;**不要改名**)。
|
||||
#[must_use]
|
||||
pub const fn slug(self) -> &'static str {
|
||||
match self {
|
||||
Self::NotStarted => "not_started",
|
||||
Self::RolledBack => "rolled_back",
|
||||
Self::RollbackIncomplete => "rollback_incomplete",
|
||||
}
|
||||
}
|
||||
|
||||
/// 本次失败到底动没动目标目录。
|
||||
#[must_use]
|
||||
pub const fn touched_target(self) -> bool {
|
||||
!matches!(self, Self::NotStarted)
|
||||
}
|
||||
}
|
||||
|
||||
/// 导入三态(`Partial` **不是** `Success`)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ImportStatusView {
|
||||
@@ -112,6 +81,10 @@ pub enum ImportStatusView {
|
||||
/// 也可能是写完又回滚干净,还可能是回滚没做完——三种由
|
||||
/// [`ImportReportView::failure_stage`] 分开说,界面按它选标题与图标。
|
||||
Failed,
|
||||
/// **用户取消,管线停手并回滚**(M195 r3):不是失败,也不是「部分成功」。
|
||||
/// 「回滚干净 / 没做完」由 [`ImportReportView::cancel`] 再分;取消**太晚**(管线已跑完)
|
||||
/// 不落这一档——那时实例真装上了,状态是 `Success`/`Partial`,只在 `cancel` 记 `TooLate`。
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl ImportStatusView {
|
||||
@@ -122,6 +95,7 @@ impl ImportStatusView {
|
||||
Self::Success => "success",
|
||||
Self::Partial => "partial",
|
||||
Self::Failed => "failed",
|
||||
Self::Cancelled => "cancelled",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,14 +106,15 @@ impl ImportStatusView {
|
||||
"success" => Some(Self::Success),
|
||||
"partial" => Some(Self::Partial),
|
||||
"failed" => Some(Self::Failed),
|
||||
"cancelled" => Some(Self::Cancelled),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 实例可用吗(`Success` / `Partial` 都是「装上了」)。
|
||||
/// 实例可用吗(`Success` / `Partial` 是「装上了」;取消与失败都没装上)。
|
||||
#[must_use]
|
||||
pub const fn installed(self) -> bool {
|
||||
!matches!(self, Self::Failed)
|
||||
matches!(self, Self::Success | Self::Partial)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,6 +315,11 @@ pub struct ImportReportView {
|
||||
pub rolled_back: bool,
|
||||
/// 失败发生在哪一步(`None` = 这次不是致命失败)。
|
||||
pub failure_stage: Option<ImportFailureStageView>,
|
||||
/// 这次运行与「取消」的关系(M195 r3:取消的**真实结局**,界面按它选文案)。
|
||||
///
|
||||
/// 只靠 `status` 表达不了三档:已回滚 / 回滚没做完都在 [`ImportStatusView::Cancelled`] 里,
|
||||
/// 而「取消太晚、已经跑完」的状态是 `Success`/`Partial`——三档见 [`ImportCancelView`]。
|
||||
pub cancel: ImportCancelView,
|
||||
}
|
||||
|
||||
impl ImportReportView {
|
||||
@@ -379,8 +359,11 @@ pub struct ProbeView {
|
||||
pub struct ImportState {
|
||||
/// 正在跑的进度(`None` = 没在跑)。
|
||||
pub progress: Option<ImportProgressView>,
|
||||
/// 用户点了取消(M113 r1):**面板收回但后台还在跑**,所以这一位要留在界面上——
|
||||
/// 否则用户会以为「没建成」,回头再导一次就撞「directory already exists」。
|
||||
/// 用户点了取消、**结果还没回来**(M195 r3 收窄了它的含义)。
|
||||
///
|
||||
/// 它只作"取消后还没有结果"的占位:真实结局随后的 `report.cancel` 才是准话(已回滚 /
|
||||
/// 回滚没做完 / 取消太晚)。所以 `PackEvent::Report` 一到就清掉这一位,免得它跨到下一次
|
||||
/// 导入上替结局下结论。
|
||||
pub cancelled: bool,
|
||||
/// 最近一次的预检(M160:冲突清单;`None` = 还没预检过或已被新结果顶掉)。
|
||||
pub scan: Option<ImportScanView>,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
//! 一次导入与「取消」的关系(**真实结局**,不是"用户点没点过取消")。
|
||||
//!
|
||||
//! 从父模块拆出来只为行数(父文件已到 400 硬限附近),判据与文案口径原样。
|
||||
//!
|
||||
//! 三档各有各的话要说,混成一句就是撒谎(M195 r3):
|
||||
//! - [`ImportCancelView::RolledBack`]:停手了,目标目录与导入前一致;
|
||||
//! - [`ImportCancelView::RollbackIncomplete`]:停手了,但回滚没做完,目标目录可能与导入前不同;
|
||||
//! - [`ImportCancelView::TooLate`]:**没来得及停**,管线已经跑完——实例会保留,同名文件可能已被覆盖。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 一次导入与「取消」的关系(**真实结局**)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub enum ImportCancelView {
|
||||
/// 这次运行没被取消过。
|
||||
#[default]
|
||||
NotRequested,
|
||||
/// 取消生效,管线停手并**完整**回滚(目标目录与导入前一致,实例没登记)。
|
||||
RolledBack,
|
||||
/// 取消生效,但回滚没做完(目标目录可能与导入前不同,逐条在 `warnings` 里)。
|
||||
RollbackIncomplete,
|
||||
/// 取消到得太晚:管线已经跑完,实例保留,同名文件可能已被覆盖。
|
||||
TooLate,
|
||||
}
|
||||
|
||||
impl ImportCancelView {
|
||||
/// 稳定 slug。
|
||||
#[must_use]
|
||||
pub const fn slug(self) -> &'static str {
|
||||
match self {
|
||||
Self::NotRequested => "not_requested",
|
||||
Self::RolledBack => "rolled_back",
|
||||
Self::RollbackIncomplete => "rollback_incomplete",
|
||||
Self::TooLate => "too_late",
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户这次取消有没有**真的生效**(前两档是真停手了)。
|
||||
#[must_use]
|
||||
pub const fn stopped(self) -> bool {
|
||||
matches!(self, Self::RolledBack | Self::RollbackIncomplete)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//! 致命失败发生在哪一步(M161:`rolled_back` 一个人说不了两件事)。
|
||||
//!
|
||||
//! 从父模块拆出来只为行数;判据与文案口径原样。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 致命失败的**阶段**(M161:`rolled_back` 一个人说不了两件事)。
|
||||
///
|
||||
/// core 的 `ImportReport::rolled_back` 语义是「目标目录已确认与导入前一致」——它同时覆盖
|
||||
/// 「回滚完整完成」和「**从未写过**目标目录」两种情形(见 `core::addon::import::pipeline` 的
|
||||
/// `record_rollback` / `abandoned`)。UI 拿一个 bool 判不出该说哪一句,于是预检失败也会被
|
||||
/// 说成「已回滚」,而实际上一个字节都没写。这个枚举把两种情形重新分开,并补上第三种
|
||||
/// (回滚有失败项,目标目录可能与导入前不同)。
|
||||
///
|
||||
/// 只在 `status == Failed` 时有值:成功/部分成功的报告没有「失败阶段」可言。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ImportFailureStageView {
|
||||
/// 预检/装配阶段就失败了:**目标目录一个字节都没写**(用户该去修包/修配置再试)。
|
||||
NotStarted,
|
||||
/// 已经写过目标目录,回滚**完整**完成(零残留)。
|
||||
RolledBack,
|
||||
/// 写过了,但回滚有失败项:目标目录可能与导入前不同(`warnings` 里逐条列了)。
|
||||
RollbackIncomplete,
|
||||
}
|
||||
|
||||
impl ImportFailureStageView {
|
||||
/// 稳定 slug(日志 / 测试用;**不要改名**)。
|
||||
#[must_use]
|
||||
pub const fn slug(self) -> &'static str {
|
||||
match self {
|
||||
Self::NotStarted => "not_started",
|
||||
Self::RolledBack => "rolled_back",
|
||||
Self::RollbackIncomplete => "rollback_incomplete",
|
||||
}
|
||||
}
|
||||
|
||||
/// 本次失败到底动没动目标目录。
|
||||
#[must_use]
|
||||
pub const fn touched_target(self) -> bool {
|
||||
!matches!(self, Self::NotStarted)
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ mod plan;
|
||||
mod pack;
|
||||
mod sources;
|
||||
|
||||
pub(super) use pack::{cancel_pack, pack_epoch};
|
||||
pub(super) use pack::{cancel_pack, pack_epoch, take_terminal_epoch};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -24,19 +24,27 @@
|
||||
//! 所以每个可扫文件都证明不了可被远程替代 ⇒ 内联;`SkippedLocal` 就是「无法导出」。
|
||||
//! `tests::preview_counts_match_the_written_report` 用真管线钉住这个等式。
|
||||
//!
|
||||
//! ## 取消是**界面级**的
|
||||
//! ## 取消是**管线级**的(M195 起)
|
||||
//!
|
||||
//! [`cancel_pack`] 置模块级标记 + `dispatch` 里的 `CancelInstall` 把代次 +1:
|
||||
//! [`cancel_pack`] 置模块级标记 + `dispatch` 里的 `CancelInstall` 把代次 +1,而**同一个标记**
|
||||
//! 从 M195 起就是 core 的取消令牌(`ImportRequest::with_cancel`):
|
||||
//!
|
||||
//! 1. 面板立刻收回(不是等后台回一句话);
|
||||
//! 2. 这一轮之后回来的事件因代次不符被 `handle` 丢掉;
|
||||
//! 3. **后台那次导入会继续跑到结束**——core 的 `ImportRequest` 没有取消令牌,进度回调也拿不到
|
||||
//! 控制流。所以「取消」的真实含义是「不再显示、结果作废」,不是「把它停手」。要真停必须给
|
||||
//! core 加 cancel token(已登记 finding,报告里同样划出来);
|
||||
//! 4. **提示语不能借用安装那条**(复审 r1 P1-2):下载页的取消 toast 写着「实例未创建」,
|
||||
//! 而导入会把实例**登记上**。所以 `cancel_pack()` 回一个「刚才有没有导入在跑」,
|
||||
//! `dispatch` 据此改发 [`crate::dto::PackEvent::Cancelled`],界面上换成
|
||||
//! 「导入会在后台继续跑完 —— 本次结果作废」这句。
|
||||
//! 3. **后台那次导入真的停手**:core 在阶段边界与长循环内检查令牌,停下就回滚(暂存目录删掉、
|
||||
//! 落盘过的撤回、实例不登记),结果是 `ImportStatus::Cancelled` 而**不是**失败。响应延迟的
|
||||
//! 底线是「当前这一条文件读完/下完」,见 `core::addon::import::cancel` 的逐段表;
|
||||
//!
|
||||
//! **收尾永远把真实结局送到界面**(M195 r3):取消可能落在最后一个检查点之后 —— 那时管线
|
||||
//! 已经跑完(用户的同名文件被覆盖、实例已登记),也可能回滚没做完。这两种结果以前被
|
||||
//! `cancel` 的早退 + 代次过滤一起丢掉,界面只剩一句无条件的"已停手并回滚"。现在
|
||||
//! `import::finish` 无条件发 `PackEvent::Report`,`HostPackState::terminal_epochs` 给
|
||||
//! "刚被取消的那一次"留一条**只放行 Report** 的一次性通道([`take_terminal_epoch`]),
|
||||
//! DTO 的 `ImportReportView::cancel` 三档把区别带到界面;
|
||||
//! 4. **提示语不能借用安装那条**(复审 r1 P1-2):`cancel_pack()` 回一个「刚才有没有导入在跑」,
|
||||
//! `dispatch` 据此改发 [`crate::dto::PackEvent::Cancelled`]。取消**通常**意味着实例没建
|
||||
//! (管线回滚了),但取消也可能恰好在两个检查点之间落下(那时已经装完并登记)——所以文案
|
||||
//! 只承诺「本次结果作废 + 目标目录已回滚到导入前」,不承诺「一条命令都没执行」。
|
||||
|
||||
mod convert;
|
||||
mod diagnose;
|
||||
@@ -77,10 +85,17 @@ use super::super::job::{HostTask, JobSender};
|
||||
///
|
||||
/// 为什么不挪进 `CoreHost`:那要给 `host/mod.rs` 加字段(不在本 mission 的 scope)。这张表等价,
|
||||
/// 只是把「每 host 的字段」放在模块里;`host/mod.rs` 一旦进 scope,搬过去只是把这张表删掉。
|
||||
#[derive(Default, Clone, Copy)]
|
||||
#[derive(Default)]
|
||||
struct HostPackState {
|
||||
epoch: u64,
|
||||
in_flight: usize,
|
||||
/// 「刚被取消、还欠界面一份终态报告」的那几个代次(M195 r3)。
|
||||
///
|
||||
/// `cancel_pack` 会把代次 +1,于是被取消那次运行交上来的 `PackEvent::Report` 会被 `handle`
|
||||
/// 按代次丢掉——可那份报告才是**真实结局**(停手并回滚 / 回滚没做完 / 取消太晚其实已装完)。
|
||||
/// 记在这里的代次允许**一条** `Report` 穿透代次过滤,取走即销;新一轮导入(`fresh_cancel`)
|
||||
/// 清空整表,旧运行的报告不会污染新面板。
|
||||
terminal_epochs: Vec<u64>,
|
||||
}
|
||||
|
||||
static PACK_STATE: Mutex<Vec<(PathBuf, HostPackState)>> = Mutex::new(Vec::new());
|
||||
@@ -125,7 +140,7 @@ pub(in crate::host) fn in_flight(env: &HostEnv) -> usize {
|
||||
///
|
||||
/// 复审 r2 P2-1 的教训:手工 `finished()` 只在 `block_on(import_pack(..))` 之后调用,
|
||||
/// 而失败早退(探测失败 / 目标目录不可用)都在它之前 ⇒ 计数永远留在「在跑」,于是下一次
|
||||
/// **下载页**的「取消安装」不再发 `InstallCancelled`,还给内容库画一句「导入还在后台跑」的假话。
|
||||
/// **下载页**的「取消安装」不再发 `InstallCancelled`,还给内容库画一句「导入还在跑」的假话。
|
||||
///
|
||||
/// 现在计数由句柄的 `Drop` 负责:**任何**退出路径(早退、报错、`?`、panic 展开)都会减一。
|
||||
pub(super) struct InFlight {
|
||||
@@ -151,7 +166,7 @@ impl Drop for InFlight {
|
||||
/// 取消这个 host 正在跑的导入(`CancelInstall` 与「关面板」都走它;没在跑时是 no-op)。
|
||||
///
|
||||
/// 返回值 = 「刚才确实有一次导入在跑」。调用方据此决定提示语:**导入的取消不是 install 的取消**
|
||||
/// (后台会跑完并登记实例,见模块文档),所以不能复用下载页那条「安装已取消、实例未创建」。
|
||||
/// (M195 起 core 真的停手并回滚,见模块文档),所以不能复用下载页那条「安装已取消、实例未创建」。
|
||||
pub(in crate::host) fn cancel_pack(env: &HostEnv) -> bool {
|
||||
let key = host_key(env);
|
||||
{
|
||||
@@ -162,12 +177,42 @@ pub(in crate::host) fn cancel_pack(env: &HostEnv) -> bool {
|
||||
cancel.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
let running = in_flight(env) > 0;
|
||||
if running {
|
||||
// 记下**马上要被作废的那个代次**:那次运行随后交上来的报告是它的真实结局,必须能穿透
|
||||
// 代次过滤(否则"取消太晚、其实已经装完"在界面上永远不成立)。重复取消只记一次。
|
||||
let stale = pack_epoch(env);
|
||||
with_state(env, |state| {
|
||||
if !state.terminal_epochs.contains(&stale) {
|
||||
state.terminal_epochs.push(stale);
|
||||
}
|
||||
});
|
||||
}
|
||||
bump_pack_epoch(env);
|
||||
in_flight(env) > 0
|
||||
running
|
||||
}
|
||||
|
||||
/// 这个代次是不是「刚被取消、还欠一份终态报告」的那一次(**取走即销**:只放行一条)。
|
||||
///
|
||||
/// 只给 `PackEvent::Report` 用:取消之后的**进度**事件仍旧一律按代次丢掉(面板早就收回了,
|
||||
/// 再点亮它就是「点了取消没反应」的旧病)。
|
||||
pub(in crate::host) fn take_terminal_epoch(env: &HostEnv, epoch: u64) -> bool {
|
||||
with_state(env, |state| {
|
||||
match state.terminal_epochs.iter().position(|seen| *seen == epoch) {
|
||||
Some(index) => {
|
||||
state.terminal_epochs.remove(index);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 这个 host 新一次导入的取消标记(挂进表里供 [`cancel_pack`] 用)。
|
||||
///
|
||||
/// 顺手清掉「还欠终态报告」的代次:新一次运行占住面板,上一次的结局不该再冒出来。
|
||||
fn fresh_cancel(env: &HostEnv) -> Arc<AtomicBool> {
|
||||
with_state(env, |state| state.terminal_epochs.clear());
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
let key = host_key(env);
|
||||
let mut table = PACK_CANCEL
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
//! 于是「翻译表」本身就是一份可审计的清单:core 加了新变体,`from_slug` 返回 `None`
|
||||
//! 时这里会落到一个有名字的兜底,而不是悄悄画成别的意思。
|
||||
|
||||
mod cancel;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use unlucky_core::addon::export::{ExportReport, FileClass, FileReason};
|
||||
@@ -100,10 +102,19 @@ const fn reason_of(reason: ConflictReason) -> ConflictReasonView {
|
||||
}
|
||||
|
||||
/// 一次导入结果 → DTO。
|
||||
pub(super) fn report_view(report: &ImportReport, target: &Path) -> ImportReportView {
|
||||
///
|
||||
/// `cancel_requested` = 这次运行**被点过取消**(presenter 手里的那个开关)。它只用来区分
|
||||
/// "取消太晚"那一档:管线已经跑完时报告是 `Success`/`Partial`,但那不是一次正常导入——
|
||||
/// 用户的取消没赶上,实例会保留、同名文件可能已被覆盖(M195 r3)。
|
||||
pub(super) fn report_view(
|
||||
report: &ImportReport,
|
||||
target: &Path,
|
||||
cancel_requested: bool,
|
||||
) -> ImportReportView {
|
||||
ImportReportView {
|
||||
status: ImportStatusView::from_slug(report.status.as_str())
|
||||
.unwrap_or(ImportStatusView::Failed),
|
||||
cancel: cancel::cancel_view(report, cancel_requested),
|
||||
format: report.format.as_deref().and_then(PackFormatView::from_slug),
|
||||
pack_name: report.pack_name.clone(),
|
||||
pack_version: report.pack_version.clone(),
|
||||
@@ -319,6 +330,8 @@ fn failed_report_with(target: PathBuf, pack: &Path, error: ImportErrorView) -> I
|
||||
rolled_back: true,
|
||||
// 预检/装配阶段就失败了:目标目录一个字节都没写(不是「写完又回滚」)。
|
||||
failure_stage: Some(ImportFailureStageView::NotStarted),
|
||||
// 这一路根本没进管线,谈不上取消。
|
||||
cancel: crate::dto::ImportCancelView::NotRequested,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
//! 「取消的**真实结局**」→ DTO 三档(M195 r3)。
|
||||
//!
|
||||
//! 从父模块拆出来只为行数(父文件已到 400 硬限附近),判据与文案口径原样。
|
||||
|
||||
use unlucky_core::addon::import::report::ImportStatus;
|
||||
use unlucky_core::addon::import::ImportReport;
|
||||
|
||||
use crate::dto::ImportCancelView;
|
||||
|
||||
/// core 的「取消真实结局」→ DTO 的三档(M195 r3:**不许**把三档混成一句)。
|
||||
///
|
||||
/// - core 说 `Cancelled` ⇒ 管线真的停手了;回滚完整与否看 `rolled_back`(两档分开说);
|
||||
/// - core 说 `Success`/`Partial` 而这次被点过取消 ⇒ **取消太晚**:报告是真结局,但它不是
|
||||
/// "停下来了",界面必须照实说「实例会保留、文件可能已被覆盖」;
|
||||
/// - core 说 `Failed` ⇒ **一律 `NotRequested`**(复审 r3 P2-1):失败卡自己已经把真相说全了
|
||||
/// (`failure_stage` 分「没写过 / 回滚干净 / 回滚没做完」),再贴一句「取消太晚、已经跑完、
|
||||
/// 文件可能被覆盖」就是与红卡**直接对立**的第二句假话——失败的导入没有"跑完"这一说。
|
||||
pub(super) fn cancel_view(report: &ImportReport, cancel_requested: bool) -> ImportCancelView {
|
||||
match report.status {
|
||||
ImportStatus::Cancelled => {
|
||||
if report.rolled_back {
|
||||
ImportCancelView::RolledBack
|
||||
} else {
|
||||
ImportCancelView::RollbackIncomplete
|
||||
}
|
||||
}
|
||||
ImportStatus::Success | ImportStatus::Partial if cancel_requested => {
|
||||
ImportCancelView::TooLate
|
||||
}
|
||||
ImportStatus::Success | ImportStatus::Partial | ImportStatus::Failed => {
|
||||
ImportCancelView::NotRequested
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! 三档的**推导**(不是"给定这一档、文案怎么画"):什么输入会推出哪一档。
|
||||
//!
|
||||
//! 这一族用例是复审 r3 P2-1/P2-2 的直接产物:此前只有下半段(文案)有用例,把推导删掉
|
||||
//! 也全绿。
|
||||
|
||||
use unlucky_core::addon::import::report::ImportStatus;
|
||||
use unlucky_core::addon::import::ImportReport;
|
||||
|
||||
use crate::dto::ImportCancelView;
|
||||
|
||||
use super::super::report_view;
|
||||
|
||||
fn core_report(status: ImportStatus, rolled_back: bool) -> ImportReport {
|
||||
let mut report = ImportReport::empty(std::path::PathBuf::from("instance"));
|
||||
report.status = status;
|
||||
report.rolled_back = rolled_back;
|
||||
report
|
||||
}
|
||||
|
||||
fn cancel_of(
|
||||
status: ImportStatus,
|
||||
rolled_back: bool,
|
||||
cancel_requested: bool,
|
||||
) -> ImportCancelView {
|
||||
report_view(
|
||||
&core_report(status, rolled_back),
|
||||
std::path::Path::new("instance"),
|
||||
cancel_requested,
|
||||
)
|
||||
.cancel
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_incomplete_rollback_is_its_own_bucket() {
|
||||
// 推导:`Cancelled` + `rolled_back == false` ⇒ **回滚没做完**(不是"已回滚")。
|
||||
// 把这条推导删掉(一律按已回滚)这里就红——那正是复审 r3 P2-2 说的零覆盖。
|
||||
assert_eq!(
|
||||
cancel_of(ImportStatus::Cancelled, false, true),
|
||||
ImportCancelView::RollbackIncomplete
|
||||
);
|
||||
// 对照:回滚完整才是「已回滚」——两档不许并成一个。
|
||||
assert_eq!(
|
||||
cancel_of(ImportStatus::Cancelled, true, true),
|
||||
ImportCancelView::RolledBack
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_run_is_never_dressed_up_as_a_late_cancel() {
|
||||
// P2-1:失败 + 点过取消(例如"点取消的同时摘要不符")⇒ 失败卡自己说全了真相,
|
||||
// 不许再贴「取消太晚、已经跑完、文件可能被覆盖」——那两句直接对立。
|
||||
assert_eq!(
|
||||
cancel_of(ImportStatus::Failed, false, true),
|
||||
ImportCancelView::NotRequested,
|
||||
"失败的导入没有「跑完」这一说"
|
||||
);
|
||||
assert_eq!(
|
||||
cancel_of(ImportStatus::Failed, true, true),
|
||||
ImportCancelView::NotRequested
|
||||
);
|
||||
// 对照:成功/部分成功 + 点过取消,才是「太晚」。
|
||||
for status in [ImportStatus::Success, ImportStatus::Partial] {
|
||||
assert_eq!(
|
||||
cancel_of(status, false, true),
|
||||
ImportCancelView::TooLate,
|
||||
"{status:?} + 取消 ⇒ 太晚"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_untouched_run_carries_no_cancel_verdict() {
|
||||
for status in [
|
||||
ImportStatus::Success,
|
||||
ImportStatus::Partial,
|
||||
ImportStatus::Failed,
|
||||
] {
|
||||
assert_eq!(
|
||||
cancel_of(status, false, false),
|
||||
ImportCancelView::NotRequested,
|
||||
"没点过取消就不该有取消结论"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,15 +7,18 @@
|
||||
//! 往下导(不打扰用户);有冲突就回 [`PackEvent::Scan`] 让页面画清单,等用户点完再导;
|
||||
//! 3. **缺省档永远 `KeepAll`**:决策从 DTO 逐字段搬([`convert::decisions_of`]),页面没点过的
|
||||
//! 路径在 core 侧落到"保留";
|
||||
//! 4. **取消后不再打扰界面**:进度回调与最终报告都先看取消标记。
|
||||
//! 4. **取消是真取消**(M195):`cancel` 标记既挡界面事件、又**交给 core 当取消令牌**——管线
|
||||
//! 在检查点停手并回滚(逐段检查点见 `core::addon::import::cancel`)。取消之后的**进度**不再
|
||||
//! 打扰界面(回调与代次都挡),但**终态报告一定发出**:取消可能太晚(管线已跑完)或回滚
|
||||
//! 没做完,那两种结局必须让用户看见(见 [`finish`])。
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use unlucky_core::addon::import::{
|
||||
import_pack, scan_pack_online, DownloadEngineFetcher, ImportProgress, ImportProgressSink,
|
||||
ImportRequest,
|
||||
import_pack, scan_pack_online, DownloadEngineFetcher, ImportCancel, ImportProgress,
|
||||
ImportProgressSink, ImportRequest,
|
||||
};
|
||||
use unlucky_core::download::blocking::block_on;
|
||||
|
||||
@@ -181,8 +184,11 @@ pub(super) fn run(
|
||||
|
||||
let engine = super::super::super::engine::engine(env);
|
||||
let fetcher = DownloadEngineFetcher::new(&engine);
|
||||
// 这个开关从 M195 起是**真令牌**:core 的每个检查点都看它(不只是挡界面事件)。
|
||||
let token = ImportCancel::from_flag(Arc::clone(&cancel));
|
||||
let mut core_request = ImportRequest::new(&request.pack, &target)
|
||||
.with_fetcher(&fetcher)
|
||||
.with_cancel(&token)
|
||||
.with_progress(sink)
|
||||
// 决策逐字段搬过来:`KeepAll`(缺省)/ `OverwriteAll` / 逐项,语义在 core 那一侧落地。
|
||||
.with_decisions(convert::decisions_of(&request.decisions));
|
||||
@@ -191,21 +197,49 @@ pub(super) fn run(
|
||||
}
|
||||
|
||||
let report = block_on(import_pack(&core_request));
|
||||
if report.status == ImportStatus::Failed && register {
|
||||
// 失败已回滚,但**目录是我们建的**、core 只清文件不清目录 ⇒ 不清掉的话
|
||||
// 下一次重试会撞「directory already exists」(复审 r1 P2-9)。
|
||||
remove_if_empty(&target);
|
||||
}
|
||||
if cancel.load(Ordering::SeqCst) {
|
||||
// 取消之后不再打扰界面(面板已经收回,代次也已经作废)。
|
||||
// 注意:后台这一步**已经跑完**(core 没有取消令牌),实例可能已被登记 ——
|
||||
// 所以取消的提示语必须说「还在后台跑」,不能说「实例未创建」。
|
||||
return;
|
||||
// 取消标记要**在管线返回之后**再读一次:取消可能来得太晚(管线已经跑完),这一读就是
|
||||
// "太晚"的唯一证据(`finish` 用它把报告标成 `TooLate`)。
|
||||
let cancel_requested = cancel.load(Ordering::SeqCst);
|
||||
finish(tx, &target, register, epoch, cancel_requested, &report);
|
||||
}
|
||||
|
||||
/// 收尾:清掉我们自建的空目录 + 把 core 的报告变成事件。
|
||||
///
|
||||
/// # 取消也照发(M195 r3)
|
||||
///
|
||||
/// 以前这里 `cancel.load()` 是对**所有**事件的早退,于是"取消太晚、后台其实已经装完并登记"
|
||||
/// 这件事永远到不了界面,界面只能照着旧文案说「已停手并回滚」。现在报告无条件发出:它就是
|
||||
/// 真实结局——core 说 `Cancelled`(停手并回滚 / 回滚没做完),或者说 `Success`/`Partial`
|
||||
/// (取消没赶上),由 DTO 的 `cancel` 三档把区别带到界面。
|
||||
///
|
||||
/// 代次仍用**出发时**那个(`epoch`):`handle` 给"刚被取消的那一次"留了一次性终态通道
|
||||
/// ([`super::super::addon::pack::take_terminal_epoch`]),进度事件照旧按代次丢。
|
||||
pub(super) fn finish(
|
||||
tx: &JobSender,
|
||||
target: &std::path::Path,
|
||||
register: bool,
|
||||
epoch: u64,
|
||||
cancel_requested: bool,
|
||||
report: &unlucky_core::addon::import::ImportReport,
|
||||
) {
|
||||
if register
|
||||
&& matches!(
|
||||
report.status,
|
||||
ImportStatus::Failed | ImportStatus::Cancelled
|
||||
)
|
||||
{
|
||||
// 失败 / 取消都已回滚,但**目录是我们建的**(`prepare_target`)、core 只清文件不清
|
||||
// 目录 ⇒ 不清掉的话下一次重试会撞「directory already exists」(复审 r1 P2-9)。
|
||||
remove_if_empty(target);
|
||||
}
|
||||
send(
|
||||
tx,
|
||||
epoch,
|
||||
PackEvent::Report(Box::new(convert::report_view(&report, &target))),
|
||||
PackEvent::Report(Box::new(convert::report_view(
|
||||
report,
|
||||
target,
|
||||
cancel_requested,
|
||||
))),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,9 +9,11 @@
|
||||
//! | [`a_failed_new_instance_directory_can_be_reused_for_retry`] | 失败残留的空目录不挡重试 |
|
||||
//! | [`pack_epoch_is_per_host_and_is_its_own_counter`] | 代次按 host 分开,不再跨 host 串扰 |
|
||||
//! | [`a_failed_import_releases_the_in_flight_flag_and_keeps_install_cancel_intact`] | 早退路径放回「在跑」计数,取消安装仍是安装的取消 |
|
||||
//! | [`cancel::a_pre_cancelled_flag_really_stops_the_core_pipeline`] | 取消标记**真的**交给 core(盘上零残留、档案零实例) |
|
||||
//!
|
||||
//! 夹具(临时目录、写文件、泵 host)在 [`harness`]。
|
||||
|
||||
mod cancel;
|
||||
mod harness;
|
||||
mod scan;
|
||||
|
||||
@@ -388,7 +390,7 @@ fn a_failed_import_releases_the_in_flight_flag_and_keeps_install_cancel_intact()
|
||||
!pack_events(&events)
|
||||
.iter()
|
||||
.any(|event| matches!(event, PackEvent::Cancelled)),
|
||||
"此刻没有导入在跑,不该给内容库发「导入还在后台跑」"
|
||||
"此刻没有导入在跑,不该给内容库发「导入已取消」"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
//! presenter 的**真令牌**接线(M195 复审 P2-2):取消标记必须真的交给 core。
|
||||
//!
|
||||
//! # 为什么判据只能是盘上的事实
|
||||
//!
|
||||
//! 只断言"没收到 `PackEvent::Report`"是**两条路都成立**的伪证明:`import::run` 的早退条件是
|
||||
//! `report.status == Cancelled || cancel.load()`,后者会把"根本没接线"完全遮住——core 照旧跑完
|
||||
//! 并登记实例,界面只看到面板收回。那正是本 mission 要消灭的形态,所以本用例盯的是**目标目录
|
||||
//! 与 `profiles.json`**:标记一进来就是 true ⇒ 目录里一个文件都不该有、实例一个都不该登记。
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::dto::{ConflictDecisionsView, ImportRequestView};
|
||||
|
||||
use super::harness::{temp_dir, write};
|
||||
|
||||
/// 一个能走完管线的最小 MultiMC 包(目录形态:不需要 zip 写入器)。
|
||||
fn multimc_pack(dir: &std::path::Path) -> std::path::PathBuf {
|
||||
let source = dir.join("Better MC");
|
||||
write(
|
||||
&source.join("mmc-pack.json"),
|
||||
br#"{"formatVersion": 1, "components": [
|
||||
{"uid": "net.minecraft", "version": "1.20.1"}]}"#,
|
||||
);
|
||||
write(&source.join(".minecraft/mods/sodium.jar"), &[0x11; 1024]);
|
||||
write(&source.join(".minecraft/config/options.txt"), b"fov=90\n");
|
||||
source
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pre_cancelled_flag_really_stops_the_core_pipeline() {
|
||||
let dir = temp_dir("cancel-wired");
|
||||
let source = multimc_pack(&dir);
|
||||
let data = dir.join("data");
|
||||
std::fs::create_dir_all(&data).expect("data root");
|
||||
let env = crate::host::HostEnv::at_root(&data);
|
||||
let request = ImportRequestView {
|
||||
pack: source,
|
||||
instance: None,
|
||||
instance_name: Some("Cancelled Pack".to_string()),
|
||||
decisions: ConflictDecisionsView::default(),
|
||||
};
|
||||
// 目标目录与"是否登记"都按 host 的真口径算(判据不能自己另算一份)。
|
||||
let (target, register) = super::super::import::target_of(&env, &request).expect("目标目录");
|
||||
assert!(register, "走新实例这条路才覆盖「登记」这一环");
|
||||
|
||||
let (tx, _rx) = crate::host::job::channel();
|
||||
let cancel = Arc::new(AtomicBool::new(true));
|
||||
super::super::import::run(&env, &tx, &request, 11, Arc::clone(&cancel));
|
||||
drop(tx);
|
||||
assert!(cancel.load(Ordering::SeqCst), "用例前提:标记一直是 true");
|
||||
|
||||
assert!(
|
||||
!target.exists(),
|
||||
"取消后目标目录不该存在(core 在入口检查点就停手,暂存目录也删了):{}",
|
||||
target.display()
|
||||
);
|
||||
let profiles =
|
||||
unlucky_core::game::install::instance::read_profiles(&env.layout).expect("读档案");
|
||||
assert!(
|
||||
profiles.profiles.is_empty(),
|
||||
"取消后不许登记实例:{:?}",
|
||||
profiles.profiles
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// **路径 A:取消来得太晚**(M195 r3 的核心用例)。
|
||||
///
|
||||
/// 三段拼起来,全部确定性、没有 sleep:
|
||||
/// 1. **core 真管线**跑一次合并态导入,把取消置在**最后一条** `ImportStage::Commit` 进度上——
|
||||
/// 之后没有任何检查点,于是管线一路跑完(用户的 `mods/a.jar` 被覆盖),报告是
|
||||
/// `Success` + `rolled_back == false`(core 一直是诚实的);
|
||||
/// 2. `cancel_pack` 把代次 +1(这就是"取消"),并记下被作废的那个代次;
|
||||
/// 3. `finish` 照发 `PackEvent::Report`,`handle` 让它**穿透代次**——界面因此拿到「已完成 /
|
||||
/// 未回滚 / 取消太晚」,而不是旧行为里那句无条件的「已停手并回滚」。
|
||||
///
|
||||
/// 变异:把终态通道去掉(`handle` 恢复"代次不符一律丢")⇒ 本用例立刻红(报告到不了界面)。
|
||||
#[test]
|
||||
fn a_late_cancel_still_delivers_the_real_outcome_to_the_ui() {
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
|
||||
use unlucky_core::addon::import::{
|
||||
import_pack, ConflictPolicy, ImportCancel, ImportProgress, ImportProgressSink,
|
||||
ImportRequest, ImportStage, ImportStatus,
|
||||
};
|
||||
use unlucky_core::download::blocking::block_on;
|
||||
|
||||
use crate::dto::{AppEvent, Backend, CoreEvent, PackEvent};
|
||||
|
||||
let dir = temp_dir("cancel-late");
|
||||
// 包里有 `mods/a.jar`,目标目录里也有(内容不同)⇒ 合并态"被覆盖"是真的会发生。
|
||||
let source = dir.join("Path A Pack");
|
||||
write(
|
||||
&source.join("mmc-pack.json"),
|
||||
br#"{"formatVersion": 1, "components": [
|
||||
{"uid": "net.minecraft", "version": "1.20.1"}]}"#,
|
||||
);
|
||||
write(&source.join(".minecraft/mods/a.jar"), b"pack a");
|
||||
let target = dir.join("instance");
|
||||
write(&target.join("mods/a.jar"), b"user a");
|
||||
|
||||
let data = dir.join("data");
|
||||
std::fs::create_dir_all(&data).expect("data root");
|
||||
let env = crate::host::HostEnv::at_root(&data);
|
||||
|
||||
// ① core:取消置在最后一条逐文件进度上 ⇒ 之后没有检查点 ⇒ 管线跑完。
|
||||
let token = ImportCancel::new();
|
||||
let sink_token = token.clone();
|
||||
let seen = Arc::new(AtomicUsize::new(0));
|
||||
let seen_by_sink = Arc::clone(&seen);
|
||||
let sink: ImportProgressSink = Arc::new(move |progress: &ImportProgress| {
|
||||
if progress.stage != ImportStage::Commit || progress.total == 0 {
|
||||
return;
|
||||
}
|
||||
seen_by_sink.fetch_add(1, Ordering::SeqCst);
|
||||
if progress.done + 1 == progress.total {
|
||||
sink_token.cancel();
|
||||
}
|
||||
});
|
||||
let report = block_on(import_pack(
|
||||
&ImportRequest::new(&source, &target)
|
||||
.with_cancel(&token)
|
||||
// 覆盖档:让用户的 `mods/a.jar` 真的被覆盖(缺省是"保留用户那一侧")。
|
||||
.with_conflict_policy(ConflictPolicy::Overwrite)
|
||||
.with_progress(sink),
|
||||
));
|
||||
assert!(token.is_cancelled(), "用例前提:取消确实置位了");
|
||||
assert!(
|
||||
seen.load(Ordering::SeqCst) > 1,
|
||||
"确实逐文件走到了底(合并态)"
|
||||
);
|
||||
assert_eq!(
|
||||
report.status,
|
||||
ImportStatus::Success,
|
||||
"取消置在最后一条之后 ⇒ 管线已经跑完:{:?}",
|
||||
report.error
|
||||
);
|
||||
assert!(
|
||||
!report.rolled_back,
|
||||
"没有回滚就别说回滚过(core 的机器位是诚实的)"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(target.join("mods/a.jar")).expect("读被覆盖的文件"),
|
||||
b"pack a",
|
||||
"路径 A 的前提:用户的同名文件**已经被覆盖**"
|
||||
);
|
||||
|
||||
// ② 用户点取消:代次 +1,被作废的那个代次进了终态通道。
|
||||
let epoch = super::super::pack_epoch(&env);
|
||||
let _in_flight = super::super::InFlight::begin(&env);
|
||||
assert!(
|
||||
super::super::cancel_pack(&env),
|
||||
"取消应当看到一条在跑的导入"
|
||||
);
|
||||
assert_ne!(
|
||||
super::super::pack_epoch(&env),
|
||||
epoch,
|
||||
"取消会作废本次代次(这正是旧实现丢报告的成因)"
|
||||
);
|
||||
|
||||
// ③ 收尾照发报告;同代次上的**进度**事件仍按代次丢掉。
|
||||
let mut host = crate::host::CoreHost::headless(env.clone());
|
||||
let (tx, rx) = crate::host::job::channel();
|
||||
super::super::import::finish(&tx, &target, false, epoch, true, &report);
|
||||
tx.send(crate::host::job::HostTask::Pack {
|
||||
event: Box::new(PackEvent::Progress(None)),
|
||||
epoch,
|
||||
})
|
||||
.expect("送一条同代次的进度事件");
|
||||
drop(tx);
|
||||
for task in rx.iter() {
|
||||
host.handle(task);
|
||||
}
|
||||
let events = host.drain_events();
|
||||
|
||||
let delivered: Vec<&crate::dto::ImportReportView> = events
|
||||
.iter()
|
||||
.filter_map(|event| match event {
|
||||
AppEvent::Core(CoreEvent::Pack(inner)) => match inner.as_ref() {
|
||||
PackEvent::Report(view) => Some(view.as_ref()),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let view = delivered
|
||||
.first()
|
||||
.expect("取消太晚时,真实结果必须到达界面(丢掉就是路径 A 的假话)");
|
||||
assert_eq!(
|
||||
view.status,
|
||||
crate::dto::ImportStatusView::Success,
|
||||
"界面拿到的结局是「已完成」"
|
||||
);
|
||||
assert!(
|
||||
!view.rolled_back,
|
||||
"界面拿到的结局是「未回滚」,不是「已回滚」"
|
||||
);
|
||||
assert_eq!(
|
||||
view.cancel,
|
||||
crate::dto::ImportCancelView::TooLate,
|
||||
"必须标成「取消太晚」,不能标成「已回滚」"
|
||||
);
|
||||
assert!(
|
||||
!events.iter().any(|event| match event {
|
||||
AppEvent::Core(CoreEvent::Pack(inner)) =>
|
||||
matches!(inner.as_ref(), PackEvent::Progress(_)),
|
||||
_ => false,
|
||||
}),
|
||||
"终态通道只放行报告:取消后的进度事件仍按代次丢掉"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -72,9 +72,10 @@ impl CoreHost {
|
||||
if let Some(cancel) = &self.install_cancel {
|
||||
cancel.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
// M113: import/export —— 同一块面板的「取消」对导入也生效:立标记 + 作废 pack 代次
|
||||
// M113: import/export —— 同一块面板的「取消」对导入也生效:立标记 + 作废 pack 代次。
|
||||
// 返回值告诉我们「刚才有没有导入在跑」:有的话**不能**再发 install 那条取消信号
|
||||
// (它说的是「安装已取消、实例未创建」,而导入会在后台跑完并登记实例)。
|
||||
// (它说的是「安装已取消、实例未创建」,而导入的取消是**管线级**的:core 在检查点
|
||||
// 停手并回滚,文案见 `PackCancelled`;M195 之前它会在后台跑完并登记实例)。
|
||||
let pack_was_running = addon::cancel_pack(&self.env);
|
||||
if pack_was_running {
|
||||
self.send(CoreEvent::Pack(Box::new(PackEvent::Cancelled)));
|
||||
|
||||
@@ -49,7 +49,15 @@ impl CoreHost {
|
||||
// pack 用**自己**的代次(复审 r1 P2-7):共用安装的计数器会让
|
||||
// 「关一次导入面板」把下载页正在跑的安装静默丢掉。
|
||||
if epoch != addon::pack_epoch(&self.env) {
|
||||
return;
|
||||
// 唯一例外(M195 r3):**刚被取消的那一次**交上来的终态报告。取消会把代次
|
||||
// +1,若连报告一起丢,界面就永远学不到"取消太晚、其实已经装完/回滚没做完"
|
||||
// 这个真相——只剩一句无条件的"已停手并回滚"。
|
||||
// 只放行 `Report`、且同一代次只放行一条;进度事件照旧按代次丢。
|
||||
if !(matches!(&*event, PackEvent::Report(_))
|
||||
&& addon::take_terminal_epoch(&self.env, epoch))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 导入成功可能登记了新实例:与 `InstallDone` 同一条刷新(复审 r1 P2-1),
|
||||
// 否则新实例要等下一次无关的 `RefreshAll` 才出现在实例页与目标 chips 里。
|
||||
|
||||
Reference in new issue
Block a user