diff --git a/crates/app-iced/src/pages/content/packui/model.rs b/crates/app-iced/src/pages/content/packui/model.rs index 2f9907f..ccb0882 100644 --- a/crates/app-iced/src/pages/content/packui/model.rs +++ b/crates/app-iced/src/pages/content/packui/model.rs @@ -196,8 +196,9 @@ pub struct Pack { pub running: Option, /// 探测失败诊断 pub probe: Option, - /// 用户取消过这次导入:面板收回但**后台还在跑**(core 没有取消令牌), - /// 所以要把这句话留在界面上,别让用户以为「没建成」(复审 r1 P1-2)。 + /// 用户取消过这次导入:面板收回,**后台也真的停手并回滚了**(M195 起 core 有取消令牌: + /// 暂存目录删掉、落盘过的撤回、实例不登记)。这句话仍要留在界面上——取消不是"什么都没 + /// 发生",而是"按你的意思停下来了",别让用户以为点空了。 pub cancelled: bool, /// 取消提示的正文 pub cancelled_note: &'static str, diff --git a/crates/app-iced/src/pages/content/packui/model/tests.rs b/crates/app-iced/src/pages/content/packui/model/tests.rs index ac8e77c..8a8e876 100644 --- a/crates/app-iced/src/pages/content/packui/model/tests.rs +++ b/crates/app-iced/src/pages/content/packui/model/tests.rs @@ -382,19 +382,17 @@ 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:面板收回之后,界面上必须留一句「后台还在跑」 +fn cancelled_state_is_visible_and_says_the_import_stopped() { + // M195:取消是管线级的(core 停手并回滚),旧文案「后台还在跑」已是假话。 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(), "取消态不能被当成「什么都没有」"); + let note = model.cancelled_note.to_lowercase(); + let stopped = note.contains("stopped") && note.contains("rolled back"); + assert!(model.cancelled && !model.is_idle(), "取消态不能当成空闲"); + assert!(stopped, "文案要说清停手并回滚"); + assert!(!note.contains("background"), "文案不许说后台跑"); } diff --git a/crates/core/src/addon/import/cancel_tests.rs b/crates/core/src/addon/import/cancel_tests.rs index dddae12..ebc1375 100644 --- a/crates/core/src/addon/import/cancel_tests.rs +++ b/crates/core/src/addon/import/cancel_tests.rs @@ -6,16 +6,26 @@ //! | 用例 | 删掉哪一处就红 | //! |------|----------------| //! | `a_cancel_before_the_first_write_touches_nothing` | 入口检查点 | -//! | `a_cancel_inside_the_extract_loop_leaves_no_half_unpacked_pack` | 解压循环里的检查点 | +//! | `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()` 那条路 | //! -//! 取消的触发点全部走**进度回调**(回调里置位 = 用户此刻点了取消,没有 sleep、没有竞态)。 -//! 全部离线:手造 mrpack(`files` 为空,内容全在 `overrides/`)。 +//! # 解压那条必须数事件,其余没有真守卫的检查点 +//! +//! 解压只写**暂存目录**,所以"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; @@ -35,7 +45,7 @@ const INDEX: &str = r#"{ } "#; -/// 手造一个 mrpack(`entries` 是 `overrides/` 下的实例内路径与字节)。 +/// 手造 mrpack(`entries` 是 `overrides/` 下的实例内路径与字节)。 fn mrpack(tag: &str, entries: &[(&str, &[u8])]) -> (TempRoot, PathBuf) { let root = TempRoot::new(tag); let source = root.join("src"); @@ -105,11 +115,28 @@ fn cancel_on(token: &ImportCancel, stage: ImportStage) -> ImportProgressSink { }) } -/// 在**合并态第二条逐文件进度**上置位取消。 -/// -/// 合并是**排序后**逐文件搬:第 0 条是基线(`.ull/pack-baseline.json`,`.` 排在最前),第 1 条 -/// 才是 `mods/a.jar`。所以 `done == 1` 正好是"用户那个被覆盖的文件已经搬进去、后面还有"的 -/// 窗口——journal 回滚要接住的正是这一刻(`total > 0` 顺带把阶段开始那条 0/0 排除掉)。 +/// 数**解压阶段**的进度条数,并在第一条上置位取消(这是解压循环检查点唯一的可观测判据: +/// 有它 ⇒ 第一条之后立刻停 = 1;删掉 ⇒ 一路解完 = 文件条数)。 +fn cancel_on_first_extract_counting( + token: &ImportCancel, +) -> (ImportProgressSink, Arc) { + 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| { @@ -119,7 +146,7 @@ fn cancel_on_second_merged_file(token: &ImportCancel) -> ImportProgressSink { }) } -/// 目标目录里**全部文件**的实例内相对路径(排序后),用来钉"取消后与导入前逐文件相同"。 +/// 目标目录里全部文件的实例内相对路径(排序后):钉"取消后与导入前逐文件相同"。 fn files_under(root: &Path) -> Vec { let mut out = Vec::new(); let mut pending = vec![root.to_path_buf()]; @@ -193,14 +220,22 @@ fn a_cancel_inside_the_extract_loop_leaves_no_half_unpacked_pack() { 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(cancel_on(&token, ImportStage::Extract)), + .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!( diff --git a/crates/i18n/src/catalog/content.rs b/crates/i18n/src/catalog/content.rs index a7829e3..1716c4f 100644 --- a/crates/i18n/src/catalog/content.rs +++ b/crates/i18n/src/catalog/content.rs @@ -179,8 +179,8 @@ 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.", - "导入会在后台继续跑完 —— 本次结果作废;已经写下的文件会留下。" + "Import cancelled — it stopped and rolled back: nothing was left half-done, no existing files were overwritten.", + "导入已取消 —— 后台已停手并回滚:没有留下半包,也没有覆盖已有文件。" ), PackOverwriteAction => ("Re-import, the pack wins", "以包为准重新导入"), PackOverwriteNote => ( diff --git a/crates/i18n/src/catalog/golden/tail2.rs b/crates/i18n/src/catalog/golden/tail2.rs index a7570c7..65310fc 100644 --- a/crates/i18n/src/catalog/golden/tail2.rs +++ b/crates/i18n/src/catalog/golden/tail2.rs @@ -29,7 +29,7 @@ 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", "Import cancelled — it stopped and rolled back: nothing was left half-done, no existing files were 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", "离线导入"), diff --git a/crates/presenter/src/host/addon/pack.rs b/crates/presenter/src/host/addon/pack.rs index 96e2722..d453ab5 100644 --- a/crates/presenter/src/host/addon/pack.rs +++ b/crates/presenter/src/host/addon/pack.rs @@ -126,7 +126,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 { diff --git a/crates/presenter/src/host/addon/pack/tests.rs b/crates/presenter/src/host/addon/pack/tests.rs index feb2da5..eaf6d5d 100644 --- a/crates/presenter/src/host/addon/pack/tests.rs +++ b/crates/presenter/src/host/addon/pack/tests.rs @@ -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); diff --git a/crates/presenter/src/host/addon/pack/tests/cancel.rs b/crates/presenter/src/host/addon/pack/tests/cancel.rs new file mode 100644 index 0000000..0c7728f --- /dev/null +++ b/crates/presenter/src/host/addon/pack/tests/cancel.rs @@ -0,0 +1,67 @@ +//! 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); +} diff --git a/crates/presenter/src/host/dispatch.rs b/crates/presenter/src/host/dispatch.rs index 081d9fd..1bfc4e0 100644 --- a/crates/presenter/src/host/dispatch.rs +++ b/crates/presenter/src/host/dispatch.rs @@ -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)));