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:
1 parent
30d4163816
commit
4952ee2706
3 files changed
+224
-19
No files matched your search
@@ -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` 上。
|
||||
///
|
||||
/// 前半段(入口 → 模式)由上一个用例守;这里守后半段(模式 → 实例字段)。两者缺一,
|
||||
|
||||
Reference in new issue
Block a user