修复分离状态跨平台原子替换

使用唯一临时文件并同步写入,避免 checkpoint 临时文件互相覆盖。

Unix 使用 rename,Windows 使用 MoveFileExW 原子覆盖已有 state.json。

拒绝符号链接和非普通文件目标,并增加覆盖已有目标回归测试。
This commit is contained in:
2026-09-12 15:40:12 +08:00
parent 8a9d37d5ad
commit 6235fa3298
@@ -1,7 +1,9 @@
use super::model::*;
use crate::ui_editor::commands::separation::*;
use std::fs;
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use uuid::Uuid;
const SEPARATION_STATE_MAX_BYTES: usize = 8 * 1024 * 1024;
pub fn separation_sidecar_dir(root: &Path, asset_id: &str) -> Result<PathBuf, String> {
@@ -53,8 +55,8 @@ pub async fn write_separation_state(path: PathBuf, state: &SeparationState) -> R
.map_err(|error| format!("序列化 separation state 失败:{error}"))?;
write_separation_state_blocking(&path, &bytes)
})
.await
.map_err(|error| format!("写入 separation state 任务失败:{error}"))?
.await
.map_err(|error| format!("写入 separation state 任务失败:{error}"))?
}
fn write_separation_state_blocking(path: &Path, bytes: &[u8]) -> Result<(), String> {
@@ -84,15 +86,40 @@ fn write_separation_state_blocking(path: &Path, bytes: &[u8]) -> Result<(), Stri
app_log!("ui_separation.error stage=state_write reason=create_parent error={error}");
format!("创建 separation sidecar 失败:{error}")
})?;
let temporary = path.with_extension("json.tmp");
fs::write(&temporary, bytes).map_err(|error| {
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
app_log!("ui_separation.error stage=state_write reason=unsafe_target");
return Err("separation state 目标必须是普通文件".to_string());
}
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
app_log!("ui_separation.error stage=state_write reason=target_metadata error={error}");
return Err(format!("检查 separation state 目标失败:{error}"));
}
}
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("state.json");
let temporary = parent.join(format!(".{file_name}.tmp.{}", Uuid::new_v4()));
let mut temporary_file = OpenOptions::new();
temporary_file.write(true).create_new(true);
let mut file = temporary_file.open(&temporary).map_err(|error| {
app_log!("ui_separation.error stage=state_write reason=write_temp error={error}");
format!("写入 separation state 失败:{error}")
})?;
fs::rename(&temporary, path).map_err(|error| {
if let Err(error) = file.write_all(bytes).and_then(|_| file.sync_data()) {
let _ = fs::remove_file(&temporary);
app_log!("ui_separation.error stage=state_write reason=write_temp error={error}");
return Err(format!("写入 separation state 失败:{error}"));
}
drop(file);
if let Err(error) = replace_separation_state_atomically(&temporary, path) {
let _ = fs::remove_file(&temporary);
app_log!("ui_separation.error stage=state_write reason=install error={error}");
format!("安装 separation state 失败:{error}")
})?;
return Err(format!("安装 separation state 失败:{error}"));
}
app_log!(
"ui_separation.state_write.completed file={} bytes={}",
path.file_name()
@@ -105,6 +132,42 @@ fn write_separation_state_blocking(path: &Path, bytes: &[u8]) -> Result<(), Stri
Ok(())
}
#[cfg(not(windows))]
fn replace_separation_state_atomically(temporary: &Path, target: &Path) -> std::io::Result<()> {
fs::rename(temporary, target)
}
#[cfg(windows)]
fn replace_separation_state_atomically(temporary: &Path, target: &Path) -> std::io::Result<()> {
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Storage::FileSystem::{
MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW,
};
let source = temporary
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect::<Vec<_>>();
let destination = target
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect::<Vec<_>>();
let moved = unsafe {
MoveFileExW(
source.as_ptr(),
destination.as_ptr(),
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
)
};
if moved == 0 {
Err(std::io::Error::last_os_error())
} else {
Ok(())
}
}
pub fn read_separation_state(path: &Path) -> Result<SeparationState, String> {
app_log!(
"ui_separation.state_read.start file={}",
@@ -215,3 +278,23 @@ fn remove_separation_state(root: &Path, asset_id: &str) -> Result<(), String> {
Err(error) => Err(format!("删除 separation state 失败:{error}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn atomic_state_replacement_overwrites_existing_target() {
let directory = tempfile::tempdir().expect("create temporary state directory");
let target = directory.path().join("state.json");
let temporary = directory.path().join("state.json.tmp");
fs::write(&target, b"old").expect("write old state");
fs::write(&temporary, b"new").expect("write new state");
replace_separation_state_atomically(&temporary, &target)
.expect("replacement should overwrite existing state");
assert_eq!(fs::read(&target).expect("read replaced state"), b"new");
assert!(!temporary.exists());
}
}