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:
1 parent
f03411d58b
commit
23c7beee8d
4 files changed
+372
-35
No files matched your search
@@ -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<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`])。
|
||||
///
|
||||
/// 与 `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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -149,12 +149,13 @@ fn sorted_ids(snapshot: &[InstanceSummary]) -> Vec<String> {
|
||||
|
||||
// ------------------------------------------------------------------ 判据 ①:真的不在 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);
|
||||
|
||||
Reference in new issue
Block a user