feat(m195): 导入管线取消令牌与原子回滚(取消不是失败)

M190 复核 §3 A-2:导入管线没有取消令牌,「取消导入」只改界面状态,后台仍跑完并写盘
(半包 / 覆盖风险,丢数据档)。本轮把取消做成管线级:

- core 新增 `import/cancel.rs`:`ImportCancel` 令牌(可 `from_flag` 接管既有
  `Arc<AtomicBool>`)+ 内部 `Stopped::{Cancelled, Failed}` 两条停止路线;
- 结果类型单列 `ImportStatus::Cancelled`(`error` 恒为 None、退出码 130、文案 key 独立),
  与 `Failed` 是两条路——UI 不弹红、脚本不会把「改主意」记成「装坏了」;
- 七个检查点:入口 → 解析后 → 补全后 → 开暂存目录前 → 提交前 → 合并态逐文件 → 登记前;
  解压 / 远端下载各逐条检查;单条下载在飞不可中断(fetcher 无取消参数)已在文档里说准;
- 取消后不留半包 / 不覆盖已有实例:暂存目录整个删、合并态 journal 逆序回滚(备份改回原名)、
  落盘后取消连内容一起回滚;`files / conflicts / missing / bytes_written` 清零,不留假账;
- presenter:同一个取消标记现在真交给 core(`with_cancel`),取消后不再「后台跑完并登记」;
  CLI 增 `cancelled → 130` 一档与三档人读名。

守门三条各带变异证明(见 mission 报告):删回滚 → 红、删登记前检查 → 红、把取消并进
`fail()` → 红。

没验:超大整合包的真实 IO 下取消响应延迟(最坏 = 当前一条文件读完/下完 + 一次回滚)、
单条下载在飞不可中断、macOS / Linux 未跑。
This commit is contained in:
AskaEth committed 2026-11-09 05:06:52 +08:00
1 parent 3116f43c9f
commit 1441a240fe
18 files changed
+1144 -189

No files matched your search

+11 -10
View File
@@ -24,19 +24,20 @@
//! 所以每个可扫文件都证明不了可被远程替代 ⇒ 内联;`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` 的逐段表;
//! 4. **提示语不能借用安装那条**(复审 r1 P1-2):`cancel_pack()` 回一个「刚才有没有导入在跑」,
//! `dispatch` 据此改发 [`crate::dto::PackEvent::Cancelled`]。取消**通常**意味着实例没建
//! (管线回滚了),但取消也可能恰好在两个检查点之间落下(那时已经装完并登记)——所以文案
//! 只承诺「本次结果作废 + 目标目录已回滚到导入前」,不承诺「一条命令都没执行」。
mod convert;
mod diagnose;
@@ -151,7 +152,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);
{
@@ -100,10 +100,21 @@ const fn reason_of(reason: ConflictReason) -> ConflictReasonView {
}
/// 一次导入结果 → DTO。
///
/// # `Cancelled` 为什么在这里没有对应档(M195)
///
/// DTO 的 `ImportStatusView` 只有三档,取消走的是 [`crate::dto::PackEvent::Cancelled`]
/// (`import::run` 拿到 `ImportStatus::Cancelled` 就不发报告事件了)。所以本函数只会在
/// "取消恰好落在两个检查点之间、报告仍然是成功/部分成功"时被调用。真收到 `Cancelled`
/// 时按 `Partial` 落地:**绝不是** `Failed`——取消不是失败,不许画成红的。
pub(super) fn report_view(report: &ImportReport, target: &Path) -> ImportReportView {
ImportReportView {
status: ImportStatusView::from_slug(report.status.as_str())
.unwrap_or(ImportStatusView::Failed),
status: match report.status {
ImportStatus::Cancelled => ImportStatusView::Partial,
other => {
ImportStatusView::from_slug(other.as_str()).unwrap_or(ImportStatusView::Failed)
}
},
format: report.format.as_deref().and_then(PackFormatView::from_slug),
pack_name: report.pack_name.clone(),
pack_version: report.pack_version.clone(),
+19 -9
View File
@@ -7,15 +7,17 @@
//! 往下导(不打扰用户);有冲突就回 [`PackEvent::Scan`] 让页面画清单,等用户点完再导;
//! 3. **缺省档永远 `KeepAll`**:决策从 DTO 逐字段搬([`convert::decisions_of`]),页面没点过的
//! 路径在 core 侧落到"保留";
//! 4. **取消后不再打扰界面**:进度回调与最终报告都先看取消标记。
//! 4. **取消是真取消**(M195):`cancel` 标记既挡界面事件、又**交给 core 当取消令牌**——管线
//! 在检查点停手并回滚,不再"后台跑完并登记"(逐段检查点见
//! `core::addon::import::cancel`)。取消后不再打扰界面:进度回调与最终报告都先看取消标记。
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 +183,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,15 +196,20 @@ 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)。
if (report.status == ImportStatus::Failed || report.status == ImportStatus::Cancelled)
&& register
{
// 失败 / 取消都已回滚,但**目录是我们建的**(`prepare_target`)、core 只清文件不清
// 目录 ⇒ 不清掉的话下一次重试会撞「directory already exists」(复审 r1 P2-9)。
remove_if_empty(&target);
}
if cancel.load(Ordering::SeqCst) {
if report.status == ImportStatus::Cancelled || cancel.load(Ordering::SeqCst) {
// 取消之后不再打扰界面(面板已经收回,代次也已经作废)。
// 注意:后台这一步**已经跑完**(core 没有取消令牌),实例可能已被登记 ——
// 所以取消的提示语必须说「还在后台跑」,不能说「实例未创建」。
//
// M195 起这条是**真的停下来了**:core 在检查点回滚(暂存目录已删,落盘过的已撤回,
// 实例没有登记)。所以提示语可以照实说「已取消:目标目录未改动」;
// `cancel.load(..)` 单独留一条是因为取消也可能**恰好落在两个检查点之间**(管线已经
// 跑完并登记),那时同样不再打扰界面,但别对用户说"没装"。
return;
}
send(