修复 UI 状态恢复并发覆盖

恢复安装与项目保存共用写锁并在持锁后复查主文件

新增恢复写锁竞争回归测试

同步 UI State 持久化技术方案
This commit is contained in:
2026-08-17 18:06:01 +08:00
parent 37b15ffe9c
commit fb7dbb7f92
2 changed files with 77 additions and 18 deletions
@@ -118,7 +118,7 @@ pub(crate) fn save_ui_design_state_at(
return Err("UI 设计资源在保存锁获取期间发生变化,请重试".to_string());
}
let current =
read_ui_design_document(root, &asset.local_path, &expected_project_id, &asset_id)?;
read_ui_design_document_locked(root, &asset.local_path, &expected_project_id, &asset_id)?;
validate_document(&current, &expected_project_id, &asset_id)?;
if current.revision != input.expected_revision {
return Ok(SaveUiDesignStateResult::Conflict {
@@ -152,7 +152,7 @@ pub(crate) fn save_ui_design_state_at(
validate_document(&next, &expected_project_id, &asset_id)?;
write_ui_design_document(root, &asset.local_path, &next)?;
let installed =
read_ui_design_document(root, &asset.local_path, &expected_project_id, &asset_id)?;
read_ui_design_document_locked(root, &asset.local_path, &expected_project_id, &asset_id)?;
validate_document(&installed, &expected_project_id, &asset_id)?;
if installed != next {
return Err("UI 设计 State 安装后回读与待写内容不一致".to_string());
@@ -221,25 +221,47 @@ fn read_ui_design_document(
asset_id: &str,
) -> Result<PersistedUiDesignState, String> {
let primary_path = resolve_local_project_path(root, relative_path)?;
let backup_path = agent_runtime_json_sidecar_backup_path(&primary_path);
let read_candidate = |path: &Path| {
read_ui_design_document_path(path).and_then(|document| {
validate_document(&document, project_id, asset_id)?;
Ok(document)
})
};
match read_candidate(&primary_path) {
match read_valid_ui_design_document_path(&primary_path, project_id, asset_id) {
Ok(document) => Ok(document),
Err(primary_error) => match read_candidate(&backup_path) {
Ok(document) => {
write_ui_design_document(root, relative_path, &document)?;
Ok(document)
}
Err(_) => Err(primary_error),
},
Err(_) => {
let _lock = acquire_project_write_lock(root, "ui_design.state_recover")?;
read_ui_design_document_locked(root, relative_path, project_id, asset_id)
}
}
}
fn read_ui_design_document_locked(
root: &Path,
relative_path: &str,
project_id: &str,
asset_id: &str,
) -> Result<PersistedUiDesignState, String> {
let primary_path = resolve_local_project_path(root, relative_path)?;
let backup_path = agent_runtime_json_sidecar_backup_path(&primary_path);
match read_valid_ui_design_document_path(&primary_path, project_id, asset_id) {
Ok(document) => Ok(document),
Err(primary_error) => {
match read_valid_ui_design_document_path(&backup_path, project_id, asset_id) {
Ok(document) => {
write_ui_design_document(root, relative_path, &document)?;
Ok(document)
}
Err(_) => Err(primary_error),
}
}
}
}
fn read_valid_ui_design_document_path(
path: &Path,
project_id: &str,
asset_id: &str,
) -> Result<PersistedUiDesignState, String> {
let document = read_ui_design_document_path(path)?;
validate_document(&document, project_id, asset_id)?;
Ok(document)
}
fn read_ui_design_document_path(path: &Path) -> Result<PersistedUiDesignState, String> {
let (mut file, metadata) = open_project_snapshot_regular_file(path, "UI 设计 State")?;
if metadata.len() > UI_DESIGN_STATE_MAX_BYTES as u64 {
@@ -681,6 +703,43 @@ mod tests {
);
}
#[test]
fn recovery_install_respects_the_project_write_lock() {
let (directory, asset_id) = fixture();
let first = state_with_unavailable_image("assets/first-missing.png");
let second = state_with_unavailable_image("assets/second-missing.png");
save_ui_design_state_at(input(directory.path(), &asset_id, 0, first.clone()))
.expect("save first state");
save_ui_design_state_at(input(directory.path(), &asset_id, 1, second))
.expect("save second state");
let asset = ui_design_asset(directory.path(), PROJECT_ID, &asset_id).expect("find asset");
let primary = resolve_local_project_path(directory.path(), &asset.local_path)
.expect("resolve primary");
fs::write(&primary, b"{broken").expect("corrupt primary");
let project_lock = acquire_project_write_lock(directory.path(), "test.concurrent-save")
.expect("hold project write lock");
let error = load_ui_design_state_at(LoadUiDesignStateInput {
project_path: directory.path().to_string_lossy().into_owned(),
expected_project_id: PROJECT_ID.to_string(),
asset_id: asset_id.clone(),
})
.expect_err("recovery must not install while another writer holds the lock");
assert!(error.contains("项目正在被其他写操作占用"));
assert!(read_ui_design_document_path(&primary).is_err());
drop(project_lock);
let recovered = load_ui_design_state_at(LoadUiDesignStateInput {
project_path: directory.path().to_string_lossy().into_owned(),
expected_project_id: PROJECT_ID.to_string(),
asset_id,
})
.expect("recover after project lock is released");
assert_eq!(recovered.revision, 1);
assert_eq!(recovered.state, first);
}
#[test]
fn rejects_legacy_empty_object_and_unknown_envelope_fields() {
let (directory, asset_id) = fixture();
@@ -1078,5 +1078,5 @@ game-project/
- UI 编辑器复用现有 manifest `kind: "UI"``mediaType: "application/json"` 资源,不增加平行 asset kind。资源文件固定为严格 `game-creator-ui-design-state.v1` JSON envelope`projectId``assetId`、每资源 `revision` 和 Rust 唯一源 `State`;旧空对象、未知字段、身份错配、超限、无效内部引用和不安全相对路径均失败关闭。
- Tauri 专用 load/save command 只接受项目路径、期望项目 ID、manifest asset ID 和(保存时)资源 revisionRust 按 manifest 解析受控本地路径并在项目写锁内做 CAS。相同 `State` 返回 unchanged 且不推进 project revision;不同内容安装并回读一致后才推进 revision,后续推进失败返回 `reconciliation-required`,不伪装为完整保存。
- UI State 原子安装保留最近一个有效 `.previous` 恢复副本。主文件损坏时仅在恢复副本通过同一严格校验后恢复;任一候选均不可信则停在加载错误,前端禁编辑和保存。新建 UI 资源先登记并安装合法 envelope,任一步失败补偿 manifest/文件,避免把空 JSON 留给资源卡。
- UI State 原子安装保留最近一个有效 `.previous` 恢复副本。主文件损坏时仅在恢复副本通过同一严格校验后恢复;恢复安装与保存共用项目写锁,并在持锁后重新读取主文件,已有并发保存的有效新版本时直接返回而不安装旧副本。任一候选均不可信则停在加载错误,前端禁编辑和保存。新建 UI 资源先登记并安装合法 envelope,任一步失败补偿 manifest/文件,避免把空 JSON 留给资源卡。
- 图片路径只需是安全项目相对路径,不要求外部图片仍存在或已登记为 manifest asset;缺失媒体只导致 preview 占位。`imageOrder`、当前选择、缩放、面板开关和 preview URL 不写入 State,加载后由 State 派生。保存冻结提交快照,保存期间的新编辑继续保持 dirty;AI state lock 和加载期间禁保存。