修复锁失败终态判据漏传平台导致 CI 把权限改判成争用
Project CI / Repository checks (pull_request) Successful in 6m17s
Project CI / Frontend tests (pull_request) Successful in 8m23s
Project CI / Backend tests (pull_request) Successful in 12m7s
Project CI / Native shell tests (pull_request) Successful in 25m6s

- project_write_lock_permission_is_ambiguous 改为按平台加原始错误码判定(Windows 的 ACCESS_DENIED(5));只看 ErrorKind 在 Linux 上会得出相反结论:errno 5 在 Windows 是 ACCESS_DENIED、在 Linux 是 EIO
- ProjectWriteLockFailure::Retryable 携带 platform,终态改判与争用文案都用同一次分类的平台,不再依赖宿主 errno 语义
- 终态投影用例显式用 Windows 平台构造失败并补 Unix 反例,两个平台上结论一致;CI 首次推送正是在此失败(2358 passed / 1 failed,left contention / right permission_denied)
- pitfalls 补充“判据的每一环都要带平台”的排障经验
This commit is contained in:
2026-09-10 20:46:10 +08:00
parent 06f738dc99
commit 71e9ad3133
2 changed files with 41 additions and 10 deletions
@@ -447,8 +447,9 @@ fn project_write_lock_open_failure_for(
#[derive(Debug)]
pub(crate) enum ProjectWriteLockFailure {
/// 目标被占用、删除挂起或正处于删除拆链窗口:允许进入有界等待。
/// 保留 `create_new` 的原始错误,等待层才能在预算耗尽后做终态投影。
/// 保留平台与 `create_new` 的原始错误,等待层才能在预算耗尽后做终态投影。
Retryable {
platform: ProjectWriteLockPlatform,
path: PathBuf,
source: std::io::Error,
},
@@ -456,10 +457,16 @@ pub(crate) enum ProjectWriteLockFailure {
Terminal(String),
}
/// Windows `ACCESS_DENIED(5)` 可能是删除拆链窗口,也可能是真实权限拒绝,
/// 错误码本身不可区分;只有这一类的可重试失败才可能在等待之后被改判。
fn project_write_lock_permission_is_ambiguous(source: &std::io::Error) -> bool {
source.kind() == std::io::ErrorKind::PermissionDenied
/// 只有 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 {
@@ -474,10 +481,14 @@ impl ProjectWriteLockFailure {
pub(crate) fn message(&self) -> String {
match self {
Self::Terminal(message) => message.clone(),
Self::Retryable { path, source } => project_write_lock_contention_error(
Self::Retryable {
platform,
path,
source,
} => project_write_lock_contention_error(
path,
ProjectWriteLockSnapshot::read(path).as_ref(),
project_write_lock_permission_is_ambiguous(source) && !path.exists(),
project_write_lock_permission_is_ambiguous(*platform, source) && !path.exists(),
),
}
}
@@ -488,10 +499,16 @@ impl ProjectWriteLockFailure {
/// 说明这不是瞬时争用而是权限 / ACL 拒绝,此时才改判。单次试探(`max_attempts == 1`
/// 没有等待证据,保持争用语义,不做终态改判。
pub(crate) fn exhausted_projection(&self, waited: bool) -> (&'static str, String) {
let Self::Retryable { path, source } = self else {
let Self::Retryable {
platform,
path,
source,
} = self
else {
return ("terminal", self.message());
};
if waited && project_write_lock_permission_is_ambiguous(source) && !path.exists() {
if waited && project_write_lock_permission_is_ambiguous(*platform, source) && !path.exists()
{
return (
"permission_denied",
project_write_lock_permission_error(path, source),
@@ -600,11 +617,15 @@ fn project_write_lock_retryability_comes_from_the_error_code_not_a_metadata_prob
/// 终态改判的三个条件必须同时成立:真的等过预算、目标此刻仍不存在、错误码是 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),
};
@@ -641,11 +662,20 @@ fn project_write_lock_exhausted_projection_needs_a_waited_budget_and_a_missing_t
// 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,
@@ -847,6 +877,7 @@ pub(crate) fn acquire_project_write_lock_failure(
// 争用不在零等待入口里记日志:有界等待会把这个函数调用上千次,
// 每次记一行会淹掉日志。等待方在预算耗尽时记一条带等待时长的记录。
return Err(ProjectWriteLockFailure::Retryable {
platform: PROJECT_WRITE_LOCK_PLATFORM,
path: path.clone(),
source: error,
});
@@ -5073,7 +5073,7 @@
- **现象**:AGC 新建项目后第一轮 Direct 对话里,唯一的项目写入通道 `agc_write_file` 每次都返回 `项目正在被其他写操作占用:<项目根>\.agent\project.lock`;同一轮 15 次写入全部 `status=failed``durationMs` 只有 24-42ms,而只读工具(`agc_list_project_files``agc_list_registered_assets``client.session.info`)全部正常,整轮无法写入任何项目文件。
- **原因**`.agent/project.lock``create_new` 存在性锁,Direct 通道却调零等待的 `acquire_project_write_lock`,与 App 自身其它写通道(美术 lane、revision、conversation、预览等)撞车就直接判死;而 `file.write / file.patch / file.delete` 等入口走的是约 10 秒有界等待。**失败耗时本身就是判据**:几十毫秒说明这个入口根本没等,同等争用在其它通道会被等待窗口吸收。此外错误文案不带 `commandId / pid / createdAt / ownerIsSelf`,又把 ACL 拒绝、delete-pending 和真实跨进程争用压成同一句话,现场很容易被误判成“残留锁”。
- **处理**:Direct 写路径改用统一的有界等待;`create_new` 失败按可重试 / 权限 / 其它分三类并给不同文案;争用错误与等待日志都带持锁方身份,`ownerIsSelf` 区分“自己人”和“别人”。
- **补充:重试性不能由一次 metadata 观察决定**。Windows 上 `create_new` 在目标被删除的拆链窗口里会返回 `ACCESS_DENIED(5)`,而此刻 `exists()` 往往已经报 false——本机 6 万次建锁 / 删锁竞争实测 396-538 例命中“5 + 目标不可见”。用 `path.exists()` 当场判成权限拒绝,等待层会立刻失败关闭,把同一个问题换成更误导的 ACL 文案。正确形状是:重试性只看错误码(`ACCESS_DENIED(5)` / sharing violation(32) / lock violation(33) / 已存在都可重试),终态改判放到等满预算之后——真的等过、目标此刻仍不存在,才改判成权限拒绝。分类判据把平台作为参数传入,Linux CI 才能覆盖 Windows 分支(CI 没有 Windows runner`#[cfg(windows)]` 用例在 CI 里一次都不跑)。
- **补充:重试性不能由一次 metadata 观察决定**。Windows 上 `create_new` 在目标被删除的拆链窗口里会返回 `ACCESS_DENIED(5)`,而此刻 `exists()` 往往已经报 false——本机 6 万次建锁 / 删锁竞争实测 396-538 例命中“5 + 目标不可见”。用 `path.exists()` 当场判成权限拒绝,等待层会立刻失败关闭,把同一个问题换成更误导的 ACL 文案。正确形状是:重试性只看错误码(`ACCESS_DENIED(5)` / sharing violation(32) / lock violation(33) / 已存在都可重试),终态改判放到等满预算之后——真的等过、目标此刻仍不存在,才改判成权限拒绝。分类判据把平台作为参数传入,Linux CI 才能覆盖 Windows 分支(CI 没有 Windows runner`#[cfg(windows)]` 用例在 CI 里一次都不跑)。**判据的每一环都要带平台**:只看 `ErrorKind` 的判据在 Linux 上会给出相反结论——errno 5 在 Windows 是 `ACCESS_DENIED`、在 Linux 是 `EIO``Uncategorized`)。所以“等满预算再改判成权限拒绝”这第二个判据也必须连同平台与原始错误码一起传,否则 Windows 侧的行为在 CI 上永远测不到(本批第一次推送就是 CI 抓到 `permission_denied` 被改判成 `contention`:判据只比了 `kind()`)。
- **排查顺序**:① 先看失败耗时——几十毫秒说明该入口没等,是等待窗口缺失,不是锁没释放。② 看错误里的 `ownerIsSelf``true` 指向同进程另一条写通道,`false` 指向外部进程;该字段只比 PID,PID 复用会把外人报成自己人,只当线索、不当判据(回收判据另有 `processStartedAt` 兜底)。③ **锁文件在失败后通常已被 Drop 删掉,现场缺文件不否定争用**;同理 `agc_list_registered_assets``pendingOperations: []` 只表示没有在跑的付费生成,与项目写锁无关,不构成“锁没有持有者”的证据。④ `.agent/.manifest.json.lock` 是 manifest 的持久 OS 文件锁(Windows 不共享写句柄 / Unix `flock`),0 字节长期存在是设计如此,不是残留锁,也不要用项目写锁的回收判据去处理它。⑤ 看到“项目写锁路径权限被拒绝”时注意它的含义:这是**等满等待窗口后**的终态改判(Windows 上真实 ACL 拒绝就走这条路),不是某一瞬间的 metadata 观察;反过来,`项目正在被其他写操作占用:…(持锁方身份不可读:锁文件此刻不存在…)` 是零等待入口无法区分拆链窗口与 ACL 拒绝时的并列表述,两者不要互相否定。
- **验证**:Rust 定向覆盖同进程重叠写等待、同轮并行写、活外部进程持锁带身份、权限拒绝不投影成争用,以及两条平台无关判据用例(重试性只由错误码决定、终态改判三条件);`runtime_project_write_lock_waits_for_delete_pending_target` 继续覆盖带句柄的 delete-pending 必须等到成功。
- **关联**`apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs``apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs`