diff --git a/crates/app-iced/src/app/update/instances/scan_phase.rs b/crates/app-iced/src/app/update/instances/scan_phase.rs index 56fdefc..3b5b303 100644 --- a/crates/app-iced/src/app/update/instances/scan_phase.rs +++ b/crates/app-iced/src/app/update/instances/scan_phase.rs @@ -180,3 +180,84 @@ fn retry_scan_dispatches_a_scan_only_refresh() { "重试不该重拉版本清单(那是一条网络请求)" ); } + +/// **已就绪之后再刷新:相位仍是 `Ready`,真控件树里不出现扫描占位**(复审 r2 P2-4)。 +/// +/// 现场:列表已经有数据,用户手动刷新 / 别处触发刷新。若那一轮把相位打回 `Scanning`,界面就把 +/// 有数据的一屏换成「正在扫描实例…」——把卡顿换成闪屏(任务 ⑤ 禁止)。 +/// +/// 抓得住:① 相位被打回 `Scanning`/`Stalled`;② 控件树变了(占位替换掉列表:文字/按钮节点数 +/// 与刷新前不再相等)。前半条的直接来源是 host 的守卫(presenter 的 +/// `a_refresh_after_ready_emits_no_scanning_phase` 用真 host 钉它),这一条验的是「守卫成立时 +/// 界面确实不动」。 +/// 抓不住:真 host 的那一轮扫描(这一条用只记账的后端:命令出去了、事件一个都没回)。 +#[test] +fn a_refresh_after_ready_keeps_the_list_and_the_tree() { + use iced::advanced::widget::tree::Tag; + use iced::advanced::widget::Tree; + use iced::widget::text; + use iced::Element; + + fn tree_of(el: &Element<'_, crate::pages::Message>) -> Tree { + Tree::new(el.as_widget()) + } + fn count(tree: &Tree, tag: Tag) -> usize { + usize::from(tree.tag == tag) + + tree + .children + .iter() + .map(|child| count(child, tag)) + .sum::() + } + /// 一屏的 `(文字节点数, 按钮数)`。 + fn counts(app: &App) -> (usize, usize) { + let el = crate::pages::app(&app.ui, &app.view, &app.launch_form); + let tree = tree_of(&el); + let text_tag = tree_of(&{ + let probe: Element<'static, crate::pages::Message> = text("probe").into(); + probe + }) + .tag; + let button_tag = tree_of(&crate::app::components::buttons::filled( + &app.ui, + crate::app::components::buttons::Button::new( + "probe", + crate::pages::Message::Action(crate::pages::Action::Back), + ), + )) + .tag; + (count(&tree, text_tag), count(&tree, button_tag)) + } + + let (mut app, recorded) = app(); + // 首份快照落地 ⇒ Ready + 有行 + app.state.apply(AppEvent::Core(CoreEvent::InstancesChanged( + unlucky_presenter::mock_data::mock_instances(), + ))); + app.refresh_view(); + assert_eq!(app.view.instances.scan, ScanPhase::Ready); + assert!(!app.view.instances.rows.is_empty(), "前提:列表里有数据"); + let before = counts(&app); + + // 再触发一次刷新:命令出去了,后端这一拍**一个事件都不回**(= 扫描在飞) + let _ = app.on_instance_message(InstanceMessage::RetryScan); + app.pump(); + app.refresh_view(); + + assert!( + recorded.borrow().contains(&AppCommand::RefreshInstances), + "前提:刷新命令真的发出去了({:?})", + recorded.borrow() + ); + assert_eq!( + app.view.instances.scan, + ScanPhase::Ready, + "已就绪之后再刷新,相位不许被打回占位态" + ); + assert_eq!( + counts(&app), + before, + "控件树必须与刷新前同构(占位/空态会多出文字与按钮节点)" + ); + assert!(!app.view.instances.rows.is_empty(), "列表也不许被清空"); +} diff --git a/crates/presenter/src/host/game/refresh.rs b/crates/presenter/src/host/game/refresh.rs index d4f0e19..787cbfa 100644 --- a/crates/presenter/src/host/game/refresh.rs +++ b/crates/presenter/src/host/game/refresh.rs @@ -160,22 +160,138 @@ impl Scan { } } +/// 单测用的**扫描探针**(M302 r2 P2-1):记录扫描体跑在哪条线程上、跑完了几趟、有没有被闸门拦住。 +/// +/// 为什么要它:上一轮那条「刷新返回时快照还没到」证明不了**扫盘不在调用方线程**——把 +/// `spawn("instances", job)` 换成直接 `job()`(inline),事件照样要等 `tick` 才进 host 队列, +/// 那条断言仍然绿(复审 r2 实测整棵树全绿)。真正的判据只能是「扫描体在另一条线程上跑、 +/// 而且调用方返回时它还没跑完」,于是这里给它一个可注入的闸门。 +/// +/// **只在 `cfg(test)` 里存在**:产品路径没有这个结构,也读不到它。 +#[cfg(test)] +#[derive(Debug)] +pub(super) struct Probe { + /// 只有这个数据根起的扫描才会被闸门拦住(别的用例并发跑扫描时不受影响) + root: std::path::PathBuf, + /// 扫描体进入时记下的线程 id(每趟一条) + threads: std::sync::Mutex>, + /// 已经跑完几趟 + finished: std::sync::atomic::AtomicUsize, + /// 闸门:扫描体在这里等「放行」(带超时,免得 inline 变异把用例挂死) + gate: std::sync::Mutex>, + /// 闸门有没有被放行过 + released: std::sync::atomic::AtomicBool, +} + +#[cfg(test)] +static PROBE: std::sync::Mutex>> = std::sync::Mutex::new(None); + +#[cfg(test)] +impl Probe { + /// 扫描体进入:记线程、在闸门下等放行(最多 `GATE`,超时自行离开而不是把用例挂死)。 + fn enter(&self) { + self.threads + .lock() + .unwrap_or_else(|error| error.into_inner()) + .push(std::thread::current().id()); + let receiver = self.gate.lock().unwrap_or_else(|error| error.into_inner()); + if receiver.recv_timeout(GATE).is_ok() { + self.released + .store(true, std::sync::atomic::Ordering::SeqCst); + } + } + + fn leave(&self) { + self.finished + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + } + + /// 跑过扫描的线程 id。 + pub(super) fn threads(&self) -> Vec { + self.threads + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clone() + } + + /// 跑完了几趟。 + pub(super) fn finished(&self) -> usize { + self.finished.load(std::sync::atomic::Ordering::SeqCst) + } + + /// 闸门被放行过没有。 + pub(super) fn released(&self) -> bool { + self.released.load(std::sync::atomic::Ordering::SeqCst) + } +} + +/// 闸门的等待上限:够大到「inline 变异一定超时」(那时用例本来也要靠它才不挂死), +/// 又够小到不会把门禁拖慢(正常路径毫秒级就放行)。 +#[cfg(test)] +pub(super) const GATE: Duration = Duration::from_secs(2); + +/// 装一个探针(返回放行端);同一时刻只允许一个(用例自己负责先卸再装)。 +#[cfg(test)] +pub(super) fn install_probe( + root: std::path::PathBuf, +) -> (std::sync::Arc, std::sync::mpsc::Sender<()>) { + let (tx, rx) = std::sync::mpsc::channel(); + let probe = std::sync::Arc::new(Probe { + root, + threads: std::sync::Mutex::new(Vec::new()), + finished: std::sync::atomic::AtomicUsize::new(0), + gate: std::sync::Mutex::new(rx), + released: std::sync::atomic::AtomicBool::new(false), + }); + *PROBE.lock().unwrap_or_else(|error| error.into_inner()) = Some(std::sync::Arc::clone(&probe)); + (probe, tx) +} + +/// 卸掉探针(用例结束前必调,否则会影响同进程里的其它用例)。 +#[cfg(test)] +pub(super) fn clear_probe() { + *PROBE.lock().unwrap_or_else(|error| error.into_inner()) = None; +} + +/// 这一趟扫描要不要过闸门(只有「探针在场 + 数据根相同」才拦)。 +#[cfg(test)] +fn probe_for(env: &HostEnv) -> Option> { + let guard = PROBE.lock().unwrap_or_else(|error| error.into_inner()); + guard + .as_ref() + .filter(|probe| probe.root == env.game.root()) + .cloned() +} + /// 后台扫一遍实例目录,把结果连代次一起送回 host([`HostTask::Instances`])。 /// /// 与 `versions::spawn_list` / `loader_versions::spawn_list` 同一条形状:线程里只做 core 调用, /// 出来的已经是 [`crate::dto::InstanceSummary`](跨线程传的东西在 `HostTask` 一处看全)。 pub fn spawn_scan(env: HostEnv, tx: JobSender, epoch: Epoch) { - spawn("instances", move || { - let started = Instant::now(); - let instances = super::instances(&env); - let elapsed_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX); - // 接收端没了(host 已销毁)也不必报错:这条线程的活儿本来就作废了 - let _ = tx.send(HostTask::Instances { - epoch, - instances, - elapsed_ms, - }); + spawn("instances", move || scan_body(env, tx, epoch)); +} + +/// 扫描体的本体(**从 `spawn_scan` 里提出来**,好让单测证明它跑在另一条线程上)。 +fn scan_body(env: HostEnv, tx: JobSender, epoch: Epoch) { + #[cfg(test)] + let probe = probe_for(&env); + #[cfg(test)] + if let Some(probe) = &probe { + probe.enter(); + } + let started = Instant::now(); + let instances = super::instances(&env); + let elapsed_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX); + // 接收端没了(host 已销毁)也不必报错:这条线程的活儿本来就作废了 + let _ = tx.send(HostTask::Instances { + epoch, + instances, + elapsed_ms, }); + #[cfg(test)] + if let Some(probe) = &probe { + probe.leave(); + } } impl super::super::CoreHost { @@ -185,6 +301,12 @@ impl super::super::CoreHost { /// 「正在扫描实例…」。这里只**报**超时(面板翻到可点的「重新扫描」),**不**掐线程—— /// 判据与取值理由在 [`Scan::stall`] / [`SCAN_STALL`]。 pub(crate) fn check_scan_stall(&mut self) { + // **已就绪之后不再报超时**(复审 r2 P2-2):列表里已经有真数据,一轮慢刷新不该把界面 + // 打回「扫描实例比平时久 + 重新扫描」——那会把有数据的一屏换成占位。判据与 + // `refresh_instances` 发 `Scanning` 的那道守卫**同一个**(`adopted().is_none()`)。 + if self.instances_scan.adopted().is_some() { + return; + } if !self.instances_scan.stall() { return; } diff --git a/crates/presenter/src/host/game/refresh_stall_tests.rs b/crates/presenter/src/host/game/refresh_stall_tests.rs index d84d153..a5ef5e6 100644 --- a/crates/presenter/src/host/game/refresh_stall_tests.rs +++ b/crates/presenter/src/host/game/refresh_stall_tests.rs @@ -1,4 +1,4 @@ -//! 空数据根 / 超时兜底(M302 的第二组:`Scan` 的落地与 stall 判据)。 +//! 扫盘**线程** / 首屏**三态** / 超时兜底(M302 第二组;第一组是 `refresh_tests.rs` 的交错用例)。 //! //! 从 `refresh_tests.rs` 拆出来守 400 行硬线(那边是三条**交错**用例 + 账本单测)。 //! 这一组验的是另外两件事: @@ -8,10 +8,10 @@ //! | [`an_empty_root_publishes_an_empty_snapshot`] | 把「零实例」当成「没扫完」(界面永远停在「正在扫描」),或者反过来在没扫完时发空快照 | 界面上这两态怎么画(`pages/instances/tests/cold_boot.rs`) | //! | [`a_stalled_scan_reports_once_and_a_late_snapshot_still_lands`] | `tick` 不检查超时 / 每拍都报 / 报完把在飞的结果丢掉 | 真实 15 s 阈值合不合适(那要真盘压测) | -use std::time::Duration; +use std::time::{Duration, Instant}; use super::tests::{host_with, next_scan, snapshots}; -use super::SCAN_STALL; +use super::{clear_probe, install_probe, GATE, SCAN_STALL}; use crate::dto::{AppEvent, Backend, CoreEvent, InstancePhase}; // ------------------------------------------------------------------ 空数据根 / 超时兜底 @@ -63,25 +63,20 @@ fn a_stalled_scan_reports_once_and_a_late_snapshot_still_lands() { host.tick(Duration::from_millis(1)); let events = host.drain_events(); - assert!( - events.iter().any(|event| matches!( - event, - AppEvent::Core(CoreEvent::InstancePhaseChanged(InstancePhase::Stalled)) - )), - "超过上限必须报一条「超时」(否则占位永远不消失):{events:?}" + assert_eq!( + stalls(&events), + 1, + "超过上限必须**恰好**报一条「超时」(否则占位永远不消失):{events:?}" ); - // 再拍两下:不许重复报 - host.tick(Duration::from_millis(1)); - host.tick(Duration::from_millis(1)); - assert!( - snapshots(host.drain_events()).is_empty() - && !host.drain_events().iter().any(|event| matches!( - event, - AppEvent::Core(CoreEvent::InstancePhaseChanged(InstancePhase::Stalled)) - )), - "超时只许报一次(tick 每拍都会问)" - ); + // 再拍三下:一条都不许再报。复审 r2 P2-3:老断言把 `drain_events` 调了两次,第二次已经是空的, + // 「只报一次」根本抓不住重复上报——现在数的是**实际发出的条数**。 + let mut extra = 0; + for _ in 0..3 { + host.tick(Duration::from_millis(1)); + extra += stalls(&host.drain_events()); + } + assert_eq!(extra, 0, "超时只许报一次,又报了 {extra} 次"); // 迟到的结果照样落地(代次没变 ⇒ 不掐线程的代价是零) host.handle(task); @@ -89,3 +84,141 @@ fn a_stalled_scan_reports_once_and_a_late_snapshot_still_lands() { assert_eq!(published.len(), 1, "报过超时之后,迟到的快照仍然要落地"); assert!(!published[0].is_empty()); } + +// ------------------------------------------------------------------ 相位守卫 / 线程判据 + +fn stalls(events: &[AppEvent]) -> usize { + events + .iter() + .filter(|event| { + matches!( + event, + AppEvent::Core(CoreEvent::InstancePhaseChanged(InstancePhase::Stalled)) + ) + }) + .count() +} + +fn phases(events: &[AppEvent]) -> usize { + events + .iter() + .filter(|event| matches!(event, AppEvent::Core(CoreEvent::InstancePhaseChanged(_)))) + .count() +} + +/// **已就绪之后再刷新,不许把相位打回 `Scanning`**(复审 r2 P2-4:实现里有守卫,但没有判据)。 +/// +/// 现场:用户已经在看列表(`Ready`),任何一次刷新都起一轮新扫描。若那一轮也发 `Scanning`, +/// 界面就把有数据的一屏换成「正在扫描实例…」——把卡顿换成闪屏,正是任务 ⑤ 禁止的。 +/// +/// 抓得住:删掉 `refresh_instances` 里 `if adopted().is_none()` 那道守卫(发 `Scanning` 变成无条件)。 +/// 抓不住:页面怎么画(`pages/instances/tests/cold_boot.rs` 与 app 级 +/// `scan_phase::a_refresh_after_ready_keeps_the_list_and_the_tree`)。 +#[test] +fn a_refresh_after_ready_emits_no_scanning_phase() { + let (mut host, _env, _ids, _guard) = host_with("ready-refresh", 1); + let _ = host.drain_events(); + host.refresh_instances(); + host.pump_instances(); + let _ = host.drain_events(); + assert!( + host.instances_scan.adopted().is_some(), + "前提:首份快照已经落地(Ready)" + ); + + host.refresh_instances(); + let events = host.drain_events(); + assert_eq!( + phases(&events), + 0, + "已就绪之后再刷新**一条相位事件都不许发**(发了 = 界面退回占位):{events:?}" + ); + + // 结果照样落地(列表换新,而不是退回占位) + host.pump_instances(); + assert_eq!(snapshots(host.drain_events()).len(), 1); +} + +/// **已就绪之后,一轮慢刷新不许报「超时」**(复审 r2 P2-2:`check_scan_stall` 少了同一个守卫)。 +/// +/// 现场:`refresh_instances` 有 `adopted().is_none()` 守卫(不打回 `Scanning`),但 +/// `check_scan_stall` 没有 ⇒ 只要某一轮扫描慢过 `SCAN_STALL`,界面就被打回「扫描实例比平时久 + +/// 重新扫描」,而列表里其实有数据。 +/// +/// 抓得住:去掉 `check_scan_stall` 里那道 `adopted().is_some() ⇒ return`。 +/// 抓不住:真实 15 s(用 `shorten_budget(0)` 压到 0,判据仍是 `tick` 那条路径)。 +#[test] +fn a_slow_refresh_after_ready_never_reports_stalled() { + let (mut host, _env, _ids, _guard) = host_with("stall-after-ready", 1); + let _ = host.drain_events(); + host.refresh_instances(); + host.pump_instances(); + let _ = host.drain_events(); + + host.refresh_instances(); + host.instances_scan.shorten_budget(Duration::ZERO); + host.tick(Duration::from_millis(1)); + host.tick(Duration::from_millis(1)); + let events = host.drain_events(); + assert_eq!( + stalls(&events), + 0, + "已就绪之后一轮慢刷新不许把界面打回「超时 + 重新扫描」:{events:?}" + ); +} + +/// **扫盘不在调用方线程**(复审 r2 P2-1:上一轮那条抓不住 inline,整棵树全绿)。 +/// +/// 做法:给扫描体装探针 + 闸门(只拦这个数据根的扫描)——扫描体一进来就记线程 id、在闸门下等 +/// 放行;调用方返回时断言四件事:**跑过扫描的线程 ≠ 本线程**、**扫描体一趟都没跑完**、 +/// **闸门还没放行**、**返回耗时远小于闸门上限**。放行后快照照样落地(不是靠「什么都不扫」换的绿)。 +/// +/// 抓得住:把 `spawn("instances", job)` 换成直接 `job()`(复审 r2 的变异原样)——四条同时红, +/// 且闸门自带超时(`GATE`),变异时是**红**而不是把用例挂死。 +/// 抓不住:线程复用、扫盘真实耗时(后者要真盘量,见 M297 口径)。 +#[test] +fn a_refresh_returns_while_the_scan_body_is_still_parked() { + let (mut host, env, _ids, _guard) = host_with("probe", 1); + let _ = host.drain_events(); + let (probe, release) = install_probe(env.game.root().to_path_buf()); + + let started = Instant::now(); + host.refresh_instances(); + let elapsed = started.elapsed(); + + // 扫描体可能还没抢到 CPU:给它一点时间进入闸门 + let deadline = Instant::now() + Duration::from_secs(1); + while probe.threads().is_empty() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(2)); + } + assert_eq!( + probe.threads().len(), + 1, + "扫描体必须已经起跑(否则闸门没拦住任何东西,后三条断言会假绿)" + ); + assert_ne!( + probe.threads()[0], + std::thread::current().id(), + "扫描体跑在调用方线程上(= 扫盘又回到 UI 线程了)" + ); + assert_eq!( + probe.finished(), + 0, + "刷新返回时扫描体**还没跑完**(inline 时它已经跑完了)" + ); + assert!( + !probe.released(), + "闸门还没放行,扫描体却已经走过去了(= 它根本没在后台线程上等)" + ); + assert!( + elapsed < GATE / 2, + "刷新返回耗时 {elapsed:?} 接近闸门上限 {GATE:?} ⇒ 调用方在等扫描(inline)" + ); + + // 放行:结果照样落地 + let _ = release.send(()); + host.pump_instances(); + assert_eq!(snapshots(host.drain_events()).len(), 1); + assert_eq!(probe.finished(), 1); + clear_probe(); +} diff --git a/crates/presenter/src/host/game/refresh_tests.rs b/crates/presenter/src/host/game/refresh_tests.rs index 71e9301..f4cbe33 100644 --- a/crates/presenter/src/host/game/refresh_tests.rs +++ b/crates/presenter/src/host/game/refresh_tests.rs @@ -149,12 +149,13 @@ fn sorted_ids(snapshot: &[InstanceSummary]) -> Vec { // ------------------------------------------------------------------ 判据 ①:真的不在 UI 线程 -/// **把 `job::spawn` 改回 inline 就红**:`refresh_instances()` 返回时,队列里不该已经有快照。 +/// `refresh_instances()` 返回时,**host 的事件队列里**还没有快照。 /// -/// 抓得住:谁把 `refresh_instances` 改回「同步扫 + 同步发事件」(M302 之前的样子), -/// 这一条立刻红——那时 `drain_events()` 会拿到快照;也抓得住「干脆不扫了」(后半段要求它照样会到)。 -/// 抓不住:**具体**跑在哪条线程(没有可移植的查询口);这里断言的是「返回即完成」这件事不成立, -/// 而那正是「扫盘跑在调用方线程(= UI 线程)」的唯一可观测指纹。 +/// 抓得住:谁把「扫盘 + `handle` 落地」整条塞进调用方线程(那样 `drain_events()` 立刻有快照); +/// 也抓得住「干脆不扫了」(后半段要求它照样会到)。 +/// **抓不住**:扫盘本身跑在哪条线程——inline 之后 `HostTask` 仍然要等 `tick` 才进队列,这条照样绿 +/// (复审 r2 实测)。「不在调用方线程」由 +/// `refresh_stall_tests::a_refresh_returns_while_the_scan_body_is_still_parked` 证明(那里有闸门与线程 id)。 #[test] fn a_refresh_returns_before_the_scan_is_done() { let (mut host, _env, _ids, _guard) = host_with("inline", 1);