修复 UI 状态恢复并发覆盖
恢复安装与项目保存共用写锁并在持锁后复查主文件 新增恢复写锁竞争回归测试 同步 UI State 持久化技术方案
This commit is contained in:
@@ -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(¤t, &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 和(保存时)资源 revision;Rust 按 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 和加载期间禁保存。
|
||||
|
||||
Reference in New Issue
Block a user