fix(m171): 跨卷替换改成「目标同目录中转名 + 同卷原子改名」
已发布版本上的真 bug:暂存包在 <数据根>/cache/update/staging,目标 exe 在安装目录,
两者不同卷(便携版放 U 盘 / 数据根在 D 盘而程序在 C 盘 / 部分 macOS 配置)时
`rename(staged → dest)` 直接报 EXDEV / ERROR_NOT_SAME_DEVICE(io::ErrorKind::CrossesDevices),
更新必然失败,而 is_transient 不含 CrossesDevices,也没有任何降级路径。
复现(本机真跨卷:C:/Temp NVMe ↔ D:/Temp 机械盘,原实现):
InstallFailure { error: UpdateError { kind: Io, message: "系统无法将文件移到不同的磁盘驱动器。
(os error 17)", path: Some("D:/Temp\ull-xdev-…\app\ull.exe") }, rolled_back: true }
失败点就是 `rename(暂存包 → dest)`;唯一调用方是 core::update::job::Job::apply(CLI/GUI 共用)。
修法(不把原子替换退化成可能留半截的复制):
0. 新包 → <目标同目录>/.<文件名>.ullnew-<pid>-<seq> 同卷 rename;跨卷才 copy 到该中转名,
并核对写出字节数(少了直接判失败)
1. dest → <同目录 .ullold-…> 旧版本先安全落地
2. rename(中转名 → dest) 同卷,原子
3a. 成功 → InstallReceipt;3b. 失败 → 回滚 + 清中转名
目标路径上的新字节只来自一次原子命名,任何时刻都不是半截文件;同卷路径一个字节都不多搬
(注入用例断言 copies == 0,rename 恰好 3 次)。
备份清理策略一并定死:成功后只留最近一份 .ullold-*(更早的与残留的 .ullnew-* 删掉,
别的程序名的备份不碰)。旧行为是每次 apply 留一份、全仓无人调 remove_backup ⇒ 无限堆积。
顺手把文件动作抽成 replace_io::ReplaceFs(真实现 = std::fs),让「跨卷被拒」「复制到一半
失败」「改名失败」成为确定性输入;判据与顺序仍在 install 里,注入改不了。
守卫(都做过变异实测,均能变红):
* install_xdev_cases:真跨卷安装成功且目标字节完整(去掉降级 → 红,报 os error 17)
* install_io_cases:跨卷降级只复制一次 / 半截复制后目标仍是旧字节且无 .ullnew- 残留 /
短复制被字节数判据拒掉 / 最后一步改名失败回滚并清中转名 / 同卷 copies == 0
* cli update_e2e_test::installs_across_volumes_from_the_cli:数据根一卷、安装目录另一卷,
整环从 CLI 入口跑通(去掉降级 → 红,输出 update_apply_failed + os error 17)
This commit is contained in:
1 parent
ab79a28b28
commit
36ff193609
11 files changed
+1007
-119
No files matched your search
@@ -17,17 +17,26 @@ pub struct TempDir {
|
||||
impl TempDir {
|
||||
/// 建一个唯一目录。
|
||||
pub fn new(tag: &str) -> Self {
|
||||
Self::in_root(&std::env::temp_dir(), tag).expect("创建临时目录")
|
||||
}
|
||||
|
||||
/// 建在**另一块卷**上(跨卷验收用);本机只有一块可写卷时返回 `None`。
|
||||
pub fn on_other_volume(tag: &str) -> Option<Self> {
|
||||
other_volume_root().and_then(|root| Self::in_root(&root, tag))
|
||||
}
|
||||
|
||||
fn in_root(root: &Path, tag: &str) -> Option<Self> {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, |duration| duration.as_nanos());
|
||||
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
let path = root.join(format!(
|
||||
"ull-cli-test-{tag}-{}-{seq}-{nanos}",
|
||||
std::process::id()
|
||||
));
|
||||
std::fs::create_dir_all(&path).expect("创建临时目录");
|
||||
Self { path }
|
||||
std::fs::create_dir_all(&path).ok()?;
|
||||
Some(Self { path })
|
||||
}
|
||||
|
||||
/// 目录路径。
|
||||
@@ -47,6 +56,75 @@ impl Drop for TempDir {
|
||||
}
|
||||
}
|
||||
|
||||
/// 指定异卷根目录的环境变量(本机找不到第二块卷时,用它显式指一块)。
|
||||
pub const XDEV_ROOT_ENV: &str = "ULL_XDEV_TEST_ROOT";
|
||||
|
||||
/// 一块与会话临时目录**真的**不同卷的可写根目录(探出来的,不是按平台猜的)。
|
||||
///
|
||||
/// 候选:`ULL_XDEV_TEST_ROOT` → Windows 各盘符的 `Temp`(没有就盘根)/ Unix 的 `TMPDIR`、
|
||||
/// `/dev/shm`、`/tmp`、`/run/user/<uid>`。每个候选都要过「从 temp 目录 rename 进它必须报
|
||||
/// `CrossesDevices`」这一关,所以返回值**一定**是异卷——同一块卷上的候选会被跳过,
|
||||
/// 否则跨卷用例会在同卷上跑,等于什么都没验。
|
||||
pub fn other_volume_root() -> Option<PathBuf> {
|
||||
let mut candidates = Vec::new();
|
||||
if let Some(root) = std::env::var_os(XDEV_ROOT_ENV) {
|
||||
candidates.push(PathBuf::from(root));
|
||||
}
|
||||
candidates.extend(volume_candidates());
|
||||
candidates
|
||||
.into_iter()
|
||||
.find(|candidate| is_other_volume(candidate))
|
||||
}
|
||||
|
||||
fn volume_candidates() -> Vec<PathBuf> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
('A'..='Z')
|
||||
.map(|letter| PathBuf::from(format!("{letter}:/")))
|
||||
.filter(|root| root.is_dir())
|
||||
.map(|root| {
|
||||
if root.join("Temp").is_dir() {
|
||||
root.join("Temp")
|
||||
} else {
|
||||
root
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
let mut candidates: Vec<PathBuf> = ["TMPDIR", "TMP", "TEMP"]
|
||||
.iter()
|
||||
.filter_map(|key| std::env::var_os(key).map(PathBuf::from))
|
||||
.collect();
|
||||
for root in ["/dev/shm", "/tmp", "/var/tmp"] {
|
||||
candidates.push(PathBuf::from(root));
|
||||
}
|
||||
if let Some(uid) = std::env::var_os("UID") {
|
||||
candidates.push(PathBuf::from("/run/user").join(uid));
|
||||
}
|
||||
candidates
|
||||
}
|
||||
}
|
||||
|
||||
fn is_other_volume(dir: &Path) -> bool {
|
||||
if dir.as_os_str().is_empty() || std::fs::create_dir_all(dir).is_err() {
|
||||
return false;
|
||||
}
|
||||
let probe = std::env::temp_dir().join(format!("ull-cli-probe-{}", std::process::id()));
|
||||
if std::fs::write(&probe, b"probe").is_err() {
|
||||
return false;
|
||||
}
|
||||
let landed = dir.join(format!("ull-cli-probe-{}", std::process::id()));
|
||||
let cross = matches!(
|
||||
std::fs::rename(&probe, &landed),
|
||||
Err(ref error) if error.kind() == std::io::ErrorKind::CrossesDevices
|
||||
);
|
||||
let _ = std::fs::remove_file(&probe);
|
||||
let _ = std::fs::remove_file(&landed);
|
||||
cross
|
||||
}
|
||||
|
||||
/// 跑一次 CLI,返回(退出码, stdout, stderr)。
|
||||
pub fn run_cli(args: &[&str]) -> (i32, String, String) {
|
||||
let owned: Vec<String> = args.iter().map(|arg| arg.to_string()).collect();
|
||||
|
||||
@@ -222,9 +222,21 @@ fn payload_bytes() -> Vec<u8> {
|
||||
|
||||
impl Fixture {
|
||||
pub fn new(tag: &str) -> Self {
|
||||
Self::with_install(tag, super::TempDir::new(&format!("{tag}-install")))
|
||||
}
|
||||
|
||||
/// 安装目录在**另一块卷**上的现场(数据根与暂存仍在会话 temp):M171 的真机跨卷形态。
|
||||
///
|
||||
/// 找不到第二块可写卷时返回 `None`(调用方跳过并说明原因)——这条用例在只有一块卷的
|
||||
/// 机器/CI 上**不生效**,跨卷逻辑本身由 `crates/core` 的注入用例守着。
|
||||
pub fn cross_volume(tag: &str) -> Option<Self> {
|
||||
let install = super::TempDir::on_other_volume(&format!("{tag}-install"))?;
|
||||
Some(Self::with_install(tag, install))
|
||||
}
|
||||
|
||||
fn with_install(tag: &str, install: super::TempDir) -> Self {
|
||||
let payload = payload_bytes();
|
||||
let version = next_version(env!("CARGO_PKG_VERSION"));
|
||||
let install = super::TempDir::new(&format!("{tag}-install"));
|
||||
let dest = install.path().join("unlucky-launcher.exe");
|
||||
std::fs::write(&dest, INSTALLED).expect("写旧版本");
|
||||
Self {
|
||||
|
||||
@@ -33,8 +33,9 @@
|
||||
//! | 清单 → 选版本 → 下载 → 双检 → 就位整环跑通 | 任一环断了(字段拼错、渠道不符、没下、没校验、没搬)都会红 | 真 GitHub Release 的下载链路(本套一个字节不出网) |
|
||||
//! | 清单 `sha256` 与盘上字节不符 ⇒ 拒绝 + 目标不动 | 「校验和判据被绕过/写成恒真」;篡改包体或篡改清单都能抓(见 `update_e2e_tamper_test`) | 「CDN 换了内容**而清单里的 sha 也一起换**」——这条路上没有可信根,只能靠 GitHub 侧 |
|
||||
//! | 清单 `size` 与盘上长度不符 ⇒ 拒绝 | 截断/拼接(长度判据先跑,比摘要便宜) | 同上:清单自己撒谎时没有任何判据能发现 |
|
||||
//! | 重复 `apply` 后目标字节稳定、暂存无残留 | 「第二次把 dest 写坏 / 暂存留下垃圾」 | 「版本号真的前进了」——`current` 取的是**编译期**版本,不读 dest 里的 exe |
|
||||
//! | 失败后目标仍是旧字节、无 `.ullold-*` | 「校验失败却已经动过目标」 | 跨卷(数据根与 exe 不同盘)时的替换:`rename` 不能跨卷,那条路没验 |
|
||||
//! | 重复 `apply` 后目标字节稳定、暂存无残留、只留一份备份 | 「第二次把 dest 写坏 / 暂存留下垃圾 / 备份无限堆积」 | 「版本号真的前进了」——`current` 取的是**编译期**版本,不读 dest 里的 exe |
|
||||
//! | 失败后目标仍是旧字节、无 `.ullold-*` | 「校验失败却已经动过目标」 | 跨卷失败路径:本文件只验了跨卷**成功**那一半(见下一行);跨卷下的中途失败由 `install_io_cases` 注入验 |
|
||||
//! | 跨卷 `apply`(数据根一卷、安装目录另一卷,M171) | 「rename 不能跨卷 ⇒ 更新必然失败」这条已发布版本上的真 bug;用例自己先证明前提(rename 报 `CrossesDevices`) | 只有一块可写卷的机器/CI 上**跳过**(明文打印);macOS 的跨卷行为一台没跑过 |
|
||||
//! | 失败路径三例(`update_e2e_tamper_test`:篡改清单 sha / 篡改包体 / 截断) | 「校验判据被绕过」「失败却已动过目标」「失败留下 `.ullupd`」 | **回滚与备份**:摘要/长度失败发生在 `install_staged` **之前**,那一步根本走不到——回滚靠正路的备份断言 + `install_cases` 单测(r1 复审 ⑤ 的变异实测:去掉 dest 备份后失败路径三例仍全绿,只有正路/幂等变红) |
|
||||
//!
|
||||
//! # 不验的部分(如实划界)
|
||||
@@ -47,6 +48,9 @@
|
||||
//! (`update_session_tests.rs`),CLI 侧没有对应开关,所以这里不覆盖。
|
||||
//! 4. **`--dest` 之外的自替换**(替换本进程 exe):`tests/support/update_e2e.rs` 的文件头写了为什么
|
||||
//! 这几条用例永远带 `--dest`,以及「去掉它会真的改名换掉测试二进制」这件事。
|
||||
//! 5. **macOS / Linux 上的跨卷**:跨卷用例只在**本机 Windows**(C: NVMe + D: 机械盘)上真跑过;
|
||||
//! macOS 的卷(APFS 宗卷之间 `/Volumes` 挂载)与 Linux 的 tmpfs↔ext4 一次都没跑——两边的
|
||||
//! `EXDEV` 语义相同,但「同目录中转 + 改名」在同一台机器上有没有别的坑,本套没有证据。
|
||||
|
||||
#![cfg(feature = "update-test-source")]
|
||||
|
||||
@@ -160,10 +164,11 @@ fn an_empty_local_channel_is_up_to_date_not_an_error() {
|
||||
assert_eq!(data["test_source"], true);
|
||||
}
|
||||
|
||||
/// 重复 `apply` 的**终态**与一次相同:目标字节不变、暂存不留垃圾。
|
||||
/// 重复 `apply` 的**终态**与一次相同:目标字节不变、暂存不留垃圾、备份只有一份。
|
||||
///
|
||||
/// 如实记两条:`apply` 每次都留一个备份(CLI 从不删,等下一次启动/人工清理),而且第二次之所以
|
||||
/// 还会「装上」,是因为 `current` 是编译期版本——重复执行不是空操作,只是结果幂等。
|
||||
/// 如实记两条:`apply` 每次都留一份备份(CLI 从不删,等下一次启动/人工清理),但**只留最近一份**
|
||||
/// (`install::prune_stale_backups`,M171);第二次之所以还会「装上」,是因为 `current` 是编译期
|
||||
/// 版本——重复执行不是空操作,只是结果幂等。
|
||||
#[test]
|
||||
fn applying_twice_leaves_the_same_target_bytes() {
|
||||
let _serial = channel_lock();
|
||||
@@ -189,8 +194,68 @@ fn applying_twice_leaves_the_same_target_bytes() {
|
||||
}
|
||||
assert_eq!(
|
||||
fx.backups().len(),
|
||||
2,
|
||||
"每次 apply 留一个备份(CLI 不删):{:?}",
|
||||
1,
|
||||
"只留最近一份备份(每次 apply 建一份、成功即清更早的):{:?}",
|
||||
fx.backups()
|
||||
);
|
||||
}
|
||||
|
||||
/// 跨卷(M171):数据根在一卷、安装目录在另一卷时,整环从 CLI 入口仍然跑通。
|
||||
///
|
||||
/// 这正是已发布版本上必然失败的那个场景(便携版放 U 盘、数据根在 D 盘而程序在 C 盘):
|
||||
/// `stage()` 把包落在 `<数据根>/cache/update/staging`,`install_staged()` 要把包搬进 exe 目录,
|
||||
/// 而 `rename` 不能跨卷。修法是先落到**目标同目录**的中转名(同卷改名)再顶上去。
|
||||
///
|
||||
/// 用例先自己证明**前提成立**(从数据根到安装目录 rename 必须报 `CrossesDevices`),否则
|
||||
/// 「跨卷成功」这句话就是假的。本机只有一块可写卷时**跳过**(明文打印原因)——跨卷逻辑本身由
|
||||
/// `crates/core` 的 `install_xdev_cases`(真跨卷)与 `install_io_cases`(注入)守着。
|
||||
#[test]
|
||||
fn installs_across_volumes_from_the_cli() {
|
||||
let _serial = channel_lock();
|
||||
let _restore = Channel::capture();
|
||||
let Some(fx) = Fixture::cross_volume("e2e-xdev") else {
|
||||
eprintln!(
|
||||
"跳过跨卷 e2e:本机找不到第二块可写卷(可用 ULL_XDEV_TEST_ROOT 指一块,\
|
||||
例如 C:/Temp + D:/Temp)"
|
||||
);
|
||||
return;
|
||||
};
|
||||
Channel::enable(fx.dist.path());
|
||||
fx.publish_good();
|
||||
|
||||
// 前提:数据根(暂存所在地)与安装目录**真的**不同卷。
|
||||
let install_dir = fx.dest.parent().expect("安装目录").to_path_buf();
|
||||
let probe = fx.data.path().join("volume-probe.bin");
|
||||
std::fs::write(&probe, b"probe").expect("写探针");
|
||||
let cross = std::fs::rename(&probe, install_dir.join("volume-probe.bin"))
|
||||
.expect_err("跨卷 rename 必须失败");
|
||||
assert_eq!(
|
||||
cross.kind(),
|
||||
std::io::ErrorKind::CrossesDevices,
|
||||
"前提不成立:数据根与安装目录同卷({cross})"
|
||||
);
|
||||
|
||||
let (code, json) = fx.apply();
|
||||
assert_eq!(code, 0, "跨卷 apply 应成功:{json}");
|
||||
let data = support::update_e2e::payload(&json);
|
||||
assert_eq!(data["status"], "installed", "{json}");
|
||||
assert_eq!(data["path"], fx.dest.display().to_string().as_str());
|
||||
assert_eq!(
|
||||
fx.installed_bytes(),
|
||||
fx.payload,
|
||||
"装上去的必须是清单里那一份完整字节(不是半截)"
|
||||
);
|
||||
assert!(
|
||||
fx.staging_leftovers().is_empty(),
|
||||
"暂存目录里不许留 .ullupd:{:?}",
|
||||
fx.staging_leftovers()
|
||||
);
|
||||
let backups = fx.backups();
|
||||
assert_eq!(backups.len(), 1, "旧版本要留一份备份:{backups:?}");
|
||||
let backup = install_dir.join(&backups[0]);
|
||||
assert_eq!(
|
||||
std::fs::read(&backup).expect("读备份"),
|
||||
INSTALLED,
|
||||
"旧字节必须完整留在备份里"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user