fix(accounts,probe): 测试缝换稳定身份(ABA)+ 超时墙钟对齐真实预算(M236)

两条 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 红。
This commit is contained in:
AskaEth committed 2026-11-11 06:53:52 +08:00
1 parent 30d4163816
commit 4952ee2706
3 files changed
+224 -19

No files matched your search

+106 -14
View File
@@ -163,10 +163,23 @@ pub fn remove(env: &HostEnv, key: &str) -> Result<String, AccountFailure> {
}
/// 微软登录任务句柄(取消标志 + 账户名回执;两条流共用)。
#[derive(Debug, Clone, Default)]
#[derive(Debug, Clone)]
pub struct LoginJob {
/// 用户点了取消(线程收尾时据此丢弃结果)
pub cancelled: Arc<AtomicBool>,
/// 测试缝的**稳定身份**(见 `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<Vec<(usize, bool)>> = 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<AtomicBool>,
browser_first: bool,
}
impl Entry {
pub(super) fn new(job: JobId, cancelled: &Arc<AtomicBool>, 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<Vec<Entry>> = Mutex::new(Vec::new());
/// 写一条记录:**同一个 job 只留最后一次**,顺手收走已 drop 的 job。
///
/// 「多条时取第一条」的歧义在这里被消掉:表里每个 job 恒为一条,所以 [`mode_for`] 取到的
/// 那条就是它最后一次请求的模式。收死条目的 `retain` 也在这里——表只随活 job 增长。
pub(super) fn remember(entries: &mut Vec<Entry>, 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<bool> {
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<AtomicBool>) -> LoginJob {
LoginJob {
cancelled: Arc::clone(cancelled),
id: JobId::next(),
}
}
}
@@ -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<bool> {
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` 上。
///
/// 前半段(入口 → 模式)由上一个用例守;这里守后半段(模式 → 实例字段)。两者缺一,