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:
AskaEth committed 2026-11-13 17:58:59 +08:00
1 parent 38fbd2a778
commit a0413a5f94
8 files changed
+753 -28

No files matched your search

+80 -2
View File
@@ -181,7 +181,7 @@ impl App {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use unlucky_design::color::SchemeFlavor; use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::dto::LaunchArgsView; use unlucky_presenter::dto::{AppCommand, AppEvent, Backend, CoreEvent, LaunchArgsView};
use unlucky_presenter::settings_bridge::SettingsBridge; use unlucky_presenter::settings_bridge::SettingsBridge;
use crate::app::state::App; use crate::app::state::App;
@@ -267,7 +267,85 @@ mod tests {
); );
} }
/// 表单没绑定到当前实例时不接编辑(否则草稿会写到别的实例上)。 /// **M302:刷新在飞时列表不清空,快照落地后时钟不被钉住**。
///
/// 现场:扫盘搬进后台线程之后,「派发刷新 → 结果回来」之间有一小段真空。这段真空里界面
/// 只有两条路:**留着旧列表**(用户看到的是「还在,但还没变」)或者**清空再重填**(用户
/// 看到的是一屏白/空态)。M302 要的是前者;后者等于把卡顿换成闪屏。
///
/// 第二件事是 M298 的型态:`view_dirty` 一旦永久为真,`advance` 就每拍无条件 `refresh_view`
/// (整页视图模型重建),而「快照落地」这条路最容易被写成「只置脏不重建」。
///
/// 抓得住:① 谁在派发刷新的那一刻清空 `state.instances`;② 谁把 `InstancesChanged` 的落地
/// 路径写成「置脏不落地」或者让 `refresh_view` 收尾不再复位 `view_dirty`。
/// 抓不住:真线程的时序与竞争(那在 presenter 的 `host::game::refresh::tests` 交错用例里);
/// 也抓不住「扫盘真要多久」(那是真实测量,不是单测)。
#[test]
fn a_refresh_in_flight_keeps_the_list_and_does_not_pin_the_clock() {
/// 只喂首屏快照、派发命令一律不回答的后端(= 「刷新还在飞」)。
#[derive(Debug)]
struct RecorderBackend {
seeded: Vec<AppEvent>,
}
impl Backend for RecorderBackend {
fn drain_events(&mut self) -> Vec<AppEvent> {
std::mem::take(&mut self.seeded)
}
fn dispatch(&mut self, _command: AppCommand) {}
}
let mut app = App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(RecorderBackend {
seeded: vec![AppEvent::Core(CoreEvent::InstancesChanged(
unlucky_presenter::mock_data::mock_instances(),
))],
}),
SettingsBridge::memory_only(),
);
// 前提:首屏快照已落地、入场动效已经跑完(否则 `needs_frames` 为真是动效的锅,不是刷新的)
app.pump();
while crate::app::components::motion::needs_frames() {
crate::app::components::motion::advance_by(crate::app::components::motion::FRAME_MS);
}
let tiles = app.state.tiles().len();
assert!(tiles > 0, "前提:首屏该有实例");
// 刷新在飞:命令出去了,后端这一拍一个字都不回
app.backend.dispatch(AppCommand::RefreshAll);
app.pump();
assert_eq!(
app.state.tiles().len(),
tiles,
"刷新在飞时列表被清空了(清空再重填 = 把卡顿换成闪屏)"
);
assert!(
!app.view_dirty,
"刷新在飞时 `view_dirty` 不许留在真上(M298:每拍重建整页视图模型)"
);
assert!(
!app.needs_frames(),
"刷新在飞时不该多出帧节拍(真要等回程,走派发后的 3 s 观察窗,不是永久订阅)"
);
// 回程到了:整份替换(不是追加),并且这一拍就把脏标收回去
app.backend = Box::new(RecorderBackend {
seeded: vec![AppEvent::Core(CoreEvent::InstancesChanged(
unlucky_presenter::mock_data::mock_instances(),
))],
});
app.pump();
assert_eq!(app.state.tiles().len(), tiles, "快照是整体替换");
assert!(
!app.view_dirty,
"快照落地之后 `view_dirty` 必须被 `refresh_view` 收回去(M298)"
);
}
#[test] #[test]
fn edits_are_ignored_when_the_form_is_bound_elsewhere() { fn edits_are_ignored_when_the_form_is_bound_elsewhere() {
let mut app = app_with_form(); let mut app = app_with_form();
+3
View File
@@ -27,9 +27,12 @@ use crate::dto::{
use super::env::HostEnv; use super::env::HostEnv;
mod discovered; mod discovered;
// M302:实例扫盘的代次账 + 后台线程入口(`host/mod.rs` 贴 400 行硬线,逻辑放这里)
mod refresh;
mod roots; mod roots;
pub(super) use discovered::{base_mode, game_for}; pub(super) use discovered::{base_mode, game_for};
pub(super) use refresh::{spawn_scan, Scan};
use discovered::{display_name, external_summary, external_version_of}; use discovered::{display_name, external_summary, external_version_of};
+169
View File
@@ -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));
}
+70 -1
View File
@@ -2,7 +2,10 @@
//! //!
//! 父模块的私有字段对子模块可见,所以这里直接读写 `self.tx` / `self.launches` 等。 //! 父模块的私有字段对子模块可见,所以这里直接读写 `self.tx` / `self.launches` 等。
use crate::dto::{AppEvent, CoreEvent, InstanceRuntime, LaunchFailureCause, LogOutcome, PackEvent}; use crate::dto::{
AppEvent, CoreEvent, InstanceRuntime, InstanceSummary, LaunchFailureCause, LogOutcome,
PackEvent,
};
use crate::toast::ToastKind; use crate::toast::ToastKind;
use super::{addon, logs, mods, CoreHost, HostTask, LiveLaunch}; use super::{addon, logs, mods, CoreHost, HostTask, LiveLaunch};
@@ -15,6 +18,12 @@ impl CoreHost {
HostTask::Versions(Err(reason)) => { HostTask::Versions(Err(reason)) => {
self.toast(ToastKind::Warn, format!("拉取版本清单失败:{reason}")); self.toast(ToastKind::Warn, format!("拉取版本清单失败:{reason}"));
} }
// M302:实例扫描回程。**代次不符一律丢**(先发后到的扫描可能带着已被删掉的实例)。
HostTask::Instances {
epoch,
instances,
elapsed_ms,
} => self.apply_instances(epoch, instances, elapsed_ms),
HostTask::LoaderVersions { HostTask::LoaderVersions {
kind, kind,
mc_version, mc_version,
@@ -243,6 +252,66 @@ impl CoreHost {
} }
} }
/// 把一份**当代次**的扫盘结果落地:丢旧代次 → 盖运行时态 → 记账 → 发快照。
///
/// ## 运行时态为什么在这里盖(M302)
///
/// `game::instances` 只读盘,每条都是 `Idle`;在跑的实例必须按 host 自己的启动表覆写
/// `Running{pid}`/`Busy`/`Failed`,否则任意一次刷新(装版本成功、加删账户、另一实例收尾)
/// 都会把磁贴打回 Idle:停止键与「运行中」条消失,进程还在跑却再也停不下来。
///
/// 盖的时机是**结果回来那一刻**而不是「发请求那一刻」:扫描期间发生的启动/收尾已经落在
/// host 上了,这一读就是最新的(老实现同步扫,两件事本来就是同一刻)。所以异步化没有改变
/// 这条结论——`launch_state_tests` 的 Running/Busy/Failed 三条照旧成立。
///
/// ## 代次
///
/// 不符就整份丢掉并计数:那份结果的盘面已经过期,采纳它会让「删掉的实例又回来」。
/// 丢完就 return,**不发事件**——界面上的旧快照原样留着(清空再重填只是把卡顿换成白屏)。
fn apply_instances(
&mut self,
epoch: u64,
mut instances: Vec<InstanceSummary>,
elapsed_ms: u64,
) {
if !self.instances_scan.admit(epoch) {
// 丢掉的那一份也记一行:交错真的发生时,光看「采纳」那一行是看不出来的
eprintln!(
"[instances] 丢掉过期扫描(epoch {epoch} ≠ 当代次 {},累计丢 {} 份)",
self.instances_scan.epoch(),
self.instances_scan.dropped()
);
return;
}
for instance in &mut instances {
if let Some(live) = self
.launches
.iter()
.find(|live| live.instance == instance.id)
{
instance.runtime = InstanceRuntime::Running { pid: live.pid };
} else if self.pending.contains(&instance.id) {
instance.runtime = InstanceRuntime::Busy {
stage: crate::dto::LaunchStage::Launch,
progress: crate::dto::Progress { done: 0, total: 1 },
};
} else if let Some(cause) = self.failed.get(&instance.id) {
instance.runtime = InstanceRuntime::Failed {
cause: cause.clone(),
};
}
}
let count = instances.len();
self.instances_scan.adopt(epoch, count, elapsed_ms);
// M302 的可观测口径:一次刷新一行(前缀与 `[mods]` / `[auth]` 同一种形状,好 grep)。
// 「扫了几条、扫了多久、丢过几份」三样都在这一行里——回归时它比"感觉快了"硬。
eprintln!(
"[instances] {count} 个实例 · 扫盘 {elapsed_ms} ms · epoch {epoch} · 丢旧代次 {} 份",
self.instances_scan.dropped()
);
self.send(CoreEvent::InstancesChanged(instances));
}
/// 尾随运行中会话(每 `TAIL_POLL_TICKS` 拍一次)。 /// 尾随运行中会话(每 `TAIL_POLL_TICKS` 拍一次)。
pub(super) fn poll_tail(&mut self) { pub(super) fn poll_tail(&mut self) {
let Some((session, lines)) = self.tail.poll() else { let Some((session, lines)) = self.tail.poll() else {
+14
View File
@@ -18,6 +18,7 @@ use crate::dto::{
InstallProgressView, InstallProgressView,
InstanceId, InstanceId,
InstanceRuntime, InstanceRuntime,
InstanceSummary,
JavaPageView, JavaPageView,
LaunchFinished, LaunchFinished,
LiveLogBatch, LiveLogBatch,
@@ -37,6 +38,19 @@ use crate::toast::Toast;
pub enum HostTask { pub enum HostTask {
/// 版本清单(下载页) /// 版本清单(下载页)
Versions(Result<Vec<VersionSummary>, String>), Versions(Result<Vec<VersionSummary>, String>),
/// 实例快照(M302:扫盘在后台线程,回程靠代次判「谁说了算」)
///
/// 载荷里**只有**扫盘结果(每条运行时态都是 `Idle`):把运行时态盖回去要读 host 自己的
/// `launches/pending/failed`,那一半留在 host 线程(见 `handle::apply_instances`)。
Instances {
/// 发起这次扫描时领的代次([`super::game::Scan`]);与当代次不符 = 已有更新的刷新在飞,
/// 这份结果过期了(可能带着刚被删掉的实例),一律丢。
epoch: u64,
/// 扫出来的磁贴
instances: Vec<InstanceSummary>,
/// 纯扫盘耗时(毫秒):可观测口径,回归时拿它比快慢(不是"感觉快了")
elapsed_ms: u64,
},
/// 加载器版本清单(下载页「安装选项」的下拉) /// 加载器版本清单(下载页「安装选项」的下拉)
LoaderVersions { LoaderVersions {
/// 加载器种类 /// 加载器种类
@@ -161,6 +161,7 @@ fn refresh_keeps_running_instances_running() {
let _ = host.drain_events(); let _ = host.drain_events();
host.refresh_instances(); host.refresh_instances();
host.pump_instances();
let instance = instances_from_events(host.drain_events()) let instance = instances_from_events(host.drain_events())
.into_iter() .into_iter()
@@ -181,6 +182,7 @@ fn refresh_keeps_pending_as_busy_and_failed_as_failed() {
host.mark_pending(&id); host.mark_pending(&id);
let pending = instances_from_events({ let pending = instances_from_events({
host.refresh_instances(); host.refresh_instances();
host.pump_instances();
host.drain_events() host.drain_events()
}) })
.into_iter() .into_iter()
@@ -208,6 +210,7 @@ fn refresh_keeps_pending_as_busy_and_failed_as_failed() {
let failed = instances_from_events({ let failed = instances_from_events({
host.refresh_instances(); host.refresh_instances();
host.pump_instances();
host.drain_events() host.drain_events()
}) })
.into_iter() .into_iter()
@@ -252,6 +255,7 @@ fn launch_end_before_launch_started_still_releases_the_instance() {
let failed = instances_from_events({ let failed = instances_from_events({
host.refresh_instances(); host.refresh_instances();
host.pump_instances();
host.drain_events() host.drain_events()
}) })
.into_iter() .into_iter()
@@ -336,6 +340,7 @@ fn a_launch_end_without_a_stage_reports_the_outcome_not_a_start_failure() {
}))); })));
let failed = instances_from_events({ let failed = instances_from_events({
host.refresh_instances(); host.refresh_instances();
host.pump_instances();
host.drain_events() host.drain_events()
}) })
.into_iter() .into_iter()
+17 -25
View File
@@ -116,6 +116,12 @@ pub struct CoreHost {
adopt_epoch: u64, adopt_epoch: u64,
/// 自更新(M159):检查结论 / 应用状态 / 更新域偏好。 /// 自更新(M159):检查结论 / 应用状态 / 更新域偏好。
update: update_state::Runtime, update: update_state::Runtime,
/// 实例快照的代次 + 观测账(M302):每次刷新 +1,**旧代次的回程结果一律丢掉**。
///
/// 扫盘搬进后台线程之后,「先发出的扫描可能后回来」:它的结果已经过期(可能带着刚被删掉的
/// 实例、漏掉刚装好的),采纳它等于把新快照盖回旧的样子。判据在 `handle::apply_instances`,
/// 账目(实例数 / 扫盘耗时 / 丢了几份)在 [`game::Scan`] 里,单测直接读它。
instances_scan: game::Scan,
} }
impl CoreHost { impl CoreHost {
@@ -170,6 +176,7 @@ impl CoreHost {
install_epoch: 0, install_epoch: 0,
adopt_epoch: 0, adopt_epoch: 0,
update, update,
instances_scan: game::Scan::default(),
}; };
if refresh { if refresh {
host.refresh_all(); host.refresh_all();
@@ -221,33 +228,18 @@ impl CoreHost {
} }
} }
/// 重建实例快照,并把**真实运行时**盖回去。 /// 重扫实例目录(M302:**扫盘在后台线程**,回程由 `handle::apply_instances` 落地)。
/// ///
/// `game::instances` 只读盘(每条都是 `Idle`),所以在跑的实例必须在重建后按 /// 两半分家:`game::instances(&env)` 只读盘(纯函数,可搬线程);「把运行时态盖回去」要读
/// host 自己的启动表覆写 `Running{pid}`/`Busy`,否则任意一次刷新(装版本成功、 /// host 自己的 `launches/pending/failed`,**必须留在 host 线程**,并且在**结果回来那一刻**盖
/// 加删账户、另一实例收尾)都会把磁贴打回 Idle:停止键与「运行中」条消失, /// ——判断理由见 `handle::apply_instances`。
/// 进程还在跑却再也停不下来。 ///
/// 代次只增不减:连点刷新 / 收尾触发的刷新 / 扫描途中删实例这三种交错下,先发出的扫描可能
/// 后回来,它的结果已经过期,一律丢([`game::Scan`])。**不做线程取消**:扫描是纯读,
/// 掐断它省不下什么,却可能让一份快照永远不回来。
fn refresh_instances(&mut self) { fn refresh_instances(&mut self) {
let mut instances = game::instances(&self.env); let epoch = self.instances_scan.begin();
for instance in &mut instances { game::spawn_scan(self.env.clone(), self.tx.clone(), epoch);
if let Some(live) = self
.launches
.iter()
.find(|live| live.instance == instance.id)
{
instance.runtime = InstanceRuntime::Running { pid: live.pid };
} else if self.pending.contains(&instance.id) {
instance.runtime = InstanceRuntime::Busy {
stage: crate::dto::LaunchStage::Launch,
progress: crate::dto::Progress { done: 0, total: 1 },
};
} else if let Some(cause) = self.failed.get(&instance.id) {
instance.runtime = InstanceRuntime::Failed {
cause: cause.clone(),
};
}
}
self.send(CoreEvent::InstancesChanged(instances));
} }
fn refresh_java(&mut self) { fn refresh_java(&mut self) {