diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs index 26858f748..411c60057 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs @@ -1,5 +1,7 @@ use super::*; use std::collections::HashSet; +use std::fs::File; +use std::sync::{Mutex, OnceLock}; const RESOURCE_LAYOUT_DIRECTORY: &str = ".agent/workbench/resource-layouts"; const RESOURCE_LAYOUT_LOCK_PATH: &str = ".agent/workbench/resource-layouts/.layout.lock"; @@ -9,20 +11,12 @@ const RESOURCE_LAYOUT_MAX_RESOURCE_ID_CHARS: usize = 512; const RESOURCE_LAYOUT_MAX_COORDINATE: u32 = 1_000_000; const RESOURCE_LAYOUT_LOCK_WAIT_ATTEMPTS: usize = 100; const RESOURCE_LAYOUT_LOCK_WAIT_MILLIS: u64 = 10; -const RESOURCE_LAYOUT_LOCK_STALE_AFTER_SECONDS: u64 = 600; + +static RESOURCE_LAYOUT_LOCK_OPEN_GUARD: OnceLock> = OnceLock::new(); #[derive(Debug)] struct ResourceLayoutWriteLock { - path: PathBuf, - token: String, -} - -impl Drop for ResourceLayoutWriteLock { - fn drop(&mut self) { - if fs::read_to_string(&self.path).is_ok_and(|content| content == self.token) { - let _ = fs::remove_file(&self.path); - } - } + _file: File, } fn resource_layout_mode_name(mode: ProjectResourceCanvasLayoutMode) -> &'static str { @@ -39,85 +33,251 @@ fn resource_layout_relative_path(mode: ProjectResourceCanvasLayoutMode) -> Strin ) } -fn resource_layout_lock_is_stale(path: &Path) -> bool { - let Ok(metadata) = fs::symlink_metadata(path) else { - return false; - }; - if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.len() > 4096 { - return false; - } - metadata - .modified() - .ok() - .and_then(|modified| modified.elapsed().ok()) - .is_some_and(|elapsed| elapsed.as_secs() > RESOURCE_LAYOUT_LOCK_STALE_AFTER_SECONDS) -} - fn acquire_resource_layout_write_lock(root: &Path) -> Result { - validate_project_root(root)?; - let mut path = resolve_local_project_path(root, RESOURCE_LAYOUT_LOCK_PATH)?; - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("创建资源布局锁目录失败:{}: {error}", parent.display()))?; - } - path = resolve_local_project_path(root, RESOURCE_LAYOUT_LOCK_PATH)?; - let token = format!( - "{}:{}:{}", - std::process::id(), - unix_millis(), - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos() - ); - let mut reclaimed = false; for attempt in 0..RESOURCE_LAYOUT_LOCK_WAIT_ATTEMPTS { - let mut options = fs::OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.custom_flags(libc::O_NOFOLLOW); - options.mode(0o600); + if let Some(file) = try_open_resource_layout_write_lock_file(root)? { + return Ok(ResourceLayoutWriteLock { _file: file }); } - match options.open(&path) { - Ok(mut file) => { - file.write_all(token.as_bytes()) - .and_then(|_| file.sync_data()) - .map_err(|error| { - let _ = fs::remove_file(&path); - format!("写入资源布局锁失败:{}: {error}", path.display()) - })?; - return Ok(ResourceLayoutWriteLock { - path, - token: token.clone(), - }); - } - Err(error) - if error.kind() == std::io::ErrorKind::AlreadyExists - && !reclaimed - && resource_layout_lock_is_stale(&path) => - { - fs::remove_file(&path).map_err(|error| { - format!("清理失效资源布局锁失败:{}: {error}", path.display()) - })?; - reclaimed = true; - } - Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { - if attempt + 1 < RESOURCE_LAYOUT_LOCK_WAIT_ATTEMPTS { - std::thread::sleep(Duration::from_millis(RESOURCE_LAYOUT_LOCK_WAIT_MILLIS)); - continue; - } - return Err("资源布局正在被其他窗口保存,请稍后重试".to_string()); - } - Err(error) => { - return Err(format!("创建资源布局锁失败:{}: {error}", path.display())); - } + if attempt + 1 < RESOURCE_LAYOUT_LOCK_WAIT_ATTEMPTS { + std::thread::sleep(Duration::from_millis(RESOURCE_LAYOUT_LOCK_WAIT_MILLIS)); } } Err("资源布局正在被其他窗口保存,请稍后重试".to_string()) } +#[cfg(unix)] +fn try_open_resource_layout_write_lock_file(root: &Path) -> Result, String> { + use std::ffi::CString; + use std::os::fd::{AsRawFd, FromRawFd}; + use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt}; + + let _open_guard = RESOURCE_LAYOUT_LOCK_OPEN_GUARD + .get_or_init(|| Mutex::new(())) + .lock() + .map_err(|_| "资源布局锁安全打开门禁已损坏".to_string())?; + validate_project_root(root)?; + let relative_path = normalize_relative_path(RESOURCE_LAYOUT_LOCK_PATH)?; + let path = root.join(&relative_path); + let mut components = relative_path.split('/').collect::>(); + let file_name = components + .pop() + .ok_or_else(|| "资源布局锁路径缺少文件名".to_string())?; + let mut directory = fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_CLOEXEC | libc::O_DIRECTORY | libc::O_NOFOLLOW) + .open(root) + .map_err(|error| format!("安全打开项目目录失败:{}: {error}", root.display()))?; + for component in components { + let component = + CString::new(component).map_err(|_| "资源布局锁目录包含 NUL".to_string())?; + // SAFETY: `directory` is a live directory fd and `component` is NUL terminated. + let created = unsafe { libc::mkdirat(directory.as_raw_fd(), component.as_ptr(), 0o700) }; + if created != 0 { + let error = std::io::Error::last_os_error(); + if error.kind() != std::io::ErrorKind::AlreadyExists { + return Err(format!( + "创建资源布局锁目录失败:{}: {error}", + path.display() + )); + } + } + // SAFETY: `directory` and `component` remain valid for the duration of openat. + let fd = unsafe { + libc::openat( + directory.as_raw_fd(), + component.as_ptr(), + libc::O_RDONLY | libc::O_CLOEXEC | libc::O_DIRECTORY | libc::O_NOFOLLOW, + ) + }; + if fd < 0 { + return Err(format!( + "安全打开资源布局锁目录失败:{}: {}", + path.display(), + std::io::Error::last_os_error() + )); + } + // SAFETY: openat returned a new owned fd. + directory = unsafe { File::from_raw_fd(fd) }; + } + let file_name = CString::new(file_name).map_err(|_| "资源布局锁文件名包含 NUL".to_string())?; + // SAFETY: `directory` is a live directory fd and `file_name` is NUL terminated. + let fd = unsafe { + libc::openat( + directory.as_raw_fd(), + file_name.as_ptr(), + libc::O_RDWR | libc::O_CREAT | libc::O_CLOEXEC | libc::O_NOFOLLOW, + 0o600, + ) + }; + if fd < 0 { + return Err(format!( + "安全打开资源布局锁失败:{}: {}", + path.display(), + std::io::Error::last_os_error() + )); + } + // SAFETY: openat returned a new owned fd. + let file = unsafe { File::from_raw_fd(fd) }; + let metadata = file + .metadata() + .map_err(|error| format!("读取资源布局锁句柄元数据失败:{}: {error}", path.display()))?; + // SAFETY: geteuid takes no arguments and has no memory safety preconditions. + let effective_user_id = unsafe { libc::geteuid() }; + if !metadata.is_file() || metadata.uid() != effective_user_id || metadata.nlink() != 1 { + return Err(format!( + "资源布局锁必须是当前用户持有的无硬链接普通文件:{}", + path.display() + )); + } + file.set_permissions(fs::Permissions::from_mode(0o600)) + .map_err(|error| format!("收紧资源布局锁权限失败:{}: {error}", path.display()))?; + let path_metadata = fs::symlink_metadata(&path) + .map_err(|error| format!("复核资源布局锁路径失败:{}: {error}", path.display()))?; + if path_metadata.file_type().is_symlink() + || path_metadata.dev() != metadata.dev() + || path_metadata.ino() != metadata.ino() + { + return Err(format!( + "资源布局锁路径在安全打开期间发生替换:{}", + path.display() + )); + } + let verified = file + .metadata() + .map_err(|error| format!("复核资源布局锁句柄失败:{}: {error}", path.display()))?; + if verified.uid() != effective_user_id + || verified.nlink() != 1 + || verified.permissions().mode() & 0o777 != 0o600 + { + return Err(format!( + "资源布局锁必须由当前用户持有且权限为 0600:{}", + path.display() + )); + } + // SAFETY: flock observes only the live fd owned by `file`; dropping it releases the lock. + if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 { + return Ok(Some(file)); + } + let error = std::io::Error::last_os_error(); + if error.kind() == std::io::ErrorKind::WouldBlock { + Ok(None) + } else { + Err(format!( + "获取资源布局系统文件锁失败:{}: {error}", + path.display() + )) + } +} + +#[cfg(windows)] +fn try_open_resource_layout_write_lock_file(root: &Path) -> Result, String> { + use std::os::windows::fs::{MetadataExt, OpenOptionsExt}; + + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + const FILE_SHARE_READ_WRITE: u32 = 0x0000_0003; + + let _open_guard = RESOURCE_LAYOUT_LOCK_OPEN_GUARD + .get_or_init(|| Mutex::new(())) + .lock() + .map_err(|_| "资源布局锁安全打开门禁已损坏".to_string())?; + validate_project_root(root)?; + let relative_path = normalize_relative_path(RESOURCE_LAYOUT_LOCK_PATH)?; + let path = root.join(&relative_path); + let mut components = relative_path.split('/').collect::>(); + components + .pop() + .ok_or_else(|| "资源布局锁路径缺少文件名".to_string())?; + let mut guarded_directories = Vec::with_capacity(components.len() + 1); + let mut current = root.to_path_buf(); + for component in std::iter::once(None).chain(components.into_iter().map(Some)) { + if let Some(component) = component { + current.push(component); + if !current.exists() { + fs::create_dir(¤t).map_err(|error| { + format!("创建资源布局锁目录失败:{}: {error}", current.display()) + })?; + } + } + let metadata = fs::symlink_metadata(¤t).map_err(|error| { + format!( + "读取资源布局锁目录元数据失败:{}: {error}", + current.display() + ) + })?; + if !metadata.is_dir() || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(format!( + "资源布局锁目录必须是普通目录且不能是 reparse point:{}", + current.display() + )); + } + let handle = fs::OpenOptions::new() + .read(true) + .share_mode(FILE_SHARE_READ_WRITE) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) + .open(¤t) + .map_err(|error| { + format!("安全打开资源布局锁目录失败:{}: {error}", current.display()) + })?; + let opened = handle + .metadata() + .map_err(|error| format!("复核资源布局锁目录失败:{}: {error}", current.display()))?; + if !opened.is_dir() || opened.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(format!( + "资源布局锁目录打开后身份无效:{}", + current.display() + )); + } + guarded_directories.push(handle); + } + if let Ok(metadata) = fs::symlink_metadata(&path) { + if metadata.file_type().is_symlink() + || !metadata.is_file() + || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 + { + return Err(format!( + "资源布局锁必须是普通文件且不能是 reparse point:{}", + path.display() + )); + } + } + match fs::OpenOptions::new() + .create(true) + .read(true) + .write(true) + .share_mode(0) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) + .open(&path) + { + Ok(file) => { + validate_windows_regular_file_handle(&file, "资源布局锁")?; + crate::secure_windows_game_creator_path_for_current_user(&path, false, true)?; + Ok(Some(file)) + } + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::WouldBlock + ) => + { + Ok(None) + } + Err(error) => Err(format!( + "获取资源布局系统文件锁失败:{}: {error}", + path.display() + )), + } +} + +#[cfg(not(any(unix, windows)))] +fn try_open_resource_layout_write_lock_file(root: &Path) -> Result, String> { + Err(format!( + "当前平台不支持资源布局系统文件锁:{}", + root.join(RESOURCE_LAYOUT_LOCK_PATH).display() + )) +} + fn current_resource_layout_project_id(root: &Path) -> Result { validate_project_root(root)?; let manifest_path = resolve_local_project_path(root, ".agent/manifest.json")?; @@ -276,8 +436,12 @@ pub(crate) fn update_project_resource_canvas_layout_at( positions: Vec, ) -> Result { validate_resource_layout_positions(&positions)?; + let preflight_project_id = current_resource_layout_project_id(root)?; let _lock = acquire_resource_layout_write_lock(root)?; let project_id = current_resource_layout_project_id(root)?; + if project_id != preflight_project_id { + return Err("项目 manifest 在资源布局锁获取期间发生变化,请重试".to_string()); + } let relative_path = resource_layout_relative_path(mode); let current = match read_resource_layout_sidecar(root, &relative_path)? { Some(layout) => { @@ -379,6 +543,83 @@ mod tests { fs::remove_dir_all(root).ok(); } + #[test] + fn resource_layout_invalid_roots_have_zero_workbench_side_effects() { + let missing_root = unique_resource_layout_project_path(); + let missing_error = update_project_resource_canvas_layout_at( + &missing_root, + ProjectResourceCanvasLayoutMode::Dependency, + 0, + vec![layout_position("asset-a", 10)], + ) + .expect_err("missing project root must fail"); + assert!(missing_error.contains("manifest") || missing_error.contains("项目")); + assert!(!missing_root.exists()); + + let non_project_root = unique_resource_layout_project_path(); + fs::create_dir_all(&non_project_root).expect("create non-project root"); + update_project_resource_canvas_layout_at( + &non_project_root, + ProjectResourceCanvasLayoutMode::Dependency, + 0, + vec![layout_position("asset-a", 10)], + ) + .expect_err("non-project root must fail"); + assert!(!non_project_root.join(".agent").exists()); + fs::remove_dir_all(&non_project_root).ok(); + + let invalid_manifest_root = unique_resource_layout_project_path(); + let agent_directory = invalid_manifest_root.join(".agent"); + fs::create_dir_all(&agent_directory).expect("create invalid project agent directory"); + fs::write(agent_directory.join("manifest.json"), b"{invalid-json") + .expect("write invalid manifest"); + update_project_resource_canvas_layout_at( + &invalid_manifest_root, + ProjectResourceCanvasLayoutMode::Dependency, + 0, + vec![layout_position("asset-a", 10)], + ) + .expect_err("invalid project manifest must fail"); + assert!(!agent_directory.join("workbench").exists()); + fs::remove_dir_all(invalid_manifest_root).ok(); + } + + #[cfg(unix)] + #[test] + fn resource_layout_live_system_lock_is_not_reclaimed_from_old_mtime() { + use std::fs::FileTimes; + use std::os::unix::fs::MetadataExt; + + let root = unique_resource_layout_project_path(); + init_local_game_project_at(&root, "layout-system-lock", "布局系统锁") + .expect("init project"); + let first = acquire_resource_layout_write_lock(&root).expect("acquire first lock"); + let lock_path = root.join(RESOURCE_LAYOUT_LOCK_PATH); + let original = fs::symlink_metadata(&lock_path).expect("stat original lock"); + first + ._file + .set_times(FileTimes::new().set_modified(UNIX_EPOCH)) + .expect("age live lock mtime"); + + assert!(try_open_resource_layout_write_lock_file(&root) + .expect("try competing lock") + .is_none()); + let while_locked = fs::symlink_metadata(&lock_path).expect("stat live lock"); + assert_eq!(while_locked.dev(), original.dev()); + assert_eq!(while_locked.ino(), original.ino()); + + drop(first); + let second = try_open_resource_layout_write_lock_file(&root) + .expect("try released lock") + .expect("released system lock must be acquirable"); + let after_reacquire = fs::symlink_metadata(&lock_path).expect("stat reacquired lock"); + assert_eq!(after_reacquire.dev(), original.dev()); + assert_eq!(after_reacquire.ino(), original.ino()); + drop(second); + assert!(lock_path.exists()); + fs::remove_dir_all(root).ok(); + } + #[test] fn resource_layout_compare_and_swap_returns_latest_without_overwrite() { let root = unique_resource_layout_project_path(); diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index 62d133352..d329a1549 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -195,7 +195,7 @@ type ProjectResourceCanvasLayout = { - 文件名必须与 payload 的 `mode` 一致;payload 的 `projectId` 必须与当前 `.agent/manifest.json` 一致。 - 布局只属于工作台 UI 状态,不写入 manifest,不增加游戏项目 mutation revision,不使 Runtime verification 失效,不触发权限确认,也不作为 Agent 产物、资产或 Git 提交依据。 -- 写入复用项目安全路径解析、普通文件 / 链接校验和原子 JSON sidecar 安装能力;使用资源布局专用写锁串行化 read-check-write,不用前端进程内互斥替代跨窗口锁,也不长期占用 Agent Runtime 的项目 mutation 锁。 +- 写入复用项目安全路径解析、普通文件 / 链接校验和原子 JSON sidecar 安装能力;使用资源布局专用系统文件锁串行化 read-check-write,不用前端进程内互斥替代跨窗口锁,也不长期占用 Agent Runtime 的项目 mutation 锁。`.layout.lock` 是持久锁入口,Unix 互斥跟随 `flock` 文件描述符,Windows 互斥跟随不共享的文件句柄;进程退出由操作系统释放,应用不得按 mtime、PID 文本或其它 stale 启发式删除锁文件。 - 主文件损坏、schema 不支持、身份不匹配、文件超限或安全文件检查失败时必须失败关闭,不得把默认空布局覆盖到原文件。若原子安装留下可验证的恢复副本,读取时可按既有 sidecar 恢复规则恢复后再返回。 - 本切片不把布局同步到 `api-server`、SpacetimeDB、云端账号或其它设备。 @@ -226,7 +226,7 @@ type UpdateProjectResourceCanvasLayoutResult = ``` - 读取命令固定为 `read_local_project_resource_canvas_layout`,返回当前 mode 的完整布局;文件不存在时返回合成的 revision `0` 布局,不为只读操作创建目录或文件。 -- 更新命令固定为 `update_local_project_resource_canvas_layout`。调用方不提交 `projectId / revision / updatedAt` 的权威新值;Tauri 在锁内从 manifest 取得 `projectId`,重新读取当前布局,并生成新的 revision 与时间戳。 +- 更新命令固定为 `update_local_project_resource_canvas_layout`。调用方不提交 `projectId / revision / updatedAt` 的权威新值;Tauri 必须先只读确认项目存在且 manifest 有效,随后获取系统锁并在锁内复核 `projectId`、重新读取当前布局,再生成新的 revision 与时间戳。不存在目录、普通非项目目录或损坏 manifest 均不得先创建 `.agent/workbench`、锁文件或布局文件。 - `expectedRevision` 与锁内 revision 相同才允许原子写入并返回 `updated`;不同时不得写文件,返回 `conflict` 和锁内最新完整布局。前端不得通过解析错误字符串识别 CAS 冲突。 - 项目无效、布局损坏、字段校验失败和文件系统错误继续作为安全、可理解的 Tauri command error 返回;错误不得包含配置、凭据或项目外绝对路径。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 63cf6a2cc..720212e89 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -20,7 +20,7 @@ - 背景:项目开发工作台当前只在 React 会话内保存同分类资源的一维拖拽顺序,项目切换或客户端重启后重建默认排列;工作台 PRD 虽已给出二维位置字段,但缺少落盘路径、坐标系、Tauri API、CAS、异常与安全边界,仍不足以直接编码。 - 决策:dependency 与 type 两套布局分别保存为项目内 `.agent/workbench/resource-layouts/dependency.json` 和 `type.json`,统一使用 `game-creator-resource-layout.v1`。`x / y` 是 section 内容 CSS 像素,revision 从缺文件时的 `0` 单调递增;新资源首次默认放置,任何已有坐标不因排序、筛选、模式切换或 resize 被自动覆盖。 -- 并发与失败:Tauri 用 `read_local_project_resource_canvas_layout` 和 `update_local_project_resource_canvas_layout` 暴露读写,以 `projectId + mode + expectedRevision` 在专用跨窗口布局锁内做 CAS。前端以 project/path/mode epoch 丢弃旧 scope 迟到响应,资源变化不得取消首读或在途写;同一窗口的手动拖动与资源协调进入单写者 FIFO,后一笔只使用前一笔权威响应的 revision。冲突返回最新完整布局且零写入,前端载入最新值、丢弃基于旧快照排队的手动拖动并要求重新操作;资源协调最多追加两次冲突重试,普通失败恢复最近可信布局。写入复用项目安全路径、链接校验、容量上限、恢复副本与原子替换,损坏或身份冲突不能被空布局覆盖。 +- 并发与失败:Tauri 用 `read_local_project_resource_canvas_layout` 和 `update_local_project_resource_canvas_layout` 暴露读写,以 `projectId + mode + expectedRevision` 在专用跨窗口布局锁内做 CAS。锁入口文件持久存在,Unix 以 `flock` 文件描述符、Windows 以不共享句柄持有互斥;应用不按 mtime / PID 猜测 stale、不删除锁文件,进程退出由操作系统释放。更新在创建锁目录前只读验证 manifest,锁内复核 projectId;无效根保持零 workbench 副作用。前端以 project/path/mode epoch 丢弃旧 scope 迟到响应,资源变化不得取消首读或在途写;同一窗口的手动拖动与资源协调进入单写者 FIFO,后一笔只使用前一笔权威响应的 revision。冲突返回最新完整布局且零写入,前端载入最新值、丢弃基于旧快照排队的手动拖动并要求重新操作;资源协调最多追加两次冲突重试,普通失败恢复最近可信布局。写入复用项目安全路径、链接校验、容量上限、恢复副本与原子替换,损坏或身份冲突不能被空布局覆盖。 - 业务边界:布局是本地工作台 UI sidecar,不进入 manifest,不推进游戏项目 mutation revision,不使 Runtime verification 失效,不触发 Agent 权限,也不属于资产、Agent 产物、Git 或云端事实。本切片不包含关系线、资源替换、浮层位置、缩放 / 平移、搜索 / 筛选条件和当前 mode。 - 影响范围:`packages/shared` 与 Rust `shared-contracts` 的跨边界 DTO、AI 游戏创作 Tauri 项目持久层与命令、项目开发资源画布、定向 Rust / React 测试、工作台 PRD 和客户端实施计划。 - 验证方式:序列化与字段上限测试、缺文件 / 损坏 / 原子恢复 / 链接安全测试、同 revision 双写最多一个成功、两种 mode 跨重启独立恢复、新增资源不移动旧坐标、`1280×800` 横屏无页面级溢出,以及 `npm run agc:typecheck`、定向测试、`npm run check:encoding`、`git diff --check`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 047373c3f..f79db177e 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -3862,3 +3862,11 @@ - 根因:旧实现调用 `start_local_game_preview` 前就把“项目 + parent run”的授权加入 attempted 集合并清空;首版完成投影与后续专业任务仍在写项目时,启动恰逢项目写锁竞争,catch 只显示错误却无法重试。 - 约束:一次性语义应按“成功或确定性终态”消费,不按“函数调用次数”消费。项目写锁竞争保留同一授权并轮询重试;成功、显式 deny 与非瞬时失败才清除。授权需持久化项目路径和 accepted runId,重启恢复时仍必须逐项匹配,切换项目不得继承。 - 回归:AppSurface 模拟第一次 `start_local_game_preview` 返回 `项目正在被其他写操作占用`、第二次成功,断言最终渲染游戏区域且启动调用恰为两次;完整 AppSurface 仍需覆盖显式 deny、停止隐藏与项目切换隔离。 + +## 跨窗口 CAS 锁不能用 mtime stale 删除模拟系统互斥(2026-07-30) + +- 现象:两个窗口基于同一 revision 保存资源布局时,正常测试看似只有一个成功;锁文件超过 stale 阈值或两个竞争者同时判断过期时,却可能各自删除 / 重建锁并同时进入 read-check-write,击穿“同 revision 最多一个成功”。无效绝对路径还会在 manifest 报错前遗留 `.agent/workbench/resource-layouts`。 +- 原因:`create_new` 只保证某一时刻创建文件原子,不保证“判断过期 → 删除 → 重建”整体原子;mtime 不能证明 owner 已退出,token 文本也不能阻止另一个竞争者删除新锁。先获取锁再读 manifest 又把目录创建副作用提前到了项目身份验证之前。 +- 处理:锁文件作为持久入口永不由应用删除;Unix 用文件描述符持有 `flock(LOCK_EX | LOCK_NB)`,Windows 用 `share_mode(0)` 独占句柄,Drop / 进程退出让操作系统释放锁。安全打开逐级拒绝符号链接 / reparse point,Unix 还核对 owner、硬链接数、inode 和 `0600`。更新先只读验证 manifest,再获取系统锁并在锁内复核 projectId;不存在根、非项目根和损坏 manifest 都不能创建 workbench。 +- 验证:必须覆盖活锁 mtime 被设为 epoch 后竞争者仍拿不到锁、释放后同一 inode 可重新获取、同 revision 并发双写仍恰好一个 updated / 一个 conflict,以及三类无效根零 workbench 副作用。锁等待超时只能返回可重试错误,不得转为 stale 删除。 +- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs`、`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 27ba73903..d6acf9ea5 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -336,7 +336,7 @@ game-project/ - dependency 与 type 分别保存到 `.agent/workbench/resource-layouts/dependency.json` 和 `type.json`,schema 固定为 `game-creator-resource-layout.v1`。布局是本地工作台 UI sidecar,不进入 manifest、游戏项目 mutation revision、Runtime verification、Agent 产物、资产或云端事实。 - `x / y` 使用 section 内容坐标,`updatedAt` 使用 Unix 毫秒;文件缺失只合成 revision `0` 空布局且不产生只读副作用。每个 mode 按 `projectId + mode + expectedRevision` 做 CAS,成功 revision 加一,冲突返回最新完整布局且不写文件。 -- Tauri 命令固定为 `read_local_project_resource_canvas_layout` 与 `update_local_project_resource_canvas_layout`。写命令在资源布局专用跨窗口锁内重新读取 manifest 和当前 sidecar,复用安全路径、链接检查、容量上限、恢复副本与原子替换能力;不能只依赖 React 状态或进程内锁。 +- Tauri 命令固定为 `read_local_project_resource_canvas_layout` 与 `update_local_project_resource_canvas_layout`。写命令先只读确认有效 manifest,再通过持久 `.layout.lock` 入口获取句柄级跨窗口系统锁,并在锁内复核 projectId、重新读取当前 sidecar。Unix 使用 `flock`,Windows 使用不共享文件句柄;释放只通过句柄 Drop / 进程退出完成,不使用 mtime stale 回收,也不删除锁文件。其余写入继续复用安全路径、链接检查、容量上限、恢复副本与原子替换能力;不能只依赖 React 状态或进程内锁。 - 前端从当前项目开发大组件中拆出纯布局模型与持久 Hook。默认布局、碰撞检查、资源增删协调和 section 边界由纯模型负责;读取、异步身份、CAS、错误回滚和冲突载入由 Hook 负责。Hook 以 `projectPath + projectId + mode` epoch 隔离异步结果,资源变化不取消首读或在途保存;单窗口写入经同一 FIFO 串行提交,每笔都使用最近一次成功 / 冲突响应的权威 revision。视图使用 Pointer Events 做二维拖动,保存中仍允许继续拖动并排队,普通点击、搜索、筛选和唯一资源详情浮层语义保持不变。 - 新资源只在第一次进入某个 mode 时计算默认不重叠位置;全部现存坐标保持不变。搜索、筛选、窗口 resize 和 mode 切换不得重排或回写已有坐标,窄视图通过 section 画布范围与滚动访问,不裁切持久坐标。 - 普通保存失败恢复最近可信持久布局;CAS 冲突载入对方最新布局并要求用户重新拖动,同时清除基于旧快照排队的全部手动意图,不自动重放旧坐标。资源自动协调可基于冲突布局最多追加两次重试,持续跨窗口竞争时停止自旋并保留当前会话协调结果。损坏、未知 schema、身份冲突、超限与链接文件失败关闭,不能用空布局覆盖原文件。 @@ -346,6 +346,8 @@ game-project/ 2026-07-30 前端并发与性能加固状态:首读、资源更新和拖动保存已拆成 scope epoch + 单写者 FIFO;定向 Hook 测试覆盖首读期间资源变化、在途手动 CAS 后资源协调、冲突清除排队拖动、旧 mode 迟到读取 / 写入、新 scope 队列唤醒和持续冲突有界停止。默认布局用 section 分组与二维占用索引替代逐 slot 全量扫描,dependency 使用按列单调游标,type 使用单调 slot 游标;`4096` 项双模式性能回归纳入前端测试,避免恢复到接近 `O(N³)` 的主线程阻塞实现。 +2026-07-30 Rust 并发与零副作用加固状态:资源布局锁已由 `create_new + mtime stale 删除` 改为持久锁文件上的 Unix `flock` / Windows 独占句柄,活锁即使 mtime 很旧也不能被另一个写入者回收,释放后仍复用同一文件实例。更新命令在任何目录创建前先读取 manifest,锁内再次核对 projectId;不存在根、非项目根和损坏 manifest 的回归测试均确认不产生 `.agent/workbench`。 + ## 分阶段实施 1. 在 `platform-agent` 建立游戏创作专业组与种子任务图契约。