Fix custom races transactions with linked addons on another volume

This commit is contained in:
Дмитрий committed 2026-10-23 05:43:57 +05:00
1 parent 8d2befd7f0
commit 79e9e85fac
2 files changed
+113 -9

No files matched your search

@@ -0,0 +1,7 @@
---
area: manager
type: fixed
audience: admins
title: Custom races work with shared addon folders on another drive
---
Installing, switching and removing custom races now supports linked addon folders on another drive, including recovery after an interrupted operation.
+106 -9
View File
@@ -331,6 +331,26 @@ struct PatchTransaction<'a> {
entries: Vec<SavedFile>,
finished: bool,
}
// A shared AddOns directory can resolve onto another volume. Publish a complete,
// synced copy beside the destination before deleting the source, so the journal
// always retains recoverable bytes even if copying or publishing fails.
fn move_patch_file(source: &Path, target: &Path) -> io::Result<()> {
match fs::rename(source, target) {
Ok(()) => return Ok(()),
Err(error) if error.kind() == io::ErrorKind::CrossesDevices => {}
Err(error) => return Err(error),
}
let parent = target
.parent()
.ok_or_else(|| io::Error::other("Patch target has no parent"))?;
let temporary = tempfile::NamedTempFile::new_in(parent)?;
fs::copy(source, temporary.path())?;
temporary.as_file().sync_all()?;
temporary.persist(target).map_err(|error| error.error)?;
fs::remove_file(source)
}
impl<'a> PatchTransaction<'a> {
fn new(root: &'a Path) -> Result<Self> {
recover_patch_transaction(root)?;
@@ -362,9 +382,10 @@ impl<'a> PatchTransaction<'a> {
&self.entries,
)?;
if path.is_file() {
fs::rename(
move_patch_file(
path,
self.root
&self
.root
.join(".coa-races-transaction")
.join(index.to_string()),
)?;
@@ -401,8 +422,9 @@ impl<'a> PatchTransaction<'a> {
&self.entries,
)?;
fs::create_dir_all(target.parent().unwrap())?;
fs::rename(
self.root
move_patch_file(
&self
.root
.join(".coa-races-transaction")
.join(index.to_string()),
target,
@@ -442,7 +464,7 @@ fn recover_patch_transaction(root: &Path) -> Result<()> {
if let Some(target) = &entry.moved_to {
let target = fsx::safe_join(root, target)?;
if !saved.is_file() && target.is_file() {
fs::rename(target, &saved)?;
move_patch_file(&target, &saved)?;
}
// Persist recovery progress before restoring the source, making retries safe.
entries[index].moved_to = None;
@@ -453,7 +475,7 @@ fn recover_patch_transaction(root: &Path) -> Result<()> {
if path.is_file() {
fs::remove_file(&path)?;
}
fs::rename(saved, path)?;
move_patch_file(&saved, &path)?;
} else if !entry.existed && path.is_file() {
fs::remove_file(path)?;
}
@@ -971,12 +993,87 @@ pub fn install_client_patches(
mod tests {
use super::*;
#[cfg(windows)]
fn exercise_linked_addons(shared: &Path) {
let client = tempfile::tempdir().unwrap();
let root = client.path();
fs::create_dir(root.join("Interface")).unwrap();
let link = root.join("Interface/AddOns");
let output = std::process::Command::new("cmd")
.args(["/C", "mklink", "/J"])
.arg(link.to_string_lossy().replace('/', "\\"))
.arg(shared.to_string_lossy().replace('/', "\\"))
.output()
.unwrap();
assert!(output.status.success(), "{output:?}");
let name = "Interface/AddOns/CoAHideArmor/CoAHideArmor.lua";
let active = root.join(name);
fs::create_dir_all(active.parent().unwrap()).unwrap();
fs::write(shared.join("unrelated.lua"), b"user addon").unwrap();
let staged = root.join("staged.lua");
fs::write(&staged, b"custom").unwrap();
let mut tx = PatchTransaction::new(root).unwrap();
tx.copy(&staged, &active).unwrap();
tx.finish().unwrap();
record_files(root, &[name]);
set_client_patch_enabled(root, false).unwrap();
assert!(!active.exists());
assert_eq!(fs::read(suffix(&active, "disabled")).unwrap(), b"custom");
set_client_patch_enabled(root, true).unwrap();
assert_eq!(fs::read(&active).unwrap(), b"custom");
// Simulate process termination after a move, then recover twice.
let mut tx = PatchTransaction::new(root).unwrap();
tx.move_file(&active, &suffix(&active, "disabled")).unwrap();
std::mem::forget(tx);
recover_patch_transaction(root).unwrap();
recover_patch_transaction(root).unwrap();
assert_eq!(fs::read(&active).unwrap(), b"custom");
assert!(!suffix(&active, "disabled").exists());
// Replacement installation must preserve the old bytes on failure.
{
let mut tx = PatchTransaction::new(root).unwrap();
fs::write(&staged, b"replacement").unwrap();
tx.copy(&staged, &active).unwrap();
}
assert_eq!(fs::read(&active).unwrap(), b"custom");
uninstall_client_patches(root).unwrap();
assert!(!active.exists());
assert_eq!(
fs::read(shared.join("unrelated.lua")).unwrap(),
b"user addon"
);
assert!(fs::symlink_metadata(&link)
.unwrap()
.file_type()
.is_symlink());
fs::remove_dir(link).unwrap();
}
#[test]
#[cfg(windows)]
fn shared_addons_junction_supports_install_switch_and_rollback() {
let shared = tempfile::tempdir().unwrap();
exercise_linked_addons(shared.path());
}
#[test]
#[cfg(windows)]
#[ignore = "Set COA_TEST_OTHER_VOLUME to a writable directory on another volume"]
fn shared_addons_on_another_volume_supports_install_switch_and_rollback() {
let base = std::env::var_os("COA_TEST_OTHER_VOLUME").expect("COA_TEST_OTHER_VOLUME");
let shared = tempfile::tempdir_in(base).unwrap();
exercise_linked_addons(shared.path());
}
fn record_files(root: &Path, names: &[&str]) {
let mut manifest = format!("# CoA Custom {CLIENT_VERSION}\n");
for name in names {
let (kind, base) = name
.strip_prefix("Data/")
.map_or(("ROOT", *name), |base| ("MPQ", base));
let (kind, base) = if let Some(base) = name.strip_prefix("Interface/AddOns/") {
("ADDON", base)
} else {
name.strip_prefix("Data/")
.map_or(("ROOT", *name), |base| ("MPQ", base))
};
manifest.push_str(&format!(
"{kind}:{base}\nSHA256:{}:{name}\n",
fsx::sha256_file(&root.join(name)).unwrap()