feat(host): M302 实例列表刷新搬出 UI 线程(扫盘进后台 + 代次丢弃过期结果)
`refresh_instances()` 以前把「扫盘」与「盖回运行时态」写在一起,而 `dispatch(RefreshAll)`
那一支是唯一没走 `job::spawn` 的(邻座 ListVersions / ListLoaderVersions 都走了)⇒ 同步跑在
UI 线程:官方根 200 版本约 236 ms、叠加 10 份外部档案 1.35 s(仓库内旧实测 M297)。
两半分家:
- `game::instances(&env)` 是纯 env 函数 ⇒ 搬进 `job::spawn("instances", …)`,回程是新载荷
`HostTask::Instances { epoch, instances, elapsed_ms }`;
- 「按 `launches/pending/failed` 覆写 Running/Busy/Failed」要读 host 自己的表 ⇒ 留在 host 线程,
挪到结果回来那一刻(`handle::apply_instances`),结论与之前逐字一致。
顺序:每次刷新领一个只增不减的代次(`game::Scan`),只采纳当代次那份。并发两次刷新 / 扫描途中
删实例 / 收尾触发第三次这三种交错下,先发后到的扫描一律丢(不发事件:清空再重填只是把卡顿换成
白屏)。不做线程取消 —— 扫描是纯读,没有值得取消的东西,丢结果比掐线程安全。
可观测:每次采纳打一行 `[instances] N 个实例 · 扫盘 X ms · epoch E · 丢旧代次 D 份`,
丢弃也各打一行;账本 `game::Scan` 可直接断言(实例数 / 耗时 / 丢包数)。
判据(都真构造:真临时根 + 真 profiles.json + 真线程,只有交付顺序是摆的):
- `a_refresh_returns_before_the_scan_is_done`(改回 inline 即红)
- `two_refreshes_in_flight_publish_only_the_newest`
- `an_older_scan_cannot_resurrect_what_a_newer_one_saw_deleted`
- `a_launch_end_refresh_beats_the_two_that_were_in_flight`
- app 侧 `a_refresh_in_flight_keeps_the_list_and_does_not_pin_the_clock`(不清空 + M298 的
view_dirty 不永久置真)
- `launch_state_tests` 的 5 处 `refresh_instances()` 后补一行 `pump_instances()`(断言一字未动)
This commit is contained in:
1 parent
38fbd2a778
commit
a0413a5f94
8 files changed
+753
-28
No files matched your search
@@ -0,0 +1,169 @@
|
||||
//! 实例快照的**扫描账**(M302):扫盘搬进后台线程之后,「哪一份结果说了算」由这里的代次管。
|
||||
//!
|
||||
//! ## 为什么要有这一层
|
||||
//!
|
||||
//! [`super::instances`] 是纯 `env` 函数(只读盘),但它慢:官方根 200 个版本约 236 ms,
|
||||
//! 叠加 10 份外部档案到 1.35 s(仓库内实测,见 M297 报告)。它原来跑在 `dispatch(RefreshAll)`
|
||||
//! 的那条 `match` 里——即 UI 线程上——于是列表在那段时间里不动。
|
||||
//!
|
||||
//! 搬进线程之后**顺序**就成了新问题:先发出的扫描可能后回来。那时它的结果已经过期(可能带着
|
||||
//! 刚被删掉的实例、漏掉刚装好的),采纳它等于把新快照盖回旧的样子——用户看到「删掉的实例又
|
||||
//! 回来了」「刚装好的版本不见了」。所以每次刷新领一个**只增不减的代次**,随任务进线程、随结果
|
||||
//! 回来;[`super::super::handle::CoreHost::apply_instances`] 只采纳当代次的那一份。
|
||||
//!
|
||||
//! ## 为什么不做「取消」
|
||||
//!
|
||||
//! 没有可取消的东西:扫描是**纯读**(不建目录、不写 `profiles.json`、不碰 `versions/` 一个字节),
|
||||
//! 提前掐断它省下的只是它自己那点 CPU,而掐断的代价是一个可能永远不回来的快照。
|
||||
//! 于是一条路口:**不取消线程,只丢弃过期结果**(丢掉的是那一份,不是一个状态)。
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use super::super::job::{spawn, HostTask, JobSender};
|
||||
use super::super::HostEnv;
|
||||
|
||||
/// 一次刷新领到的代次(`0` 是「还没刷新过」的初值,第一次刷新从 `1` 开始)。
|
||||
pub type Epoch = u64;
|
||||
|
||||
/// 代次 + 观测账:「最近一次被采纳的扫描多大、花了多久、之前丢了几份旧代次」。
|
||||
///
|
||||
/// 这一份账是 M302 的可观测口径([`Adopted`] 给断言用,`[instances]` 日志行给人看):
|
||||
/// 没有它,回归时只能说「感觉快了」,说不清「扫了几条、扫了多久、有没有丢包」。
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Scan {
|
||||
/// 当前代次(最近一次刷新的编号)
|
||||
epoch: Epoch,
|
||||
/// 最近一次被采纳的扫描
|
||||
adopted: Option<Adopted>,
|
||||
/// 被丢掉的**旧代次**结果条数(> 0 就证明「先发后到」真的发生过)
|
||||
dropped: u64,
|
||||
}
|
||||
|
||||
/// 一次被采纳的扫描:代次、实例数、纯扫盘耗时(毫秒)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Adopted {
|
||||
/// 这次扫描的代次
|
||||
pub epoch: Epoch,
|
||||
/// 快照里的实例条数(磁贴数)
|
||||
pub count: usize,
|
||||
/// 后台线程里 `game::instances` 的耗时(不含线程创建与排队——那是主机的事,不是扫盘的成本)
|
||||
pub elapsed_ms: u64,
|
||||
}
|
||||
|
||||
impl Scan {
|
||||
/// 开一轮新刷新:代次 +1 并返回它(调用方把它交给后台线程,随结果回来)。
|
||||
///
|
||||
/// 只增不减(`wrapping_add` 与 `install_epoch` 同款):**代次相等才是当代次**,
|
||||
/// 于是「上一轮还在飞的那份」无论什么时候回来都进不了门。
|
||||
pub fn begin(&mut self) -> Epoch {
|
||||
self.epoch = self.epoch.wrapping_add(1);
|
||||
self.epoch
|
||||
}
|
||||
|
||||
/// 这份结果还是不是当代次的?不是的话记一笔丢包(可观测)并返回 `false`。
|
||||
///
|
||||
/// 判据是**相等**而不是「更大」:代次是唯一的命名,只有最新一轮发出去的那份作数。
|
||||
pub fn admit(&mut self, epoch: Epoch) -> bool {
|
||||
if epoch == self.epoch {
|
||||
return true;
|
||||
}
|
||||
self.dropped = self.dropped.wrapping_add(1);
|
||||
false
|
||||
}
|
||||
|
||||
/// 记下这一份(只在 [`Scan::admit`] 放行之后调)。
|
||||
pub fn adopt(&mut self, epoch: Epoch, count: usize, elapsed_ms: u64) {
|
||||
self.adopted = Some(Adopted {
|
||||
epoch,
|
||||
count,
|
||||
elapsed_ms,
|
||||
});
|
||||
}
|
||||
|
||||
/// 当前代次。
|
||||
pub fn epoch(&self) -> Epoch {
|
||||
self.epoch
|
||||
}
|
||||
|
||||
/// 最近一次被采纳的扫描(还没落地过 = `None`)。
|
||||
pub fn adopted(&self) -> Option<Adopted> {
|
||||
self.adopted
|
||||
}
|
||||
|
||||
/// 已经丢掉的旧代次结果条数。
|
||||
pub fn dropped(&self) -> u64 {
|
||||
self.dropped
|
||||
}
|
||||
}
|
||||
|
||||
/// 后台扫一遍实例目录,把结果连代次一起送回 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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
// 单测要 `tick` 抽通道,它的定义在 `crate::dto::Backend` 这条 trait 上。
|
||||
use crate::dto::Backend;
|
||||
|
||||
/// 单测等待「这一代快照落地」的上限。
|
||||
///
|
||||
/// 用例的数据根都是临时目录里的一到三条档案(扫盘是毫秒级),5 s 只是给 CI 的抖动留余量;
|
||||
/// 超时意味着**真的坏了**(线程没起、通道没人抽、代次判据写反),所以见 [`CoreHost::pump_instances`]。
|
||||
#[cfg(test)]
|
||||
const PUMP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
#[cfg(test)]
|
||||
impl super::super::CoreHost {
|
||||
/// 单测专用:抽通道直到**当代次**的实例快照落地。
|
||||
///
|
||||
/// 为什么必须叫:`refresh_instances()` 现在是「派一条后台线程就返回」,用例若调完就读事件,
|
||||
/// 拿到的一定是空队列——后续 `.expect("实例存在")` 会把「结果还没回来」误报成
|
||||
/// 「这个实例不存在」,那是最误导的一种红。所以这里**超时就 panic**,并印出诊断。
|
||||
///
|
||||
/// 不抽事件队列(`self.events` 原样留给调用方读):判据读的是账本
|
||||
/// ([`Scan::adopted`] 在 `handle::apply_instances` 里**先记账再发事件**),
|
||||
/// 于是「账本已经是当代次」⇒ 事件已经在队列里。
|
||||
///
|
||||
/// 产品路径**不调它**(`#[cfg(test)]`,进不了产物);生产由 `tick` 抽通道。
|
||||
pub(crate) fn pump_instances(&mut self) {
|
||||
let epoch = self.instances_scan.epoch();
|
||||
let started = Instant::now();
|
||||
while started.elapsed() < PUMP_TIMEOUT {
|
||||
self.tick(std::time::Duration::from_millis(1));
|
||||
if self
|
||||
.instances_scan
|
||||
.adopted()
|
||||
.is_some_and(|adopted| adopted.epoch == epoch)
|
||||
{
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
}
|
||||
panic!(
|
||||
"等实例快照超时({:?}):当代次 {epoch},账本停在 {:?},已丢旧代次 {} 份,\
|
||||
事件队列 {} 条,最后一条 {:?}——刷新结果没回来(线程没起 / 通道没人抽 / 代次判据写反)",
|
||||
PUMP_TIMEOUT,
|
||||
self.instances_scan.adopted(),
|
||||
self.instances_scan.dropped(),
|
||||
self.events.len(),
|
||||
self.events.back(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "refresh_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,395 @@
|
||||
//! M302 的判据:**扫盘搬后台线程之后,谁的结果说了算**。
|
||||
//!
|
||||
//! ## 为什么这组用例必须在 `game/` 下
|
||||
//!
|
||||
//! 本 mission 的 scope 只开到 `host/mod.rs`、`host/game.rs`、`host/game/**`、`host/job.rs`、
|
||||
//! `host/dispatch.rs`、`host/handle.rs`(外加 `launch_state_tests.rs`),所以「host 级刷新编排」
|
||||
//! 的判据落在 `game/refresh.rs` 的子模块里——判据对象是 host,不是 `game::instances` 的盘面判据
|
||||
//! (后者由 `game/tests*` 那几份守着,本条**一个字节都没动**)。
|
||||
//!
|
||||
//! ## 三条交错(任务原文)
|
||||
//!
|
||||
//! | 交错 | 用例 | 抓得住 |
|
||||
//! |---|---|---|
|
||||
//! | 并发两次刷新 | [`two_refreshes_in_flight_publish_only_the_newest`] | 两份结果里只有最新那份能被采纳 |
|
||||
//! | 盘面在两轮之间变了 | [`an_older_scan_cannot_resurrect_what_a_newer_one_saw_deleted`] | 「删掉的实例又回来了」 |
|
||||
//! | 启动收尾触发的第三次刷新 | [`a_launch_end_refresh_beats_the_two_that_were_in_flight`] | 收尾后列表不许被扫描期间的旧结果盖回「运行中」 |
|
||||
//!
|
||||
//! 三份都是**真构造**:真临时数据根、真 `profiles.json` 写入、真后台线程跑真 `game::instances`。
|
||||
//! 只有**交付顺序**是人为摆的(把 `rx` 里的任务按住、按想验的顺序喂给 host)——那正是要验的
|
||||
//! 「先发后到」,靠调度器碰运气是测不出确定结论的。
|
||||
//!
|
||||
//! 每条用例的「抓不住」写在各自文档里;共同的一条:**它们不量真实耗时**(扫盘在进程内可能只有
|
||||
//! 几百微秒),也不证明用户能感知的快慢——那要真盘上的 236 ms / 1.35 s 场景,见 M297 的实测口径。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use unlucky_core::game::install::instance::write_profiles;
|
||||
use unlucky_core::game::{InstanceProfile, ProfileId, ProfileList};
|
||||
|
||||
use crate::dto::{
|
||||
AppEvent, Backend, CoreEvent, InstanceRuntime, InstanceSummary, LaunchFinished, LogOutcome,
|
||||
};
|
||||
|
||||
use crate::host::{CoreHost, HostEnv, HostTask, LiveLaunch};
|
||||
|
||||
use super::Scan;
|
||||
|
||||
/// 取任务的等待上限(真线程 + 临时根,毫秒级就回来;超时说明真的坏了)。
|
||||
const WAIT: Duration = Duration::from_secs(5);
|
||||
|
||||
// ------------------------------------------------------------------ 夹具
|
||||
|
||||
/// 临时数据根守卫:测试结束(含断言 panic)时删掉目录。
|
||||
struct TempRoot(std::path::PathBuf);
|
||||
|
||||
impl Drop for TempRoot {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// 建一个空数据根(`HostEnv::at_root` ⇒ 不做多根发现,盘面只有我们写的东西)。
|
||||
fn root(label: &str) -> (HostEnv, TempRoot) {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"ull-m302-{label}-{}-{}",
|
||||
std::process::id(),
|
||||
line!()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let env = HostEnv::at_root(&dir);
|
||||
std::fs::create_dir_all(env.layout.config()).expect("config 目录");
|
||||
(env, TempRoot(dir))
|
||||
}
|
||||
|
||||
/// 把盘上的档案**写成** `ids` 这几条(真读写的唯一真相源是 `profiles.json`),返回它们。
|
||||
fn seed(env: &HostEnv, ids: &[String]) {
|
||||
let profiles: Vec<InstanceProfile> = ids
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, id)| InstanceProfile {
|
||||
id: ProfileId::new(id),
|
||||
name: format!("demo-{index}"),
|
||||
version_id: "1.20.1".to_string(),
|
||||
game_dir: None,
|
||||
running_dir: None,
|
||||
java_path: None,
|
||||
created_at: None,
|
||||
last_played_at: None,
|
||||
extra: std::collections::BTreeMap::new(),
|
||||
})
|
||||
.collect();
|
||||
write_profiles(
|
||||
&env.layout,
|
||||
&ProfileList {
|
||||
profiles,
|
||||
..ProfileList::default()
|
||||
},
|
||||
)
|
||||
.expect("写 profiles.json");
|
||||
}
|
||||
|
||||
/// `count` 条新档案的 id(落盘用 [`seed`])。
|
||||
fn ids(count: usize) -> Vec<String> {
|
||||
(0..count)
|
||||
.map(|_| ProfileId::generate().as_str().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 建好 `count` 条档案的 host(headless:不跑 `refresh_all`,不起版本清单网络线程)。
|
||||
fn host_with(label: &str, count: usize) -> (CoreHost, HostEnv, Vec<String>, TempRoot) {
|
||||
let (env, guard) = root(label);
|
||||
let ids = ids(count);
|
||||
seed(&env, &ids);
|
||||
(CoreHost::headless(env.clone()), env, ids, guard)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 取件
|
||||
|
||||
/// 从通道里等下一份扫盘任务(**不经过 `tick`**:这里要自己按住顺序)。
|
||||
fn next_scan(host: &CoreHost) -> HostTask {
|
||||
host.rx.recv_timeout(WAIT).expect("扫盘任务要在 5 s 内回来")
|
||||
}
|
||||
|
||||
/// 任务的代次。
|
||||
fn epoch_of(task: &HostTask) -> u64 {
|
||||
match task {
|
||||
HostTask::Instances { epoch, .. } => *epoch,
|
||||
other => panic!("这不是实例扫描任务:{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 一份任务里的实例 id(排好序,方便比集合)。
|
||||
fn ids_of(task: &HostTask) -> Vec<String> {
|
||||
match task {
|
||||
HostTask::Instances { instances, .. } => {
|
||||
let mut ids: Vec<String> = instances.iter().map(|item| item.id.clone()).collect();
|
||||
ids.sort();
|
||||
ids
|
||||
}
|
||||
other => panic!("这不是实例扫描任务:{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 事件队列里所有 `InstancesChanged` 快照(按先后)。
|
||||
fn snapshots(events: Vec<AppEvent>) -> Vec<Vec<InstanceSummary>> {
|
||||
events
|
||||
.into_iter()
|
||||
.filter_map(|event| match event {
|
||||
AppEvent::Core(CoreEvent::InstancesChanged(list)) => Some(list),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 快照里的实例 id(排好序)。
|
||||
fn sorted_ids(snapshot: &[InstanceSummary]) -> Vec<String> {
|
||||
let mut ids: Vec<String> = snapshot.iter().map(|item| item.id.clone()).collect();
|
||||
ids.sort();
|
||||
ids
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 判据 ①:真的不在 UI 线程
|
||||
|
||||
/// **把 `job::spawn` 改回 inline 就红**:`refresh_instances()` 返回时,队列里不该已经有快照。
|
||||
///
|
||||
/// 抓得住:谁把 `refresh_instances` 改回「同步扫 + 同步发事件」(M302 之前的样子),
|
||||
/// 这一条立刻红——那时 `drain_events()` 会拿到快照;也抓得住「干脆不扫了」(后半段要求它照样会到)。
|
||||
/// 抓不住:**具体**跑在哪条线程(没有可移植的查询口);这里断言的是「返回即完成」这件事不成立,
|
||||
/// 而那正是「扫盘跑在调用方线程(= UI 线程)」的唯一可观测指纹。
|
||||
#[test]
|
||||
fn a_refresh_returns_before_the_scan_is_done() {
|
||||
let (mut host, _env, _ids, _guard) = host_with("inline", 1);
|
||||
let _ = host.drain_events();
|
||||
|
||||
host.refresh_instances();
|
||||
|
||||
assert!(
|
||||
snapshots(host.drain_events()).is_empty(),
|
||||
"刷新返回时快照已经到了 ⇒ 扫盘又跑回调用方线程了(M302 要的就是它不在那儿)"
|
||||
);
|
||||
|
||||
// 结果照样会到(不是把功能删掉换成「什么都不发」)
|
||||
host.pump_instances();
|
||||
assert_eq!(
|
||||
snapshots(host.drain_events()).len(),
|
||||
1,
|
||||
"等一代之后必须有且只有一份快照"
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 判据 ②-1:并发两次刷新
|
||||
|
||||
/// **并发两次刷新**:两份结果里只有**最新代次**那份能被采纳,先发的那份后到也不许盖。
|
||||
///
|
||||
/// 抓得住:去掉代次判据(`admit` 恒真)时这条红——旧代次的那份会照发出第二条快照,
|
||||
/// 而 `CoreEvent::InstancesChanged` 是**整体替换**,界面就被过期盘面盖掉了。
|
||||
/// 抓不住:两次扫描之间盘面若完全一样,两份内容无法区分——所以这条另配
|
||||
/// [`an_older_scan_cannot_resurrect_what_a_newer_one_saw_deleted`] 用内容判别。
|
||||
#[test]
|
||||
fn two_refreshes_in_flight_publish_only_the_newest() {
|
||||
let (mut host, _env, _ids, _guard) = host_with("concurrent", 2);
|
||||
let _ = host.drain_events();
|
||||
|
||||
host.refresh_instances();
|
||||
let first_epoch = host.instances_scan.epoch();
|
||||
host.refresh_instances();
|
||||
let second_epoch = host.instances_scan.epoch();
|
||||
assert!(second_epoch > first_epoch, "刷新必须换代次");
|
||||
|
||||
// 两条真线程都真扫完了
|
||||
let a = next_scan(&host);
|
||||
let b = next_scan(&host);
|
||||
// 交付顺序摆成「旧代次后到」——这就是要防的那种交错
|
||||
let (stale, fresh) = if epoch_of(&a) < epoch_of(&b) {
|
||||
(a, b)
|
||||
} else {
|
||||
(b, a)
|
||||
};
|
||||
assert_eq!(epoch_of(&stale), first_epoch);
|
||||
assert_eq!(epoch_of(&fresh), second_epoch);
|
||||
|
||||
host.handle(fresh);
|
||||
host.handle(stale);
|
||||
|
||||
let published = snapshots(host.drain_events());
|
||||
assert_eq!(
|
||||
published.len(),
|
||||
1,
|
||||
"两份结果只许落地一条快照(实际 {} 条):多了说明旧代次那份也发出去了",
|
||||
published.len()
|
||||
);
|
||||
assert_eq!(published[0].len(), 2, "盘上就是两条");
|
||||
assert_eq!(
|
||||
host.instances_scan.adopted().map(|adopted| adopted.epoch),
|
||||
Some(second_epoch),
|
||||
"落地的那份必须是当代次"
|
||||
);
|
||||
assert_eq!(
|
||||
host.instances_scan.dropped(),
|
||||
1,
|
||||
"旧代次那份要被记成丢弃(可观测口径:> 0 就证明「先发后到」真发生了)"
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 判据 ②-2:扫描途中删实例
|
||||
|
||||
/// **扫描途中有一个实例被删**:后到的那份旧扫描里还有它,绝不许把它复活。
|
||||
///
|
||||
/// 现场就是最坏的一种:刷新 #1 扫到 3 条 → 用户(或别的启动器)删掉第 3 条 → 刷新 #2 扫到 2 条
|
||||
/// → #2 先到、#1 后到。没有代次判据时,`InstancesChanged` 的整体替换会把被删的磁贴**放回来**
|
||||
/// (点进去必然报错,用户以为删失败了)。
|
||||
///
|
||||
/// 抓得住:去掉/写错 `admit`(例如判成 `epoch >= self.epoch`),这条红;把丢掉旧代次那一步做成
|
||||
/// 「发一条空快照」也红(那正是任务里禁止的「清空再重填」)。
|
||||
/// 抓不住:刷新 #1 与删除动作**同时**发生的窄窗口——本用例把两份扫描都等回来了再摆交付顺序,
|
||||
/// 所以它验的是「顺序」,不是「磁盘并发」。
|
||||
#[test]
|
||||
fn an_older_scan_cannot_resurrect_what_a_newer_one_saw_deleted() {
|
||||
let (mut host, env, ids, _guard) = host_with("delete", 3);
|
||||
let _ = host.drain_events();
|
||||
let (keep, doomed) = ids.split_at(2);
|
||||
let doomed = doomed[0].clone();
|
||||
|
||||
// #1:盘上 3 条
|
||||
host.refresh_instances();
|
||||
let older = next_scan(&host);
|
||||
assert_eq!(ids_of(&older).len(), 3, "前提:#1 扫到了 3 条");
|
||||
|
||||
// 扫描途中删掉一条(真写 profiles.json,与用户删实例是同一条落盘路径)
|
||||
seed(&env, keep);
|
||||
|
||||
// #2:盘上 2 条
|
||||
host.refresh_instances();
|
||||
let newer = next_scan(&host);
|
||||
assert_eq!(ids_of(&newer).len(), 2, "前提:#2 扫到了 2 条");
|
||||
|
||||
// 新快照先到
|
||||
host.handle(newer);
|
||||
let published = snapshots(host.drain_events());
|
||||
assert_eq!(published.len(), 1);
|
||||
assert_eq!(
|
||||
sorted_ids(&published[0]),
|
||||
{
|
||||
let mut expected = keep.to_vec();
|
||||
expected.sort();
|
||||
expected
|
||||
},
|
||||
"新扫描的盘面就是剩下两条"
|
||||
);
|
||||
|
||||
// 旧的那份后到:**一条事件都不许出**(不然被删的磁贴又回来了)
|
||||
host.handle(older);
|
||||
assert!(
|
||||
host.drain_events().is_empty(),
|
||||
"旧代次的快照不许落地,也不许发空快照(那只是把卡顿换成白屏)"
|
||||
);
|
||||
assert_eq!(host.instances_scan.dropped(), 1);
|
||||
assert!(!doomed.is_empty(), "被删的实例 id 不该是空串");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 判据 ②-3:收尾触发的第三次刷新
|
||||
|
||||
/// **启动收尾触发的第三次刷新**要压住前两次在飞的扫描。
|
||||
///
|
||||
/// 现场:连点两次「刷新」→ 扫描在飞 → 游戏退出(`LaunchEnd`)又触发一次刷新。前两次扫的时候
|
||||
/// 那个实例还在跑,它们的结果若后到,盖回运行时态虽然仍是 `Idle`(盖的时候读的是 host 当前
|
||||
/// 状态),但**盘面/最后游玩时间**是旧的——而且 `InstancesChanged` 整体替换,界面会被过期快照
|
||||
/// 顶一下。
|
||||
///
|
||||
/// 抓得住:去掉代次判据时这条会落地三条快照(实际断言 1 条);也抓得住「收尾那次刷新根本没发」
|
||||
/// (`adopted().epoch` 不是第三次)。
|
||||
/// 抓不住:真实游戏进程的收尾时序——这里喂的是 `HostTask::LaunchEnd`(host 的唯一收尾入口,
|
||||
/// 与 `launch` 线程回报的形状一致),进程真的起来/退出由 `launch_state_tests` 与真机覆盖。
|
||||
#[test]
|
||||
fn a_launch_end_refresh_beats_the_two_that_were_in_flight() {
|
||||
let (mut host, _env, ids, _guard) = host_with("launch-end", 2);
|
||||
let id = ids[0].clone();
|
||||
let _ = host.drain_events();
|
||||
|
||||
// 两份在飞的扫描(当时这个实例还在跑)
|
||||
host.launches.push(LiveLaunch {
|
||||
instance: id.clone(),
|
||||
session: "20261113T120000-m302a".to_string(),
|
||||
pid: 4_242,
|
||||
});
|
||||
host.refresh_instances();
|
||||
let flying_1 = next_scan(&host);
|
||||
host.refresh_instances();
|
||||
let flying_2 = next_scan(&host);
|
||||
|
||||
// 收尾:清启动表 + 触发第三次刷新
|
||||
host.handle(HostTask::LaunchEnd(Box::new(LaunchFinished {
|
||||
id: id.clone(),
|
||||
session: "20261113T120000-m302a".to_string(),
|
||||
outcome: LogOutcome::Ok,
|
||||
exit_code: Some(0),
|
||||
uptime_ms: 2_000,
|
||||
failure: None,
|
||||
})));
|
||||
let third_epoch = host.instances_scan.epoch();
|
||||
let third = next_scan(&host);
|
||||
assert_eq!(epoch_of(&third), third_epoch, "收尾必须触发一次新刷新");
|
||||
|
||||
// 先交付收尾那份,再把两份旧的补上
|
||||
host.handle(third);
|
||||
host.handle(flying_1);
|
||||
host.handle(flying_2);
|
||||
|
||||
let published = snapshots(host.drain_events());
|
||||
assert_eq!(
|
||||
published.len(),
|
||||
1,
|
||||
"只许收尾那次落地(实际 {} 条):多出来的是扫描期间就已经过期的盘面",
|
||||
published.len()
|
||||
);
|
||||
let tile = published[0]
|
||||
.iter()
|
||||
.find(|item| item.id == id)
|
||||
.expect("实例还在盘上");
|
||||
assert_eq!(
|
||||
tile.runtime,
|
||||
InstanceRuntime::Idle,
|
||||
"收尾之后的快照必须是 Idle(旧结果若盖回去会带着「运行中」的外观)"
|
||||
);
|
||||
assert_eq!(
|
||||
host.instances_scan.adopted().map(|adopted| adopted.epoch),
|
||||
Some(third_epoch)
|
||||
);
|
||||
assert_eq!(host.instances_scan.dropped(), 2, "前两份都要记成丢弃");
|
||||
assert!(!host.in_flight(&id), "收尾之后该实例不该还在飞行态");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 代次账(纯单元)
|
||||
|
||||
/// `Scan` 的账目:换代 → 只认当代次 → 旧代次计数 → 记账。
|
||||
///
|
||||
/// 抓得住:把 `admit` 写成 `>=`、把丢弃计数漏掉、把 `adopt` 记到别的代次上。
|
||||
/// 抓不住:真实交错的时序(那是上面三条用例的事)。
|
||||
#[test]
|
||||
fn the_ledger_only_admits_the_current_epoch() {
|
||||
let mut scan = Scan::default();
|
||||
assert_eq!(scan.epoch(), 0, "初值 = 还没刷新过");
|
||||
assert!(scan.adopted().is_none());
|
||||
|
||||
let first = scan.begin();
|
||||
assert_eq!(first, 1);
|
||||
assert!(scan.admit(first), "当代次必须放行");
|
||||
scan.adopt(first, 3, 236);
|
||||
let adopted = scan.adopted().expect("刚记的账");
|
||||
assert_eq!(
|
||||
(adopted.epoch, adopted.count, adopted.elapsed_ms),
|
||||
(1, 3, 236)
|
||||
);
|
||||
|
||||
let second = scan.begin();
|
||||
assert_eq!(second, 2);
|
||||
assert!(!scan.admit(first), "旧代次必须丢");
|
||||
assert_eq!(scan.dropped(), 1);
|
||||
assert_eq!(
|
||||
scan.adopted().map(|adopted| adopted.epoch),
|
||||
Some(1),
|
||||
"丢掉的旧结果不该改写账本"
|
||||
);
|
||||
assert!(scan.admit(second));
|
||||
}
|
||||
Reference in new issue
Block a user