test(instances): M302 r2 —— 四条 P2:线程判据真能红 + 两处相位守卫 + 「只报一次」改成可数

- P2-1:`spawn_scan` 的闭包提成 `scan_body`,单测可装探针 + 闸门(只拦同一数据根的扫描)。
  新用例 `a_refresh_returns_while_the_scan_body_is_still_parked` 断言四件事:扫描体跑在
  **另一条线程**、返回时**一趟都没跑完**、闸门**还没放行**、返回耗时 < 闸门上限的一半。
  把 `spawn("instances", job)` 换成直接 `job()` ⇒ 该用例红(原文见报告);旧用例
  `a_refresh_returns_before_the_scan_is_done` 的 docstring 改成诚实口径(它证明不了不在调用方线程,
  inline 时它照样绿——正是复审 r2 点的那条)。
- P2-2(真缺陷):`check_scan_stall` 补上与 `refresh_instances` 同一个守卫——已就绪
  (`adopted().is_some()`)之后不再报 `Stalled`,否则一轮慢刷新会把有数据的一屏换成
  「扫描实例比平时久 + 重新扫描」。新用例 `a_slow_refresh_after_ready_never_reports_stalled`。
- P2-3:`a_stalled_scan_reports_once_and_a_late_snapshot_still_lands` 原来把 `drain_events` 调了
  两次(第二次必空 ⇒ 断言空转)。改成数实际发出的条数:第一拍恰好 1 条,之后三拍必须 0 条。
- P2-4:新用例 `a_refresh_after_ready_emits_no_scanning_phase`(真 host:已就绪后再刷新一条相位
  事件都不许发)+ app 级 `a_refresh_after_ready_keeps_the_list_and_the_tree`(相位仍 `Ready`、
  真控件树的文字/按钮节点数与刷新前逐项相等、列表没被清空)。

四条变异(inline / 去 P2-2 守卫 / 去「只报一次」锁 / 去 Scanning 守卫)都实测红,原文进报告。
This commit is contained in:
AskaEth committed 2026-11-13 19:36:33 +08:00
1 parent f03411d58b
commit 23c7beee8d
4 files changed
+363 -26

No files matched your search

