fix(addon): M111 r6 —— 失败文案按 rolled_back 分叉,补「回滚不完整」报告级用例

复审 r6 的两条(都在同两个文件里十来行):

1. 文案位自相矛盾
- 改前:`pipeline::failed()` **无条件**推「目标目录保持导入前的样子」,`record_rollback()`
  随后可能再推「有 N 项未能回滚(目标目录可能与导入前不同)」——同一份 warnings 自相矛盾;
  CLI `status_label` 也恒写「失败(已回滚)」。
- 改后:`failed()` 只落错误、**不宣称目标目录状态**;口径拆成两个具名入口——
  `abandoned()`(失败发生在碰目标目录之前,才说「目标目录未被写入」)与
  `record_rollback()`(完整 ⇒ `rolled_back=true` + 「恢复为导入前的样子」;不完整 ⇒
  `rolled_back=false` + 逐项列失败项)。CLI `status_label` 按 `rolled_back` 分叉成
  「失败(已回滚)」/「失败(回滚不完整)」;`keys.rs` 的状态文档与 pipeline 模块文档表
  同步改口径。

2. 新接线零测试(这是 fix-then-merge 的理由)
- 新增 `pipeline::tests` 三条报告级用例:
  `incomplete_rollback_never_claims_the_target_is_untouched`(`rolled_back=false` + warnings
  逐项列出 + **不含**任何「保持导入前的样子 / 未被写入 / 恢复为导入前」)、
  `complete_rollback_claims_consistency_exactly_once`、
  `pre_commit_failure_claims_untouched_only_via_abandoned`。

变异自证(本轮实测,均已还原):
- A:把 `record_rollback` 的分叉整体跳过(no-op)→ 2 条红(`失败项要逐条列出:[]`、
  `assertion failed: report.rolled_back`)。
- B:把 `failed()` 恢复成无条件推「保持导入前的样子」→ 2 条红,报错原文即自相矛盾现场:
  `["…目标目录保持导入前的样子…", "有 1 项未能回滚(目标目录可能与导入前不同)…"]`。
This commit is contained in:
AskaEth committed 2026-11-04 21:19:58 +08:00
1 parent 7f657d225c
commit 29f531db23
3 files changed
+151 -30

No files matched your search

