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(),
}
}
}