From db5b08b9ab1edb6a33960a8d28ac0d1459cbb50a Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 16:35:03 +0800 Subject: [PATCH] =?UTF-8?q?fix(m195):=20=E5=A4=8D=E5=AE=A1=20r1=20?= =?UTF-8?q?=E4=B8=89=E9=A1=B9=20=E2=80=94=E2=80=94=20=E8=A7=A3=E5=8E=8B?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=E7=82=B9=E7=9C=9F=E5=AE=88=E5=8D=AB=E3=80=81?= =?UTF-8?q?presenter=20=E6=8E=A5=E7=BA=BF=E6=B5=8B=E8=AF=95=E3=80=81?= =?UTF-8?q?=E5=8F=96=E6=B6=88=E6=96=87=E6=A1=88=E6=94=B9=E5=9B=9E=E7=9C=9F?= =?UTF-8?q?=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2-1(解压循环检查点没有真守卫):那条用例原来的断言(status/target/files/bytes/无残留暂存) 在删掉 `extract_overrides` 的检查点后**依然成立**——提交前检查点⑤会替它停一次,于是几 GB 的包 会一直解到结束而测试全绿。改成数 `ImportStage::Extract` 事件:有检查点 = 1 条、删掉 = 3 条 (两面实测)。模块文档同时如实登记"还没有真守卫"的检查点:②③④⑤、全新导入 commit 的取消 分支、phase.rs 远端下载逐条、complete.rs 补全循环的 break。 P2-2(presenter 的真令牌接线零测试):新增 `pack/tests/cancel.rs`,直接调 `import::run` 并把 传入的 `Arc` **预先置 true**,判据只能是盘上事实(目标目录不存在 + profiles.json 零实例)——"没收到 Report"那种判据两条路都成立,`run` 早退条件里的 `cancel.load()` 会把 "没接线"完全遮住。 P2-3(用户可见文案说反了话):`PackCancelled` 中英双语从「导入会在后台继续跑完 / Import keeps running in the background…已经写下的文件会留下」改为「导入已取消 —— 后台已停手并回滚:没有留下 半包,也没有覆盖已有文件」;golden 金表同步;`model/tests.rs` 那条把旧文案钉死的用例改成钉新 语义(必须说清停手+回滚,且**禁止**出现 background);`model.rs` 的 `cancelled` 字段文档与 `dispatch.rs` 的取消注释同族修正。 验证:`cargo test -p unlucky-core -p unlucky-presenter` 1480 passed / 0 failed; i18n 31 / 0;app-iced packui 28 / 0;三 crate clippy 零 warning。 --- .../src/pages/content/packui/model.rs | 5 +- .../src/pages/content/packui/model/tests.rs | 16 ++--- crates/core/src/addon/import/cancel_tests.rs | 57 +++++++++++++--- crates/i18n/src/catalog/content.rs | 4 +- crates/i18n/src/catalog/golden/tail2.rs | 2 +- crates/presenter/src/host/addon/pack.rs | 2 +- crates/presenter/src/host/addon/pack/tests.rs | 4 +- .../src/host/addon/pack/tests/cancel.rs | 67 +++++++++++++++++++ crates/presenter/src/host/dispatch.rs | 5 +- 9 files changed, 133 insertions(+), 29 deletions(-) create mode 100644 crates/presenter/src/host/addon/pack/tests/cancel.rs 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)));