fix(packui): M113 r2 复审整改 —— RAII 在跑句柄、per-host 代次、注释与代码对齐

复审 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 字母数字、
空格、`-`、`_` 时保持原样(空格折成 `-`);折过**别的**字符(中文、`.`、括号…)才补 `-<hash8>`。
测试钉住四例:`Better-MC` → 原样、`Better MC` → `Better-MC`(不加哈希)、`pack` → `pack`、
`Survival 1.20.1` → `Survival-1-20-1-<hash>`(折过 `.` 必须补)。

## 必修 3:两处注释与代码相反

- `host/job.rs` 的 `HostTask::Pack`:原写「代次与 `HostTask::Install` 共用同一计数器」,改为
  「pack 自己的一条(`PACK_EPOCH`),不与 `install_epoch` 共用」并写明为什么(P2-7 的教训)。
- `host/addon/pack/import.rs` 的 `target_of`:`<slug>` → `<dir_name>`,并注明中文名不会被折成同一个 `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 行。
This commit is contained in:
AskaEth committed 2026-11-05 04:12:39 +08:00
1 parent 99538e45b9
commit 134b0a2aa4
10 files changed
+329 -110

No files matched your search

+113 -45
View File
@@ -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<AtomicBool>`,导入线程拿克隆,`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<Vec<(PathBuf, HostPackState)>> = Mutex::new(Vec::new());
static PACK_CANCEL: Mutex<Vec<(PathBuf, Arc<AtomicBool>)>> = 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<T>(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<AtomicBool>` 而不是一个裸静态量:导入线程拿一份克隆、`cancel_pack()` 拿另一份,
/// 于是**测试可以各自持有一份**,不会互相串(裸静态量在并行测试里会泄漏取消状态)。
static PACK_CANCEL: Mutex<Option<Arc<AtomicBool>>> = 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<AtomicBool> {
PACK_RUNNING.store(true, Ordering::SeqCst);
/// 这个 host 新一次导入的取消标记(挂进表里供 [`cancel_pack`] 用)。
fn fresh_cancel(env: &HostEnv) -> Arc<AtomicBool> {
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<AtomicBool> {
/// `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)));
}
}
@@ -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`),
/// **一旦折过东西**(中文/空格/符号)就补 `-<hash8>`,用原名的哈希保住区分度。
/// 判据(复审 r2 P2-2 之后与注释对齐):名字里**只含** ASCII 字母数字、空格、`-`、`_` 时
/// 保持原样(空格折成 `-`,`Better MC` → `Better-MC`);**一旦折过别的字符**(中文、`.`、
/// 括号……)就补 `-<hash8>`,用原名的哈希保住区分度 —— 否则「我的整合包」「另一个整合包」
/// 都会得到 `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))
@@ -31,9 +31,13 @@ pub(super) fn spawn_import(
request: ImportRequestView,
epoch: u64,
cancel: Arc<AtomicBool>,
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) {
/// 导入目标:`(目录, 是否登记新实例)`。
///
/// - 选了实例 ⇒ 它的运行目录,**不登记**(已经是一条实例记录);
/// - 没选实例 ⇒ `<data>/instances/<slug>` 新目录 + 登记(实例名 = 用户给的名字或包名)。
/// - 没选实例 ⇒ `<data>/instances/<dir_name>` 新目录 + 登记(实例名 = 用户给的名字或包名)。
/// 注意 M111 的既有边界:导入**不装加载器**,所以新实例报告的 `版本 id` 还要另外装。
pub(super) fn target_of(
env: &HostEnv,
+114 -32
View File
@@ -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);
}
@@ -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<crate::dto::AppEvent> {
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<crate::dto::AppEvent> {
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<crate::dto::PackEvent> {
events
.iter()
.filter_map(|event| match event {
crate::dto::AppEvent::Core(crate::dto::CoreEvent::Pack(inner)) => {
Some((**inner).clone())
}
_ => None,
})
.collect()
}
+1 -1
View File
@@ -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)));
}
+1 -1
View File
@@ -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),
+4 -2
View File
@@ -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<PackEvent>,