修复配置覆盖层写入失败后的部分更新
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();
@@ -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 产物合同