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 109b3a8cb..952ebf29b 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 @@ -55,7 +55,11 @@ impl Drop for ProjectWriteLock { #[cfg(unix)] fn project_write_lock_process_is_alive(process_id: u64) -> Option { - let process_id = i32::try_from(process_id).ok().filter(|value| *value > 0)?; + // Unix 的 pid_t 是有符号 32 位且恒大于 0,超出该范围的取值不可能是本机 + // 任何进程,说明锁文件里的 PID 已经损坏,可以直接判定持有者不存在。 + let Some(process_id) = i32::try_from(process_id).ok().filter(|value| *value > 0) else { + return Some(false); + }; let result = unsafe { libc::kill(process_id, 0) }; if result == 0 { return Some(true); @@ -78,7 +82,11 @@ fn project_write_lock_process_is_alive(process_id: u64) -> Option { fn CloseHandle(handle: *mut c_void) -> i32; } - let process_id = u32::try_from(process_id).ok().filter(|value| *value > 0)?; + // Windows 进程号是 32 位且恒大于 0,超出该范围的取值不可能是本机任何 + // 进程,说明锁文件里的 PID 已经损坏,可以直接判定持有者不存在。 + let Some(process_id) = u32::try_from(process_id).ok().filter(|value| *value > 0) else { + return Some(false); + }; const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000; const STILL_ACTIVE: u32 = 259; // SAFETY: OpenProcess returns an owned kernel handle or null; it is 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 51dfeac1e..d912659bd 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 @@ -4,13 +4,15 @@ use std::process::Stdio; // Issue #310 复现:异常退出后在项目里残留 `.agent/project.lock`,下一次打开 // 项目时所有写操作都被拒绝。 // -// 下面的用例描述的是期望行为(残留锁必须能被安全回收)。在当前实现下它们会 -// 失败,用来证明缺陷;修复后应当全部通过,并作为回归用例保留。 +// 下面的用例是回归护栏:持有者确实不存在时残留锁必须被安全回收,同时不能抢走 +// 仍然活着持有者的锁。 const PROJECT_LOCK_RELATIVE_PATH: &str = ".agent/project.lock"; -/// 一个确定不会被占用的进程号:Windows 的 OpenProcess 对它返回 -/// ERROR_INVALID_PARAMETER,Unix 的 kill(pid, 0) 返回 ESRCH。 -const DEAD_OWNER_PID: u64 = 0xFFFF_FFF0; +/// 一个确定不会被占用的进程号:高于两个平台实际分配的进程号上限,因此 Windows +/// 的 OpenProcess 对它返回 ERROR_INVALID_PARAMETER,Unix 的 kill(pid, 0) 返回 +/// ESRCH。取值必须落在 Unix 有符号 32 位 pid 范围内,否则在 Unix 上会先命中 +/// “进程号不可表示”分支,而不是这条“死进程”分支。 +const DEAD_OWNER_PID: u64 = i32::MAX as u64 - 1; fn write_project_lock_fixture(root: &Path, content: &[u8]) { fs::write(root.join(PROJECT_LOCK_RELATIVE_PATH), content).expect("写入项目写锁 fixture"); @@ -80,6 +82,28 @@ fn project_write_lock_reclaims_dead_owner_pid() { fs::remove_dir_all(root).ok(); } +/// 复现 A0:锁文件被外部改写或截断损坏,PID 超出平台进程号空间(例如 +/// u64::MAX)。它不可能属于任何活进程,必须直接回收,而不是让项目再等满 +/// 600 秒;Unix 的 pid_t 只有有符号 32 位,越界取值尤其容易在这里被漏掉。 +#[test] +fn project_write_lock_reclaims_unrepresentable_owner_pid() { + let root = unique_project_path(); + init_local_game_project_at(&root, "lock-invalid-pid", "锁回收-非法进程号").expect("初始化项目"); + write_project_lock_fixture( + &root, + &project_lock_fixture_payload(u64::MAX, unix_timestamp()), + ); + + let acquired = acquire_project_write_lock(&root, "repro.acquire-after-crash"); + assert!( + acquired.is_ok(), + "非法进程号的残留锁未被回收,实际错误:{:?}", + acquired.err() + ); + drop(acquired); + fs::remove_dir_all(root).ok(); +} + /// 复现 A:崩溃发生在 create_new 成功、payload 写盘之前,留下 0 字节锁。 /// 现在要等满 600 秒才会回收,重启后 10 分钟内所有写操作都失败。 #[test] diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 2a381f16d..7c0484ea5 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -8194,4 +8194,5 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 决策:`.agent/project.lock` 记录 `processStartedAt`;PID 存活时用启动身份区分“原持有者仍在”与“PID 被复用”,身份不一致才回收。旧锁无该字段时用“进程启动时间晚于锁 `createdAt` + 5 秒容差”推断。空锁 / 坏锁(崩溃停在 `create_new` 与落盘之间)宽限期 30 秒,无法判定存活时保持 600 秒。活持有者始终不回收。 - 决策:启动诊断日志用 `StartupLogSlot` 先按标识符推导 APPDATA 路径、配置目录就绪后切换,`startup.*.failed` 与 `show_startup_error_dialog` 必须可达;Windows 启动失败弹系统消息框,其它平台写 stderr。 - 边界:`agent-runner.lock` / `agent-runner.gui-owner.lock` 是 OS 独占句柄锁,进程退出即释放,残留文件不阻塞下次启动;不要把它们当成项目写锁的同类残留处理。 -- 验证:`project_lock_recovery` 6 条与 `diagnostic_log` 5 条定向测试通过,真实二进制双实例复现“第二个实例写 `startup.runner.owner-lock.failed` 并弹出可见提示”。 +- 边界:锁文件里的 PID 若超出平台进程号空间(Unix `pid_t` 是有符号 32 位、Windows 是 32 位,均恒大于 0),它不可能属于任何活进程,按“持有者不存在”直接回收,不再落回 600 秒保守分支。 +- 验证:`project_lock_recovery` 7 条与 `diagnostic_log` 5 条定向测试通过,真实二进制双实例复现“第二个实例写 `startup.runner.owner-lock.failed` 并弹出可见提示”。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index c864f70fb..6b9b35e4b 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5031,3 +5031,11 @@ - 处理:框选命中判断使用 `Element.closest('.genarrative-image-canvas__world')`,并保留 viewport 自身命中路径;回归测试通过真实 `CanvasWorld` DOM 的 `pointerdown` 冒泡覆盖内层 world content。 - 验证:`src/components/image-editor/useImageCanvasStageInteractions.test.tsx` 覆盖内层 world content 命中,定向交互测试通过。 - 关联:`packages/image-canvas-react/src/useImageCanvasStageInteractions.ts`、`packages/image-canvas-react/src/CanvasWorld.tsx`。 + +## 跨平台“死进程 PID” fixture 必须落在 Unix 有符号 32 位范围内(2026-09-09) + +- 现象:`project_lock_recovery::project_write_lock_reclaims_dead_owner_pid` 在 Windows 本地通过,在 Linux CI 报“死进程残留锁未被回收,实际错误:项目正在被其他写操作占用”。 +- 原因:fixture 用 `0xFFFF_FFF0` 当死 PID;Unix 的 `pid_t` 是有符号 32 位,`i32::try_from` 直接失败,存活判定返回 `None`(无法判定)而不是 `Some(false)`,于是落回 600 秒保守分支,残留锁不再被回收。 +- 处理:实现层把“平台不可能分配出的进程号”(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`。