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 {
|
||||
|
||||
Reference in new issue
Block a user