Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs
T
lhk229 70271b408e
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 7m19s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 7m35s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 7m34s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 7m39s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m13s
Project CI / AI game creator shell Rust crates (push) Successful in 3m18s
Project CI / Backend tests (push) Successful in 9m16s
Project CI / Repository checks (push) Successful in 8m11s
Project CI / Native shell tests (push) Successful in 11m56s
Project CI / Frontend tests (push) Successful in 11m49s
Project CI / AI game creator shell web tests (push) Successful in 6m37s
删除策划agent v2,与退役功能解耦 (#355)
Reviewed-on: #355
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
2026-09-20 14:49:57 +08:00

934 lines
42 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::*;
#[cfg(windows)]
pub(crate) const PROJECT_FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
static PROJECT_WRITE_LOCK_NONCE: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(1);
const PROJECT_WRITE_LOCK_STALE_AFTER_SECONDS: u64 = 600;
/// 崩溃可能停在 `create_new` 成功、payload 落盘之前,此时锁文件没有任何持有者
/// 信息。写入方正常情况下在毫秒级完成落盘,所以只需要很短的宽限期就能确认它
/// 已经放弃,而不是让项目在整整 10 分钟里都不可写。
const PROJECT_WRITE_LOCK_UNWRITTEN_GRACE_SECONDS: u64 = 30;
/// 进程启动时间与锁 `createdAt` 之间的允许偏差(秒),用来抵消时间戳精度差异。
const PROJECT_WRITE_LOCK_PID_REUSE_TOLERANCE_SECONDS: u64 = 5;
const PROJECT_WRITE_LOCK_MAX_BYTES: u64 = 4 * 1024;
/// 本进程内真正落盘持有项目写锁的线程登记表。
///
/// `.agent/project.lock` 的 `pid` 只能证明“锁由本进程的某条写通道持有”,它分不清
/// 两种完全不同的局面:
/// - **同一条调用链再次取锁**:持锁方就是自己,必须放行,否则每次嵌套项目写入都要
/// 白等一个等待预算再报“项目正在被其他写操作占用”;
/// - **本进程另一条写通道正在写**:项目 revision 侧车、steer 序号、一致快照读、
/// pending sidecar 复核和恢复安装都靠这把锁串行化,必须照旧等待。
///
/// 复用判据因此不能停在 `pid`:只有**当前线程**就是真实持锁线程时才返回 advisory
/// guard,本进程其余争用继续走有界等待与终态占用。登记按路径进行、按路径注销:
/// guard 可能被移到别的线程再 Drop(例如写入路径把锁交给阻塞线程池的持有者),
/// 按线程注销会漏项,让后续的重入判断失真。
static PROJECT_WRITE_LOCK_THREAD_OWNERS: std::sync::Mutex<Vec<(PathBuf, std::thread::ThreadId)>> =
std::sync::Mutex::new(Vec::new());
fn project_write_lock_thread_owners(
) -> std::sync::MutexGuard<'static, Vec<(PathBuf, std::thread::ThreadId)>> {
// 登记表只是复用判据的加速器:中毒时继续用内部值,不能让一次取锁失败升级成
// 整个进程再也写不了项目。
PROJECT_WRITE_LOCK_THREAD_OWNERS
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
fn register_project_write_lock_thread_owner(path: &Path) {
let mut owners = project_write_lock_thread_owners();
if owners.iter().any(|(owner, _)| owner == path) {
return;
}
owners.push((path.to_path_buf(), std::thread::current().id()));
}
fn unregister_project_write_lock_thread_owner(path: &Path) {
project_write_lock_thread_owners().retain(|(owner, _)| owner != path);
}
/// 当前线程是否就是这条锁路径上真实落盘的持有者(同线程重入)。
fn project_write_lock_reentered_by_current_thread(path: &Path) -> bool {
let thread = std::thread::current().id();
project_write_lock_thread_owners()
.iter()
.any(|(owner, owner_thread)| owner == path && *owner_thread == thread)
}
#[derive(Debug)]
pub(crate) struct ProjectWriteLock {
path: PathBuf,
content: String,
/// 两种“本进程持锁但不必自等”的争用会拿到 advisory guard:同一线程重入(同一条
/// 调用链再次取锁)和自主游戏构建流水线(它有意让并行专家动作同时在飞)。这两种
/// 情况下争用是进程内重叠而不是另一个客户端在改项目,返回的 guard 不拥有
/// `.agent/project.lock`,Drop 时也不得删除真实持有者的锁。
bypassed_same_process: bool,
}
impl ProjectWriteLock {
pub(crate) fn guards_project_root(&self, root: &Path) -> Result<bool, String> {
let expected_path = resolve_local_project_path(root, PROJECT_WRITE_LOCK_PATH)?;
if self.bypassed_same_process {
// The relaxed guard deliberately has no ownership of the durable
// `.agent/project.lock` file. It still binds the observation to
// the validated project root so callers cannot use a guard from a
// different project.
return Ok(self.path == expected_path);
}
Ok(self.path == expected_path
&& fs::read_to_string(&self.path).is_ok_and(|content| content == self.content))
}
}
impl Drop for ProjectWriteLock {
fn drop(&mut self) {
if self.bypassed_same_process {
return;
}
unregister_project_write_lock_thread_owner(&self.path);
if fs::read_to_string(&self.path).is_ok_and(|content| content == self.content) {
let _ = fs::remove_file(&self.path);
}
}
}
#[cfg(unix)]
fn project_write_lock_process_is_alive(process_id: u64) -> Option<bool> {
// 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);
}
match std::io::Error::last_os_error().raw_os_error() {
Some(libc::ESRCH) => Some(false),
Some(libc::EPERM) => Some(true),
_ => None,
}
}
#[cfg(windows)]
fn project_write_lock_process_is_alive(process_id: u64) -> Option<bool> {
use std::ffi::c_void;
#[link(name = "kernel32")]
unsafe extern "system" {
fn OpenProcess(access: u32, inherit_handle: i32, process_id: u32) -> *mut c_void;
fn GetExitCodeProcess(process: *mut c_void, exit_code: *mut u32) -> i32;
fn CloseHandle(handle: *mut c_void) -> i32;
}
// 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
// closed below. We only request the query permission needed here.
let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, process_id) };
if process.is_null() {
// ERROR_INVALID_PARAMETER means the process no longer exists. For
// access-denied/other failures we cannot prove liveness, so keep the
// conservative unknown result and let the normal bounded wait decide.
return match std::io::Error::last_os_error().raw_os_error() {
Some(87) => Some(false),
_ => None,
};
}
let mut exit_code = 0_u32;
// SAFETY: `exit_code` is a writable scalar and `process` is a live handle.
let result = unsafe { GetExitCodeProcess(process, &mut exit_code) };
// SAFETY: `process` is an owned handle returned by OpenProcess.
unsafe { CloseHandle(process) };
if result == 0 {
return None;
}
Some(exit_code == STILL_ACTIVE)
}
#[cfg(not(any(unix, windows)))]
fn project_write_lock_process_is_alive(_process_id: u64) -> Option<bool> {
None
}
/// 读取进程的启动时间(Unix 秒)。用来区分“锁记录里的 PID 仍然属于原来的持有
/// 者”和“PID 已经被系统复用给另一个进程”。无法判定的平台返回 `None`,此时
/// 保持原有的保守回收策略。
#[cfg(windows)]
pub(crate) fn project_write_lock_process_start_time_seconds(process_id: u64) -> Option<u64> {
use std::ffi::c_void;
#[repr(C)]
struct FileTime {
low_date_time: u32,
high_date_time: u32,
}
#[link(name = "kernel32")]
unsafe extern "system" {
fn OpenProcess(access: u32, inherit_handle: i32, process_id: u32) -> *mut c_void;
fn GetProcessTimes(
process: *mut c_void,
creation_time: *mut FileTime,
exit_time: *mut FileTime,
kernel_time: *mut FileTime,
user_time: *mut FileTime,
) -> i32;
fn CloseHandle(handle: *mut c_void) -> i32;
}
const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
/// Windows FILETIME 起点(1601-01-01)到 Unix 纪元之间的 100 纳秒数。
const FILETIME_UNIX_EPOCH_OFFSET: u64 = 116_444_736_000_000_000;
let process_id = u32::try_from(process_id).ok().filter(|value| *value > 0)?;
// SAFETY: OpenProcess returns an owned kernel handle or null; it is closed below.
let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, process_id) };
if process.is_null() {
return None;
}
// SAFETY: every FileTime is plain data filled by GetProcessTimes.
let mut creation = unsafe { std::mem::zeroed::<FileTime>() };
let mut exit = unsafe { std::mem::zeroed::<FileTime>() };
let mut kernel = unsafe { std::mem::zeroed::<FileTime>() };
let mut user = unsafe { std::mem::zeroed::<FileTime>() };
// SAFETY: `process` is a live handle and all four pointers are writable scalars.
let result =
unsafe { GetProcessTimes(process, &mut creation, &mut exit, &mut kernel, &mut user) };
// SAFETY: `process` is an owned handle returned by OpenProcess.
unsafe { CloseHandle(process) };
if result == 0 {
return None;
}
let file_time = (u64::from(creation.high_date_time) << 32) | u64::from(creation.low_date_time);
file_time
.checked_sub(FILETIME_UNIX_EPOCH_OFFSET)
.map(|unix_100ns| unix_100ns / 10_000_000)
}
#[cfg(target_os = "linux")]
pub(crate) fn project_write_lock_process_start_time_seconds(process_id: u64) -> Option<u64> {
let process_id = u32::try_from(process_id).ok().filter(|value| *value > 0)?;
// SAFETY: sysconf has no memory safety preconditions and returns -1 on failure.
let clock_ticks = unsafe { libc::sysconf(libc::_SC_CLK_TCK) };
if clock_ticks <= 0 {
return None;
}
let stat = fs::read_to_string(format!("/proc/{process_id}/stat")).ok()?;
let start_ticks = stat
.rsplit_once(") ")?
.1
.split_whitespace()
.nth(19)?
.parse::<u64>()
.ok()?;
let boot_time = fs::read_to_string("/proc/stat")
.ok()?
.lines()
.find_map(|line| line.strip_prefix("btime "))?
.trim()
.parse::<u64>()
.ok()?;
Some(boot_time + start_ticks / clock_ticks as u64)
}
#[cfg(not(any(windows, target_os = "linux")))]
pub(crate) fn project_write_lock_process_start_time_seconds(_process_id: u64) -> Option<u64> {
None
}
/// 一次读到的锁文件字节与解析结果。回收判据和随后的删除必须基于同一份快照:
/// 分别重读 `pid` / `createdAt` / `processStartedAt` 会把旧 inode 的持有者信息
/// 和新 inode 的启动身份拼在一起,也会让判定与删除命中不同的文件。
#[derive(Debug, Clone)]
pub(crate) struct ProjectWriteLockSnapshot {
content: Vec<u8>,
command_id: Option<String>,
pid: Option<u64>,
created_at: Option<u64>,
process_started_at: Option<u64>,
}
impl ProjectWriteLockSnapshot {
pub(crate) fn read(path: &Path) -> Option<Self> {
let content = fs::read(path).ok()?;
let payload = serde_json::from_slice::<serde_json::Value>(&content).ok();
let number = |key: &str| {
payload
.as_ref()
.and_then(|payload| payload.get(key))
.and_then(serde_json::Value::as_u64)
};
Some(Self {
command_id: payload
.as_ref()
.and_then(|payload| payload.get("commandId"))
.and_then(serde_json::Value::as_str)
.map(str::to_string),
pid: number("pid"),
created_at: number("createdAt"),
process_started_at: number("processStartedAt"),
content,
})
}
/// 持锁方身份的单行描述。Issue #318 的现场只有一句"别人在写",无法回答"到底是谁、
/// 是不是自己人",所以争用错误和等待日志都要带上这几个字段。
/// `ownerIsSelf` 用 `pid` 判定:`true` 是同进程另一条写通道,`false` 才是真外部进程。
pub(crate) fn describe_holder(&self) -> String {
format!(
"commandId={} pid={} createdAt={} ownerIsSelf={}",
self.command_id.as_deref().unwrap_or("unknown"),
self.pid
.map(|pid| pid.to_string())
.unwrap_or_else(|| "unknown".to_string()),
self.created_at
.map(|created_at| created_at.to_string())
.unwrap_or_else(|| "unknown".to_string()),
match self.pid {
Some(pid) if pid == u64::from(std::process::id()) => "true",
Some(_) => "false",
None => "unknown",
},
)
}
}
fn project_write_lock_is_owned_by_current_process(path: &Path) -> bool {
ProjectWriteLockSnapshot::read(path).and_then(|snapshot| snapshot.pid)
== Some(u64::from(std::process::id()))
}
/// 读取锁文件 mtime 的 Unix 秒数;读不到时返回 `None`。调用方必须把“mtime 未知”
/// 和“mtime 等于纪元 0”区分开:后者会被算成极大的年龄,反而把保守判定反转成
/// “立刻回收”,甚至把活持有者的锁当成 PID 复用抢走。
fn project_write_lock_file_modified_seconds(metadata: &fs::Metadata) -> Option<u64> {
metadata
.modified()
.ok()
.and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
.map(|duration| duration.as_secs())
}
/// 锁文件年龄(秒)。`createdAt` 与 mtime 都无法确定时返回 `None`:未知年龄只能
/// 按“不回收”处理,不能退化成 0 或极大值。
fn project_write_lock_age_seconds(
snapshot: &ProjectWriteLockSnapshot,
modified_at: Option<u64>,
now: u64,
) -> Option<u64> {
if let Some(created_at) = snapshot.created_at {
return Some(now.saturating_sub(created_at));
}
modified_at.map(|modified_at| now.saturating_sub(modified_at))
}
/// 回收判据。进程存活与启动时间查询作为参数传入,便于用确定性用例覆盖真实进程
/// 难以构造的分支(存活状态无法判定、mtime 不可读)。
pub(crate) fn project_write_lock_reclaim_decision(
snapshot: &ProjectWriteLockSnapshot,
modified_at: Option<u64>,
now: u64,
process_is_alive: impl Fn(u64) -> Option<bool>,
process_started_at: impl Fn(u64) -> Option<u64>,
) -> 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);
};
match process_is_alive(owner_pid) {
Some(false) => true,
Some(true) => {
// PID 会被系统复用,必须确认当前同名进程就是当时的持有者。
match (snapshot.process_started_at, process_started_at(owner_pid)) {
// 新锁自带启动身份:同一进程的身份恒定,不一致即为 PID 复用。
(Some(stored), Some(actual)) => stored != actual,
// 旧锁没有启动身份,只能用“启动时间晚于锁创建时间”推断 PID 复用;
// 锁创建时间未知时不做推断,避免把“未知”当成“复用”抢走活持有者。
(None, Some(actual)) => {
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)
}
_ => false,
}
}
// 无法判定持有者是否存活时保持保守策略:只有明显过期才回收。
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<ProjectWriteLockSnapshot> {
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<bool, String> {
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())),
}
}
/// `.agent/project.lock` 的争用错误前缀。`project_gates.rs`、`provider_recovery.rs`、
/// `direct_runtime.rs` 和前端 `App.tsx` 都按这个前缀把争用
/// 识别成"可以等一下"的瞬时状态;文案扩展时要保持前缀逐字不变。
pub(crate) const PROJECT_WRITE_LOCK_CONTENTION_PREFIX: &str = "项目正在被其他写操作占用:";
/// 锁分类判据必须能被两个平台覆盖,所以平台由参数传入而不是藏在 `#[cfg]` 后面:
/// CI 只有 Linux runner,`#[cfg(windows)]` 的用例在 CI 里一次都不会跑,而 Windows 特有的
/// `ACCESS_DENIED(5)` / sharing violation(32) / lock violation(33) 分支恰恰是最危险的一段。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ProjectWriteLockPlatform {
Windows,
Unix,
}
pub(crate) const PROJECT_WRITE_LOCK_PLATFORM: ProjectWriteLockPlatform = if cfg!(windows) {
ProjectWriteLockPlatform::Windows
} else {
ProjectWriteLockPlatform::Unix
};
/// 一次 `create_new` 失败在**等待契约**上的归类。三类的处置完全不同:可重试、权限拒绝
/// 必须失败关闭、其它 I/O 错误原样上报。混成一句「项目正在被其他写操作占用」会把 ACL
/// 问题、删除挂起和真实跨进程争用一起藏起来(Issue #318 第 3 条)。
///
/// 判据只能是错误码本身。**不要用 `path.exists()` 这种一次 metadata 观察决定"要不要重试"**:
/// 目标被删除时目录项先消失、删除挂起随后才结束,`create_new` 会在这个拆链窗口里返回
/// `ACCESS_DENIED(5)`,而 `exists()` 往往已经报 false。本机实测 60000 次建锁 / 删锁竞争里
/// 有 396-538 例命中"5 + 目标不可见";按"目标不存在"判成权限拒绝,等待层就会立刻失败关闭
/// ——正是 Issue #318 要消灭的"毫秒级直接失败",只是换成了更误导的 ACL 文案。
/// 终态投影放在等待预算耗尽之后做,见 `ProjectWriteLockFailure::exhausted_projection`。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ProjectWriteLockOpenFailure {
/// 目标被占用、删除挂起或正处于删除拆链窗口:短暂重叠,可重试。
Retryable,
/// 明确不是争用:权限 / ACL 拒绝,失败关闭。
Permission,
/// 其它 I/O 错误,原样上报。
Other,
}
fn project_write_lock_open_failure_for(
platform: ProjectWriteLockPlatform,
error: &std::io::Error,
) -> ProjectWriteLockOpenFailure {
if error.kind() == std::io::ErrorKind::AlreadyExists {
return ProjectWriteLockOpenFailure::Retryable;
}
match platform {
ProjectWriteLockPlatform::Windows => {
// 32 / 33 是 sharing / lock violation,只可能在目标被占用时出现,恒定可重试。
// ACCESS_DENIED(5) 既可能是 delete-pending / 删除拆链窗口,也可能是真实 ACL
// 拒绝,错误码上不可区分,因此同样先按可重试处理,由等待方在预算耗尽后再判定终态。
if matches!(error.raw_os_error(), Some(5 | 32 | 33))
|| error.kind() == std::io::ErrorKind::PermissionDenied
{
return ProjectWriteLockOpenFailure::Retryable;
}
}
ProjectWriteLockPlatform::Unix => {
// Unix 没有删除挂起:目标存在必然先命中 AlreadyExists,EACCES 就是权限拒绝,
// 可以立刻判定,不必让调用方白等一个等待窗口。
if error.kind() == std::io::ErrorKind::PermissionDenied {
return ProjectWriteLockOpenFailure::Permission;
}
}
}
ProjectWriteLockOpenFailure::Other
}
/// 一次取锁失败的完整形状。
///
/// 等待层需要它做两件事:按分类决定是否重试,以及在预算耗尽后用**当时的**目标状态做终态
/// 投影。把"这一次失败"整份传下去,调用方就不必回头解析错误文案。
#[derive(Debug)]
pub(crate) enum ProjectWriteLockFailure {
/// 目标被占用、删除挂起或正处于删除拆链窗口:允许进入有界等待。
/// 保留平台与 `create_new` 的原始错误,等待层才能在预算耗尽后做终态投影。
Retryable {
platform: ProjectWriteLockPlatform,
path: PathBuf,
source: std::io::Error,
},
/// 已经定稿、不可重试的失败文案:权限拒绝、其它 I/O 错误、前置校验失败。
Terminal(String),
}
/// 只有 Windows 的 `ACCESS_DENIED(5)` 才可能在等待之后被改判:它在分类阶段与删除拆链
/// 窗口不可区分。平台必须一起传进来——Linux 上 errno 5 是 `EIO` 而不是 `EACCES`,
/// 只看 `kind()` 会让同一条判据在两个平台上得出不同结论,而这条判据正是要在 CI 上跑。
fn project_write_lock_permission_is_ambiguous(
platform: ProjectWriteLockPlatform,
source: &std::io::Error,
) -> bool {
platform == ProjectWriteLockPlatform::Windows
&& (source.kind() == std::io::ErrorKind::PermissionDenied
|| source.raw_os_error() == Some(5))
}
impl ProjectWriteLockFailure {
/// 是否允许进入有界等待。判据是失败分类,不是错误文案。
pub(crate) fn is_retryable(&self) -> bool {
matches!(self, Self::Retryable { .. })
}
/// 零等待入口的文案。可重试的失败保持争用前缀逐字不变:`provider_recovery.rs`、
/// `direct_runtime.rs` 和前端 `App.tsx` 都按这个前缀把错误
/// 当成可等待的瞬时状态,改前缀等于顺手改掉它们的重试语义。
pub(crate) fn message(&self) -> String {
match self {
Self::Terminal(message) => message.clone(),
Self::Retryable {
platform,
path,
source,
} => project_write_lock_contention_error(
path,
ProjectWriteLockSnapshot::read(path).as_ref(),
project_write_lock_permission_is_ambiguous(*platform, source) && !path.exists(),
),
}
}
/// 等待预算耗尽后的终态投影:`(日志分类, 给用户的文案)`。
///
/// 删除拆链窗口是微秒级:真的等过预算(`waited`)仍在失败、且目标此刻仍然不存在,
/// 说明这不是瞬时争用而是权限 / ACL 拒绝,此时才改判。单次试探(`max_attempts == 1`)
/// 没有等待证据,保持争用语义,不做终态改判。
pub(crate) fn exhausted_projection(&self, waited: bool) -> (&'static str, String) {
let Self::Retryable {
platform,
path,
source,
} = self
else {
return ("terminal", self.message());
};
if waited && project_write_lock_permission_is_ambiguous(*platform, source) && !path.exists()
{
return (
"permission_denied",
project_write_lock_permission_error(path, source),
);
}
("contention", self.message())
}
}
/// 争用错误必须带上持锁方身份。锁文件处于 delete-pending 或尚未写完时读不到身份,
/// 也必须显式表达成"不可读",不能默认成"没有持锁方"。
///
/// `permission_ambiguous` 为真表示目标此刻不存在、而错误码是 Windows 上无法与权限拒绝
/// 区分的 `ACCESS_DENIED(5)`:零等待入口没有等待窗口可以证伪,文案必须把两种处置都说
/// 出来,而不是替调用方猜一个。
fn project_write_lock_contention_error(
path: &Path,
snapshot: Option<&ProjectWriteLockSnapshot>,
permission_ambiguous: bool,
) -> String {
match snapshot {
Some(snapshot) => format!(
"{PROJECT_WRITE_LOCK_CONTENTION_PREFIX}{}(持锁方 {})",
path.display(),
snapshot.describe_holder()
),
None if permission_ambiguous => format!(
"{PROJECT_WRITE_LOCK_CONTENTION_PREFIX}{}(持锁方身份不可读:锁文件此刻不存在,可能是删除挂起、删除拆链窗口或权限 / ACL 拒绝)",
path.display()
),
None => format!(
"{PROJECT_WRITE_LOCK_CONTENTION_PREFIX}{}(持锁方身份不可读:锁文件可能处于删除挂起或尚未写完)",
path.display()
),
}
}
fn project_write_lock_permission_error(path: &Path, error: &std::io::Error) -> String {
// 文案刻意不含争用前缀:`..._with_wait`、`provider_recovery.rs` 和前端都按前缀把
// 错误当成"等一下就好"的瞬时状态,权限拒绝必须失败关闭。
format!(
"项目写锁路径权限被拒绝,不是写锁争用(请检查项目目录与 .agent 目录的 ACL):{}: {error}",
path.display()
)
}
/// 等待预算耗尽时写进 App 日志的持锁方快照。
pub(crate) fn project_write_lock_contention_diagnostic(root: &Path) -> String {
let Ok(path) = resolve_project_write_lock_path(root) else {
return "持锁方身份不可解析".to_string();
};
match ProjectWriteLockSnapshot::read(&path) {
Some(snapshot) => snapshot.describe_holder(),
None => "持锁方身份不可读(锁文件可能处于删除挂起或尚未写完)".to_string(),
}
}
/// **重试性只由错误码决定,不由一次 metadata 观察决定。** 这条用例把平台作为参数,
/// 因此 CI 的 Linux runner 也会执行 Windows 分支:删除拆链窗口里 `create_new` 报
/// `ACCESS_DENIED(5)` 而目标已经不可见,用 `exists()` 判"要不要等"会把这批瞬时失败判死。
#[test]
fn project_write_lock_retryability_comes_from_the_error_code_not_a_metadata_probe() {
for error in [
std::io::Error::from_raw_os_error(5),
std::io::Error::from_raw_os_error(32),
std::io::Error::from_raw_os_error(33),
std::io::Error::from(std::io::ErrorKind::PermissionDenied),
std::io::Error::from(std::io::ErrorKind::AlreadyExists),
] {
assert_eq!(
project_write_lock_open_failure_for(ProjectWriteLockPlatform::Windows, &error),
ProjectWriteLockOpenFailure::Retryable,
"Windows 上 {error:?} 必须进入有界等待"
);
}
assert_eq!(
project_write_lock_open_failure_for(
ProjectWriteLockPlatform::Unix,
&std::io::Error::from(std::io::ErrorKind::AlreadyExists)
),
ProjectWriteLockOpenFailure::Retryable,
"Unix 上目标存在必须是争用"
);
// Unix 没有删除挂起,EACCES 就是权限拒绝,可以立刻判定,不必白等一个等待窗口。
assert_eq!(
project_write_lock_open_failure_for(
ProjectWriteLockPlatform::Unix,
&std::io::Error::from(std::io::ErrorKind::PermissionDenied)
),
ProjectWriteLockOpenFailure::Permission
);
for platform in [
ProjectWriteLockPlatform::Windows,
ProjectWriteLockPlatform::Unix,
] {
assert_eq!(
project_write_lock_open_failure_for(
platform,
&std::io::Error::from(std::io::ErrorKind::NotFound)
),
ProjectWriteLockOpenFailure::Other,
"其它 I/O 错误必须原样上报,不进入等待"
);
}
}
/// 终态改判的三个条件必须同时成立:真的等过预算、目标此刻仍不存在、错误码是 Windows 上
/// 不可区分的 `ACCESS_DENIED(5)`。缺任何一个都保持争用语义(前缀逐字不变)。
///
/// 平台连同错误一起构造,用例因此不依赖宿主的 errno 语义:Linux 上 errno 5 是 `EIO`
/// 而不是 `EACCES`,只看 `kind()` 会让同一条判据在 CI 与 Windows 上得出不同结论。
#[test]
fn project_write_lock_exhausted_projection_needs_a_waited_budget_and_a_missing_target() {
let temporary = tempfile::tempdir().expect("create projection root");
let path = temporary.path().join("project.lock");
let ambiguous = ProjectWriteLockFailure::Retryable {
platform: ProjectWriteLockPlatform::Windows,
path: path.clone(),
source: std::io::Error::from_raw_os_error(5),
};
assert!(
ambiguous.is_retryable(),
"ACCESS_DENIED 必须允许进入有界等待"
);
// 目标不存在 + 真的等过预算:改判权限拒绝,文案不得再含争用前缀。
let (projection, message) = ambiguous.exhausted_projection(true);
assert_eq!(projection, "permission_denied");
assert!(
!message.starts_with(PROJECT_WRITE_LOCK_CONTENTION_PREFIX),
"预算耗尽且目标缺失时不得再投影成争用:{message}"
);
// 同一形状的单次试探没有等待证据:保持争用语义,前缀逐字不变。
let (projection, message) = ambiguous.exhausted_projection(false);
assert_eq!(projection, "contention");
assert!(
message.starts_with(PROJECT_WRITE_LOCK_CONTENTION_PREFIX),
"{message}"
);
// 目标此刻存在(真实争用或带句柄的删除挂起):永远按争用上报。
fs::write(&path, b"{}").expect("write a visible lock fixture");
let (projection, message) = ambiguous.exhausted_projection(true);
assert_eq!(projection, "contention");
assert!(
message.starts_with(PROJECT_WRITE_LOCK_CONTENTION_PREFIX),
"{message}"
);
fs::remove_file(&path).expect("remove lock fixture");
// 32 / 33 只可能在目标被占用时出现,不会因为目标缺失被改判成权限拒绝。
let occupied = ProjectWriteLockFailure::Retryable {
platform: ProjectWriteLockPlatform::Windows,
path: path.clone(),
source: std::io::Error::from_raw_os_error(32),
};
assert_eq!(occupied.exhausted_projection(true).0, "contention");
// Unix 侧没有这种不可区分的错误码:EACCES 在分类阶段就是终态,永远不会被改判。
let unix_denied = ProjectWriteLockFailure::Retryable {
platform: ProjectWriteLockPlatform::Unix,
path: path.clone(),
source: std::io::Error::from_raw_os_error(5),
};
assert_eq!(unix_denied.exhausted_projection(true).0, "contention");
// 明确判定的权限拒绝(Unix EACCES)不携带争用前缀,调用方不会当成瞬时状态。
let denied = ProjectWriteLockFailure::Terminal(project_write_lock_permission_error(
&path,
&std::io::Error::from(std::io::ErrorKind::PermissionDenied),
));
assert!(!denied.is_retryable());
assert!(
!denied
.message()
.starts_with(PROJECT_WRITE_LOCK_CONTENTION_PREFIX),
"{}",
denied.message()
);
}
#[cfg(all(test, windows))]
#[test]
fn project_write_lock_hardens_space_containing_path_in_process() {
let parent = tempfile::tempdir().expect("create spaced lock parent");
let root = parent
.path()
.join("Genarrative GameAgent")
.join("gameagent-space");
fs::create_dir_all(&root).expect("create spaced project root");
let lock = acquire_project_write_lock(&root, "planning.v2.approval")
.expect("acquire project lock under a space-containing path");
let lock_path = root.join(".agent").join("project.lock");
assert!(lock_path.is_file(), "project lock must exist while held");
crate::secure_windows_game_creator_path_for_current_user(&lock_path, false, false)
.expect("new project lock must already satisfy the private DACL contract");
drop(lock);
assert!(
!lock_path.exists(),
"project lock must be removed when the guard is dropped"
);
}
fn resolve_project_write_lock_path(root: &Path) -> Result<PathBuf, String> {
let normalized = normalize_relative_path(PROJECT_WRITE_LOCK_PATH)?;
let (parent_relative, file_name) = normalized
.rsplit_once('/')
.ok_or_else(|| "项目写锁路径必须包含安全父目录".to_string())?;
let parent = resolve_local_project_path(root, parent_relative)?;
// create_new is the authority for the final lock component. On Windows a
// delete-pending lock can make a metadata preflight fail with ACCESS_DENIED
// before the existing bounded contention wait has a chance to run.
Ok(parent.join(file_name))
}
pub(crate) fn acquire_project_write_lock(
root: &Path,
command_id: &str,
) -> Result<ProjectWriteLock, String> {
acquire_project_write_lock_failure(root, command_id).map_err(|failure| failure.message())
}
/// 与 `acquire_project_write_lock` 同一实现,但把失败分类交给调用方。
///
/// 有界等待必须按失败类型决定是否重试:用错误文案前缀做控制流时,改一次文案就等于改一次
/// 重试语义。前置校验失败没有可重试语义,统一作为终态文案上报。
pub(crate) fn acquire_project_write_lock_failure(
root: &Path,
command_id: &str,
) -> Result<ProjectWriteLock, ProjectWriteLockFailure> {
validate_project_root(root).map_err(ProjectWriteLockFailure::Terminal)?;
let mut path =
resolve_project_write_lock_path(root).map_err(ProjectWriteLockFailure::Terminal)?;
if let Some(parent) = path.parent() {
ensure_game_creator_private_directory_tree(parent, "项目锁目录")
.map_err(ProjectWriteLockFailure::Terminal)?;
prepare_game_creator_private_path_for_read(parent, true, "项目锁目录")
.map_err(ProjectWriteLockFailure::Terminal)?;
}
// Re-check the parent after creation so skipping metadata only for the final
// create_new target cannot weaken the normal ancestor link/reparse checks.
path = resolve_project_write_lock_path(root).map_err(ProjectWriteLockFailure::Terminal)?;
let payload = serde_json::json!({
"commandId": command_id,
"pid": std::process::id(),
// 进程启动身份:崩溃残留锁要靠它区分“PID 被复用”和“持有者仍然活着”。
"processStartedAt": project_write_lock_process_start_time_seconds(u64::from(
std::process::id()
)),
"createdAt": unix_timestamp(),
"nonce": PROJECT_WRITE_LOCK_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
});
let content = serde_json::to_string_pretty(&payload)
.map_err(|error| format!("生成项目写锁失败:{error}"))
.map_err(ProjectWriteLockFailure::Terminal)?;
let mut retried_after_reclaim = false;
loop {
let mut options = fs::OpenOptions::new();
options.create_new(true).write(true);
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
options.custom_flags(PROJECT_FILE_FLAG_OPEN_REPARSE_POINT);
}
match options.open(&path) {
Ok(mut file) => {
if let Err(error) = file.write_all(content.as_bytes()) {
drop(file);
let _ = fs::remove_file(&path);
return Err(ProjectWriteLockFailure::Terminal(format!(
"写入项目写锁失败:{}: {error}",
path.display()
)));
}
if let Err(error) = file.sync_all() {
drop(file);
let _ = fs::remove_file(&path);
return Err(ProjectWriteLockFailure::Terminal(format!(
"落盘项目写锁失败:{}: {error}",
path.display()
)));
}
drop(file);
if let Err(error) = harden_new_game_creator_private_path(&path, false, "项目写锁")
{
let _ = fs::remove_file(&path);
return Err(ProjectWriteLockFailure::Terminal(error));
}
let actual = match fs::read_to_string(&path) {
Ok(actual) => actual,
Err(error) => {
let _ = fs::remove_file(&path);
return Err(ProjectWriteLockFailure::Terminal(format!(
"读取项目写锁失败:{}: {error}",
path.display()
)));
}
};
if actual != content {
let _ = fs::remove_file(&path);
return Err(ProjectWriteLockFailure::Terminal(format!(
"项目写锁内容校验失败:{}",
path.display()
)));
}
register_project_write_lock_thread_owner(&path);
return Ok(ProjectWriteLock {
path,
content: content.clone(),
bypassed_same_process: false,
});
}
Err(error) => {
// 是否可重试只看错误码(平台判据见 `project_write_lock_open_failure_for`):
// 拿 `path.exists()` 当场判死会在删除拆链窗口里把瞬时争用变成永久失败。
let failure =
project_write_lock_open_failure_for(PROJECT_WRITE_LOCK_PLATFORM, &error);
if failure == ProjectWriteLockOpenFailure::Permission {
// 只有 Unix 的 EACCES 能在这里被明确判定(Windows 的 ACCESS_DENIED
// 归可重试,终态由等待方在预算耗尽后投影)。权限拒绝不会重试,所以在
// 这里记录:它必须能在 App 日志里和"别人正在写"区分开。
app_log!(
"project.write_lock.permission_denied commandId={command_id} path={} osError={:?}",
path.display(),
error.raw_os_error()
);
return Err(ProjectWriteLockFailure::Terminal(
project_write_lock_permission_error(&path, &error),
));
}
if failure == ProjectWriteLockOpenFailure::Other {
return Err(ProjectWriteLockFailure::Terminal(format!(
"创建项目写锁失败:{}: {error}",
path.display()
)));
}
if !retried_after_reclaim {
if let Some(snapshot) = project_write_lock_reclaimable_snapshot(&path) {
if project_write_lock_reclaim(&path, &snapshot)
.map_err(ProjectWriteLockFailure::Terminal)?
{
app_log!(
"project.write_lock.reclaim_stale commandId={command_id} path={} holder={}",
path.display(),
snapshot.describe_holder()
);
retried_after_reclaim = true;
continue;
}
}
}
if project_write_lock_is_owned_by_current_process(&path)
&& (crate::agent::autonomous_game_build_root_run_active_at(root)
|| project_write_lock_reentered_by_current_thread(&path))
{
// 持锁方就是本进程自己时必须区分重入与并发:同一条调用链(同一
// 线程)再次取锁,以及自主流水线有意并行专家动作,返回 advisory
// guard、不自等、不动真实锁;本进程**其它线程**正在写则继续走
// 有界等待,保住 revision 侧车、steer 序号、一致快照读与恢复安装
// 的串行化。
return Ok(ProjectWriteLock {
path,
content: String::new(),
bypassed_same_process: true,
});
}
// 争用不在零等待入口里记日志:有界等待会把这个函数调用上千次,
// 每次记一行会淹掉日志。等待方在预算耗尽时记一条带等待时长的记录。
return Err(ProjectWriteLockFailure::Retryable {
platform: PROJECT_WRITE_LOCK_PLATFORM,
path: path.clone(),
source: error,
});
}
}
}
}