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 e98095a..eb019f1 100644 --- a/crates/presenter/src/host/accounts.rs +++ b/crates/presenter/src/host/accounts.rs @@ -13,41 +13,43 @@ //! //! M132:缺 client id 只在**登录入口**失败(`auth_failure`),所以说明接在账户页的登录面板, //! 不接启动流程——没配 id 的发布版根本建不出微软账户,启动那条路用户撞不到。 +//! +//! M236 拆分:两条流的编排搬到 `accounts/login.rs`(纯搬移,路径靠下面的 `pub use` 保住), +//! 本文件只留列表/增删/句柄/测试缝——原文件正好 400 行零余量,测试缝换身份前必须先拆。 use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use unlucky_core::auth::account::Credentials; -use unlucky_core::auth::microsoft::{ - now_ms, BrowserFallbackReason, MicrosoftAuth, MicrosoftSession, -}; +use unlucky_core::auth::microsoft::{now_ms, BrowserFallbackReason}; use unlucky_core::auth::offline::offline_meta; -use unlucky_core::auth::{AccountStore, AuthError, AuthResult}; -use unlucky_core::download::blocking::block_on; +use unlucky_core::auth::{AccountStore, AuthError}; use crate::dto::CoreEvent; use crate::dto::{ valid_offline_name, AccountKind, AccountNotice, AccountNoticeCode, AccountView, - BrowserLoginNote, DeviceLoginView, LoginFailure, + BrowserLoginNote, LoginFailure, }; use super::env::HostEnv; -use super::job::{spawn, HostTask, JobSender}; -use super::opener; /// 第三方轨(服务发现 / 密码登录 / 皮肤抓取)。 pub mod authlib; +mod login; + mod refresh; mod failure; pub use failure::AccountFailure; -use failure::classify; +pub use login::{start_browser_login, start_login}; pub use authlib::{fetch_skin, spawn_probe, start_login as start_authlib_login}; +use failure::classify; + impl super::CoreHost { /// 推一条账户页横幅(一次性操作结果;页面渲染后由用户关掉)。 pub(super) fn notice(&mut self, code: AccountNoticeCode, subject: &str, detail: &str) { @@ -161,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 { @@ -172,6 +187,8 @@ impl LoginJob { pub fn new() -> Self { Self { cancelled: Arc::new(AtomicBool::new(false)), + #[cfg(test)] + id: JobId::next(), } } @@ -186,78 +203,24 @@ impl LoginJob { } } -/// 起一次微软登录(M178:**GUI 的默认路**——设备码)。 +/// [`LoginJob`] 的稳定身份:进程内单调递增,**永不复用**。 /// -/// 设备码流不依赖任何应用注册项,是唯一「在官方产物上开箱可用」的路;它**一个本地监听口都不开**, -/// 也就不会撞上 `redirect_uri is not valid`(守卫断言 `listeners_opened == 0`,在 bind 现场加)。 -pub fn start_login(env: HostEnv, tx: JobSender, job: LoginJob) { - spawn("msa-login", move || { - let auth = match msa_auth(&env, MsaEntry::DeviceCode, &job) { - Ok(auth) => auth, - Err(error) => return fail(&tx, auth_failure(&error), error.to_string()), - }; - let tx_prompt = tx.clone(); - let result = block_on(auth.device_code_login(|prompt| { - let _ = tx_prompt.send(HostTask::Login(Box::new(DeviceLoginView::waiting( - &prompt.user_code, - &prompt.verification_uri, - prompt.expires_in.as_secs(), - &format!("{}#{}", prompt.verification_uri, prompt.user_code), - )))); - })); - finish(&env, &tx, &job, result); - }); -} +/// 为什么不用 `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); -/// 起一次「浏览器优先、失败自动回退设备码」的登录(M178:可选加速路)。 -/// -/// 只在用户**显式要求**时才走到这里(账户页的「用浏览器登录」)。即使如此也不盲试:`core` 先做 -/// 回环注册预检,没登记就**不绑端口、不弹浏览器**,直接设备码;试了但失败同样回退并给一句说明。 -pub fn start_browser_login(env: HostEnv, tx: JobSender, job: LoginJob) { - spawn("msa-browser", move || { - // **P1-1 的那一行**:这里是「用户点了用浏览器登录」,必须把 browser_first 置位, - // 否则下面这段预检/回退编排整段不执行(复审 r1 实测的洞)。 - let auth = match msa_auth(&env, MsaEntry::Browser, &job) { - Ok(auth) => auth, - Err(error) => return fail(&tx, auth_failure(&error), error.to_string()), - }; - let tx_browser = tx.clone(); - let tx_device = tx.clone(); - let result = block_on(auth.browser_then_device_login( - |prompt| { - // 浏览器拉不起来不是致命错误:面板里还有可复制的授权链接。 - let opened = opener::open_url(&prompt.authorization_url); - let _ = - tx_browser.send(HostTask::Login(Box::new(DeviceLoginView::browser_waiting( - &prompt.authorization_url, - prompt.timeout.as_secs(), - opened.err().as_deref(), - )))); - }, - |prompt, fallback| { - let view = DeviceLoginView::waiting( - &prompt.user_code, - &prompt.verification_uri, - prompt.expires_in.as_secs(), - &format!("{}#{}", prompt.verification_uri, prompt.user_code), - ); - let view = match fallback { - Some(fallback) => { - // 回退原文只进日志;面板只看码。 - eprintln!( - "[auth] 浏览器登录未走成({}),已回退设备码:{}", - fallback.reason.code(), - fallback.detail - ); - view.with_browser_note(browser_note(fallback.reason)) - } - None => view, - }; - let _ = tx_device.send(HostTask::Login(Box::new(view))); - }, - )); - finish(&env, &tx, &job, result.map(|outcome| outcome.session)); - }); +#[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 的中文诊断)。 @@ -285,55 +248,97 @@ impl MsaEntry { } } -/// 生产入口的 `MicrosoftAuth`(client id 取环境/构建期,失败日志落 `<数据根>/logs/`)。 -/// -/// **`browser_first` 必须在这里按入口置位**(复审 r1 P1-1):只构造默认 `MicrosoftAuth` 的话, -/// 浏览器按钮会静默退化成设备码——预检/回退码/说明全不可达。 -fn msa_auth(env: &HostEnv, entry: MsaEntry, _job: &LoginJob) -> AuthResult { - #[cfg(test)] - test_seam::record(_job, entry); - MicrosoftAuth::from_env().map(|auth| { - auth.with_browser_first(entry.browser_first()) - .with_journal(Some(unlucky_core::auth::AuthJournal::in_logs_dir( - env.layout.logs(), - ))) - }) -} - /// 测试缝:记下生产入口**实际请求**的模式,让守卫能钉住「按钮 → browser_first=true」这条线。 /// /// 为什么需要它:两条入口在没有 client id 的测试构建里都会在网络之前早退,拿不到 /// `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(), + } } } @@ -349,52 +354,5 @@ fn auth_failure(error: &AuthError) -> LoginFailure { } } -/// 失败收尾(带**原因码**):原文进日志,页面据码出 i18n(复审 r1 P1-1)。 -fn fail(tx: &JobSender, failure: LoginFailure, detail: impl Into) { - let detail = detail.into(); - eprintln!("[auth] 微软登录失败({}):{detail}", failure.code()); - let _ = tx.send(HostTask::Login(Box::new(DeviceLoginView::failed_as( - failure, detail, - )))); -} - -/// 收尾:取消就吞掉结果;成功入库;失败带 core 的诊断文案(M38 的 403 也走这条)。 -fn finish(env: &HostEnv, tx: &JobSender, job: &LoginJob, result: AuthResult) { - if job.is_cancelled() { - let _ = tx.send(HostTask::Login(Box::default())); - return; - } - match result { - Ok(session) => match store_session(env, session) { - Ok(name) => { - let _ = tx.send(HostTask::Login(Box::new(DeviceLoginView::succeeded(&name)))); - let _ = tx.send(HostTask::Accounts(accounts(env, None))); - } - Err(message) => fail(tx, LoginFailure::StoreFailed, message), - }, - Err(error) => { - let failure = classify(&error); - fail(tx, failure, error.to_string()); - } - } -} - -/// 微软会话入库(元数据 + 凭据分文件,见 `core::auth::AccountStore`)。 -fn store_session(env: &HostEnv, session: MicrosoftSession) -> Result { - let meta = unlucky_core::auth::account::AccountMeta::new( - unlucky_core::auth::account::AccountKind::Microsoft, - session.profile_id.clone(), - session.profile_name.clone(), - now_ms(), - ); - let name = meta.display_name().to_string(); - let mut store = AccountStore::open(env.layout.auth_data()); - store - .insert(meta, Credentials::Microsoft(session)) - .and_then(|()| store.save()) - .map_err(|error| format!("账户入库失败:{error}"))?; - Ok(name) -} - #[cfg(test)] mod tests; diff --git a/crates/presenter/src/host/accounts/login.rs b/crates/presenter/src/host/accounts/login.rs new file mode 100644 index 0000000..f599ed5 --- /dev/null +++ b/crates/presenter/src/host/accounts/login.rs @@ -0,0 +1,161 @@ +//! 微软登录的两条流(设备码默认 / 浏览器优先失败回退设备码)+ 会话入库。 +//! +//! 从 `accounts.rs` 拆出来(M236):父文件顶着 400 行硬线,而本轮要给测试缝换一个**稳定身份** +//! (`Arc::as_ptr` 会被堆地址复用骗到,见父模块的 `test_seam`),拆出子模块才有地方落。 +//! +//! **纯搬移**:下面每个函数体、每一行签名都与搬移前逐行相同(没有改一个字的可见性);原来的 +//! 公开路径靠父模块的 `pub use` 保住(`host::accounts::start_login` / `start_browser_login`)。 +//! +//! 两条流除了「怎么拿到 MS access_token」之外完全共用:同一个取消槽位(父模块的 `LoginJob`)、 +//! 同一段入库(`store_session`)、同一个收尾(`finish`)——所以它们必须住在同一个文件里。 + +use unlucky_core::auth::account::Credentials; +use unlucky_core::auth::microsoft::{now_ms, MicrosoftAuth, MicrosoftSession}; +use unlucky_core::auth::{AccountStore, AuthResult}; +use unlucky_core::download::blocking::block_on; + +use crate::dto::{DeviceLoginView, LoginFailure}; + +use super::super::env::HostEnv; +use super::super::job::{spawn, HostTask, JobSender}; +use super::super::opener; +use super::failure::classify; +#[cfg(test)] +use super::test_seam; +use super::{accounts, auth_failure, browser_note, LoginJob, MsaEntry}; + +/// 起一次微软登录(M178:**GUI 的默认路**——设备码)。 +/// +/// 设备码流不依赖任何应用注册项,是唯一「在官方产物上开箱可用」的路;它**一个本地监听口都不开**, +/// 也就不会撞上 `redirect_uri is not valid`(守卫断言 `listeners_opened == 0`,在 bind 现场加)。 +pub fn start_login(env: HostEnv, tx: JobSender, job: LoginJob) { + spawn("msa-login", move || { + let auth = match msa_auth(&env, MsaEntry::DeviceCode, &job) { + Ok(auth) => auth, + Err(error) => return fail(&tx, auth_failure(&error), error.to_string()), + }; + let tx_prompt = tx.clone(); + let result = block_on(auth.device_code_login(|prompt| { + let _ = tx_prompt.send(HostTask::Login(Box::new(DeviceLoginView::waiting( + &prompt.user_code, + &prompt.verification_uri, + prompt.expires_in.as_secs(), + &format!("{}#{}", prompt.verification_uri, prompt.user_code), + )))); + })); + finish(&env, &tx, &job, result); + }); +} + +/// 起一次「浏览器优先、失败自动回退设备码」的登录(M178:可选加速路)。 +/// +/// 只在用户**显式要求**时才走到这里(账户页的「用浏览器登录」)。即使如此也不盲试:`core` 先做 +/// 回环注册预检,没登记就**不绑端口、不弹浏览器**,直接设备码;试了但失败同样回退并给一句说明。 +pub fn start_browser_login(env: HostEnv, tx: JobSender, job: LoginJob) { + spawn("msa-browser", move || { + // **P1-1 的那一行**:这里是「用户点了用浏览器登录」,必须把 browser_first 置位, + // 否则下面这段预检/回退编排整段不执行(复审 r1 实测的洞)。 + let auth = match msa_auth(&env, MsaEntry::Browser, &job) { + Ok(auth) => auth, + Err(error) => return fail(&tx, auth_failure(&error), error.to_string()), + }; + let tx_browser = tx.clone(); + let tx_device = tx.clone(); + let result = block_on(auth.browser_then_device_login( + |prompt| { + // 浏览器拉不起来不是致命错误:面板里还有可复制的授权链接。 + let opened = opener::open_url(&prompt.authorization_url); + let _ = + tx_browser.send(HostTask::Login(Box::new(DeviceLoginView::browser_waiting( + &prompt.authorization_url, + prompt.timeout.as_secs(), + opened.err().as_deref(), + )))); + }, + |prompt, fallback| { + let view = DeviceLoginView::waiting( + &prompt.user_code, + &prompt.verification_uri, + prompt.expires_in.as_secs(), + &format!("{}#{}", prompt.verification_uri, prompt.user_code), + ); + let view = match fallback { + Some(fallback) => { + // 回退原文只进日志;面板只看码。 + eprintln!( + "[auth] 浏览器登录未走成({}),已回退设备码:{}", + fallback.reason.code(), + fallback.detail + ); + view.with_browser_note(browser_note(fallback.reason)) + } + None => view, + }; + let _ = tx_device.send(HostTask::Login(Box::new(view))); + }, + )); + finish(&env, &tx, &job, result.map(|outcome| outcome.session)); + }); +} + +/// 生产入口的 `MicrosoftAuth`(client id 取环境/构建期,失败日志落 `<数据根>/logs/`)。 +/// +/// **`browser_first` 必须在这里按入口置位**(复审 r1 P1-1):只构造默认 `MicrosoftAuth` 的话, +/// 浏览器按钮会静默退化成设备码——预检/回退码/说明全不可达。 +fn msa_auth(env: &HostEnv, entry: MsaEntry, _job: &LoginJob) -> AuthResult { + #[cfg(test)] + test_seam::record(_job, entry); + MicrosoftAuth::from_env().map(|auth| { + auth.with_browser_first(entry.browser_first()) + .with_journal(Some(unlucky_core::auth::AuthJournal::in_logs_dir( + env.layout.logs(), + ))) + }) +} + +/// 失败收尾(带**原因码**):原文进日志,页面据码出 i18n(复审 r1 P1-1)。 +fn fail(tx: &JobSender, failure: LoginFailure, detail: impl Into) { + let detail = detail.into(); + eprintln!("[auth] 微软登录失败({}):{detail}", failure.code()); + let _ = tx.send(HostTask::Login(Box::new(DeviceLoginView::failed_as( + failure, detail, + )))); +} + +/// 收尾:取消就吞掉结果;成功入库;失败带 core 的诊断文案(M38 的 403 也走这条)。 +fn finish(env: &HostEnv, tx: &JobSender, job: &LoginJob, result: AuthResult) { + if job.is_cancelled() { + let _ = tx.send(HostTask::Login(Box::default())); + return; + } + match result { + Ok(session) => match store_session(env, session) { + Ok(name) => { + let _ = tx.send(HostTask::Login(Box::new(DeviceLoginView::succeeded(&name)))); + let _ = tx.send(HostTask::Accounts(accounts(env, None))); + } + Err(message) => fail(tx, LoginFailure::StoreFailed, message), + }, + Err(error) => { + let failure = classify(&error); + fail(tx, failure, error.to_string()); + } + } +} + +/// 微软会话入库(元数据 + 凭据分文件,见 `core::auth::AccountStore`)。 +fn store_session(env: &HostEnv, session: MicrosoftSession) -> Result { + let meta = unlucky_core::auth::account::AccountMeta::new( + unlucky_core::auth::account::AccountKind::Microsoft, + session.profile_id.clone(), + session.profile_name.clone(), + now_ms(), + ); + let name = meta.display_name().to_string(); + let mut store = AccountStore::open(env.layout.auth_data()); + store + .insert(meta, Credentials::Microsoft(session)) + .and_then(|()| store.save()) + .map_err(|error| format!("账户入库失败:{error}"))?; + Ok(name) +} diff --git a/crates/presenter/src/host/accounts/tests.rs b/crates/presenter/src/host/accounts/tests.rs index ba5558e..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}; @@ -16,6 +18,7 @@ use unlucky_core::auth::AuthError; use super::*; +use super::super::job::{HostTask, JobSender}; use super::failure::mentions_whitelist; #[test] @@ -239,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` 上。 /// /// 前半段(入口 → 模式)由上一个用例守;这里守后半段(模式 → 实例字段)。两者缺一,