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:
1 parent
7f657d225c
commit
29f531db23
3 files changed
+151
-30
No files matched your search
@@ -126,7 +126,7 @@ fn outcome(
|
||||
}
|
||||
human.push(format!(
|
||||
"结果:{} 文件 新{} / 覆盖{} / 跳过{} / 保留{} 冲突 {} 缺文件 {}",
|
||||
status_label(report.status),
|
||||
status_label(report),
|
||||
count_action(report, FileAction::Installed),
|
||||
count_action(report, FileAction::Replaced),
|
||||
count_action(report, FileAction::SkippedIdentical),
|
||||
@@ -207,11 +207,13 @@ fn outcome(
|
||||
Outcome::new(Report { data, human }).with_exit_code(report.status.exit_code())
|
||||
}
|
||||
|
||||
fn status_label(status: ImportStatus) -> &'static str {
|
||||
match status {
|
||||
/// 状态的人读名。**失败要按回滚是否干净分叉**:不完整时不能说"已回滚"。
|
||||
fn status_label(report: &ImportReport) -> &'static str {
|
||||
match report.status {
|
||||
ImportStatus::Success => "成功",
|
||||
ImportStatus::Partial => "部分成功",
|
||||
ImportStatus::Failed => "失败(已回滚)",
|
||||
ImportStatus::Failed if report.rolled_back => "失败(已回滚)",
|
||||
ImportStatus::Failed => "失败(回滚不完整)",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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/` 客户端不解压。
|
||||
|
||||
Reference in new issue
Block a user