From c0ef876b14d39db9fbc302dc9d9cc9c3730cac40 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Wed, 9 Sep 2026 19:44:46 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20AGC=20=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E5=86=99=E9=94=81=E5=9B=9E=E6=94=B6=E7=9A=84=E5=B9=B6=E5=8F=91?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E7=AB=9E=E4=BA=89=E4=B8=8E=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E8=AF=8A=E6=96=AD=E6=AD=BB=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 锁文件回收改为单次快照解析:payload 只解析一次,删除前重新核对字节,内容已被并发方替换或文件已消失时返回 false 并重试 create_new,不再无条件 unlink,也不再把 remove_file 的 NotFound 当成硬失败 - mtime 读取失败改用 Option 区分“未知”与“纪元 0”:未知年龄不回收,未知锁创建时间不做 PID 复用推断,避免把保守判定反转成抢走活持有者 - 新增并发替换/已消失、旧格式活持有者、存活未知、mtime 未知四条回归用例 - 启动日志槽优先用已经生效的配置目录(含 --config-dir),否则退到平台配置根(APPDATA / Application Support / XDG_CONFIG_HOME) - 日志路径未知时 StartupLogSlot::fail 仍然给出用户可见提示,四处 inspect_err 不再被路径判空挡掉 - 同步 check-config 守卫、decision-log、pitfalls 与技术方案文档的用例数与新判据 --- .../scripts/check-config.mjs | 5 +- .../src-tauri/src/main.rs | 170 ++++++++++----- .../src-tauri/src/project/filesystem.rs | 203 +++++++++++------- .../src/tests/project_lock_recovery.rs | 144 ++++++++++++- .../shared-memory/decision-log.md | 7 +- docs/project-memory/shared-memory/pitfalls.md | 8 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 7 +- 7 files changed, 398 insertions(+), 146 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index fb115058b..f2c19ac58 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -1711,7 +1711,10 @@ if ( ) || !tauriHandlerSource.includes('impl StartupLogSlot {') || !tauriHandlerSource.includes('append_bounded_diagnostic_line(&path, line)') || - !tauriHandlerSource.includes('show_startup_error_dialog(&path)') + !tauriHandlerSource.includes( + 'self.append(line);\n show_startup_error_dialog(self.path().as_deref());', + ) || + !tauriHandlerSource.includes('early_startup_log_path(') ) { throw new Error( 'AI game creator setup must configure the runtime AppData directory and log sanitized setup failures', diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index bc46a3074..de5353beb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -1999,9 +1999,10 @@ pub(crate) fn sanitize_diagnostic_message(value: &str, private_root: Option<&Pat } /// 启动阶段的致命失败必须让用户看得见:release 双击启动时 stderr 不可见,只写日志 -/// 等于什么都没发生。Windows 用系统消息框,其它平台退化为 stderr。 +/// 等于什么都没发生。Windows 用系统消息框,其它平台退化为 stderr。日志路径尚未 +/// 确定时仍然要提示,只是不给路径。 #[cfg(windows)] -fn show_startup_error_dialog(log_path: &Path) { +fn show_startup_error_dialog(log_path: Option<&Path>) { use std::os::windows::ffi::OsStrExt; use windows_sys::Win32::UI::WindowsAndMessaging::{ MessageBoxW, MB_ICONERROR, MB_OK, MB_SETFOREGROUND, @@ -2014,10 +2015,15 @@ fn show_startup_error_dialog(log_path: &Path) { .encode_wide() .chain(Some(0)) .collect::>(); - let message_text = format!( - "应用启动失败,请把下面的诊断日志发给开发人员:\n{}", - log_path.display() - ); + let message_text = match log_path { + Some(log_path) => format!( + "应用启动失败,请把下面的诊断日志发给开发人员:\n{}", + log_path.display() + ), + None => { + "应用启动失败,诊断日志路径尚未确定;请把这条提示和复现步骤发给开发人员。".to_string() + } + }; let message = std::ffi::OsStr::new(&message_text) .encode_wide() .chain(Some(0)) @@ -2034,14 +2040,56 @@ fn show_startup_error_dialog(log_path: &Path) { } #[cfg(not(windows))] -fn show_startup_error_dialog(log_path: &Path) { +fn show_startup_error_dialog(log_path: Option<&Path>) { if STARTUP_ERROR_DIALOG_SHOWN.swap(true, std::sync::atomic::Ordering::AcqRel) { return; } - eprintln!( - "Genarrative AI Game Creator startup failed; see {}", - log_path.display() - ); + match log_path { + Some(log_path) => eprintln!( + "Genarrative AI Game Creator startup failed; see {}", + log_path.display() + ), + None => eprintln!( + "Genarrative AI Game Creator startup failed before the diagnostics log path was known" + ), + } +} + +/// 配置目录就绪前的启动日志路径:优先用已经生效的配置目录(例如 `--config-dir` +/// 已经设置好的目录),否则退到平台配置根。两者都不可用时返回 `None`,此时 +/// `StartupLogSlot::fail` 仍然必须给出用户可见提示。 +fn early_startup_log_path(identifier: &str) -> Option { + let configured_dir = game_creator_runtime_config_dir(); + resolve_early_startup_log_path(configured_dir.as_deref(), identifier) +} + +fn resolve_early_startup_log_path( + configured_dir: Option<&Path>, + identifier: &str, +) -> Option { + match configured_dir { + Some(directory) => Some(directory.join("diagnostics/startup.log")), + None => { + platform_config_root().map(|root| root.join(identifier).join("diagnostics/startup.log")) + } + } +} + +#[cfg(windows)] +fn platform_config_root() -> Option { + std::env::var_os("APPDATA").map(PathBuf::from) +} + +#[cfg(target_os = "macos")] +fn platform_config_root() -> Option { + std::env::var_os("HOME").map(|home| PathBuf::from(home).join("Library/Application Support")) +} + +#[cfg(not(any(windows, target_os = "macos")))] +fn platform_config_root() -> Option { + std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config"))) } /// 启动诊断日志槽位。`configure_game_creator_runtime_config_dir` 之前只能退回按 @@ -2074,12 +2122,11 @@ impl StartupLogSlot { } } - /// 启动阶段的致命失败:先落盘,再给出用户可见提示。 + /// 启动阶段的致命失败:先落盘,再给出用户可见提示。日志路径未知时仍然要 + /// 提示,否则早期失败依旧表现为“双击没反应”。 fn fail(&self, line: &str) { self.append(line); - if let Some(path) = self.path() { - show_startup_error_dialog(&path); - } + show_startup_error_dialog(self.path().as_deref()); } } @@ -2407,17 +2454,12 @@ fn main() { } let mut tauri_context = tauri::generate_context!(); - // 配置目录确定之前先按标识符推导 APPDATA 下的日志路径,确定后再切到真实配置 - // 目录,保证 `configure_game_creator_runtime_config_dir` 自身失败也有落点。 - let startup_log = Arc::new(StartupLogSlot::new( - std::env::var_os("APPDATA") - .map(PathBuf::from) - .map(|appdata| { - appdata - .join(tauri_context.config().identifier.as_str()) - .join("diagnostics/startup.log") - }), - )); + // 配置目录确定之前先推导启动日志路径:优先用已经生效的配置目录(例如 + // `--config-dir`),否则退到平台配置根,保证 + // `configure_game_creator_runtime_config_dir` 自身失败也有落点。 + let startup_log = Arc::new(StartupLogSlot::new(early_startup_log_path( + tauri_context.config().identifier.as_str(), + ))); let setup_log = Arc::clone(&startup_log); let app = tauri::Builder::default() .plugin(tauri_plugin_opener::init()) @@ -2432,12 +2474,13 @@ fn main() { setup_log.append("startup.setup.begin"); setup_log.append("startup.appdata.configure.begin"); configure_game_creator_runtime_config_dir(app.handle()).inspect_err(|error| { - if let Some(path) = setup_log.path() { - let details = sanitize_diagnostic_message(&error.to_string(), path.parent()); - setup_log.fail(&format!( - "startup.appdata.configure.failed details={details}" - )); - } + // 日志路径未知也必须记录并提示:不能因为拿不到路径就静默失败。 + let config_dir = game_creator_runtime_config_dir(); + let details = + sanitize_diagnostic_message(&error.to_string(), config_dir.as_deref()); + setup_log.fail(&format!( + "startup.appdata.configure.failed details={details}" + )); })?; if let Some(directory) = game_creator_runtime_config_dir() { setup_log.set(directory.join("diagnostics/startup.log")); @@ -2460,13 +2503,10 @@ fn main() { setup_log.append("startup.runner.configure.begin"); configure_external_agent_runner(&config_dir) .inspect_err(|error| { - if let Some(path) = setup_log.path() { - let details = - sanitize_diagnostic_message(error, Some(config_dir.as_path())); - setup_log.fail(&format!( - "startup.runner.configure.failed details={details}" - )); - } + let details = sanitize_diagnostic_message(error, Some(config_dir.as_path())); + setup_log.fail(&format!( + "startup.runner.configure.failed details={details}" + )); }) .map_err(|error| { std::io::Error::new( @@ -2477,13 +2517,10 @@ fn main() { setup_log.append("startup.runner.configure.complete"); let gui_owner_lock = acquire_external_agent_runner_gui_owner_lock(&config_dir) .inspect_err(|error| { - if let Some(path) = setup_log.path() { - let details = - sanitize_diagnostic_message(error, Some(config_dir.as_path())); - setup_log.fail(&format!( - "startup.runner.owner-lock.failed details={details}" - )); - } + let details = sanitize_diagnostic_message(error, Some(config_dir.as_path())); + setup_log.fail(&format!( + "startup.runner.owner-lock.failed details={details}" + )); }) .map_err(|error| { std::io::Error::new( @@ -2499,13 +2536,10 @@ fn main() { start_game_creator_manifest_invalidation_event_sink(app.handle().clone())?; attach_external_agent_runner_gui_owner(&manifest_event_sink, &gui_owner_epoch) .inspect_err(|error| { - if let Some(path) = setup_log.path() { - let details = - sanitize_diagnostic_message(error, Some(config_dir.as_path())); - setup_log.fail(&format!( - "startup.runner.attach-owner.failed details={details}" - )); - } + let details = sanitize_diagnostic_message(error, Some(config_dir.as_path())); + setup_log.fail(&format!( + "startup.runner.attach-owner.failed details={details}" + )); }) .map_err(|error| { std::io::Error::new( @@ -2734,6 +2768,36 @@ mod diagnostic_log_tests { assert!(content.contains("startup.appdata.configure.complete")); } + #[test] + fn early_startup_log_path_prefers_the_already_applied_config_dir() { + let directory = tempfile::tempdir().expect("create config directory"); + assert_eq!( + resolve_early_startup_log_path(Some(directory.path()), "world.genarrative.test"), + Some(directory.path().join("diagnostics/startup.log")) + ); + + // 配置目录尚未生效时才退到平台配置根,且仍要按标识符分层。 + if let Some(fallback) = resolve_early_startup_log_path(None, "world.genarrative.test") { + assert!(fallback.ends_with("world.genarrative.test/diagnostics/startup.log")); + } + } + + #[test] + fn startup_log_slot_fail_without_path_still_reports_instead_of_going_silent() { + let directory = tempfile::tempdir().expect("create diagnostics directory"); + let slot = StartupLogSlot::new(None); + + // 路径未知时 fail 不能静默:它必须仍然走到用户可见提示,同时不造日志文件。 + slot.fail("startup.runner.owner-lock.failed details=test"); + assert_eq!(fs::read_dir(directory.path()).expect("read dir").count(), 0); + + let path = directory.path().join("startup.log"); + let slot = StartupLogSlot::new(Some(path.clone())); + slot.fail("startup.runner.owner-lock.failed details=test"); + let content = fs::read_to_string(&path).expect("read startup log"); + assert!(content.contains("startup.runner.owner-lock.failed details=test")); + } + #[test] fn diagnostic_message_redacts_sensitive_values_and_absolute_paths() { assert_eq!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs index 952ebf29b..02f3c83a0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs @@ -202,81 +202,92 @@ pub(crate) fn project_write_lock_process_start_time_seconds(_process_id: u64) -> None } -fn project_write_lock_owner_pid(path: &Path) -> Option { - fs::read_to_string(path) - .ok() - .and_then(|content| serde_json::from_str::(&content).ok()) - .and_then(|payload| payload.get("pid").and_then(serde_json::Value::as_u64)) +/// 一次读到的锁文件字节与解析结果。回收判据和随后的删除必须基于同一份快照: +/// 分别重读 `pid` / `createdAt` / `processStartedAt` 会把旧 inode 的持有者信息 +/// 和新 inode 的启动身份拼在一起,也会让判定与删除命中不同的文件。 +#[derive(Debug, Clone)] +pub(crate) struct ProjectWriteLockSnapshot { + content: Vec, + pid: Option, + created_at: Option, + process_started_at: Option, } -fn project_write_lock_owner_created_at(path: &Path) -> Option { - fs::read_to_string(path) - .ok() - .and_then(|content| serde_json::from_str::(&content).ok()) - .and_then(|payload| payload.get("createdAt").and_then(serde_json::Value::as_u64)) -} - -fn project_write_lock_owner_started_at(path: &Path) -> Option { - fs::read_to_string(path) - .ok() - .and_then(|content| serde_json::from_str::(&content).ok()) - .and_then(|payload| { +impl ProjectWriteLockSnapshot { + pub(crate) fn read(path: &Path) -> Option { + let content = fs::read(path).ok()?; + let payload = serde_json::from_slice::(&content).ok(); + let number = |key: &str| { payload - .get("processStartedAt") + .as_ref() + .and_then(|payload| payload.get(key)) .and_then(serde_json::Value::as_u64) + }; + Some(Self { + pid: number("pid"), + created_at: number("createdAt"), + process_started_at: number("processStartedAt"), + content, }) + } } fn project_write_lock_is_owned_by_current_process(path: &Path) -> bool { - project_write_lock_owner_pid(path) == Some(u64::from(std::process::id())) + ProjectWriteLockSnapshot::read(path).and_then(|snapshot| snapshot.pid) + == Some(u64::from(std::process::id())) } -fn project_write_lock_file_modified_seconds(metadata: &fs::Metadata) -> u64 { +/// 读取锁文件 mtime 的 Unix 秒数;读不到时返回 `None`。调用方必须把“mtime 未知” +/// 和“mtime 等于纪元 0”区分开:后者会被算成极大的年龄,反而把保守判定反转成 +/// “立刻回收”,甚至把活持有者的锁当成 PID 复用抢走。 +fn project_write_lock_file_modified_seconds(metadata: &fs::Metadata) -> Option { metadata .modified() .ok() .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok()) .map(|duration| duration.as_secs()) - .unwrap_or_default() } -fn project_write_lock_age_seconds(path: &Path, metadata: &fs::Metadata) -> u64 { - if let Some(created_at) = project_write_lock_owner_created_at(path) { - return unix_timestamp().saturating_sub(created_at); +/// 锁文件年龄(秒)。`createdAt` 与 mtime 都无法确定时返回 `None`:未知年龄只能 +/// 按“不回收”处理,不能退化成 0 或极大值。 +fn project_write_lock_age_seconds( + snapshot: &ProjectWriteLockSnapshot, + modified_at: Option, + now: u64, +) -> Option { + if let Some(created_at) = snapshot.created_at { + return Some(now.saturating_sub(created_at)); } - let modified_at = project_write_lock_file_modified_seconds(metadata); - unix_timestamp().saturating_sub(modified_at) + modified_at.map(|modified_at| now.saturating_sub(modified_at)) } -fn project_write_lock_can_be_reclaimed(path: &Path) -> bool { - let Ok(metadata) = fs::symlink_metadata(path) else { - return false; +/// 回收判据。进程存活与启动时间查询作为参数传入,便于用确定性用例覆盖真实进程 +/// 难以构造的分支(存活状态无法判定、mtime 不可读)。 +pub(crate) fn project_write_lock_reclaim_decision( + snapshot: &ProjectWriteLockSnapshot, + modified_at: Option, + now: u64, + process_is_alive: impl Fn(u64) -> Option, + process_started_at: impl Fn(u64) -> Option, +) -> bool { + let Some(owner_pid) = snapshot.pid else { + // 没有可用的持有者信息(空锁、坏锁、无数字 pid 的锁):只按短宽限期回收。 + return project_write_lock_age_seconds(snapshot, modified_at, now) + .is_some_and(|age| age > PROJECT_WRITE_LOCK_UNWRITTEN_GRACE_SECONDS); }; - if metadata.file_type().is_symlink() - || windows_metadata_is_reparse_point(&metadata) - || !metadata.is_file() - || metadata.len() > PROJECT_WRITE_LOCK_MAX_BYTES - { - return false; - } - let created_at = project_write_lock_owner_created_at(path); - let Some(owner_pid) = project_write_lock_owner_pid(path) else { - // 没有任何持有者信息:只可能是崩溃在落盘 payload 之前留下的空锁或坏锁。 - return project_write_lock_age_seconds(path, &metadata) - > PROJECT_WRITE_LOCK_UNWRITTEN_GRACE_SECONDS; - }; - match project_write_lock_process_is_alive(owner_pid) { + match process_is_alive(owner_pid) { Some(false) => true, Some(true) => { // PID 会被系统复用,必须确认当前同名进程就是当时的持有者。 - let actual_started_at = project_write_lock_process_start_time_seconds(owner_pid); - match (project_write_lock_owner_started_at(path), actual_started_at) { + match (snapshot.process_started_at, process_started_at(owner_pid)) { // 新锁自带启动身份:同一进程的身份恒定,不一致即为 PID 复用。 (Some(stored), Some(actual)) => stored != actual, - // 旧锁没有启动身份,只能用“启动时间晚于锁创建时间”推断 PID 复用。 + // 旧锁没有启动身份,只能用“启动时间晚于锁创建时间”推断 PID 复用; + // 锁创建时间未知时不做推断,避免把“未知”当成“复用”抢走活持有者。 (None, Some(actual)) => { - let lock_created_at = created_at - .unwrap_or_else(|| project_write_lock_file_modified_seconds(&metadata)); + let Some(lock_created_at) = snapshot.created_at.or(modified_at) else { + return false; + }; actual > lock_created_at .saturating_add(PROJECT_WRITE_LOCK_PID_REUSE_TOLERANCE_SECONDS) @@ -284,11 +295,53 @@ fn project_write_lock_can_be_reclaimed(path: &Path) -> bool { _ => false, } } - // 无法判定持有者是否存活时保持原有保守策略:只有明显过期才回收。 - None => { - project_write_lock_age_seconds(path, &metadata) > PROJECT_WRITE_LOCK_STALE_AFTER_SECONDS + // 无法判定持有者是否存活时保持保守策略:只有明显过期才回收。 + None => project_write_lock_age_seconds(snapshot, modified_at, now) + .is_some_and(|age| age > PROJECT_WRITE_LOCK_STALE_AFTER_SECONDS), + } +} + +/// 判定残留锁可回收时返回判定所依据的快照,否则返回 `None`。 +fn project_write_lock_reclaimable_snapshot(path: &Path) -> Option { + let metadata = fs::symlink_metadata(path).ok()?; + if metadata.file_type().is_symlink() + || windows_metadata_is_reparse_point(&metadata) + || !metadata.is_file() + || metadata.len() > PROJECT_WRITE_LOCK_MAX_BYTES + { + return None; + } + let snapshot = ProjectWriteLockSnapshot::read(path)?; + project_write_lock_reclaim_decision( + &snapshot, + project_write_lock_file_modified_seconds(&metadata), + unix_timestamp(), + project_write_lock_process_is_alive, + project_write_lock_process_start_time_seconds, + ) + .then_some(snapshot) +} + +/// 删除判定为残留的锁文件。判定只是快照观察,删除前必须重新核对字节,确认删掉的 +/// 仍是判定时的那个文件:并发方可能已经回收并装上了自己的活锁。文件已经消失或 +/// 已被替换时返回 `false`,让调用方重试 `create_new` 重新竞争,而不是报错。 +pub(crate) fn project_write_lock_reclaim( + path: &Path, + snapshot: &ProjectWriteLockSnapshot, +) -> Result { + match fs::read(path) { + Ok(content) if content == snapshot.content => {} + Ok(_) => return Ok(false), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => { + return Err(format!("读取失效项目写锁失败:{}: {error}", path.display())); } } + match fs::remove_file(path) { + Ok(()) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(format!("清理失效项目写锁失败:{}: {error}", path.display())), + } } fn project_write_lock_open_error_is_contention(error: &std::io::Error) -> bool { @@ -420,33 +473,29 @@ pub(crate) fn acquire_project_write_lock( bypassed_same_process: false, }); } - Err(error) - if project_write_lock_open_error_is_contention(&error) - && !retried_after_reclaim - && project_write_lock_can_be_reclaimed(&path) => - { - fs::remove_file(&path).map_err(|error| { - format!("清理失效项目写锁失败:{}: {error}", path.display()) - })?; - retried_after_reclaim = true; - } - Err(error) - if project_write_lock_open_error_is_contention(&error) - && crate::agent::autonomous_game_build_root_run_active_at(root) - && project_write_lock_is_owned_by_current_process(&path) => - { - // The autonomous game-build lane intentionally permits - // parallel specialist actions. If the durable lock belongs - // to this very process, contention is an in-process overlap, - // not another application editing the project. Return an - // advisory guard and leave the real lock untouched. - return Ok(ProjectWriteLock { - path, - content: String::new(), - bypassed_same_process: true, - }); - } Err(error) if project_write_lock_open_error_is_contention(&error) => { + if !retried_after_reclaim { + if let Some(snapshot) = project_write_lock_reclaimable_snapshot(&path) { + if project_write_lock_reclaim(&path, &snapshot)? { + retried_after_reclaim = true; + continue; + } + } + } + if crate::agent::autonomous_game_build_root_run_active_at(root) + && project_write_lock_is_owned_by_current_process(&path) + { + // The autonomous game-build lane intentionally permits + // parallel specialist actions. If the durable lock belongs + // to this very process, contention is an in-process overlap, + // not another application editing the project. Return an + // advisory guard and leave the real lock untouched. + return Ok(ProjectWriteLock { + path, + content: String::new(), + bypassed_same_process: true, + }); + } return Err(format!("项目正在被其他写操作占用:{}", path.display())); } Err(error) => { diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project_lock_recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project_lock_recovery.rs index d912659bd..9f7dc6541 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project_lock_recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project_lock_recovery.rs @@ -37,6 +37,13 @@ fn backdate_project_lock_fixture(root: &Path, seconds: u64) { .expect("回拨项目写锁 fixture mtime"); } +/// 写入锁 fixture 并读回同一份快照,供纯判据用例使用。 +fn project_lock_snapshot(root: &Path, content: &[u8]) -> ProjectWriteLockSnapshot { + write_project_lock_fixture(root, content); + ProjectWriteLockSnapshot::read(&root.join(PROJECT_LOCK_RELATIVE_PATH)) + .expect("读取项目写锁快照") +} + #[cfg(windows)] fn spawn_unrelated_live_process() -> std::process::Child { std::process::Command::new("ping") @@ -82,9 +89,9 @@ fn project_write_lock_reclaims_dead_owner_pid() { fs::remove_dir_all(root).ok(); } -/// 复现 A0:锁文件被外部改写或截断损坏,PID 超出平台进程号空间(例如 -/// u64::MAX)。它不可能属于任何活进程,必须直接回收,而不是让项目再等满 -/// 600 秒;Unix 的 pid_t 只有有符号 32 位,越界取值尤其容易在这里被漏掉。 +/// 锁文件被外部改写或截断损坏、PID 超出平台进程号空间(例如 u64::MAX)时,它不 +/// 可能属于任何活进程,必须直接回收而不是再等满 600 秒;Unix 的 pid_t 只有有符号 +/// 32 位,越界取值尤其容易在这里被漏掉。 #[test] fn project_write_lock_reclaims_unrepresentable_owner_pid() { let root = unique_project_path(); @@ -104,8 +111,8 @@ fn project_write_lock_reclaims_unrepresentable_owner_pid() { fs::remove_dir_all(root).ok(); } -/// 复现 A:崩溃发生在 create_new 成功、payload 写盘之前,留下 0 字节锁。 -/// 现在要等满 600 秒才会回收,重启后 10 分钟内所有写操作都失败。 +/// 空锁超过 30 秒宽限期即回收:崩溃停在 `create_new` 与落盘 payload 之间时写入方 +/// 已经放弃,不能继续阻塞项目。 #[test] fn project_write_lock_reclaims_empty_body_after_short_grace() { let root = unique_project_path(); @@ -123,8 +130,8 @@ fn project_write_lock_reclaims_empty_body_after_short_grace() { fs::remove_dir_all(root).ok(); } -/// 复现 B:崩溃后 Windows 把同一个 PID 复用给了另一个无关进程。 -/// 只要那个进程还活着,残留锁就永远不会被回收。 +/// 旧格式锁(无 `processStartedAt`)记录的 PID 已被复用给另一个活进程时,按“进程 +/// 启动时间晚于锁创建时间”推断原持有者已退出并回收。 #[cfg(any(windows, target_os = "linux"))] #[test] fn project_write_lock_reclaims_pid_reused_by_other_live_process() { @@ -150,8 +157,8 @@ fn project_write_lock_reclaims_pid_reused_by_other_live_process() { fs::remove_dir_all(root).ok(); } -/// 复现 C:新锁自带进程启动身份。即使时间戳看起来“刚刚写过”,只要身份对不上 -/// 就说明是 PID 复用,必须回收;这条路径不依赖系统时钟是否发生跳变。 +/// 新格式锁自带进程启动身份:即使时间戳看起来“刚刚写过”,只要身份对不上就判定 +/// PID 复用并回收;这条路径不依赖系统时钟是否发生跳变。 #[cfg(any(windows, target_os = "linux"))] #[test] fn project_write_lock_reclaims_pid_reused_identity_mismatch() { @@ -230,3 +237,122 @@ fn project_write_lock_keeps_fresh_empty_body() { ); fs::remove_dir_all(root).ok(); } + +/// 护栏:旧格式锁(有 `pid` 和 `createdAt`、没有 `processStartedAt`)的持有者确实 +/// 活着,且进程启动时间早于锁创建时间时,不能被当成 PID 复用抢走。 +#[cfg(any(windows, target_os = "linux"))] +#[test] +fn project_write_lock_keeps_live_old_format_holder_started_before_created_at() { + let root = unique_project_path(); + init_local_game_project_at(&root, "lock-old-format-live", "锁回收-旧格式活持有者") + .expect("初始化项目"); + let unrelated = spawn_unrelated_live_process(); + let started_at = project_write_lock_process_start_time_seconds(u64::from(unrelated.id())) + .expect("读取活进程启动时间"); + // 锁是在持有者启动之后才写下的,所以进程启动时间自然早于 createdAt。 + write_project_lock_fixture( + &root, + &project_lock_fixture_payload(u64::from(unrelated.id()), started_at + 60), + ); + + let error = acquire_project_write_lock(&root, "repro.acquire-concurrent"); + stop_unrelated_live_process(unrelated); + assert!( + matches!(&error, Err(message) if message.contains("项目正在被其他写操作占用")), + "旧格式活持有者不能被当成 PID 复用,实际结果:{error:?}" + ); + fs::remove_dir_all(root).ok(); +} + +/// 护栏:回收判定只是快照观察,删除前必须重新核对内容。判定之后被并发方替换成 +/// 自己的活锁时不能删除它;文件已经消失时也不算失败。 +#[test] +fn project_write_lock_reclaim_skips_replaced_or_removed_lock_file() { + let root = unique_project_path(); + init_local_game_project_at(&root, "lock-reclaim-race", "锁回收-并发替换").expect("初始化项目"); + let lock_path = root.join(PROJECT_LOCK_RELATIVE_PATH); + write_project_lock_fixture( + &root, + &project_lock_fixture_payload(4_242, unix_timestamp()), + ); + let stale_snapshot = ProjectWriteLockSnapshot::read(&lock_path).expect("读取残留锁快照"); + + // 并发方回收了残留锁并装上了自己的活锁。 + write_project_lock_fixture( + &root, + &project_lock_fixture_payload(u64::from(std::process::id()), unix_timestamp()), + ); + assert!( + !project_write_lock_reclaim(&lock_path, &stale_snapshot).expect("核对被替换的锁文件"), + "内容已经变化时必须放弃删除" + ); + assert!(lock_path.exists(), "并发方新装的活锁不能被删掉"); + + fs::remove_file(&lock_path).expect("删除锁文件"); + assert!( + !project_write_lock_reclaim(&lock_path, &stale_snapshot).expect("核对已消失的锁文件"), + "锁文件已经消失时不算失败" + ); + fs::remove_dir_all(root).ok(); +} + +/// 护栏:存活状态无法判定(`None`)时保持保守——年轻锁必须保留,明显过期才回收。 +#[test] +fn project_write_lock_decision_keeps_young_lock_when_liveness_is_unknown() { + let root = unique_project_path(); + init_local_game_project_at(&root, "lock-unknown-liveness", "锁回收-存活未知") + .expect("初始化项目"); + let now = unix_timestamp(); + let snapshot = project_lock_snapshot(&root, &project_lock_fixture_payload(4_242, now)); + + assert!( + !project_write_lock_reclaim_decision(&snapshot, Some(now), now, |_| None, |_| None), + "存活状态无法判定时,新鲜锁必须保持占用" + ); + assert!( + project_write_lock_reclaim_decision(&snapshot, Some(now), now + 601, |_| None, |_| None), + "存活状态无法判定且明显过期时必须回收" + ); + fs::remove_dir_all(root).ok(); +} + +/// 护栏:mtime 不可读(未知)时不能退化成“年龄极大”。未知年龄按不回收处理, +/// 未知创建时间也不能满足“启动时间晚于创建时间”的 PID 复用推断。 +#[test] +fn project_write_lock_decision_keeps_lock_when_mtime_is_unknown() { + let root = unique_project_path(); + init_local_game_project_at(&root, "lock-unknown-mtime", "锁回收-mtime未知") + .expect("初始化项目"); + let now = unix_timestamp(); + + let empty = project_lock_snapshot(&root, b""); + assert!( + !project_write_lock_reclaim_decision(&empty, None, now + 601, |_| None, |_| None), + "mtime 未知时空锁必须保持占用" + ); + assert!( + project_write_lock_reclaim_decision(&empty, Some(now - 120), now, |_| None, |_| None), + "mtime 已知且超过宽限期的空锁必须回收" + ); + + let live_old_format = project_lock_snapshot( + &root, + &serde_json::to_vec_pretty(&serde_json::json!({ + "commandId": "repro.live-writer", + "pid": 4_242, + "nonce": 1, + })) + .expect("序列化项目写锁 fixture"), + ); + assert!( + !project_write_lock_reclaim_decision( + &live_old_format, + None, + now + 601, + |_| Some(true), + |_| Some(now + 3_600) + ), + "创建时间未知时不能按 PID 复用抢走活持有者" + ); + fs::remove_dir_all(root).ok(); +} diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 7c0484ea5..acd0c02a6 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -8191,8 +8191,9 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 ## 2026-09-09 项目写锁残留回收与启动诊断 -- 决策:`.agent/project.lock` 记录 `processStartedAt`;PID 存活时用启动身份区分“原持有者仍在”与“PID 被复用”,身份不一致才回收。旧锁无该字段时用“进程启动时间晚于锁 `createdAt` + 5 秒容差”推断。空锁 / 坏锁(崩溃停在 `create_new` 与落盘之间)宽限期 30 秒,无法判定存活时保持 600 秒。活持有者始终不回收。 -- 决策:启动诊断日志用 `StartupLogSlot` 先按标识符推导 APPDATA 路径、配置目录就绪后切换,`startup.*.failed` 与 `show_startup_error_dialog` 必须可达;Windows 启动失败弹系统消息框,其它平台写 stderr。 +- 决策:`.agent/project.lock` 记录 `processStartedAt`;PID 存活时用启动身份区分“原持有者仍在”与“PID 被复用”,身份不一致才回收。旧锁无该字段时用“进程启动时间晚于锁 `createdAt` + 5 秒容差”推断,锁创建时间未知时不做该推断。空锁 / 坏锁(崩溃停在 `create_new` 与落盘之间)宽限期 30 秒,无法判定存活时保持 600 秒,mtime 不可读时按未知年龄不回收。活持有者始终不回收。 +- 决策:回收判据与删除基于同一次读到的锁文件快照——payload 只解析一次,删除前重新核对字节,只有内容仍是判定时的内容才 unlink;文件已消失或被替换时重试 `create_new`,不把并发回收当成错误。 +- 决策:启动诊断日志用 `StartupLogSlot`,优先用已生效的配置目录(含 `--config-dir`),否则退到平台配置根(Windows APPDATA、macOS Application Support、其它平台 `XDG_CONFIG_HOME` / `~/.config`),配置目录就绪后再切换;`startup.*.failed` 与 `show_startup_error_dialog` 必须可达,日志路径未知时也必须给出用户可见提示;Windows 启动失败弹系统消息框,其它平台写 stderr。 - 边界:`agent-runner.lock` / `agent-runner.gui-owner.lock` 是 OS 独占句柄锁,进程退出即释放,残留文件不阻塞下次启动;不要把它们当成项目写锁的同类残留处理。 - 边界:锁文件里的 PID 若超出平台进程号空间(Unix `pid_t` 是有符号 32 位、Windows 是 32 位,均恒大于 0),它不可能属于任何活进程,按“持有者不存在”直接回收,不再落回 600 秒保守分支。 -- 验证:`project_lock_recovery` 7 条与 `diagnostic_log` 5 条定向测试通过,真实二进制双实例复现“第二个实例写 `startup.runner.owner-lock.failed` 并弹出可见提示”。 +- 验证:`project_lock_recovery` 11 条与 `diagnostic_log` 7 条定向测试通过,真实二进制双实例复现“第二个实例写 `startup.runner.owner-lock.failed` 并弹出可见提示”。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 6b9b35e4b..5cdc004ac 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5039,3 +5039,11 @@ - 处理:实现层把“平台不可能分配出的进程号”(0 或超出平台 pid 宽度)判为持有者不存在并直接回收;fixture 改用 `i32::MAX as u64 - 1`,另加 `u64::MAX` 非法进程号用例。 - 验证:WSL Ubuntu 上 `cargo test --bin genarrative-ai-game-creator-shell project_lock_recovery` 7 条全过;Windows 上把可表示性判据临时回退到 HEAD 后,只有 `project_write_lock_reclaims_unrepresentable_owner_pid` 失败,说明该用例确实覆盖这条分支;Linux CI 的原始失败记录覆盖越界 PID 分支。 - 关联:`apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs`、`apps/ai-game-creator-shell/src-tauri/src/tests/project_lock_recovery.rs`。 + +## 锁文件回收的判定与删除必须基于同一份快照(2026-09-09) + +- 现象:两个实例同时恢复同一个崩溃项目时,后判定的一方可能删掉另一方刚装上的活锁;`remove_file` 的 `NotFound` 还会被当成硬失败,直接报“清理失效项目写锁失败”。 +- 原因:`project_write_lock_can_be_reclaimed` 只是快照观察,调用方拿到 true 后无条件 unlink;helper 还分别重读 `createdAt` / `pid` / `processStartedAt`,并发替换会拼出“旧 inode 的死 PID + 新 inode 的启动身份”。 +- 处理:payload 只解析一次并连同字节一起快照;删除前重新核对字节,只有内容仍是判定时的内容才 unlink;文件已消失或被替换时返回 false 并重试 `create_new`,不报错。 +- 补充:`project_write_lock_file_modified_seconds` 读不到 mtime 时不要返回 `0`——纪元 0 会被算成极大年龄,把保守判定反转成“立刻回收”,甚至把活持有者当 PID 复用抢走;要用 `Option` 区分“mtime 未知”和“mtime 等于纪元 0”。 +- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs`。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index ec85badf8..9313fba40 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1333,6 +1333,7 @@ DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过 ## 2026-09-09 项目写锁残留回收与启动诊断 -- `.agent/project.lock` 新增 `processStartedAt`(持有进程启动时间,Unix 秒)。PID 仍存活时必须先核对启动身份:身份不一致即判定 PID 复用,可直接回收;旧锁没有该字段时退回“进程启动时间晚于锁 `createdAt` 加 5 秒容差”的推断。崩溃停在 `create_new` 与落盘 payload 之间的空锁 / 坏锁宽限期从 600 秒收紧到 30 秒;无法判定持有者是否存活时继续按 600 秒保守回收,活持有者仍然不回收。 -- 启动诊断日志改为 `StartupLogSlot`:`configure_game_creator_runtime_config_dir` 之前按应用标识符推导 APPDATA 路径,成功后再切换到真实配置目录,`startup.*.failed` 与 `show_startup_error_dialog` 不再是死分支。Windows 启动失败恢复系统消息框并附诊断日志路径,其它平台写 stderr,同一进程只提示一次。 -- 边界与验证:残留的 `agent-runner.lock` / `agent-runner.gui-owner.lock` 是 OS 独占句柄锁,进程退出即释放,文件本身不阻塞下次启动;真正阻塞启动的是仍有活进程持锁。验证覆盖 `project_lock_recovery` 6 条(死 PID、空锁宽限、PID 复用时间推断、PID 复用身份不一致、身份一致不抢锁、新鲜空锁不抢锁)、`diagnostic_log` 5 条,以及真实二进制双实例:第二个实例写入 `startup.runner.owner-lock.failed` 并弹出可见提示。 +- `.agent/project.lock` 新增 `processStartedAt`(持有进程启动时间,Unix 秒)。PID 仍存活时必须先核对启动身份:身份不一致即判定 PID 复用,可直接回收;旧锁没有该字段时退回“进程启动时间晚于锁 `createdAt` 加 5 秒容差”的推断,锁创建时间未知时不做该推断。崩溃停在 `create_new` 与落盘 payload 之间的空锁 / 坏锁宽限期从 600 秒收紧到 30 秒;无法判定持有者是否存活、或 mtime 不可读时保持保守(前者 600 秒、后者不回收),活持有者仍然不回收。 +- 回收判据与删除必须基于同一次读到的锁文件快照:payload 只解析一次,删除前重新核对字节,只有内容仍是判定时的内容才 unlink;文件已消失或被替换时重试 `create_new`,不把并发回收当成错误。 +- 启动诊断日志改为 `StartupLogSlot`:优先用已经生效的配置目录(含 `--config-dir`),否则退到平台配置根(Windows APPDATA、macOS Application Support、其它平台 `XDG_CONFIG_HOME` / `~/.config`),成功后再切换到真实配置目录。`startup.*.failed` 与 `show_startup_error_dialog` 不再是死分支;日志路径未知时同样给出用户可见提示。Windows 启动失败恢复系统消息框并附诊断日志路径,其它平台写 stderr,同一进程只提示一次。 +- 边界与验证:残留的 `agent-runner.lock` / `agent-runner.gui-owner.lock` 是 OS 独占句柄锁,进程退出即释放,文件本身不阻塞下次启动;真正阻塞启动的是仍有活进程持锁。验证覆盖 `project_lock_recovery` 11 条(死 PID、非法进程号、空锁宽限、PID 复用时间推断、PID 复用身份不一致、身份一致不抢锁、旧格式活持有者不抢锁、新鲜空锁不抢锁、存活未知保守回收、mtime 未知保守回收、并发替换或已消失时不删除)、`diagnostic_log` 7 条,以及真实二进制双实例:第二个实例写入 `startup.runner.owner-lock.failed` 并弹出可见提示。