+144 -25
View File
@@ -17,7 +17,7 @@
//! | 某条远端文件摸不到(离线 / CF 无客户端 / 404) | `Partial`,逐条进 `missing`,实例照装 |
//! | 下载到的内容与声明摘要不符 | `Failed`(这是**损坏或被换过**,不是"暂时搞不到") |
//! | 本地写盘失败 | `Failed` |
//! | 实例登记失败 | `Failed`(连内容一起回滚,不留"有目录没档案"的半个实例) |
//! | 实例登记失败 | `Failed`(连内容一起回滚,不留"有目录没档案"的半个实例;回滚不完整时如实列出) |
//!
//! **回滚也要如实**:回滚本身可能失败(文件被占用、权限),这时 `rolled_back` 为 `false`
//! 并在 `rollback.failed` 里逐项列出——不许在"可能没回干净"时宣称一致。
@@ -45,14 +45,14 @@ pub async fn run(request: &ImportRequest<'_>) -> ImportReport {
tell(&request.progress, ImportStage::Probe, 0, 0, None);
let (archive, plan) = match probe_and_plan(request) {
Ok(pair) => pair,
Err(error) => return failed(report, error),
Err(error) => return abandoned(report, error),
};
account_plan(&mut report, &plan);
let baseline = load_baseline(request.target, &plan, &mut report);
let staging = match Staging::begin(request.target) {
Ok(staging) => staging,
Err(error) => return failed(report, error),
Err(error) => return abandoned(report, error),
};
let mut next = baseline.clone();
@@ -73,7 +73,7 @@ pub async fn run(request: &ImportRequest<'_>) -> ImportReport {
.await;
if let Err(error) = staged {
staging.abort();
return failed(report, error);
return abandoned(report, error);
}
// 引擎的续传残片(`.ullpart-*`)与 meta 不是整合包内容,提交前剔掉。
@@ -85,18 +85,14 @@ pub async fn run(request: &ImportRequest<'_>) -> ImportReport {
)),
Err(error) => {
staging.abort();
return failed(report, error);
return abandoned(report, error);
}
}
tell(&request.progress, ImportStage::Commit, 0, 0, None);
let guard = match staging.commit() {
Ok(guard) => guard,
Err(failure) => {
let mut report = failed(report, failure.error);
record_rollback(&mut report, failure.rollback);
return report;
}
Err(failure) => return record_rollback(failed(report, failure.error), failure.rollback),
};
if let Some(layout) = request.register {
@@ -124,9 +120,10 @@ pub async fn run(request: &ImportRequest<'_>) -> ImportReport {
Err(error) => {
// 内容落盘了但档案没建起来 = 用户看不见的半个实例 ⇒ 连内容一起回滚。
let outcome = guard.rollback();
let mut report = failed(report, ImportError::instance(error.to_string()));
record_rollback(&mut report, outcome);
return report;
return record_rollback(
failed(report, ImportError::instance(error.to_string())),
outcome,
);
}
}
}
@@ -198,9 +195,19 @@ fn load_baseline(
}
}
/// 把一次回滚的结果写进报告:不完整时**不许**继续宣称"目标目录与导入前一致"。
fn record_rollback(report: &mut ImportReport, outcome: RollbackOutcome) {
if !outcome.is_complete() {
/// 把一次回滚的结果写进报告:**文案与机器位同源**,只有完整回滚才宣称"与导入前一致"。
fn record_rollback(mut report: ImportReport, outcome: RollbackOutcome) -> ImportReport {
if outcome.is_complete() {
report.rolled_back = true;
report.warnings.push(ImportIssue::new(
keys::ROLLBACK_DONE,
format!(
"已回滚 {} 项:目标目录恢复为导入前的样子({})",
outcome.attempted,
report.target.display()
),
));
} else {
report.rolled_back = false;
report.warnings.push(ImportIssue::new(
keys::NOTE_ROLLBACK_INCOMPLETE,
@@ -212,6 +219,7 @@ fn record_rollback(report: &mut ImportReport, outcome: RollbackOutcome) {
));
}
report.rollback = Some(outcome);
report
}
/// 基线作为**最后一个暂存文件**写进去:它跟同一个事务一起提交 / 回滚。
@@ -255,16 +263,127 @@ pub(super) fn tell(
}
}
/// 失败收尾:走到这里的失败都**没有写过目标目录**(写盘在 `staging` 里),因此可以宣称一致。
/// 失败收尾:**只落错误,不宣称目标目录状态**。
///
/// 口径由调用方补:从未写过目标目录 ⇒ [`abandoned`];已经写过 ⇒ [`record_rollback`]。
/// 早先这里无条件推"目标目录保持导入前的样子",而随后的 `record_rollback` 可能再推
/// "有 N 项未能回滚"——同一份 warnings 自相矛盾(复审 r6 抓到)。
fn failed(mut report: ImportReport, error: ImportError) -> ImportReport {
report.warnings.push(ImportIssue::new(
keys::ROLLBACK_DONE,
format!(
"导入失败,已清理临时目录;目标目录保持导入前的样子({})",
report.target.display()
),
));
report.rolled_back = true;
report.rolled_back = false;
report.fail(error);
report
}
/// 失败发生在**碰目标目录之前**(暂存目录已清):这时才可以宣称"目标目录未被写入"。
fn abandoned(report: ImportReport, error: ImportError) -> ImportReport {
let mut report = failed(report, error);
report.rolled_back = true;
report.warnings.push(ImportIssue::new(
keys::ROLLBACK_DONE,
format!(
"导入失败,已清理临时目录;目标目录未被写入({})",
report.target.display()
),
));
report
}
#[cfg(test)]
mod tests {
use super::*;
use crate::addon::import::ImportStatus;
fn base() -> ImportReport {
ImportReport::empty(std::path::PathBuf::from("instance"))
}
fn messages(report: &ImportReport) -> Vec<&str> {
report
.warnings
.iter()
.map(|note| note.message.as_str())
.collect()
}
fn incomplete() -> RollbackOutcome {
RollbackOutcome {
attempted: 3,
failed: vec!["删除 config/a.toml 失败:拒绝访问".to_string()],
}
}
#[test]
fn incomplete_rollback_never_claims_the_target_is_untouched() {
// 复审 r6 第 1 条:机器位(rolled_back=false)诚实,文案位也必须诚实——同一份 warnings
// 里不许同时出现"保持导入前的样子"与"有 N 项未能回滚"。
let report = record_rollback(failed(base(), ImportError::fetch("提交失败")), incomplete());
assert_eq!(report.status, ImportStatus::Failed);
assert!(!report.rolled_back, "回滚不完整时不许宣称一致");
assert_eq!(
report.rollback.as_ref().map(RollbackOutcome::failure_count),
Some(1)
);
let messages = messages(&report);
assert!(
messages
.iter()
.any(|line| line.contains("未能回滚") && line.contains("config/a.toml")),
"失败项要逐条列出:{messages:?}"
);
assert!(
!messages.iter().any(|line| line.contains("保持导入前的样子")
|| line.contains("未被写入")
|| line.contains("恢复为导入前的样子")),
"回滚不完整却宣称一致(自相矛盾):{messages:?}"
);
}
#[test]
fn complete_rollback_claims_consistency_exactly_once() {
let report = record_rollback(
failed(base(), ImportError::fetch("提交失败")),
RollbackOutcome {
attempted: 2,
failed: Vec::new(),
},
);
assert!(report.rolled_back);
assert_eq!(
report.rollback.as_ref().map(RollbackOutcome::failure_count),
Some(0)
);
let messages = messages(&report);
assert!(
messages
.iter()
.any(|line| line.contains("恢复为导入前的样子")),
"{messages:?}"
);
assert!(
!messages.iter().any(|line| line.contains("未能回滚")),
"{messages:?}"
);
}
#[test]
fn pre_commit_failure_claims_untouched_only_via_abandoned() {
// `failed()` 本身不宣称任何目标目录状态;"从未写过目标目录"这条口径只由 `abandoned()` 给。
let bare = failed(base(), ImportError::fetch("开包失败"));
assert!(!bare.rolled_back);
assert!(bare.rollback.is_none());
assert!(!messages(&bare)
.iter()
.any(|line| line.contains("保持导入前的样子") || line.contains("未被写入")));
let report = abandoned(base(), ImportError::fetch("开包失败"));
assert!(report.rolled_back);
assert!(report.rollback.is_none());
let messages = messages(&report);
assert!(
messages.iter().any(|line| line.contains("未被写入")),
"{messages:?}"
);
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ pub const STAGE_REGISTER: &str = "pack.import.stage.register";
pub const STATUS_SUCCESS: &str = "pack.import.status.success";
/// 状态:部分成功。
pub const STATUS_PARTIAL: &str = "pack.import.status.partial";
/// 状态:失败(已回滚)。
/// 状态:失败(是否回滚干净看 `ImportReport::rolled_back`)。
pub const STATUS_FAILED: &str = "pack.import.status.failed";
/// 注记:`server-overrides/` 客户端不解压。