From 4952ee2706fb007e7c3b93cad820fb100204c0d6 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 11 Nov 2026 06:22:07 +0800 Subject: [PATCH] =?UTF-8?q?fix(accounts,probe):=20=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E7=BC=9D=E6=8D=A2=E7=A8=B3=E5=AE=9A=E8=BA=AB=E4=BB=BD=EF=BC=88?= =?UTF-8?q?ABA=EF=BC=89+=20=E8=B6=85=E6=97=B6=E5=A2=99=E9=92=9F=E5=AF=B9?= =?UTF-8?q?=E9=BD=90=E7=9C=9F=E5=AE=9E=E9=A2=84=E7=AE=97=EF=BC=88M236?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两条 M230 台账判定「真开着」的测试债: 1. accounts 测试缝的 key 是 Arc::as_ptr(&job.cancelled)。job 一 drop,控制块 立刻可能分给下一个 LoginJob,于是新 job 读到旧 job 的记录(ABA),并行跑的 「设备码入口不许置位 browser_first」因此偶发假红。改成 LoginJob 自带的 稳定身份 JobId(cfg(test) 字段,进程内单调递增、永不复用);ENTRIES 从 Vec<(usize,bool)> 改成带 Weak 的 Entry:record 时 retain 收走已 drop 的 job, 同一 job 覆写成「最后一次」(消掉 mode_for 取第一条的歧义)。 反向证据:身份退回堆地址 ⇒ the_test_seam_does_not_reuse_a_heap_address_as_identity 红(mode_for(&younger) 读到 Some(true))。 2. probe 的 silent_server_times_out 断 `elapsed < 3s`,跟真实预算(400ms)无关。 改成 预算 + SLACK(400ms + 1s):抓「预算没生效」(那要等假服务器睡满 5s), 放开毫秒级精度;并新增两条判据——detail 里的时限就是本次预算(证明走的是 预算分支)、假服务器真的收到 1 次请求(不是没连上就早退)。 反向证据:摘掉 tokio::time::timeout ⇒ 5.08s / unreachable 红;把计时值硬编码 3s ⇒ 3.006s > 1.4s 红。 --- crates/cli/src/commands/agent/probe/tests.rs | 51 +++++++- crates/presenter/src/host/accounts.rs | 120 ++++++++++++++++--- crates/presenter/src/host/accounts/tests.rs | 72 +++++++++++ 3 files changed, 224 insertions(+), 19 deletions(-) diff --git a/crates/cli/src/commands/agent/probe/tests.rs b/crates/cli/src/commands/agent/probe/tests.rs index 0d56513..0c92430 100644 --- a/crates/cli/src/commands/agent/probe/tests.rs +++ b/crates/cli/src/commands/agent/probe/tests.rs @@ -5,7 +5,7 @@ //! | `auth_failure_is_classified_as_auth` | 401 → 鉴权失败 | `map_error` 的 401 分支改成 `Other` | //! | `missing_model_is_model_not_found` | 404 → 模型不存在 | 把 404 归进 auth | //! | `closed_port_is_unreachable` | 连不上 → 端点不可达 | 把 `ErrorKind::Http` 归到 `Other` | -//! | `silent_server_times_out` | 不响应 → 超时 | 去掉 `tokio::time::timeout` | +//! | `silent_server_times_out` | 不响应 → 超时,且在**本次预算**到点收手 | 去掉 `tokio::time::timeout`(那就得等假服务器睡满 5s) | //! | `success_is_one_real_completion` | 200 + SSE → 通过 | 把 `Ok(Ok(_))` 当成失败 | //! | `the_request_carries_the_configured_model` | **请求体里的 `model` 就是配置那个** | `probe_with` 里把 model 写死成别的名字 | //! | 每条失败用例都断言 `target` | 失败结论带「对哪个端点/模型」 | `outcome_from_error` 丢掉 target(r1 复审 P2-1) | @@ -203,24 +203,65 @@ fn closed_port_is_unreachable() { ); } +/// 超时用例的预算:跟产品默认时限(`DEFAULT_TIMEOUT` = 8s)同一个量级,取 1/20 只是为了让整个 +/// 测试套不必等 8 秒。判据只依赖「预算生效时远早于假服务器沉默的 5 秒」,不依赖这个绝对值。 +const TIMEOUT_BUDGET: Duration = Duration::from_millis(400); + +/// 允许的调度噪声:一次本地回环往返 + 建 tokio 运行时是毫秒级,1 秒是给繁忙机器的余量;它必须 +/// **远小于**「预算没生效」的代价(假服务器要睡 5s 才收手),否则两种情形区分不开。 +const TIMEOUT_SLACK: Duration = Duration::from_secs(1); + +/// 不回应 ⇒ 超时,且在**本次预算**到点时就收手(不是等对面 5 秒沉默、也不是等别处的默认时限)。 +/// +/// 三层判据各管一件事,别混: +/// +/// 1. **走了哪条分支**:`kind == "timeout"`。没被本模块预算截住的「不回应」落到的是传输层错误 +/// ——实测:摘掉 `tokio::time::timeout` ⇒ 假服务器睡满 5s 后关连接,结论是 `unreachable`, +/// 所以这一条先把「根本没走到超时分支」挡在外面。 +/// 2. **是本次预算那条分支**:`detail` 里的时限就是传进去的预算(Timeout 分支把 `timeout` 原样 +/// 写进 detail)。它证明「结论由这条预算分支产出」,**不**证明「计时器用的就是这个值」:分支 +/// 里把计时值换掉、detail 照抄参数,本条抓不住——那是下一条的活。 +/// 3. **墙钟**:`elapsed < 预算 + SLACK`,抓的正是上一条抓不住的形态(「分支对、但等的不是这个 +/// 预算」)。实测:把计时值硬编码成 3s ⇒ 本断言红(3.006s > 1.4s)。 +/// +/// 抓得住:「预算没生效 / 生效的不是这个预算」——那种情况要等假服务器睡满 5s 或等 3s 之类的 +/// 别处时限,远超 `预算 + SLACK`。 +/// 抓不住:预算到底是 400ms 还是 1.2s(都落在 SLACK 里),也不量定时器精度;机器再忙只吃掉 +/// SLACK 的余量,不吃预算本身。 #[test] fn silent_server_times_out() { + // 假服务器收下请求后睡 5s(`Behaviour::Silent`):这就是「预算没生效」时本用例会等到的墙钟。 let server = Server::start(Behaviour::Silent); let started = std::time::Instant::now(); let outcome = probe_with( &RigProvider::factory(), &settings(&server.base_url(), "gpt-test"), - Duration::from_millis(400), + TIMEOUT_BUDGET, ); + let elapsed = started.elapsed(); + assert_eq!( outcome.kind(), "timeout", "不回应的端点必须归到超时,实际 {outcome:?}" ); + let ProbeOutcome::Timeout { detail, .. } = &outcome else { + unreachable!("上一行已经断言 kind 是 timeout") + }; assert!( - started.elapsed() < Duration::from_secs(3), - "超时必须在时限附近收手,实际 {:?}", - started.elapsed() + detail.contains(&TIMEOUT_BUDGET.as_secs_f32().to_string()), + "超时结论里的时限不是本次预算({TIMEOUT_BUDGET:?}):{detail}" + ); + assert_eq!( + server.hits().len(), + 1, + "超时之前必须真的把一次请求发给了假服务器(否则「超时」可能只是没连上)" + ); + assert!( + elapsed < TIMEOUT_BUDGET + TIMEOUT_SLACK, + "预算没生效或收手太晚:预算 {TIMEOUT_BUDGET:?},实际 {elapsed:?};假服务器会沉默 5s,\ + 超过 {:?} 就说明等的不是这个预算", + TIMEOUT_BUDGET + TIMEOUT_SLACK ); } diff --git a/crates/presenter/src/host/accounts.rs b/crates/presenter/src/host/accounts.rs index 4c1b582..eb019f1 100644 --- a/crates/presenter/src/host/accounts.rs +++ b/crates/presenter/src/host/accounts.rs @@ -163,10 +163,23 @@ pub fn remove(env: &HostEnv, key: &str) -> Result { } /// 微软登录任务句柄(取消标志 + 账户名回执;两条流共用)。 -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone)] pub struct LoginJob { /// 用户点了取消(线程收尾时据此丢弃结果) pub cancelled: Arc, + /// 测试缝的**稳定身份**(见 `test_seam`):进程内单调递增、永不复用。 + /// + /// 只在测试构建里存在——发布产物不需要它。它会存在,是因为「这条记录属于哪个 job」 + /// 不能用堆地址回答:地址是会被复用的资源,不是身份(M236 的 ABA 假红)。 + #[cfg(test)] + id: JobId, +} + +impl Default for LoginJob { + /// 与 [`LoginJob::new`] 同一个东西:`id` 不许走 `Default` 的零值,否则两个 job 撞身份。 + fn default() -> Self { + Self::new() + } } impl LoginJob { @@ -174,6 +187,8 @@ impl LoginJob { pub fn new() -> Self { Self { cancelled: Arc::new(AtomicBool::new(false)), + #[cfg(test)] + id: JobId::next(), } } @@ -188,6 +203,26 @@ impl LoginJob { } } +/// [`LoginJob`] 的稳定身份:进程内单调递增,**永不复用**。 +/// +/// 为什么不用 `Arc::as_ptr(&job.cancelled)`:job 一 drop,它的控制块就可以被下一个 +/// `LoginJob` 复用,于是「新 job」会读到「旧 job」记下的模式(ABA)——并行跑的用例因此 +/// 偶发假红(实测形态:设备码入口读到别人的 `browser_first=true`)。 +#[cfg(test)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct JobId(u64); + +#[cfg(test)] +impl JobId { + /// 取下一个身份。`Relaxed` 够用:只要求唯一与单调,不要求跨线程的可见性顺序。 + fn next() -> Self { + use std::sync::atomic::AtomicU64; + + static NEXT: AtomicU64 = AtomicU64::new(1); + Self(NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) + } +} + /// 回退原因码 → 页面说明码(两边各留一个枚举:presenter 的 DTO 不依赖 core 的中文诊断)。 fn browser_note(reason: BrowserFallbackReason) -> BrowserLoginNote { match reason { @@ -219,34 +254,91 @@ impl MsaEntry { /// `MicrosoftAuth` 实例去问 `browser_first()`;而 P1-1 的 bug 恰恰在**调用点**。这里在被调用 /// 的一刻就把模式记下来,把调用点变成可断言的事实。 /// -/// 为什么按 `LoginJob` 的取消槽指针分桶:同一进程里别的用例也在调这两个入口,一个全局 -/// 「最后一次」记录会串台(实测过:并行跑时读到别人的 Browser ⇒ 假红)。 +/// 为什么按 job 分桶:同一进程里别的用例也在调这两个入口,一个全局「最后一次」记录会串台 +/// (实测过:并行跑时读到别人的 Browser ⇒ 假红)。 +/// +/// **身份是 [`JobId`],不是堆地址**(M236):`Arc::as_ptr` 那种做法会被地址复用骗到——job A +/// drop 之后控制块立刻分给 job B,B 于是读到 A 的记录(ABA 假红)。 #[cfg(test)] mod test_seam { - use std::sync::{Arc, Mutex}; + use std::sync::atomic::AtomicBool; + use std::sync::{Arc, Mutex, Weak}; - use super::{LoginJob, MsaEntry}; + use super::{JobId, LoginJob, MsaEntry}; - static ENTRIES: Mutex> = Mutex::new(Vec::new()); - - fn key(job: &LoginJob) -> usize { - Arc::as_ptr(&job.cancelled) as usize + /// 表里的一条记录。 + /// + /// `alive` 只用来回收「job 已经 drop」的条目,**不参与身份判定**(身份是 `job`)。它同时把 + /// 控制块钉住:`Weak` 还在时控制块不会释放,`strong_count() == 0` 因此读得准。 + pub(super) struct Entry { + job: JobId, + alive: Weak, + browser_first: bool, } + impl Entry { + pub(super) fn new(job: JobId, cancelled: &Arc, browser_first: bool) -> Self { + Self { + job, + alive: Arc::downgrade(cancelled), + browser_first, + } + } + + /// job 还活着吗(`false` ⇒ 这条只是还没被收走的死条目)。 + pub(super) fn is_alive(&self) -> bool { + self.alive.strong_count() > 0 + } + + pub(super) fn job(&self) -> JobId { + self.job + } + + pub(super) fn browser_first(&self) -> bool { + self.browser_first + } + } + + static ENTRIES: Mutex> = Mutex::new(Vec::new()); + + /// 写一条记录:**同一个 job 只留最后一次**,顺手收走已 drop 的 job。 + /// + /// 「多条时取第一条」的歧义在这里被消掉:表里每个 job 恒为一条,所以 [`mode_for`] 取到的 + /// 那条就是它最后一次请求的模式。收死条目的 `retain` 也在这里——表只随活 job 增长。 + pub(super) fn remember(entries: &mut Vec, entry: Entry) { + entries.retain(Entry::is_alive); + match entries.iter_mut().find(|known| known.job == entry.job) { + Some(slot) => slot.browser_first = entry.browser_first, + None => entries.push(entry), + } + } + + /// 生产入口被调用时记一笔。 pub(super) fn record(job: &LoginJob, entry: MsaEntry) { - let key = key(job); let mut entries = ENTRIES.lock().unwrap_or_else(|error| error.into_inner()); - entries.push((key, entry.browser_first())); + remember( + &mut entries, + Entry::new(job.id, &job.cancelled, entry.browser_first()), + ); } /// 这个 job 的生产构造请求的模式(`None` = 还没调用过)。 pub(super) fn mode_for(job: &LoginJob) -> Option { - let key = key(job); let entries = ENTRIES.lock().unwrap_or_else(|error| error.into_inner()); entries .iter() - .find(|(recorded, _)| *recorded == key) - .map(|(_, browser_first)| *browser_first) + .find(|known| known.job == job.id) + .map(Entry::browser_first) + } + + /// 造一个「共用同一个取消槽、但确定是另一个 job」的句柄(ABA 用例的构造件)。 + /// + /// 两个不同 job 指向同一个控制块:这正是 `Arc::as_ptr` 做 key 时无法区分的情形。 + pub(super) fn job_around(cancelled: &Arc) -> LoginJob { + LoginJob { + cancelled: Arc::clone(cancelled), + id: JobId::next(), + } } } diff --git a/crates/presenter/src/host/accounts/tests.rs b/crates/presenter/src/host/accounts/tests.rs index 33e93d8..1e9ac58 100644 --- a/crates/presenter/src/host/accounts/tests.rs +++ b/crates/presenter/src/host/accounts/tests.rs @@ -9,6 +9,8 @@ //! 拆法照抄仓库既有先例(`dto/accounts.rs`、`host/install.rs` 等):父文件里留 //! `#[cfg(test)] mod tests;`,本文件 `use super::*;` 照旧拿到父模块的一切。 +use std::sync::atomic::AtomicBool; +use std::sync::Arc; use std::time::Duration; use unlucky_core::auth::microsoft::{client_id_status, CLIENT_ID_ENV}; @@ -240,6 +242,76 @@ fn wait_for_entry(job: &LoginJob) -> Option { None } +/// **M236(台账「真开着的测试债」之一):测试缝的身份不许是会复用的堆地址。** +/// +/// 构造:两个**不同**的 `LoginJob` 共用同一个 `cancelled` 控制块地址(`Arc::clone` 不换地址, +/// `job_around` 各自给一个新身份)。这正是 `Arc::as_ptr` 做 key 时那个假设——「地址相同 ⇒ +/// 同一个 job」——失效的形态;真实世界里它由「job A 被 drop 后控制块立刻分给 job B」触发。 +/// +/// 反向证据(实测过):把身份退回 `Arc::as_ptr` ⇒ 本用例红(`mode_for(&younger)` 读到 +/// `Some(true)`);那个读串台正是并行跑时「设备码入口读到别人的 browser_first=true」的假红。 +/// 抓不住:两条入口真的去连微软(无 client id 的测试构建里它们都在网络之前早退)。 +#[test] +fn the_test_seam_does_not_reuse_a_heap_address_as_identity() { + let shared = Arc::new(AtomicBool::new(false)); + let older = super::test_seam::job_around(&shared); + let younger = super::test_seam::job_around(&shared); + assert_eq!( + Arc::as_ptr(&older.cancelled), + Arc::as_ptr(&younger.cancelled), + "前提:两个 job 必须共用同一个取消槽地址(否则本用例什么也没构造出来)" + ); + assert_eq!( + super::test_seam::mode_for(&younger), + None, + "前提:这两个 job 都还没请求过" + ); + super::test_seam::record(&older, MsaEntry::Browser); + assert_eq!( + super::test_seam::mode_for(&older), + Some(true), + "前提:记录本身要能按 job 读回来" + ); + assert_eq!( + super::test_seam::mode_for(&younger), + None, + "同一个堆地址、但确定是另一个 job:读到 older 的记录就是把「会被复用的地址」当身份" + ); +} + +/// **M236**:测试缝的书写规则——每 job 恒一条(覆盖成最后一次)、死 job 的条目被 `retain` 收走。 +/// +/// 改之前 `ENTRIES` 只 push 不 clear/retain:条目随登录次数无上限增长;`mode_for` 又用「第一条」 +/// 回答,同一个 job 记两次(重试)时会永远读到第一次的模式。 +/// 这里直接对纯函数 `remember` 造活/死条目,不碰全局表——并行用例不会互相污染。 +#[test] +fn the_test_seam_keeps_the_latest_mode_per_job_and_forgets_dropped_ones() { + let alive = Arc::new(AtomicBool::new(false)); + // 一个已经 drop 的 job:`Entry` 里的 `Weak` 把控制块钉住 ⇒ `strong_count() == 0` 读得准。 + let dead = { + let gone = Arc::new(AtomicBool::new(false)); + super::test_seam::Entry::new(JobId(1), &gone, true) + }; + assert!(!dead.is_alive(), "构造失败:这条应当已经是死条目"); + + let mut entries = vec![dead, super::test_seam::Entry::new(JobId(2), &alive, false)]; + super::test_seam::remember( + &mut entries, + super::test_seam::Entry::new(JobId(2), &alive, true), + ); + + assert_eq!( + entries.len(), + 1, + "死 job 的条目必须被收走(表只随活 job 增长)" + ); + assert_eq!(entries[0].job(), JobId(2)); + assert!( + entries[0].browser_first(), + "同一个 job 记两次必须取最后一次" + ); +} + /// 置位不是「记了个数」:`msa_auth` 必须把模式真的落到 `MicrosoftAuth::browser_first` 上。 /// /// 前半段(入口 → 模式)由上一个用例守;这里守后半段(模式 → 实例字段)。两者缺一,