fix(credential): 修复并行测试偶发失败——Credential Manager 调用串行化,模拟 Gitea 改阻塞读
All checks were successful
Push & PR Check / check (push) Successful in 1m29s

全量并行 cargo test 10 次失败 3 次(单跑稳定通过),两个独立根因:

- 同进程并发 CredWriteW/CredDeleteW 会丢更新:写入并校验成功的凭据随后读回 NOT_FOUND,
  删除成功的条目又被并发写回。R10 readiness 报 needs_repair、开发变量试点报「凭证引用缺失」
  均源于此(诊断日志证实无跨测试删除;8 线程压力实验直接复现)。windows_store 的
  put/get/delete 经进程级锁串行,生产中 MCP 与 UI 并发写凭据同样受益;跨进程不在保护范围
- Windows 上从 nonblocking listener accept 的 socket 继承 nonblocking,set_read_timeout
  不生效,请求晚到即 WouldBlock。mcp_server 两处模拟 Gitea 补 set_nonblocking(false),
  与 commands/gitea.rs 既有写法一致

验证:修复后全量并行 cargo test 连续 30 次 0 失败(247 passed、5 ignored)。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
lanrtop 2026-10-02 18:52:05 +09:00
parent 79853b71f2
commit 4a37438a58
4 changed files with 32 additions and 2 deletions

View File

@ -494,7 +494,7 @@ modules:
area: infra area: infra
status: done status: done
progress: 100 progress: 100
updated: 2026-09-26 updated: 2026-10-02
note: "R1–R11 的合成路径和真实 Codex CLI 客户端试点已验证;真实用户凭证迁移、正式版 UI 与生产链路未验证,未启动第二个日常 Tauri 实例。" note: "R1–R11 的合成路径和真实 Codex CLI 客户端试点已验证;真实用户凭证迁移、正式版 UI 与生产链路未验证,未启动第二个日常 Tauri 实例。"
position: [3000, 950] position: [3000, 950]

View File

@ -68,6 +68,16 @@ progress: 100
## 任务拆解 ## 任务拆解
### ✅ R12 — 修复并行测试偶发失败:Credential Manager 串行化 + 模拟 Gitea 阻塞读
- status: done
- complexity: S
- files: src-tauri/src/credential_store.rs, src-tauri/src/mcp_server.rs
- depends: R3, R10
- acceptance: "agent_verify: [cargo test] 全量并行连续 30 次 → 0 失败(修复前基线 10 次失败 3 次:R10 scoped_mcp_create_pull_request_uses_synthetic_credential 的 readiness=needs_repair、scoped_dev_variable_http_pilot 的「凭证引用缺失」;仅修凭据后 30 次仍有 1 次 R10 模拟 Gitea read_line WouldBlock)。"
- verification: "2026-10-02:修复后全量并行 cargo test 连续 30 次 0 失败(247 passed、5 ignored);仅凭据串行化的中间版本 30 次中凭据类失败为 0。"
- unverified: "跨进程并发(如 tauri dev 实例与 cargo test 同时写凭据)不受进程内锁保护;历史失败可能在本机 Credential Manager 残留无权限合成条目,非 diag 前缀的未逐条清理。"
- root_cause: "①同进程并发 CredWriteW/CredDeleteW 丢更新——写入成功后立即读回 NOT_FOUND、删除成功的条目被并发写回(8 线程压力实验复现;诊断日志证实无跨测试删除);②Windows 上 nonblocking listener accept 出的 socket 继承 nonblocking,set_read_timeout 不生效,客户端请求晚到即 WouldBlock(commands/gitea.rs 已有同款修复,mcp_server 两处 mock 遗漏)。"
### ✅ R11 — 隔离真实客户端试点宿主 ### ✅ R11 — 隔离真实客户端试点宿主
- status: done - status: done
- complexity: M - complexity: M

View File

