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!(
|
human.push(format!(
|
||||||
"结果:{} 文件 新{} / 覆盖{} / 跳过{} / 保留{} 冲突 {} 缺文件 {}",
|
"结果:{} 文件 新{} / 覆盖{} / 跳过{} / 保留{} 冲突 {} 缺文件 {}",
|
||||||
status_label(report.status),
|
status_label(report),
|
||||||
count_action(report, FileAction::Installed),
|
count_action(report, FileAction::Installed),
|
||||||
count_action(report, FileAction::Replaced),
|
count_action(report, FileAction::Replaced),
|
||||||
count_action(report, FileAction::SkippedIdentical),
|
count_action(report, FileAction::SkippedIdentical),
|
||||||
@@ -207,11 +207,13 @@ fn outcome(
|
|||||||
Outcome::new(Report { data, human }).with_exit_code(report.status.exit_code())
|
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::Success => "成功",
|
||||||
ImportStatus::Partial => "部分成功",
|
ImportStatus::Partial => "部分成功",
|
||||||
ImportStatus::Failed => "失败(已回滚)",
|
ImportStatus::Failed if report.rolled_back => "失败(已回滚)",
|
||||||
|
ImportStatus::Failed => "失败(回滚不完整)",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
//! | 某条远端文件摸不到(离线 / CF 无客户端 / 404) | `Partial`,逐条进 `missing`,实例照装 |
|
//! | 某条远端文件摸不到(离线 / CF 无客户端 / 404) | `Partial`,逐条进 `missing`,实例照装 |
|
||||||
//! | 下载到的内容与声明摘要不符 | `Failed`(这是**损坏或被换过**,不是"暂时搞不到") |
|
//! | 下载到的内容与声明摘要不符 | `Failed`(这是**损坏或被换过**,不是"暂时搞不到") |
|
||||||
//! | 本地写盘失败 | `Failed` |
|
//! | 本地写盘失败 | `Failed` |
|
||||||
//! | 实例登记失败 | `Failed`(连内容一起回滚,不留"有目录没档案"的半个实例) |
|
//! | 实例登记失败 | `Failed`(连内容一起回滚,不留"有目录没档案"的半个实例;回滚不完整时如实列出) |
|
||||||
//!
|
//!
|
||||||
//! **回滚也要如实**:回滚本身可能失败(文件被占用、权限),这时 `rolled_back` 为 `false`
|
//! **回滚也要如实**:回滚本身可能失败(文件被占用、权限),这时 `rolled_back` 为 `false`
|
||||||
//! 并在 `rollback.failed` 里逐项列出——不许在"可能没回干净"时宣称一致。
|
//! 并在 `rollback.failed` 里逐项列出——不许在"可能没回干净"时宣称一致。
|
||||||
@@ -45,14 +45,14 @@ pub async fn run(request: &ImportRequest<'_>) -> ImportReport {
|
|||||||
tell(&request.progress, ImportStage::Probe, 0, 0, None);
|
tell(&request.progress, ImportStage::Probe, 0, 0, None);
|
||||||
let (archive, plan) = match probe_and_plan(request) {
|
let (archive, plan) = match probe_and_plan(request) {
|
||||||
Ok(pair) => pair,
|
Ok(pair) => pair,
|
||||||
Err(error) => return failed(report, error),
|
Err(error) => return abandoned(report, error),
|
||||||
};
|
};
|
||||||
account_plan(&mut report, &plan);
|
account_plan(&mut report, &plan);
|
||||||
|
|
||||||
let baseline = load_baseline(request.target, &plan, &mut report);
|
let baseline = load_baseline(request.target, &plan, &mut report);
|
||||||
let staging = match Staging::begin(request.target) {
|
let staging = match Staging::begin(request.target) {
|
||||||
Ok(staging) => staging,
|
Ok(staging) => staging,
|
||||||
Err(error) => return failed(report, error),
|
Err(error) => return abandoned(report, error),
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut next = baseline.clone();
|
let mut next = baseline.clone();
|
||||||
@@ -73,7 +73,7 @@ pub async fn run(request: &ImportRequest<'_>) -> ImportReport {
|
|||||||
.await;
|
.await;
|
||||||
if let Err(error) = staged {
|
if let Err(error) = staged {
|
||||||
staging.abort();
|
staging.abort();
|
||||||
return failed(report, error);
|
return abandoned(report, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 引擎的续传残片(`.ullpart-*`)与 meta 不是整合包内容,提交前剔掉。
|
// 引擎的续传残片(`.ullpart-*`)与 meta 不是整合包内容,提交前剔掉。
|
||||||
@@ -85,18 +85,14 @@ pub async fn run(request: &ImportRequest<'_>) -> ImportReport {
|
|||||||
)),
|
)),
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
staging.abort();
|
staging.abort();
|
||||||
return failed(report, error);
|
return abandoned(report, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tell(&request.progress, ImportStage::Commit, 0, 0, None);
|
tell(&request.progress, ImportStage::Commit, 0, 0, None);
|
||||||
let guard = match staging.commit() {
|
let guard = match staging.commit() {
|
||||||
Ok(guard) => guard,
|
Ok(guard) => guard,
|
||||||
Err(failure) => {
|
Err(failure) => return record_rollback(failed(report, failure.error), failure.rollback),
|
||||||
let mut report = failed(report, failure.error);
|
|
||||||
record_rollback(&mut report, failure.rollback);
|
|
||||||
return report;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(layout) = request.register {
|
if let Some(layout) = request.register {
|
||||||
@@ -124,9 +120,10 @@ pub async fn run(request: &ImportRequest<'_>) -> ImportReport {
|
|||||||
Err(error) => {
|
Err(error) => {
|
||||||
// 内容落盘了但档案没建起来 = 用户看不见的半个实例 ⇒ 连内容一起回滚。
|
// 内容落盘了但档案没建起来 = 用户看不见的半个实例 ⇒ 连内容一起回滚。
|
||||||
let outcome = guard.rollback();
|
let outcome = guard.rollback();
|
||||||
let mut report = failed(report, ImportError::instance(error.to_string()));
|
return record_rollback(
|
||||||
record_rollback(&mut report, outcome);
|
failed(report, ImportError::instance(error.to_string())),
|
||||||
return report;
|
outcome,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -198,9 +195,19 @@ fn load_baseline(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 把一次回滚的结果写进报告:不完整时**不许**继续宣称"目标目录与导入前一致"。
|
/// 把一次回滚的结果写进报告:**文案与机器位同源**,只有完整回滚才宣称"与导入前一致"。
|
||||||
fn record_rollback(report: &mut ImportReport, outcome: RollbackOutcome) {
|
fn record_rollback(mut report: ImportReport, outcome: RollbackOutcome) -> ImportReport {
|
||||||
if !outcome.is_complete() {
|
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.rolled_back = false;
|
||||||
report.warnings.push(ImportIssue::new(
|
report.warnings.push(ImportIssue::new(
|
||||||
keys::NOTE_ROLLBACK_INCOMPLETE,
|
keys::NOTE_ROLLBACK_INCOMPLETE,
|
||||||
@@ -212,6 +219,7 @@ fn record_rollback(report: &mut ImportReport, outcome: RollbackOutcome) {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
report.rollback = Some(outcome);
|
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 {
|
fn failed(mut report: ImportReport, error: ImportError) -> ImportReport {
|
||||||
report.warnings.push(ImportIssue::new(
|
report.rolled_back = false;
|
||||||
keys::ROLLBACK_DONE,
|
|
||||||
format!(
|
|
||||||
"导入失败,已清理临时目录;目标目录保持导入前的样子({})",
|
|
||||||
report.target.display()
|
|
||||||
),
|
|
||||||
));
|
|
||||||
report.rolled_back = true;
|
|
||||||
report.fail(error);
|
report.fail(error);
|
||||||
report
|
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_SUCCESS: &str = "pack.import.status.success";
|
||||||
/// 状态:部分成功。
|
/// 状态:部分成功。
|
||||||
pub const STATUS_PARTIAL: &str = "pack.import.status.partial";
|
pub const STATUS_PARTIAL: &str = "pack.import.status.partial";
|
||||||
/// 状态:失败(已回滚)。
|
/// 状态:失败(是否回滚干净看 `ImportReport::rolled_back`)。
|
||||||
pub const STATUS_FAILED: &str = "pack.import.status.failed";
|
pub const STATUS_FAILED: &str = "pack.import.status.failed";
|
||||||
|
|
||||||
/// 注记:`server-overrides/` 客户端不解压。
|
/// 注记:`server-overrides/` 客户端不解压。
|
||||||
|
|||||||
Reference in new issue
Block a user