修复配置覆盖层写入失败后的部分更新
提前序列化配置变更并在多文件写入失败时逆序回滚 保留单文件保存路径且不增加外部诊断或保存后回读 补充覆盖层写入失败回归测试及配置保存文档
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
> 当前口径:本文件保留可复用的排障经验;历史条目的旧路由、旧版本和已删除文档仅作根因背景,不得据此恢复退役入口。当前命令、路由和 schema 以代码与 `docs/README.md` 为准。
|
||||
|
||||
## 2026-09-09 常用设置跨文件保存失败
|
||||
|
||||
主配置与 local overlay 的单文件原子写入不能保证整体成功;覆盖层写入失败会留下混合配置。保存前先序列化全部变更,多文件保存保留原内容,错误时逆序恢复并报告回滚失败;单文件保持原写入路径,成功后不回读、不触发外部诊断。此回滚仅处理可捕获错误,不承诺进程崩溃下的事务恢复。
|
||||
|
||||
## 2026-09-02 Tauri 事件桥在浏览器预览中必须 fail-safe
|
||||
|
||||
- **现象**:Vitest/jsdom 挂载 AGC 客户端时,错误报告通知调用 `@tauri-apps/api/event.listen`,因缺少 `window.__TAURI_INTERNALS__` 产生未处理拒绝;测试断言虽通过,CI 仍以 unhandled errors 失败。
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
常用设置负责运行参数的读取、编辑和保存,配置读写独立于账号权限诊断。账号权限由登录会话与实际智能服务请求链路处理,设置面板只维护配置草稿与读写反馈。
|
||||
|
||||
保存配置复用写入前读取的高优先级本地覆盖内容:常用设置同步覆盖文件中已有的对应配置项,模型选择仅同步 `selectedModelId`;无冲突时不写覆盖文件。主配置和需要更新的覆盖文件均写入成功后返回规范化配置,写入失败则报告错误,不执行保存后回读或外部诊断。各文件沿用现有原子写入;跨文件失败可能已保存部分内容,用户可重新保存。
|
||||
保存配置复用写入前读取的高优先级本地覆盖内容:常用设置同步覆盖文件中已有的对应配置项,模型选择仅同步 `selectedModelId`;无冲突时不写覆盖文件。所有内容先完成序列化,多文件写入前保存原始内容,任一写入失败时逆序恢复已变更文件,回滚失败须明确报告。各文件沿用现有原子写入,不提供断电或进程崩溃下的多文件事务保证。全部成功后直接返回规范化配置,不执行保存后回读或外部诊断;单文件保存保持原路径。
|
||||
|
||||
## 2026-09-08 Web 游戏 npm 与 Phaser 4 产物合同
|
||||
|
||||
|
||||
Reference in New Issue
Block a user