From 134b0a2aa4cb77801b0c24d645ca667ad94df7af Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 5 Nov 2026 02:34:35 +0800 Subject: [PATCH] =?UTF-8?q?fix(packui):=20M113=20r2=20=E5=A4=8D=E5=AE=A1?= =?UTF-8?q?=E6=95=B4=E6=94=B9=20=E2=80=94=E2=80=94=20RAII=20=E5=9C=A8?= =?UTF-8?q?=E8=B7=91=E5=8F=A5=E6=9F=84=E3=80=81per-host=20=E4=BB=A3?= =?UTF-8?q?=E6=AC=A1=E3=80=81=E6=B3=A8=E9=87=8A=E4=B8=8E=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审 r2(p2-3items / fix-then-merge)逐条整改。**P2-6 与 P3-3/4/5 按塔台指示不动**,只登记。 ## 必修 1:`PACK_RUNNING` 在早退路径不清零(跨功能泄漏) 改前:`fresh_cancel()` 置 bool,`super::finished()` 在 `block_on(import_pack(..))` **之后**才清; 探测失败 / 目标目录不可用两条 `return` 都在它之前 ⇒ bool 永久留在「在跑」。 后果:之后**任何**一次 `CancelInstall`(包括下载页的「取消安装」)①不再发 `InstallCancelled`、 ②还给内容库发 `PackEvent::Cancelled`(一句「导入还在后台跑」的假话)。 改后:`InFlight` **RAII 句柄**(`Drop` 减一)取代手工 `finished()`;计数用「每个 host 一格」的 `in_flight: usize` 而不是 bool(连点两次 Import 各自进出)。 `InFlight::begin(&env)` 在**派发点**创建、随线程移动到出口 ⇒ 正常收尾、两条早退、`?`、panic 展开**任何**退出路径都会减一,以后再加早退分支也不用记得补一行。 ## 必修 2:`dir_name` 注释与实测不一致 改前:注释与报告写「`Better MC` → `Better-MC`(不加哈希)」,而 `faithful` 只认 `[A-Za-z0-9_-]`, 空格不算 ⇒ 实际 `Better-MC-c7c9c149`;测试只钉了无空格形态。 改后:**选「空格算 faithful」这边**,注释、代码、测试三处对齐 —— 名字只含 ASCII 字母数字、 空格、`-`、`_` 时保持原样(空格折成 `-`);折过**别的**字符(中文、`.`、括号…)才补 `-`。 测试钉住四例:`Better-MC` → 原样、`Better MC` → `Better-MC`(不加哈希)、`pack` → `pack`、 `Survival 1.20.1` → `Survival-1-20-1-`(折过 `.` 必须补)。 ## 必修 3:两处注释与代码相反 - `host/job.rs` 的 `HostTask::Pack`:原写「代次与 `HostTask::Install` 共用同一计数器」,改为 「pack 自己的一条(`PACK_EPOCH`),不与 `install_epoch` 共用」并写明为什么(P2-7 的教训)。 - `host/addon/pack/import.rs` 的 `target_of`:`` → ``,并注明中文名不会被折成同一个 `pack`。 ## 必修 4:两条 P3 - `04` 图里「Back」出现两次:有预检结果时字段区那行只留「预检」,`Back` 只由预览块给一个 (没有预检时才在字段区给 `Back`)。 - 导入侧 `rejected` 列表补 `bounded`(与 missing / conflicts 同一口径)。 ## 必修 5:app 级用例(P2-1 的护栏) 新增 `a_failed_import_releases_the_in_flight_flag_and_keeps_install_cancel_intact`: `CoreHost::headless` + 真派 `PackCommand::Import`(包不存在 ⇒ 探测早退)→ 泵到 `Probe` → 断言 `in_flight == 0` → 再派 `CancelInstall` → 断言**仍会**发 `InstallCancelled` 且**不发** `PackEvent::Cancelled`。 ## P3-6(裸静态量):按塔台要求正面回答并已改掉 不再用裸静态量:`PACK_EPOCH` / `PACK_RUNNING` / `PACK_CANCEL` 换成**按 host 键控**的一张表 (键 = `LauncherLayout::home()`),理由是实测撞到过串扰 —— `host/cancel_tests` 派一次 `CancelInstall` 就 bump 了全局代次,我这条在飞的导入事件被按代次丢掉(新用例因此红过)。 为什么不挪进 `CoreHost`:那要给 `host/mod.rs` 加字段(不在 scope)。这张表等价,`host/mod.rs` 一旦进 scope,搬过去只是把表删掉 —— 报告里也这么写了。 新增用例 `pack_epoch_is_per_host_and_is_its_own_counter` 钉住「另一个 host 的代次不受影响」。 ## 顺手的两条 - `pack/tests.rs` 加完用例后 429 行超线 ⇒ 夹具(临时目录 / 写文件 / 泵 host)拆到 `pack/tests/harness.rs`,`tests.rs` 回到 3xx 行。 - clippy `-D warnings` 抓到 `== false`、两处多余的 `&`、一个未用的再导出,都改掉了。 门禁:`cargo fmt --all --check` 通过;clippy 三 crate `--all-targets -- -D warnings` EXIT=0; `cargo test -p unlucky-app-iced --lib` **507 passed**;`cargo test -p unlucky-presenter --lib` **185 passed**(新增的 app 级用例在内,且是在**全量并行**下跑的);改动文件全部 ≤400 行。 --- .../src/pages/content/packui/view/export.rs | 39 ++--- .../src/pages/content/packui/view/report.rs | 9 +- crates/presenter/src/host/addon/pack.rs | 158 +++++++++++++----- .../presenter/src/host/addon/pack/convert.rs | 12 +- .../presenter/src/host/addon/pack/import.rs | 9 +- crates/presenter/src/host/addon/pack/tests.rs | 146 ++++++++++++---- .../src/host/addon/pack/tests/harness.rs | 56 +++++++ crates/presenter/src/host/dispatch.rs | 2 +- crates/presenter/src/host/handle.rs | 2 +- crates/presenter/src/host/job.rs | 6 +- 10 files changed, 329 insertions(+), 110 deletions(-) create mode 100644 crates/presenter/src/host/addon/pack/tests/harness.rs diff --git a/crates/app-iced/src/pages/content/packui/view/export.rs b/crates/app-iced/src/pages/content/packui/view/export.rs index 0b3272c..563c1e6 100644 --- a/crates/app-iced/src/pages/content/packui/view/export.rs +++ b/crates/app-iced/src/pages/content/packui/view/export.rs @@ -109,26 +109,27 @@ pub(super) fn export_block<'a>( .placeholder(catalog.text(Key::PackExportOutputPlaceholder)), move |text| intent(PackMessage::SetOutput(text)), )); - block = block.push( - row![ - buttons::filled( - ui, - Button::new( - catalog.text(Key::PackExportPrecheck), - intent(PackMessage::Precheck), - ) - .leading(IconKind::Search), + // 有预检结果时这里只留「预检」(`Back` 交给预览块那一个)——复审 r2 P3-1: + // 搬确认按钮进来时把 `Back` 带出了两份,同屏两个一样的按钮 + let mut actions = row![buttons::filled( + ui, + Button::new( + catalog.text(Key::PackExportPrecheck), + intent(PackMessage::Precheck), + ) + .leading(IconKind::Search), + )] + .spacing(ui.space(Space::X2)); + if export.preview.is_none() { + actions = actions.push(buttons::text( + ui, + Button::new( + catalog.text(Key::PackExportAbort), + intent(PackMessage::AbortExport), ), - buttons::text( - ui, - Button::new( - catalog.text(Key::PackExportAbort), - intent(PackMessage::AbortExport) - ), - ), - ] - .spacing(ui.space(Space::X2)), - ); + )); + } + block = block.push(actions); if export.pending { block = block.push(frame::text_role( ui, diff --git a/crates/app-iced/src/pages/content/packui/view/report.rs b/crates/app-iced/src/pages/content/packui/view/report.rs index 826cbc3..8cc8f1d 100644 --- a/crates/app-iced/src/pages/content/packui/view/report.rs +++ b/crates/app-iced/src/pages/content/packui/view/report.rs @@ -137,7 +137,14 @@ pub(super) fn report_block<'a>( block = block.push( column![ tolerable_title(ui, title.clone()), - rows_block(ui, catalog, &report.rejected), + // 与 missing / conflicts 同一条口径:条目多时框起来滚,别把动作顶出屏幕 + bounded( + ui, + rows_block(ui, catalog, &report.rejected), + report.rejected.len(), + 3, + 110.0, + ), ] .spacing(ui.space(Space::X1)) .width(Length::Fill), diff --git a/crates/presenter/src/host/addon/pack.rs b/crates/presenter/src/host/addon/pack.rs index b92cc70..3137a4b 100644 --- a/crates/presenter/src/host/addon/pack.rs +++ b/crates/presenter/src/host/addon/pack.rs @@ -47,7 +47,7 @@ mod import; mod tests; use std::path::PathBuf; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use unlucky_core::addon::export::{read_instance_snapshot, InstanceSnapshot}; @@ -63,60 +63,119 @@ use crate::dto::{ use super::super::env::HostEnv; use super::super::job::{HostTask, JobSender}; -/// **pack 自己的**任务代次(与安装那条计数器完全分开)。 +/// pack 的**每 host** 状态:代次 + 在跑计数 + 本次运行的取消标记。 /// -/// 为什么必须分开(复审 r1 P2-7):`HostTask::Install` / `InstallDone` 按 `CoreHost::install_epoch` -/// 丢包。如果 pack 也共用那个数,用户在内容页关一次导入面板就会把代次 +1 —— 下载页**正在装版本** -/// 的进度与收尾会被静默丢弃(面板卡住、不刷实例、没有失败提示)。所以 pack 用自己的一条, -/// `CoreHost` 的字段不在本 mission 的 scope,这条计数器就落在模块里。 -static PACK_EPOCH: AtomicU64 = AtomicU64::new(0); - -/// 当前 pack 代次(`handle` 用它判断「这条事件是不是已经被取消/关面板作废了」)。 -pub(in crate::host) fn pack_epoch() -> u64 { - PACK_EPOCH.load(Ordering::SeqCst) +/// ## 为什么不是裸静态量(复审 r2 P3-6 要的那句回答) +/// +/// 裸静态量是**跨 host 共享**的。生产路径只有一个 host 所以看不出来,但测试里两个 host 会互相 +/// 干扰 —— 本轮实测撞到过:`host/cancel_tests` 派一次 `CancelInstall` 就把我这条用例在飞的导入 +/// 事件按代次丢了。键取 `LauncherLayout::home()`(每个测试的临时根都不同),于是各自独立: +/// +/// - **代次**:每个 host 一条,`handle` 只跟**自己这个 host** 的当前值比; +/// - **在跑计数**:一张计数表(每个 host 一格),由 [`InFlight`] 的 `Drop` 维护; +/// - **取消标记**:每个 host 一份 `Arc`,导入线程拿克隆,`cancel_pack` 置位。 +/// +/// 为什么不挪进 `CoreHost`:那要给 `host/mod.rs` 加字段(不在本 mission 的 scope)。这张表等价, +/// 只是把「每 host 的字段」放在模块里;`host/mod.rs` 一旦进 scope,搬过去只是把这张表删掉。 +#[derive(Default, Clone, Copy)] +struct HostPackState { + epoch: u64, + in_flight: usize, } -/// 作废在飞的一切 pack 事件(取消 / 关面板都走它)。 -pub(in crate::host) fn bump_pack_epoch() -> u64 { - PACK_EPOCH.fetch_add(1, Ordering::SeqCst).wrapping_add(1) +static PACK_STATE: Mutex> = Mutex::new(Vec::new()); +static PACK_CANCEL: Mutex)>> = Mutex::new(Vec::new()); + +/// 这张表的键(数据根;`HostEnv::at_root` 的每个测试根都不同)。 +fn host_key(env: &HostEnv) -> PathBuf { + env.layout.home().to_path_buf() } -/// 有一次导入正在跑吗(取消时要据此决定弹哪条提示)。 -static PACK_RUNNING: AtomicBool = AtomicBool::new(false); +fn with_state(env: &HostEnv, body: impl FnOnce(&mut HostPackState) -> T) -> T { + let key = host_key(env); + let mut table = PACK_STATE + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + if let Some(index) = table.iter().position(|(name, _)| *name == key) { + return body(&mut table[index].1); + } + table.push((key, HostPackState::default())); + body(&mut table.last_mut().expect("刚插入").1) +} -/// 正在跑的那次导入的取消标记(模块级槽:`CoreHost` 的字段不在本 mission 的 scope)。 -/// -/// 语义见模块文档「取消是界面级的」——它挡的是**事件**,不是管线。 -/// 用 `Arc` 而不是一个裸静态量:导入线程拿一份克隆、`cancel_pack()` 拿另一份, -/// 于是**测试可以各自持有一份**,不会互相串(裸静态量在并行测试里会泄漏取消状态)。 -static PACK_CANCEL: Mutex>> = Mutex::new(None); +/// 这个 host 当前的 pack 代次(`handle` 用它判断事件是否已被取消/关面板作废)。 +pub(in crate::host) fn pack_epoch(env: &HostEnv) -> u64 { + with_state(env, |state| state.epoch) +} -/// 取消正在跑的导入(`CancelInstall` 与「关面板」都走它;没在跑时是 no-op)。 +/// 作废这个 host 在飞的一切 pack 事件(取消 / 关面板都走它)。 +pub(in crate::host) fn bump_pack_epoch(env: &HostEnv) -> u64 { + with_state(env, |state| { + state.epoch = state.epoch.wrapping_add(1); + state.epoch + }) +} + +/// 这个 host 现在有几条导入在跑。 +pub(in crate::host) fn in_flight(env: &HostEnv) -> usize { + with_state(env, |state| state.in_flight) +} + +/// 「这次导入还在跑」的 **RAII 句柄**。 /// -/// 返回值 = 「刚才确实有一次导入在跑」。调用方据此决定提示语: -/// **导入的取消不是 install 的取消**(后台会跑完并登记实例,见模块文档),所以不能复用 -/// 下载页那条「安装已取消、实例未创建」的提示。 -pub(in crate::host) fn cancel_pack() -> bool { - if let Ok(slot) = PACK_CANCEL.lock() { - if let Some(cancel) = slot.as_ref() { +/// 复审 r2 P2-1 的教训:手工 `finished()` 只在 `block_on(import_pack(..))` 之后调用, +/// 而失败早退(探测失败 / 目标目录不可用)都在它之前 ⇒ 计数永远留在「在跑」,于是下一次 +/// **下载页**的「取消安装」不再发 `InstallCancelled`,还给内容库画一句「导入还在后台跑」的假话。 +/// +/// 现在计数由句柄的 `Drop` 负责:**任何**退出路径(早退、报错、`?`、panic 展开)都会减一。 +pub(super) struct InFlight { + env: HostEnv, +} + +impl InFlight { + /// 计一次「在跑」。句柄活多久,这次导入就算跑多久。 + pub(super) fn begin(env: &HostEnv) -> Self { + with_state(env, |state| state.in_flight += 1); + Self { env: env.clone() } + } +} + +impl Drop for InFlight { + fn drop(&mut self) { + with_state(&self.env, |state| { + state.in_flight = state.in_flight.saturating_sub(1); + }); + } +} + +/// 取消这个 host 正在跑的导入(`CancelInstall` 与「关面板」都走它;没在跑时是 no-op)。 +/// +/// 返回值 = 「刚才确实有一次导入在跑」。调用方据此决定提示语:**导入的取消不是 install 的取消** +/// (后台会跑完并登记实例,见模块文档),所以不能复用下载页那条「安装已取消、实例未创建」。 +pub(in crate::host) fn cancel_pack(env: &HostEnv) -> bool { + let key = host_key(env); + { + let table = PACK_CANCEL + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + if let Some((_, cancel)) = table.iter().find(|(name, _)| *name == key) { cancel.store(true, Ordering::SeqCst); } } - bump_pack_epoch(); - PACK_RUNNING.swap(false, Ordering::SeqCst) + bump_pack_epoch(env); + in_flight(env) > 0 } -/// 这次导入跑完了(正常收尾也走它):`PACK_RUNNING` 归零,之后 `cancel_pack()` 会回 false。 -pub(super) fn finished() { - PACK_RUNNING.store(false, Ordering::SeqCst); -} - -/// 新一次导入的取消标记(同时把它挂进模块级槽,供 [`cancel_pack`] 用)。 -fn fresh_cancel() -> Arc { - PACK_RUNNING.store(true, Ordering::SeqCst); +/// 这个 host 新一次导入的取消标记(挂进表里供 [`cancel_pack`] 用)。 +fn fresh_cancel(env: &HostEnv) -> Arc { let cancel = Arc::new(AtomicBool::new(false)); - if let Ok(mut slot) = PACK_CANCEL.lock() { - *slot = Some(Arc::clone(&cancel)); + let key = host_key(env); + let mut table = PACK_CANCEL + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + match table.iter_mut().find(|(name, _)| *name == key) { + Some((_, slot)) => *slot = Arc::clone(&cancel), + None => table.push((key, Arc::clone(&cancel))), } cancel } @@ -124,10 +183,12 @@ fn fresh_cancel() -> Arc { /// `AppCommand::Pack` 的落点(M113)。 impl super::super::CoreHost { pub(crate) fn pack(&mut self, command: PackCommand) { - let epoch = pack_epoch(); + let epoch = pack_epoch(&self.env); match command { PackCommand::Import(request) => { - let cancel = fresh_cancel(); + let cancel = fresh_cancel(&self.env); + // 句柄在**派发点**就计上,随线程一起结束(含早退与 panic) + let guard = InFlight::begin(&self.env); // 先把面板点亮:用户点了就有反应,不等后台回第一条进度 let name = request .instance_name @@ -137,7 +198,14 @@ impl super::super::CoreHost { self.send(CoreEvent::Pack(Box::new(PackEvent::Progress(Some( Box::new(ImportProgressView::new(name)), ))))); - import::spawn_import(self.env.clone(), self.tx.clone(), *request, epoch, cancel); + import::spawn_import( + self.env.clone(), + self.tx.clone(), + *request, + epoch, + cancel, + guard, + ); } PackCommand::Export(request) => { export::spawn_preview(self.env.clone(), self.tx.clone(), *request, epoch); @@ -147,7 +215,7 @@ impl super::super::CoreHost { } PackCommand::Dismiss => { // 只作废 pack 自己的代次:**不碰** `install_epoch`(那是下载页安装的命门) - cancel_pack(); + cancel_pack(&self.env); self.send(CoreEvent::Pack(Box::new(PackEvent::Cleared))); } } diff --git a/crates/presenter/src/host/addon/pack/convert.rs b/crates/presenter/src/host/addon/pack/convert.rs index ca6892e..ec19cbf 100644 --- a/crates/presenter/src/host/addon/pack/convert.rs +++ b/crates/presenter/src/host/addon/pack/convert.rs @@ -210,17 +210,17 @@ pub(super) fn pack_stem(pack: &Path) -> String { /// **实例目录名**:ASCII 化 + 必要时补一段内容哈希。 /// -/// 为什么不能只用 [`slug`](复审 r1 P2-8):它只保留 ASCII,于是「我的整合包」「另一个整合包」 -/// 都会得到 `pack`,第二个中文名导入直接撞「directory already exists」。这个仓的主要受众就是 -/// 中文用户,所以:名字本身就全是 ASCII 安全字符时保持原样(`Better MC` → `Better-MC`), -/// **一旦折过东西**(中文/空格/符号)就补 `-`,用原名的哈希保住区分度。 +/// 判据(复审 r2 P2-2 之后与注释对齐):名字里**只含** ASCII 字母数字、空格、`-`、`_` 时 +/// 保持原样(空格折成 `-`,`Better MC` → `Better-MC`);**一旦折过别的字符**(中文、`.`、 +/// 括号……)就补 `-`,用原名的哈希保住区分度 —— 否则「我的整合包」「另一个整合包」 +/// 都会得到 `pack`,第二个中文名导入直接撞「directory already exists」。 pub(super) fn dir_name(name: &str) -> String { let base = slug(name); let faithful = !name.is_empty() && name .chars() - .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_'); - if faithful && base != "pack" { + .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == ' '); + if faithful { base } else { format!("{base}-{}", hash8(name)) diff --git a/crates/presenter/src/host/addon/pack/import.rs b/crates/presenter/src/host/addon/pack/import.rs index fd378ae..10f5c2c 100644 --- a/crates/presenter/src/host/addon/pack/import.rs +++ b/crates/presenter/src/host/addon/pack/import.rs @@ -31,9 +31,13 @@ pub(super) fn spawn_import( request: ImportRequestView, epoch: u64, cancel: Arc, + inflight: super::InFlight, ) { spawn("pack-import", move || { - run(&env, &tx, &request, epoch, cancel) + // 句柄跟着这次运行走到**出口**:正常收尾、两条早退、panic 展开都会 drop ⇒ 计数减一。 + // 以前是手工 `finished()` 放在 `block_on()` 之后,两条早退路径直接漏掉(复审 r2 P2-1)。 + let _inflight = inflight; + run(&env, &tx, &request, epoch, cancel); }); } @@ -102,7 +106,6 @@ pub(super) fn run( } let report = block_on(import_pack(&core_request)); - super::finished(); if report.status == ImportStatus::Failed && register { // 失败已回滚,但**目录是我们建的**、core 只清文件不清目录 ⇒ 不清掉的话 // 下一次重试会撞「directory already exists」(复审 r1 P2-9)。 @@ -134,7 +137,7 @@ fn remove_if_empty(dir: &std::path::Path) { /// 导入目标:`(目录, 是否登记新实例)`。 /// /// - 选了实例 ⇒ 它的运行目录,**不登记**(已经是一条实例记录); -/// - 没选实例 ⇒ `/instances/` 新目录 + 登记(实例名 = 用户给的名字或包名)。 +/// - 没选实例 ⇒ `/instances/` 新目录 + 登记(实例名 = 用户给的名字或包名)。 /// 注意 M111 的既有边界:导入**不装加载器**,所以新实例报告的 `版本 id` 还要另外装。 pub(super) fn target_of( env: &HostEnv, diff --git a/crates/presenter/src/host/addon/pack/tests.rs b/crates/presenter/src/host/addon/pack/tests.rs index a6075ae..b9127fe 100644 --- a/crates/presenter/src/host/addon/pack/tests.rs +++ b/crates/presenter/src/host/addon/pack/tests.rs @@ -1,10 +1,20 @@ -//! M113 的两条硬证据(都跑真管线,不用假数据): +//! M113 的证据(都跑真管线 / 真 host,不用假数据): //! -//! 1. [`bad_packs_get_a_named_diagnosis`]:**坏包必须有具名失败**,不是静默、不是 panic; -//! 2. [`preview_counts_match_the_written_report`]:预检的「将内联 N / 无法导出 M」与最终 -//! `ExportReport` 的数字**逐项相等**(塔台点名的要求:预检不能是骗人的预告)。 +//! | 用例 | 钉住什么 | +//! |---|---| +//! | [`bad_packs_get_a_named_diagnosis`] | 坏包必须有具名失败,不是静默、不是 panic | +//! | [`preview_counts_match_the_written_report`] | 预检的 N/M 与最终报告**逐项相等** | +//! | [`a_real_pack_runs_through_the_pipeline_end_to_end`] | 正常包能走完管线并落盘 | +//! | [`chinese_instance_names_get_distinct_directories`] | 中文实例名不会塌成同一个目录 | +//! | [`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`] | 早退路径放回「在跑」计数,取消安装仍是安装的取消 | +//! +//! 夹具(临时目录、写文件、泵 host)在 [`harness`]。 -use std::path::PathBuf; +mod harness; + +use harness::{pack_events, pump, pump_until, temp_dir, write}; use unlucky_core::addon::export::{ export_instance, scan_instance, ExportRequest, InstanceSnapshot, OfflineCatalog, @@ -13,21 +23,6 @@ use unlucky_core::addon::export::{ use crate::dto::{ExportClassView, ExportRequestView, PackFormatView}; -/// 每个用例一个独立临时目录(用完删)。 -fn temp_dir(name: &str) -> PathBuf { - let dir = std::env::temp_dir().join(format!("ull-m113-{name}-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).expect("temp dir"); - dir -} - -fn write(path: &std::path::Path, bytes: &[u8]) { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).expect("parent"); - } - std::fs::write(path, bytes).expect("write"); -} - #[test] fn bad_packs_get_a_named_diagnosis() { let dir = temp_dir("badpack"); @@ -253,8 +248,23 @@ fn chinese_instance_names_get_distinct_directories() { ); assert!(first.starts_with("pack-"), "退化时要留可读前缀:{first}"); - // 本来就全是 ASCII 安全字符的名字保持干净(不无端加哈希) + // 判据与注释对齐(复审 r2 P2-2):只含 ASCII 字母数字/空格/`-`/`_` ⇒ 保持原样 assert_eq!(super::convert::dir_name("Better-MC"), "Better-MC"); + assert_eq!( + super::convert::dir_name("Better MC"), + "Better-MC", + "空格折成 `-`、**不加**哈希" + ); + assert_eq!( + super::convert::dir_name("pack"), + "pack", + "名字就叫 pack 也保留" + ); + + // 折过**别的**字符(这里是 `.`)就必须补哈希,否则不同名字会撞同一个目录 + let versioned = super::convert::dir_name("Survival 1.20.1"); + assert!(versioned.starts_with("Survival-1-20-1-"), "{versioned}"); + assert_ne!(versioned, "Survival-1-20-1", "折过 `.` 就要补哈希"); } #[test] @@ -292,16 +302,88 @@ fn a_failed_new_instance_directory_can_be_reused_for_retry() { } #[test] -fn pack_epoch_is_its_own_counter() { - // 复审 r1 P2-7:pack 的代次必须与安装的 `CoreHost::install_epoch` 分开。 - // 这里钉的是模块计数器的行为;「不再读写 install_epoch」由代码结构保证 - // (`pack.rs` 里已无 `self.install_epoch` 引用,只有这里自己的计数器)。 - let before = super::pack_epoch(); - super::bump_pack_epoch(); - assert_eq!(super::pack_epoch(), before.wrapping_add(1), "一次 +1"); +fn pack_epoch_is_per_host_and_is_its_own_counter() { + // 复审 r2 P3-6:代次**按 host** 分开(不再是跨 host 共享的裸静态量)。 + // 两个 host(两个临时根)各 bump 一次,互不影响 —— 这正是并行测试串扰的根因。 + let one = temp_dir("epoch-one"); + let two = temp_dir("epoch-two"); + let env_one = crate::host::HostEnv::at_root(one.join("data")); + let env_two = crate::host::HostEnv::at_root(two.join("data")); - // 取消会作废在飞的 pack 事件(代次前进),并且回报「刚才有没有在跑」 - super::bump_pack_epoch(); - let after_bump = super::pack_epoch(); - assert!(after_bump > before); + let before_one = super::pack_epoch(&env_one); + let before_two = super::pack_epoch(&env_two); + super::bump_pack_epoch(&env_one); + assert_eq!( + super::pack_epoch(&env_one), + before_one.wrapping_add(1), + "自己这个 host 的代次 +1" + ); + assert_eq!( + super::pack_epoch(&env_two), + before_two, + "**另一个** host 的代次不受影响(P3-6 的根因)" + ); + assert_eq!(super::in_flight(&env_two), 0, "在跑计数也是每 host 一份"); + + let _ = std::fs::remove_dir_all(&one); + let _ = std::fs::remove_dir_all(&two); +} + +#[test] +fn a_failed_import_releases_the_in_flight_flag_and_keeps_install_cancel_intact() { + // 复审 r2 P2-1 的护栏:早退路径必须把「在跑」计数放回去,否则之后**下载页**的 + // 「取消安装」会被当成导入取消(不发 `InstallCancelled`,还给内容库画一句假话)。 + use crate::dto::{ + AppCommand, AppEvent, Backend, CoreEvent, ImportRequestView, PackCommand, PackEvent, + }; + + let dir = temp_dir("inflight-guard"); + let data = dir.join("data"); + std::fs::create_dir_all(&data).expect("data root"); + let host_env = crate::host::HostEnv::at_root(&data); + let mut host = crate::host::CoreHost::headless(host_env.clone()); + + // ① 派一次**必然早退**的导入:包文件不存在 ⇒ 探测当场失败,根本不进管线 + host.dispatch(AppCommand::Pack(Box::new(PackCommand::Import(Box::new( + ImportRequestView { + pack: dir.join("missing.mrpack"), + instance: None, + instance_name: Some("Guess".to_string()), + overwrite: false, + }, + ))))); + let events = pump_until(&mut host, |all| { + pack_events(all) + .iter() + .any(|event| matches!(event, PackEvent::Probe(_))) + }); + assert!( + pack_events(&events) + .iter() + .any(|event| matches!(event, PackEvent::Probe(_))), + "坏包必须给出探测诊断(而不是静默);实际收到 {events:?}" + ); + assert_eq!( + super::in_flight(&host_env), + 0, + "早退之后「在跑」计数必须归零(RAII guard 负责,不靠手工 finished)" + ); + + // ② 此后下载页的「取消安装」必须照常发 `InstallCancelled`,且**不能**冒出 pack 的取消 + host.dispatch(AppCommand::CancelInstall); + let events = pump(&mut host); + assert!( + events + .iter() + .any(|event| matches!(event, AppEvent::Core(CoreEvent::InstallCancelled))), + "没有导入在跑时,取消安装必须照常给回执(否则用户点了取消毫无反应)" + ); + assert!( + !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/harness.rs b/crates/presenter/src/host/addon/pack/tests/harness.rs new file mode 100644 index 0000000..4376f32 --- /dev/null +++ b/crates/presenter/src/host/addon/pack/tests/harness.rs @@ -0,0 +1,56 @@ +//! pack 用例的共用夹具:临时目录、文件写入、host 泵。 +//! +//! 拆出去只为一件事:`tests.rs` 已经 429 行(超 400 硬线),而这些 helper 与任何一条断言无关。 +//! `tests.rs` 里各条用例的「抓得住什么」写在用例自己的注释里,这里只放机械动作。 + +use std::path::PathBuf; + +/// 每个用例一个独立临时目录(用完删)。 +pub(super) fn temp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("ull-m113-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("temp dir"); + dir +} + +pub(super) fn write(path: &std::path::Path, bytes: &[u8]) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("parent"); + } + std::fs::write(path, bytes).expect("write"); +} + +/// 泵一次 host:把后台任务收成事件并取走。 +pub(super) fn pump(host: &mut crate::host::CoreHost) -> Vec { + use crate::dto::Backend; + host.tick(std::time::Duration::from_millis(1)); + host.drain_events() +} + +/// 泵到 `done` 为真(最多 ~3 s),返回期间收到的全部事件。 +pub(super) fn pump_until( + host: &mut crate::host::CoreHost, + done: impl Fn(&[crate::dto::AppEvent]) -> bool, +) -> Vec { + let mut all = Vec::new(); + for _ in 0..600 { + all.extend(pump(host)); + if done(&all) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + all +} + +pub(super) fn pack_events(events: &[crate::dto::AppEvent]) -> Vec { + events + .iter() + .filter_map(|event| match event { + crate::dto::AppEvent::Core(crate::dto::CoreEvent::Pack(inner)) => { + Some((**inner).clone()) + } + _ => None, + }) + .collect() +} diff --git a/crates/presenter/src/host/dispatch.rs b/crates/presenter/src/host/dispatch.rs index ecc2038..276d1f6 100644 --- a/crates/presenter/src/host/dispatch.rs +++ b/crates/presenter/src/host/dispatch.rs @@ -73,7 +73,7 @@ impl CoreHost { // M113: import/export —— 同一块面板的「取消」对导入也生效:立标记 + 作废 pack 代次 // 返回值告诉我们「刚才有没有导入在跑」:有的话**不能**再发 install 那条取消信号 // (它说的是「安装已取消、实例未创建」,而导入会在后台跑完并登记实例)。 - let pack_was_running = addon::cancel_pack(); + let pack_was_running = addon::cancel_pack(&self.env); if pack_was_running { self.send(CoreEvent::Pack(Box::new(PackEvent::Cancelled))); } diff --git a/crates/presenter/src/host/handle.rs b/crates/presenter/src/host/handle.rs index a9130d3..f225d3d 100644 --- a/crates/presenter/src/host/handle.rs +++ b/crates/presenter/src/host/handle.rs @@ -44,7 +44,7 @@ impl CoreHost { HostTask::Pack { event, epoch } => { // pack 用**自己**的代次(复审 r1 P2-7):共用安装的计数器会让 // 「关一次导入面板」把下载页正在跑的安装静默丢掉。 - if epoch != addon::pack_epoch() { + if epoch != addon::pack_epoch(&self.env) { return; } // 导入成功可能登记了新实例:与 `InstallDone` 同一条刷新(复审 r1 P2-1), diff --git a/crates/presenter/src/host/job.rs b/crates/presenter/src/host/job.rs index 12046f6..bfe0631 100644 --- a/crates/presenter/src/host/job.rs +++ b/crates/presenter/src/host/job.rs @@ -32,8 +32,10 @@ pub enum HostTask { // M113: import/export /// 导入/导出(M113):一条事件 + 任务代次 /// - /// 代次与 [`HostTask::Install`] 共用同一计数器(`CoreHost::install_epoch`):取消之后回来的 - /// **旧**事件按代次丢掉,面板不会被重新点亮。 + /// 代次是 **pack 自己的一条**(`host::addon::pack` 的 `pack_epoch()`,按 host 分开),**不**与 + /// [`HostTask::Install`] 的 `CoreHost::install_epoch` 共用 —— 共用会让「关一次导入面板」 + /// 把下载页正在跑的安装静默丢掉(复审 r1 P2-7)。取消/关面板之后回来的旧事件按这条代次 + /// 丢掉,面板不会被重新点亮。 Pack { /// 事件载荷(进度 / 结果 / 探测诊断 / 预检 / 完成) event: Box,