From 4a37438a581091b4ec744e63e19a46777f703e1c Mon Sep 17 00:00:00 2001 From: lanrtop Date: Fri, 2 Oct 2026 18:52:05 +0900 Subject: [PATCH] =?UTF-8?q?fix(credential):=20=E4=BF=AE=E5=A4=8D=E5=B9=B6?= =?UTF-8?q?=E8=A1=8C=E6=B5=8B=E8=AF=95=E5=81=B6=E5=8F=91=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E2=80=94=E2=80=94Credential=20Manager=20=E8=B0=83=E7=94=A8?= =?UTF-8?q?=E4=B8=B2=E8=A1=8C=E5=8C=96=EF=BC=8C=E6=A8=A1=E6=8B=9F=20Gitea?= =?UTF-8?q?=20=E6=94=B9=E9=98=BB=E5=A1=9E=E8=AF=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 全量并行 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 --- .blueprint/manifest.yaml | 2 +- .blueprint/modules/project-resource-broker.md | 10 ++++++++++ src-tauri/src/credential_store.rs | 18 +++++++++++++++++- src-tauri/src/mcp_server.rs | 4 ++++ 4 files changed, 32 insertions(+), 2 deletions(-) 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();