@@ -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::<usize>()
}
/// 一屏的 `(文字节点数, 按钮数)`。
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(), "列表也不许被清空");
}
+124 -2
View File
@@ -160,12 +160,125 @@ 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<Vec<std::thread::ThreadId>>,
/// 已经跑完几趟
finished: std::sync::atomic::AtomicUsize,
/// 闸门:扫描体在这里等「放行」(带超时,免得 inline 变异把用例挂死)
gate: std::sync::Mutex<std::sync::mpsc::Receiver<()>>,
/// 闸门有没有被放行过
released: std::sync::atomic::AtomicBool,
}
#[cfg(test)]
static PROBE: std::sync::Mutex<Option<std::sync::Arc<Probe>>> = 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<std::thread::ThreadId> {
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<Probe>, 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<std::sync::Arc<Probe>> {
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`])。 /// 后台扫一遍实例目录,把结果连代次一起送回 host([`HostTask::Instances`])。
/// ///
/// 与 `versions::spawn_list` / `loader_versions::spawn_list` 同一条形状:线程里只做 core 调用, /// 与 `versions::spawn_list` / `loader_versions::spawn_list` 同一条形状:线程里只做 core 调用,
/// 出来的已经是 [`crate::dto::InstanceSummary`](跨线程传的东西在 `HostTask` 一处看全)。 /// 出来的已经是 [`crate::dto::InstanceSummary`](跨线程传的东西在 `HostTask` 一处看全)。
pub fn spawn_scan(env: HostEnv, tx: JobSender, epoch: Epoch) { pub fn spawn_scan(env: HostEnv, tx: JobSender, epoch: Epoch) {
spawn("instances", move || { 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 started = Instant::now();
let instances = super::instances(&env); let instances = super::instances(&env);
let elapsed_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX); let elapsed_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
@@ -175,7 +288,10 @@ pub fn spawn_scan(env: HostEnv, tx: JobSender, epoch: Epoch) {
instances, instances,
elapsed_ms, elapsed_ms,
}); });
}); #[cfg(test)]
if let Some(probe) = &probe {
probe.leave();
}
} }
impl super::super::CoreHost { impl super::super::CoreHost {
@@ -185,6 +301,12 @@ impl super::super::CoreHost {
/// 「正在扫描实例…」。这里只**报**超时(面板翻到可点的「重新扫描」),**不**掐线程—— /// 「正在扫描实例…」。这里只**报**超时(面板翻到可点的「重新扫描」),**不**掐线程——
/// 判据与取值理由在 [`Scan::stall`] / [`SCAN_STALL`]。 /// 判据与取值理由在 [`Scan::stall`] / [`SCAN_STALL`]。
pub(crate) fn check_scan_stall(&mut self) { 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() { if !self.instances_scan.stall() {
return; return;
} }
@@ -1,4 +1,4 @@
//! 空数据根 / 超时兜底(M302 的第二组:`Scan` 的落地与 stall 判据)。 //! 扫盘**线程** / 首屏**三态** / 超时兜底(M302 第二组;第一组是 `refresh_tests.rs` 的交错用例)。
//! //!
//! 从 `refresh_tests.rs` 拆出来守 400 行硬线(那边是三条**交错**用例 + 账本单测)。 //! 从 `refresh_tests.rs` 拆出来守 400 行硬线(那边是三条**交错**用例 + 账本单测)。
//! 这一组验的是另外两件事: //! 这一组验的是另外两件事:
@@ -8,10 +8,10 @@
//! | [`an_empty_root_publishes_an_empty_snapshot`] | 把「零实例」当成「没扫完」(界面永远停在「正在扫描」),或者反过来在没扫完时发空快照 | 界面上这两态怎么画(`pages/instances/tests/cold_boot.rs`) | //! | [`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 阈值合不合适(那要真盘压测) | //! | [`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::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}; 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)); host.tick(Duration::from_millis(1));
let events = host.drain_events(); let events = host.drain_events();
assert!( assert_eq!(
events.iter().any(|event| matches!( stalls(&events),
event, 1,
AppEvent::Core(CoreEvent::InstancePhaseChanged(InstancePhase::Stalled)) "超过上限必须**恰好**报一条「超时」(否则占位永远不消失):{events:?}"
)),
"超过上限必须报一条「超时」(否则占位永远不消失):{events:?}"
); );
// 再拍两下:不许重复报 // 再拍三下:一条都不许再报。复审 r2 P2-3:老断言把 `drain_events` 调了两次,第二次已经是空的,
// 「只报一次」根本抓不住重复上报——现在数的是**实际发出的条数**。
let mut extra = 0;
for _ in 0..3 {
host.tick(Duration::from_millis(1)); host.tick(Duration::from_millis(1));
host.tick(Duration::from_millis(1)); extra += stalls(&host.drain_events());
assert!( }
snapshots(host.drain_events()).is_empty() assert_eq!(extra, 0, "超时只许报一次,又报了 {extra} 次");
&& !host.drain_events().iter().any(|event| matches!(
event,
AppEvent::Core(CoreEvent::InstancePhaseChanged(InstancePhase::Stalled))
)),
"超时只许报一次(tick 每拍都会问)"
);
// 迟到的结果照样落地(代次没变 ⇒ 不掐线程的代价是零) // 迟到的结果照样落地(代次没变 ⇒ 不掐线程的代价是零)
host.handle(task); 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_eq!(published.len(), 1, "报过超时之后,迟到的快照仍然要落地");
assert!(!published[0].is_empty()); 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();
}
@@ -149,12 +149,13 @@ fn sorted_ids(snapshot: &[InstanceSummary]) -> Vec<String> {
// ------------------------------------------------------------------ 判据 ①:真的不在 UI 线程 // ------------------------------------------------------------------ 判据 ①:真的不在 UI 线程
/// **把 `job::spawn` 改回 inline 就红**:`refresh_instances()` 返回时,队列里不该已经有快照。 /// `refresh_instances()` 返回时,**host 的事件队列里**还没有快照。
/// ///
/// 抓得住:谁把 `refresh_instances` 改回「同步扫 + 同步发事件」(M302 之前的样子), /// 抓得住:谁把「扫盘 + `handle` 落地」整条塞进调用方线程(那样 `drain_events()` 立刻有快照);
/// 这一条立刻红——那时 `drain_events()` 会拿到快照;也抓得住「干脆不扫了」(后半段要求它照样会到)。 /// 也抓得住「干脆不扫了」(后半段要求它照样会到)。
/// 抓不住:**具体**跑在哪条线程(没有可移植的查询口);这里断言的是「返回即完成」这件事不成立, /// **抓不住**:扫盘本身跑在哪条线程——inline 之后 `HostTask` 仍然要等 `tick` 才进队列,这条照样绿
/// 而那正是「扫盘跑在调用方线程(= UI 线程)」的唯一可观测指纹。 /// (复审 r2 实测)。「不在调用方线程」由
/// `refresh_stall_tests::a_refresh_returns_while_the_scan_body_is_still_parked` 证明(那里有闸门与线程 id)。
#[test] #[test]
fn a_refresh_returns_before_the_scan_is_done() { fn a_refresh_returns_before_the_scan_is_done() {
let (mut host, _env, _ids, _guard) = host_with("inline", 1); let (mut host, _env, _ids, _guard) = host_with("inline", 1);