修复 AGC 项目写锁回收判据的跨平台 PID 边界
Project CI / Repository checks (pull_request) Successful in 3m8s
Project CI / Frontend tests (pull_request) Successful in 4m15s
Project CI / Backend tests (pull_request) Successful in 6m47s
Project CI / Native shell tests (pull_request) Successful in 17m58s

- 项目写锁存活判定把平台不可能分配出的进程号(0 或超出平台 pid 宽度)判为持有者不存在并直接回收,不再落回 600 秒保守分支
- 回归用例的死进程 PID fixture 由 0xFFFF_FFF0 改为 i32::MAX - 1,同时落在两个平台进程号空间之外且在 Unix 有符号 32 位范围内
- 新增 project_write_lock_reclaims_unrepresentable_owner_pid 用例,覆盖 u64::MAX 非法进程号残留锁
- 修正 project_lock_recovery 头部注释,说明这些用例是回归护栏而不是待修缺陷
- 同步 decision-log 的 PID 边界决策与 pitfalls 的跨平台 fixture 经验
This commit is contained in:
2026-09-09 18:00:53 +08:00
parent 78ba9e33b6
commit b7f27721c5
4 changed files with 49 additions and 8 deletions
@@ -55,7 +55,11 @@ impl Drop for ProjectWriteLock {
#[cfg(unix)]
fn project_write_lock_process_is_alive(process_id: u64) -> Option<bool> {
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<bool> {
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
@@ -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_PARAMETERUnix 的 kill(pid, 0) 返回 ESRCH。
const DEAD_OWNER_PID: u64 = 0xFFFF_FFF0;
/// 一个确定不会被占用的进程号:高于两个平台实际分配的进程号上限,因此 Windows
/// 的 OpenProcess 对它返回 ERROR_INVALID_PARAMETERUnix 的 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]
@@ -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` 并弹出可见提示”。
@@ -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` 当死 PIDUnix 的 `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`