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
Reviewed-on: #355 Co-authored-by: Linghong <ink29535@proton.me> Co-committed-by: Linghong <ink29535@proton.me>
934 lines
42 KiB
Rust
934 lines
42 KiB
Rust
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,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|