From a0413a5f947505e9d918a08d9c42b9f56a54ddb6 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Fri, 13 Nov 2026 17:58:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(host):=20M302=20=E5=AE=9E=E4=BE=8B?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E5=88=B7=E6=96=B0=E6=90=AC=E5=87=BA=20UI=20?= =?UTF-8?q?=E7=BA=BF=E7=A8=8B=EF=BC=88=E6=89=AB=E7=9B=98=E8=BF=9B=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=20+=20=E4=BB=A3=E6=AC=A1=E4=B8=A2=E5=BC=83=E8=BF=87?= =?UTF-8?q?=E6=9C=9F=E7=BB=93=E6=9E=9C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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()`(断言一字未动) --- crates/app-iced/src/app/update/instances.rs | 82 +++- crates/presenter/src/host/game.rs | 3 + crates/presenter/src/host/game/refresh.rs | 169 ++++++++ .../presenter/src/host/game/refresh_tests.rs | 395 ++++++++++++++++++ crates/presenter/src/host/handle.rs | 71 +++- crates/presenter/src/host/job.rs | 14 + .../presenter/src/host/launch_state_tests.rs | 5 + crates/presenter/src/host/mod.rs | 42 +- 8 files changed, 753 insertions(+), 28 deletions(-) create mode 100644 crates/presenter/src/host/game/refresh.rs create mode 100644 crates/presenter/src/host/game/refresh_tests.rs diff --git a/crates/app-iced/src/app/update/instances.rs b/crates/app-iced/src/app/update/instances.rs index f264e9e..6831de6 100644 --- a/crates/app-iced/src/app/update/instances.rs +++ b/crates/app-iced/src/app/update/instances.rs @@ -181,7 +181,7 @@ impl App { #[cfg(test)] mod tests { 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 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, + } + + impl Backend for RecorderBackend { + fn drain_events(&mut self) -> Vec { + 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] fn edits_are_ignored_when_the_form_is_bound_elsewhere() { let mut app = app_with_form(); diff --git a/crates/presenter/src/host/game.rs b/crates/presenter/src/host/game.rs index b038218..61248a7 100644 --- a/crates/presenter/src/host/game.rs +++ b/crates/presenter/src/host/game.rs @@ -27,9 +27,12 @@ use crate::dto::{ use super::env::HostEnv; mod discovered; +// M302:实例扫盘的代次账 + 后台线程入口(`host/mod.rs` 贴 400 行硬线,逻辑放这里) +mod refresh; mod roots; pub(super) use discovered::{base_mode, game_for}; +pub(super) use refresh::{spawn_scan, Scan}; use discovered::{display_name, external_summary, external_version_of}; diff --git a/crates/presenter/src/host/game/refresh.rs b/crates/presenter/src/host/game/refresh.rs new file mode 100644 index 0000000..0cbe925 --- /dev/null +++ b/crates/presenter/src/host/game/refresh.rs @@ -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, + /// 被丢掉的**旧代次**结果条数(> 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 { + 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; diff --git a/crates/presenter/src/host/game/refresh_tests.rs b/crates/presenter/src/host/game/refresh_tests.rs new file mode 100644 index 0000000..fd1843d --- /dev/null +++ b/crates/presenter/src/host/game/refresh_tests.rs @@ -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 = 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 { + (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, 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 { + match task { + HostTask::Instances { instances, .. } => { + let mut ids: Vec = instances.iter().map(|item| item.id.clone()).collect(); + ids.sort(); + ids + } + other => panic!("这不是实例扫描任务:{other:?}"), + } +} + +/// 事件队列里所有 `InstancesChanged` 快照(按先后)。 +fn snapshots(events: Vec) -> Vec> { + events + .into_iter() + .filter_map(|event| match event { + AppEvent::Core(CoreEvent::InstancesChanged(list)) => Some(list), + _ => None, + }) + .collect() +} + +/// 快照里的实例 id(排好序)。 +fn sorted_ids(snapshot: &[InstanceSummary]) -> Vec { + let mut ids: Vec = 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)); +} diff --git a/crates/presenter/src/host/handle.rs b/crates/presenter/src/host/handle.rs index f16ec1e..13fd0d8 100644 --- a/crates/presenter/src/host/handle.rs +++ b/crates/presenter/src/host/handle.rs @@ -2,7 +2,10 @@ //! //! 父模块的私有字段对子模块可见,所以这里直接读写 `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 super::{addon, logs, mods, CoreHost, HostTask, LiveLaunch}; @@ -15,6 +18,12 @@ impl CoreHost { HostTask::Versions(Err(reason)) => { self.toast(ToastKind::Warn, format!("拉取版本清单失败:{reason}")); } + // M302:实例扫描回程。**代次不符一律丢**(先发后到的扫描可能带着已被删掉的实例)。 + HostTask::Instances { + epoch, + instances, + elapsed_ms, + } => self.apply_instances(epoch, instances, elapsed_ms), HostTask::LoaderVersions { kind, 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, + 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` 拍一次)。 pub(super) fn poll_tail(&mut self) { let Some((session, lines)) = self.tail.poll() else { diff --git a/crates/presenter/src/host/job.rs b/crates/presenter/src/host/job.rs index c8ffec5..5c51bad 100644 --- a/crates/presenter/src/host/job.rs +++ b/crates/presenter/src/host/job.rs @@ -18,6 +18,7 @@ use crate::dto::{ InstallProgressView, InstanceId, InstanceRuntime, + InstanceSummary, JavaPageView, LaunchFinished, LiveLogBatch, @@ -37,6 +38,19 @@ use crate::toast::Toast; pub enum HostTask { /// 版本清单(下载页) Versions(Result, String>), + /// 实例快照(M302:扫盘在后台线程,回程靠代次判「谁说了算」) + /// + /// 载荷里**只有**扫盘结果(每条运行时态都是 `Idle`):把运行时态盖回去要读 host 自己的 + /// `launches/pending/failed`,那一半留在 host 线程(见 `handle::apply_instances`)。 + Instances { + /// 发起这次扫描时领的代次([`super::game::Scan`]);与当代次不符 = 已有更新的刷新在飞, + /// 这份结果过期了(可能带着刚被删掉的实例),一律丢。 + epoch: u64, + /// 扫出来的磁贴 + instances: Vec, + /// 纯扫盘耗时(毫秒):可观测口径,回归时拿它比快慢(不是"感觉快了") + elapsed_ms: u64, + }, /// 加载器版本清单(下载页「安装选项」的下拉) LoaderVersions { /// 加载器种类 diff --git a/crates/presenter/src/host/launch_state_tests.rs b/crates/presenter/src/host/launch_state_tests.rs index 6bff8e3..69ccb34 100644 --- a/crates/presenter/src/host/launch_state_tests.rs +++ b/crates/presenter/src/host/launch_state_tests.rs @@ -161,6 +161,7 @@ fn refresh_keeps_running_instances_running() { let _ = host.drain_events(); host.refresh_instances(); + host.pump_instances(); let instance = instances_from_events(host.drain_events()) .into_iter() @@ -181,6 +182,7 @@ fn refresh_keeps_pending_as_busy_and_failed_as_failed() { host.mark_pending(&id); let pending = instances_from_events({ host.refresh_instances(); + host.pump_instances(); host.drain_events() }) .into_iter() @@ -208,6 +210,7 @@ fn refresh_keeps_pending_as_busy_and_failed_as_failed() { let failed = instances_from_events({ host.refresh_instances(); + host.pump_instances(); host.drain_events() }) .into_iter() @@ -252,6 +255,7 @@ fn launch_end_before_launch_started_still_releases_the_instance() { let failed = instances_from_events({ host.refresh_instances(); + host.pump_instances(); host.drain_events() }) .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({ host.refresh_instances(); + host.pump_instances(); host.drain_events() }) .into_iter() diff --git a/crates/presenter/src/host/mod.rs b/crates/presenter/src/host/mod.rs index d28434c..d1768de 100644 --- a/crates/presenter/src/host/mod.rs +++ b/crates/presenter/src/host/mod.rs @@ -116,6 +116,12 @@ pub struct CoreHost { adopt_epoch: u64, /// 自更新(M159):检查结论 / 应用状态 / 更新域偏好。 update: update_state::Runtime, + /// 实例快照的代次 + 观测账(M302):每次刷新 +1,**旧代次的回程结果一律丢掉**。 + /// + /// 扫盘搬进后台线程之后,「先发出的扫描可能后回来」:它的结果已经过期(可能带着刚被删掉的 + /// 实例、漏掉刚装好的),采纳它等于把新快照盖回旧的样子。判据在 `handle::apply_instances`, + /// 账目(实例数 / 扫盘耗时 / 丢了几份)在 [`game::Scan`] 里,单测直接读它。 + instances_scan: game::Scan, } impl CoreHost { @@ -170,6 +176,7 @@ impl CoreHost { install_epoch: 0, adopt_epoch: 0, update, + instances_scan: game::Scan::default(), }; if refresh { host.refresh_all(); @@ -221,33 +228,18 @@ impl CoreHost { } } - /// 重建实例快照,并把**真实运行时**盖回去。 + /// 重扫实例目录(M302:**扫盘在后台线程**,回程由 `handle::apply_instances` 落地)。 /// - /// `game::instances` 只读盘(每条都是 `Idle`),所以在跑的实例必须在重建后按 - /// host 自己的启动表覆写 `Running{pid}`/`Busy`,否则任意一次刷新(装版本成功、 - /// 加删账户、另一实例收尾)都会把磁贴打回 Idle:停止键与「运行中」条消失, - /// 进程还在跑却再也停不下来。 + /// 两半分家:`game::instances(&env)` 只读盘(纯函数,可搬线程);「把运行时态盖回去」要读 + /// host 自己的 `launches/pending/failed`,**必须留在 host 线程**,并且在**结果回来那一刻**盖 + /// ——判断理由见 `handle::apply_instances`。 + /// + /// 代次只增不减:连点刷新 / 收尾触发的刷新 / 扫描途中删实例这三种交错下,先发出的扫描可能 + /// 后回来,它的结果已经过期,一律丢([`game::Scan`])。**不做线程取消**:扫描是纯读, + /// 掐断它省不下什么,却可能让一份快照永远不回来。 fn refresh_instances(&mut self) { - let mut instances = game::instances(&self.env); - 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(), - }; - } - } - self.send(CoreEvent::InstancesChanged(instances)); + let epoch = self.instances_scan.begin(); + game::spawn_scan(self.env.clone(), self.tx.clone(), epoch); } fn refresh_java(&mut self) {