修复配置覆盖层写入失败后的部分更新
Project CI / Frontend tests (pull_request) Successful in 3m5s
Project CI / Backend tests (pull_request) Failing after 3m52s
Project CI / Native shell tests (pull_request) Failing after 4m5s
Project CI / Repository checks (pull_request) Failing after 1m8s

提前序列化配置变更并在多文件写入失败时逆序回滚
保留单文件保存路径且不增加外部诊断或保存后回读
补充覆盖层写入失败回归测试及配置保存文档
This commit is contained in:
2026-09-09 04:34:57 +00:00
parent ff065dbc6c
commit 08c5917490
5 changed files with 77 additions and 3 deletions
@@ -2040,7 +2040,7 @@ fn persist_game_creator_app_config(
let config = normalize_game_creator_app_config(config)?;
let path = writable_game_creator_config_path()?;
let content = serialize_game_creator_app_config_for_renderer_write(&config)?;
write_game_creator_config_atomically(&path, &format!("{content}\n"))?;
let mut writes = vec![(path, format!("{content}\n"))];
let saved: serde_json::Value = serde_json::from_str(&content)
.map_err(|error| format!("解析已序列化客户端配置失败:{error}"))?;
for (overlay_path, mut overlay) in overlays {
@@ -2062,9 +2062,10 @@ fn persist_game_creator_app_config(
if overlay != previous {
let content = serde_json::to_string_pretty(&overlay)
.map_err(|error| format!("序列化客户端覆盖配置失败:{error}"))?;
write_game_creator_config_atomically(&overlay_path, &format!("{content}\n"))?;
writes.push((overlay_path, format!("{content}\n")));
}
}
crate::config::write_game_creator_config_batch(&writes)?;
// `config` is already normalized and is exactly what was persisted.
// Avoid reloading it here: a reload repeats the Windows private-path and
// ACL checks and made saving the settings panel appear to hang.
@@ -3678,6 +3678,45 @@ fn game_creator_config_backup_path(path: &Path) -> PathBuf {
))
}
pub(crate) fn write_game_creator_config_batch(writes: &[(PathBuf, String)]) -> Result<(), String> {
if writes.len() == 1 {
return write_game_creator_config_atomically(&writes[0].0, &writes[0].1);
}
let originals = writes
.iter()
.map(|(path, _)| read_game_creator_config_file(path))
.collect::<Result<Vec<_>, _>>()?;
for (index, (path, content)) in writes.iter().enumerate() {
if let Err(mut error) = write_game_creator_config_atomically(path, content) {
// 写入可能在替换后的权限检查失败,因此失败目标也需要核对并恢复。
for rollback_index in (0..=index).rev() {
let path = &writes[rollback_index].0;
let original = &originals[rollback_index];
if read_game_creator_config_file(path).ok().as_ref() == Some(original) {
continue;
}
let restored = match original {
Some(content) => write_game_creator_config_atomically(path, content),
None => fs::remove_file(path)
.or_else(|error| {
if error.kind() == std::io::ErrorKind::NotFound {
Ok(())
} else {
Err(error)
}
})
.map_err(|error| error.to_string()),
};
if let Err(restore_error) = restored {
error.push_str(&format!(";恢复配置失败:{}: {restore_error}", path.display()));
}
}
return Err(error);
}
}
Ok(())
}
pub(crate) fn write_game_creator_config_atomically(
path: &Path,
content: &str,
@@ -807,6 +807,36 @@ fn app_config_commands_write_runtime_config_file() {
fs::remove_dir_all(root).expect("cleanup runtime config dir");
}
#[test]
fn app_config_batch_restores_main_when_overlay_write_fails() {
for main_exists in [false, true] {
let root = unique_project_path();
fs::create_dir_all(&root).expect("config dir");
let main = root.join(GAME_CREATOR_CONFIG_FILE_NAME);
let overlay = root.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME);
let original = "{\"llm\":{\"stream\":false}}\n";
if main_exists {
fs::write(&main, original).expect("main config");
}
fs::write(&overlay, original).expect("overlay config");
// 普通目录占据备份路径,让覆盖文件在替换前失败。
fs::create_dir(root.join(format!(".{}.previous", GAME_CREATOR_LOCAL_CONFIG_FILE_NAME)))
.expect("block overlay replacement");
crate::config::write_game_creator_config_batch(&[
(main.clone(), "{\"llm\":{\"stream\":true}}\n".to_string()),
(overlay.clone(), "{\"llm\":{\"stream\":true}}\n".to_string()),
])
.expect_err("overlay replacement must fail");
if main_exists {
assert_eq!(fs::read_to_string(&main).expect("restored main"), original);
} else {
assert!(!main.exists());
}
assert_eq!(fs::read_to_string(&overlay).expect("unchanged overlay"), original);
fs::remove_dir_all(root).expect("cleanup config dir");
}
}
#[test]
fn app_config_save_updates_conflicting_local_overlay() {
let root = unique_project_path();