修复分离状态跨平台原子替换
使用唯一临时文件并同步写入,避免 checkpoint 临时文件互相覆盖。 Unix 使用 rename,Windows 使用 MoveFileExW 原子覆盖已有 state.json。 拒绝符号链接和非普通文件目标,并增加覆盖已有目标回归测试。
This commit is contained in:
+91
-8
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user