@ -328,6 +328,17 @@ mod windows_store {
const CRED_MAX_CREDENTIAL_BLOB_SIZE: usize = 5 * 512; const CRED_MAX_CREDENTIAL_BLOB_SIZE: usize = 5 * 512;
const ERROR_NOT_FOUND: i32 = 1168; const ERROR_NOT_FOUND: i32 = 1168;
/// 同进程内并发 CredWrite/CredDelete 实测会丢更新:写入成功后立即读回 NOT_FOUND,
/// 删除成功的条目又被并发写回(2026-10-02 并行 cargo test 偶发 needs_repair 的根因)。
/// 所有 Credential Manager 调用经此锁串行;跨进程并发不在此保护范围内。
static CREDENTIAL_MANAGER_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn serialized() -> std::sync::MutexGuard<'static, ()> {
CREDENTIAL_MANAGER_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[repr(C)] #[repr(C)]
struct FileTime { struct FileTime {
low: u32, low: u32,
@ -402,7 +413,10 @@ mod windows_store {
target_alias: null_mut(), target_alias: null_mut(),
user_name: null_mut(), user_name: null_mut(),
}; };
let ok = unsafe { CredWriteW(&credential, 0) }; let ok = {
let _guard = serialized();
unsafe { CredWriteW(&credential, 0) }
};
blob.fill(0); blob.fill(0);
if ok == 0 { if ok == 0 {
Err("Credential Manager 写入失败".into()) Err("Credential Manager 写入失败".into())
@ -414,6 +428,7 @@ mod windows_store {
pub fn get(reference: &str) -> Result<Option<Vec<u8>>, String> { pub fn get(reference: &str) -> Result<Option<Vec<u8>>, String> {
let target = target(reference)?; let target = target(reference)?;
let mut raw = null_mut(); let mut raw = null_mut();
let _guard = serialized();
let ok = unsafe { CredReadW(target.as_ptr(), CRED_TYPE_GENERIC, 0, &mut raw) }; let ok = unsafe { CredReadW(target.as_ptr(), CRED_TYPE_GENERIC, 0, &mut raw) };
if ok == 0 { if ok == 0 {
let error = std::io::Error::last_os_error(); let error = std::io::Error::last_os_error();
@ -450,6 +465,7 @@ mod windows_store {
pub fn delete(reference: &str) -> Result<(), String> { pub fn delete(reference: &str) -> Result<(), String> {
let target = target(reference)?; let target = target(reference)?;
let _guard = serialized();
let ok = unsafe { CredDeleteW(target.as_ptr(), CRED_TYPE_GENERIC, 0) }; let ok = unsafe { CredDeleteW(target.as_ptr(), CRED_TYPE_GENERIC, 0) };
if ok != 0 { if ok != 0 {
return Ok(()); return Ok(());

View File

@ -1045,6 +1045,8 @@ mod tests {
Err(error) => panic!("模拟 Gitea 未收到请求: {error}"), Err(error) => panic!("模拟 Gitea 未收到请求: {error}"),
} }
}; };
// Windows 上从 nonblocking listener 接受的 socket 也可能保持 nonblocking。
stream.set_nonblocking(false).unwrap();
stream stream
.set_read_timeout(Some(std::time::Duration::from_secs(3))) .set_read_timeout(Some(std::time::Duration::from_secs(3)))
.unwrap(); .unwrap();
@ -1217,6 +1219,8 @@ mod tests {
Err(error) => panic!("5 分钟内未收到客户端的模拟 PR 请求: {error}"), Err(error) => panic!("5 分钟内未收到客户端的模拟 PR 请求: {error}"),
} }
}; };
// Windows 上从 nonblocking listener 接受的 socket 也可能保持 nonblocking。
stream.set_nonblocking(false).unwrap();
stream stream
.set_read_timeout(Some(std::time::Duration::from_secs(5))) .set_read_timeout(Some(std::time::Duration::from_secs(5)))
.unwrap(); .unwrap();