diff --git a/.blueprint/manifest.yaml b/.blueprint/manifest.yaml index cfe193a..3b14ddb 100644 --- a/.blueprint/manifest.yaml +++ b/.blueprint/manifest.yaml @@ -494,7 +494,7 @@ modules: area: infra status: done progress: 100 - updated: 2026-09-26 + updated: 2026-10-02 note: "R1–R11 的合成路径和真实 Codex CLI 客户端试点已验证;真实用户凭证迁移、正式版 UI 与生产链路未验证,未启动第二个日常 Tauri 实例。" position: [3000, 950] diff --git a/.blueprint/modules/project-resource-broker.md b/.blueprint/modules/project-resource-broker.md index 7920f91..1151b32 100644 --- a/.blueprint/modules/project-resource-broker.md +++ b/.blueprint/modules/project-resource-broker.md @@ -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 — 隔离真实客户端试点宿主 - status: done - complexity: M diff --git a/src-tauri/src/credential_store.rs b/src-tauri/src/credential_store.rs index a46e428..7db4597 100644 --- a/src-tauri/src/credential_store.rs +++ b/src-tauri/src/credential_store.rs @@ -328,6 +328,17 @@ mod windows_store { const CRED_MAX_CREDENTIAL_BLOB_SIZE: usize = 5 * 512; 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)] struct FileTime { low: u32, @@ -402,7 +413,10 @@ mod windows_store { target_alias: null_mut(), user_name: null_mut(), }; - let ok = unsafe { CredWriteW(&credential, 0) }; + let ok = { + let _guard = serialized(); + unsafe { CredWriteW(&credential, 0) } + }; blob.fill(0); if ok == 0 { Err("Credential Manager 写入失败".into()) @@ -414,6 +428,7 @@ mod windows_store { pub fn get(reference: &str) -> Result>, String> { let target = target(reference)?; let mut raw = null_mut(); + let _guard = serialized(); let ok = unsafe { CredReadW(target.as_ptr(), CRED_TYPE_GENERIC, 0, &mut raw) }; if ok == 0 { let error = std::io::Error::last_os_error(); @@ -450,6 +465,7 @@ mod windows_store { pub fn delete(reference: &str) -> Result<(), String> { let target = target(reference)?; + let _guard = serialized(); let ok = unsafe { CredDeleteW(target.as_ptr(), CRED_TYPE_GENERIC, 0) }; if ok != 0 { return Ok(()); diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index ad4e572..955caff 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -1045,6 +1045,8 @@ mod tests { Err(error) => panic!("模拟 Gitea 未收到请求: {error}"), } }; + // Windows 上从 nonblocking listener 接受的 socket 也可能保持 nonblocking。 + stream.set_nonblocking(false).unwrap(); stream .set_read_timeout(Some(std::time::Duration::from_secs(3))) .unwrap(); @@ -1217,6 +1219,8 @@ mod tests { Err(error) => panic!("5 分钟内未收到客户端的模拟 PR 请求: {error}"), } }; + // Windows 上从 nonblocking listener 接受的 socket 也可能保持 nonblocking。 + stream.set_nonblocking(false).unwrap(); stream .set_read_timeout(Some(std::time::Duration::from_secs(5))) .unwrap();