From a0413a5f947505e9d918a08d9c42b9f56a54ddb6 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Fri, 13 Nov 2026 17:58:59 +0800 Subject: [PATCH 1/3] =?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) { From f03411d58b6d46fe92d7f5cedcc3640c67110d3d Mon Sep 17 00:00:00 2001 From: AskaEth Date: Fri, 13 Nov 2026 19:00:30 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(instances):=20M302=20r1=20P2=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E5=86=B7=E5=90=AF=E5=8A=A8=E9=A6=96?= =?UTF-8?q?=E5=B8=A7=E4=B8=89=E6=80=81=EF=BC=88=E5=8D=A0=E4=BD=8D=20/=20?= =?UTF-8?q?=E7=9C=9F=E7=A9=BA=E6=80=81=20/=20=E6=9C=89=E5=AE=9E=E4=BE=8B?= =?UTF-8?q?=EF=BC=89+=20N1~N4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审 P2-1:扫盘搬后台之后,`App::dispatch(RefreshAll)` 返回时快照还没到(`pump` 只抽队列、 不 `tick`),**首帧必然**在快照之前。改前那一帧画的是「计数 0 + 还没有实例 + 实心导入 CTA」 ——一句假话,而且 CTA 能点。改后三态分开: - 真源放 presenter:`dto::InstancePhase { Scanning, Stalled, Ready }`,`AppState::instances_phase` 在 `CoreEvent::InstancesChanged` 落地时置 `Ready`;**不从 `instances.is_empty()` 推**(那就是 bug 形态)。 - `Scanning` = 首个快照未到:详情列画「正在扫描实例…」占位(新 key `InstancesScanning`,golden 两语), 列头**不写计数**(藏起来,不写 0 也不写「—」),**没有**空态 CTA。 - `Stalled` = 有界兜底:`host::game::SCAN_STALL = 15 s`(= 仓库内最坏实测 1.35 s 的约 11 倍, 理由在常量文档里)。超时只**报**一次、不掐线程;迟到的结果照样落地(代次没变)。 那一屏给一枚「重新扫描」→ `InstanceMessage::RetryScan` → 新命令 `AppCommand::RefreshInstances` (只重扫实例目录,不重拉版本清单)。那一支在 `selected_instance_id()` 守卫**之前**——冷启动没有选中项。 - `Ready` = 收到过快照(空列表也算):这时才轮到 M273 的两种空态(没选中 / 一个都没有)。 - 已就绪时**不发** `Scanning`:否则每次刷新列表都退回占位(把卡顿换成闪屏)。 判据(都能红,变异原文见报告): - `pages/instances/tests/cold_boot.rs`:真控件树(未就绪 7 枚按钮 = 有实例那一屏,零实例 = 8 枚, 多的那枚就是 CTA;每行 2 条文字节点的差分数行数)+ 源码结构(`Scanning`/`Stalled` 两支不碰 `detail_empty` 与空态文案、超时那支接 `RetryScan`、三个 key 真的被读)。 - `app/update/instances/scan_phase.rs`:首帧 = `Scanning`、空快照 = `Ready`、`Stalled` 经重试回到 `Scanning`、重试只派 `RefreshInstances`。夹具 `ULL_INSTANCE_PHASE` 只给出图(与 `ULL_SAVES_FIXTURE` 同一条纪律)。 顺手收 N1~N4(全是措辞 / 观测口径,不动行为): - N1 两处 docstring 不再声称「`admit` 写成 `>=` 会红」(实测等价),改成「去掉或恒真才红」; - N2 收尾用例那条 Idle 断言的失败消息改准(它不区分新旧快照,区分靠条数/代次断言); - N3 boot 双刷新必然丢 1 份 ⇒ 丢弃日志分成「此前没有快照落地(启动期,非交错)」与「已有落地(真交错)」, `Scan::dropped` 的文档登记这条已知噪音; - N4 app 用例 docstring 收窄成「一拍无事件不清表、不钉时钟」(它绕过 `App::dispatch`,抓不住那 5 行)。 拆文件守 400 行:`detail/scan.rs`(分派 + 占位)、`refresh_stall_tests.rs`(空根 / 超时)、 `app/update/instances/scan_phase.rs`(app 级三态用例);`i18n` 的 `PAGE_SOURCES` 同步加上新页面文件。 --- crates/app-iced/src/app/update/instances.rs | 23 +- .../src/app/update/instances/scan_phase.rs | 182 ++++++++++ crates/app-iced/src/app/view.rs | 26 +- crates/app-iced/src/pages/instances.rs | 54 ++- crates/app-iced/src/pages/instances/demo.rs | 5 +- crates/app-iced/src/pages/instances/detail.rs | 4 + .../src/pages/instances/detail/scan.rs | 67 ++++ crates/app-iced/src/pages/instances/state.rs | 5 + crates/app-iced/src/pages/instances/tests.rs | 3 + .../src/pages/instances/tests/cold_boot.rs | 310 ++++++++++++++++++ .../src/pages/instances/tests/empty_entry.rs | 33 +- .../src/pages/instances/tests/i18n.rs | 7 +- crates/app-iced/src/pages/instances/text.rs | 14 + crates/app-iced/src/pages/model.rs | 33 ++ crates/i18n/src/catalog/golden.rs | 3 + crates/i18n/src/catalog/instances.rs | 4 + crates/presenter/src/dto/events.rs | 13 + crates/presenter/src/dto/instances.rs | 32 ++ crates/presenter/src/dto/mod.rs | 6 +- crates/presenter/src/host/dispatch.rs | 2 + crates/presenter/src/host/game/refresh.rs | 91 ++++- .../src/host/game/refresh_stall_tests.rs | 91 +++++ .../presenter/src/host/game/refresh_tests.rs | 31 +- crates/presenter/src/host/handle.rs | 15 +- crates/presenter/src/host/mod.rs | 9 + crates/presenter/src/state.rs | 17 +- 26 files changed, 1010 insertions(+), 70 deletions(-) create mode 100644 crates/app-iced/src/app/update/instances/scan_phase.rs create mode 100644 crates/app-iced/src/pages/instances/detail/scan.rs create mode 100644 crates/app-iced/src/pages/instances/tests/cold_boot.rs create mode 100644 crates/presenter/src/host/game/refresh_stall_tests.rs diff --git a/crates/app-iced/src/app/update/instances.rs b/crates/app-iced/src/app/update/instances.rs index 6831de6..941a05c 100644 --- a/crates/app-iced/src/app/update/instances.rs +++ b/crates/app-iced/src/app/update/instances.rs @@ -23,12 +23,19 @@ use super::super::state::App; impl App { /// 实例页详情列的一条消息(启动参数表单 + MODS 区)。 pub(super) fn on_instance_message(&mut self, message: InstanceMessage) -> Task { + // M302:扫描超时那一屏的「重新扫描」。**必须在下面那道守卫之前**:那一刻列表还是空的、 + // 根本没有选中实例,掉进 `selected_instance_id()` 守卫就永远派不发——按钮点了没反应。 + if matches!(message, InstanceMessage::RetryScan) { + self.dispatch(AppCommand::RefreshInstances); + return Task::none(); + } let Some(instance) = self.selected_instance_id() else { return Task::none(); }; match message { - // M224:启停一个 mod。**不做乐观翻面**——先把那个开关标成「请求在飞」(置灰), + // 上面那条提前返回已经处理过了;这里留一支只为保持 match 穷尽(漏一支会编不过) + InstanceMessage::RetryScan => {} // 真状态等 host 回一份新快照(与 Save 分支同一条纪律)。 // // 这一支在 `bound_to` 守卫**之前**:MODS 区的启停只依赖「当前选中实例」, @@ -276,8 +283,13 @@ mod tests { /// 第二件事是 M298 的型态:`view_dirty` 一旦永久为真,`advance` 就每拍无条件 `refresh_view` /// (整页视图模型重建),而「快照落地」这条路最容易被写成「只置脏不重建」。 /// - /// 抓得住:① 谁在派发刷新的那一刻清空 `state.instances`;② 谁把 `InstancesChanged` 的落地 - /// 路径写成「置脏不落地」或者让 `refresh_view` 收尾不再复位 `view_dirty`。 + /// 抓得住:① **没有事件的一拍** `pump` 不把 `state.instances` 清空(用例走的是 + /// `backend.dispatch + pump`,所以要防的是「事件落地路径顺手清表」这一类错); + /// ② 谁把 `InstancesChanged` 的落地路径写成「置脏不落地」或者让 `refresh_view` 收尾不再复位 + /// `view_dirty`。 + /// 抓不住:**`App::dispatch` 那 5 行**(它置 `watch_until`、再 pump)本身——本用例故意绕过它, + /// 因为观察窗会让 `needs_frames()` 在 3 s 内为真,与「刷新有没有多订阅帧」这件事混在一起。 + /// 换句话说:这里守的是「一拍无事件 = 列表不动 + 不烧帧」,不是「派发那一刻」。 /// 抓不住:真线程的时序与竞争(那在 presenter 的 `host::game::refresh::tests` 交错用例里); /// 也抓不住「扫盘真要多久」(那是真实测量,不是单测)。 #[test] @@ -380,3 +392,8 @@ mod tests { assert_eq!(app.launch_form.memory, "4g", "派发不改表单内容"); } } + +// ------------------------------------------------------------------ M302 首屏三态 + +#[cfg(test)] +mod scan_phase; diff --git a/crates/app-iced/src/app/update/instances/scan_phase.rs b/crates/app-iced/src/app/update/instances/scan_phase.rs new file mode 100644 index 0000000..56fdefc --- /dev/null +++ b/crates/app-iced/src/app/update/instances/scan_phase.rs @@ -0,0 +1,182 @@ +//! M302 的**首屏三态接线**:presenter 的 `InstancePhase` → 页面三态,以及「重新扫描」的落点。 +//! +//! 从 `app/update/instances.rs` 拆出来只为守 400 行硬线(那边加了这一组会到 573 行)。 +//! +//! | 用例 | 抓得住 | 抓不住 | +//! |---|---|---| +//! | [`a_fresh_app_is_scanning_until_the_snapshot_lands`] | 首帧(还没有任何快照)被画成「已就绪 + 零实例」(= 复审 P2-1 的假空态) | 真的像素(出图) | +//! | [`an_empty_snapshot_still_counts_as_ready`] | 空快照被当成「未就绪」(用户永远看着「正在扫描」) | 空快照从哪来(presenter 的 `refresh_tests` 用真空盘钉) | +//! | [`a_stalled_scan_goes_back_to_scanning_on_retry`] | 超时之后没有出路(`Stalled` 不会被新一轮扫描收回 `Scanning`) | 15 s 的阈值本身(`host::game::SCAN_STALL`,单测不等真实时间) | +//! | [`retry_scan_dispatches_a_scan_only_refresh`] | 重试按钮派错命令 / 被「必须有选中实例」的守卫挡住(冷启动时根本没有选中项) | 命令真的到达 host(`host::dispatch` 的 `RefreshInstances` 分支由 presenter 的穷尽 match 守) | + +//! M302 的**首屏三态接线**:presenter 的 `InstancePhase` → 页面三态,以及「重新扫描」的落点。 +//! +//! | 用例 | 抓得住 | 抓不住 | +//! |---|---|---| +//! | [`a_fresh_app_is_scanning_until_the_snapshot_lands`] | 首帧(还没有任何快照)被画成「已就绪 + 零实例」(= 复审 P2-1 的假空态) | 真的像素(出图) | +//! | [`an_empty_snapshot_still_counts_as_ready`] | 空快照被当成「未就绪」(用户永远看着「正在扫描」) | 空快照从哪来(presenter 的 `refresh_tests` 用真空盘钉) | +//! | [`a_stalled_scan_goes_back_to_scanning_on_retry`] | 超时之后没有出路(`Stalled` 不会被新一轮扫描收回 `Scanning`) | 15 s 的阈值本身(`host::game::SCAN_STALL`,单测不等待真实时间) | +//! | [`retry_scan_dispatches_a_scan_only_refresh`] | 重试按钮派错命令 / 被「必须有选中实例」的守卫挡住(冷启动时根本没有选中项) | 命令真的到达 host(`host::dispatch` 的 `RefreshInstances` 分支由 presenter 编译期穷尽 match 守) | + +use std::cell::RefCell; +use std::rc::Rc; + +use unlucky_design::color::SchemeFlavor; +use unlucky_presenter::dto::{AppCommand, AppEvent, Backend, CoreEvent, InstancePhase}; +use unlucky_presenter::settings_bridge::SettingsBridge; + +use crate::app::state::App; +use crate::cli::Options; +use crate::pages::model::ScanPhase; +use crate::pages::InstanceMessage; + +use crate::theme::Families; + +const FAMILIES: Families = Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", +}; + +/// 只记命令、只在被喂料时回事件的后端(首帧测试要的是「什么都还没到」)。 +#[derive(Debug, Default)] +struct Recorder { + dispatched: Rc>>, + queued: Vec, +} + +impl Backend for Recorder { + fn drain_events(&mut self) -> Vec { + std::mem::take(&mut self.queued) + } + + fn dispatch(&mut self, command: AppCommand) { + self.dispatched.borrow_mut().push(command); + } +} + +/// 造一个「首帧还没收到任何快照」的 App(路由停在实例页,**没有**选中实例)。 +fn app() -> (App, Rc>>) { + let recorded = Rc::new(RefCell::new(Vec::new())); + let mut app = App::with_presenter( + Options::default(), + FAMILIES, + SchemeFlavor::Light, + Box::new(Recorder { + dispatched: Rc::clone(&recorded), + queued: Vec::new(), + }), + SettingsBridge::memory_only(), + ); + app.state + .navigate(unlucky_presenter::router::Route::Instances); + app.refresh_view(); + (app, recorded) +} + +/// 首帧:**没有快照 ⇒ 画占位**,不是「已就绪 + 零实例」。 +/// +/// 抓得住:谁把页面三态接成「`rows.is_empty()` ⇒ 空态」(复审 P2-1 的原样),这条立刻红。 +/// 抓不住:占位长什么样(`pages/instances/tests/cold_boot.rs` 的控件树 + 出图)。 +#[test] +fn a_fresh_app_is_scanning_until_the_snapshot_lands() { + let (mut app, _recorded) = app(); + + assert_eq!( + app.view.instances.scan, + ScanPhase::Scanning, + "首帧(还没有任何实例快照)必须画占位,不许画「还没有实例」" + ); + assert!(app.view.instances.rows.is_empty(), "首帧本来就没有行"); + + // 快照到了:三态翻到 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(), + "快照里的实例必须出现在列表里" + ); +} + +/// **空快照也算就绪**:真·空数据根扫完就是「零实例 + Ready」,不是「一直在扫描」。 +/// +/// 抓得住:把「收到快照」判成「没收到」(例如拿空列表当未就绪)——那会让空数据根永远转圈。 +/// 抓不住:真空盘真的产出空快照这件事(presenter 的 `refresh_tests` 用真临时根钉)。 +#[test] +fn an_empty_snapshot_still_counts_as_ready() { + let (mut app, _recorded) = app(); + + app.state + .apply(AppEvent::Core(CoreEvent::InstancesChanged(Vec::new()))); + app.refresh_view(); + + assert_eq!( + app.view.instances.scan, + ScanPhase::Ready, + "扫完且零实例 = 就绪:这时才轮到「还没有实例」+ 导入 CTA" + ); + assert!(app.view.instances.rows.is_empty()); +} + +/// 超时那屏:`Stalled` 落地 ⇒ 页面翻到「重新扫描」;点了重试 ⇒ 回到 `Scanning`。 +/// +/// 抓得住:`InstancePhaseChanged` 没接进页面(超时对用户不可见);重试之后没有回到扫描态 +/// (按钮点完还停在错误态)。 +/// 抓不住:15 s 的阈值(`SCAN_STALL`,等真实时间不是单测该做的事)。 +#[test] +fn a_stalled_scan_goes_back_to_scanning_on_retry() { + let (mut app, recorded) = app(); + app.state + .apply(AppEvent::Core(CoreEvent::InstancePhaseChanged( + InstancePhase::Stalled, + ))); + app.refresh_view(); + assert_eq!(app.view.instances.scan, ScanPhase::Stalled); + + // 用户点「重新扫描」⇒ 派一条只重扫实例的命令 + let _ = app.on_instance_message(InstanceMessage::RetryScan); + assert!( + recorded.borrow().contains(&AppCommand::RefreshInstances), + "重试必须派 `AppCommand::RefreshInstances`(实际 {:?})", + recorded.borrow() + ); + + // host 受理后会把三态打回 `Scanning`(新一轮扫描起跑) + app.state + .apply(AppEvent::Core(CoreEvent::InstancePhaseChanged( + InstancePhase::Scanning, + ))); + app.refresh_view(); + assert_eq!(app.view.instances.scan, ScanPhase::Scanning); +} + +/// 「重新扫描」派的是**只重扫实例**那条命令,而且冷启动(没有选中实例)也能派出去。 +/// +/// 抓得住:① 派成 `RefreshAll`(会顺带重拉版本清单网络请求);② 那一支被 `selected_instance_id()` +/// 守卫挡住——冷启动时列表还是空的、根本没有选中项,按钮会点了没反应(`on_instance_message` +/// 的第一条守卫就是这个形态)。 +/// 抓不住:host 收到之后真的重扫(`host::dispatch` 的 `RefreshInstances` 分支)。 +#[test] +fn retry_scan_dispatches_a_scan_only_refresh() { + let (mut app, recorded) = app(); + assert!( + app.selected_instance_id().is_none(), + "前提:冷启动没有选中实例(这正是守卫会挡住的情形)" + ); + + let _ = app.on_instance_message(InstanceMessage::RetryScan); + + let sent = recorded.borrow().clone(); + assert_eq!( + sent, + vec![AppCommand::RefreshInstances], + "只许派一条 `RefreshInstances`(不许 `RefreshAll`,也不许什么都不派)" + ); + assert!( + !sent.contains(&AppCommand::RefreshAll), + "重试不该重拉版本清单(那是一条网络请求)" + ); +} diff --git a/crates/app-iced/src/app/view.rs b/crates/app-iced/src/app/view.rs index bd83292..fec0dae 100644 --- a/crates/app-iced/src/app/view.rs +++ b/crates/app-iced/src/app/view.rs @@ -215,6 +215,25 @@ impl App { // --------------------------------------------------------------- 快照 → 视图模型 +/// 首屏三态的出图夹具(`ULL_INSTANCE_PHASE=scanning|stalled`)。 +/// +/// **只给出图**:截图通道在第 3 帧落盘(`CAPTURE_AT_FRAME`),而真快照要走一条后台线程 + +/// 至少一个 `tick`,来不及;所以「正在扫描 / 超时」这两屏必须能**注入**状态。注入点选在 +/// 「快照 → 页面模型」这一处转换(与 `ULL_SAVES_FIXTURE` / `ULL_MODS_FIXTURE` 同一条纪律: +/// 只在环境变量存在时生效,产品路径读不到它 ⇒ 一帧都不影响;三态判据本身仍由 +/// `pages/instances/tests/cold_boot.rs` 与 `app/update/instances.rs` 守着)。 +fn scan_phase(state: &AppState) -> pages::model::ScanPhase { + match std::env::var("ULL_INSTANCE_PHASE") + .ok() + .as_deref() + .map(str::trim) + { + Some("scanning") => pages::model::ScanPhase::Scanning, + Some("stalled") => pages::model::ScanPhase::Stalled, + _ => pages::model::ScanPhase::of(state.instances_phase), + } +} + /// 把 presenter 的快照转成页面视图模型(**唯一**的展示转换点)。 /// /// `forced` 是 `--force-state` 的落点(P3-1):它只影响**画出来的**状态层,不改任何状态机。 @@ -262,7 +281,12 @@ pub(super) fn model( window_width: width, window_height: height, active: route.nav(), - instances: pages::Instances { rows, detail }, + instances: pages::Instances { + rows, + detail, + // M302:首屏三态由 presenter 的快照位决定(**不**从 `rows.is_empty()` 猜) + scan: scan_phase(state), + }, download: pages::Download::from_snapshot(state, category, draft, lang.catalog()), // M109:检索条件/结果来自快照,页面内选择(类型/选中/目标/草稿)来自 `App::content` content: pages::content::Content::from_snapshot(state, content, lang.catalog()), diff --git a/crates/app-iced/src/pages/instances.rs b/crates/app-iced/src/pages/instances.rs index 762c65e..8d7cdd5 100644 --- a/crates/app-iced/src/pages/instances.rs +++ b/crates/app-iced/src/pages/instances.rs @@ -128,7 +128,7 @@ use unlucky_presenter::router::Route; use super::frame::{self, Header}; use super::layout::{FrameMetrics, PageLayout}; -use super::model::{Instances, Model, Row}; +use super::model::{Instances, Model, Row, ScanPhase}; use super::{Action, Message}; /// 列表行距:**`None` = 这一层不加间距**(页面与护栏**共用这一个真源**)。 @@ -187,7 +187,7 @@ pub fn view<'a>( forced, texts, ), - None => detail::detail_empty(ui, texts, !block.rows.is_empty()), + None => detail::detail_side(ui, block, texts), }, ), _ => match &block.detail { @@ -201,10 +201,7 @@ pub fn view<'a>( forced, texts, ), - None => frame::body( - metrics, - detail::detail_empty(ui, texts, !block.rows.is_empty()), - ), + None => frame::body(metrics, detail::detail_side(ui, block, texts)), }, }; @@ -228,30 +225,29 @@ fn list_column<'a>( forced: Option, texts: Texts, ) -> Element<'a, Message> { - let head = container( - row![ - container(frame::text_role( - ui, - TypeRole::LabelMedium, - false, - texts.all_title(), - ui.color(Role::OnSurfaceVariant), - )) - .width(Length::Fill) - .align_y(Alignment::Center), - frame::text_role_owned( - ui, - TypeRole::LabelMedium, - false, - block.rows.len().to_string(), - ui.color(Role::OnSurfaceVariant), - ), - ] - .width(Length::Fill) - .align_y(Alignment::Center), - ) + let mut head_row = row![container(frame::text_role( + ui, + TypeRole::LabelMedium, + false, + texts.all_title(), + ui.color(Role::OnSurfaceVariant), + )) .width(Length::Fill) - .padding(Padding { + .align_y(Alignment::Center)] + .width(Length::Fill) + .align_y(Alignment::Center); + // M302:**未就绪时不许写 0**(复审 r1 P2-1:计数 0 与「还没有实例」是同一句假话的两半)。 + // 藏起来而不是写「—」:破折号读起来像「零个」,而这一刻根本没有数可给。 + if block.scan == ScanPhase::Ready { + head_row = head_row.push(frame::text_role_owned( + ui, + TypeRole::LabelMedium, + false, + block.rows.len().to_string(), + ui.color(Role::OnSurfaceVariant), + )); + } + let head = container(head_row).width(Length::Fill).padding(Padding { top: ui.space(Space::X3), right: ui.space(Space::X3), bottom: ui.space(Space::X1), diff --git a/crates/app-iced/src/pages/instances/demo.rs b/crates/app-iced/src/pages/instances/demo.rs index 87e201d..5028e49 100644 --- a/crates/app-iced/src/pages/instances/demo.rs +++ b/crates/app-iced/src/pages/instances/demo.rs @@ -6,7 +6,7 @@ use unlucky_i18n::{Catalog, Key, Lang}; use unlucky_presenter::dto::LaunchArgsView; -use super::super::model::{Detail, Instances, LaunchFacts, Meta, Row}; +use super::super::model::{Detail, Instances, LaunchFacts, Meta, Row, ScanPhase}; use super::data::{DEMO, NOTE, SELECTED}; use super::state::LaunchForm; @@ -59,6 +59,9 @@ pub(crate) fn demo() -> Instances { Instances { rows, detail: Some(detail), + // 演示数据就是「已就绪」的那一屏(M302 的三态里只有它适合出图: + // 演示目录里没有后台线程,画「正在扫描」会是假的) + scan: ScanPhase::Ready, } } diff --git a/crates/app-iced/src/pages/instances/detail.rs b/crates/app-iced/src/pages/instances/detail.rs index 45bc97e..5c8dda9 100644 --- a/crates/app-iced/src/pages/instances/detail.rs +++ b/crates/app-iced/src/pages/instances/detail.rs @@ -27,6 +27,10 @@ use super::forced_state; use super::state::LaunchForm; use super::Texts; +mod scan; + +pub(in crate::pages) use scan::detail_side; + // --------------------------------------------------------------- 详情列 pub(super) fn detail_column<'a>( diff --git a/crates/app-iced/src/pages/instances/detail/scan.rs b/crates/app-iced/src/pages/instances/detail/scan.rs new file mode 100644 index 0000000..9c4cf6c --- /dev/null +++ b/crates/app-iced/src/pages/instances/detail/scan.rs @@ -0,0 +1,67 @@ +//! 首屏三态的分派(M302;从 `detail.rs` 拆出来守 400 行硬线)。 +//! +//! `detail_side` 是**唯一**分派点:`pages/instances.rs` 的宽窗与窄窗两条调用都走它, +//! 于是「未就绪」不可能只在一个版面上生效。 + +use iced::Element; + +use unlucky_design::icons::IconKind; + +use crate::app::components::buttons::{self, Button}; +use crate::theme::Ui; + +use super::super::super::frame; +use super::super::super::model::{Instances, ScanPhase}; +use super::super::state::Message as InstanceMessage; +use super::super::{Message, Texts}; +use super::detail_empty; + +/// 没选中实例时,详情列画什么(**M302 的三态唯一分派点**)。 +/// +/// | `block.scan` | 画什么 | 抓的是什么 | +/// |---|---|---| +/// | `Scanning` | [`scanning`]:一行「正在扫描实例…」 | 首个快照还没到 | +/// | `Stalled` | [`scan_stalled`]:一句话 + 可点的「重新扫描」 | 扫描线程没回来(`host::game::SCAN_STALL`) | +/// | `Ready` | [`detail_empty`]:两种空态(没选中 / 一个都没有) | 数据是真的 | +/// +/// 顺序是判据:**先看 `scan` 再看 `rows`**。反过来的话,冷启动首帧(`scan = Scanning`、 +/// `rows` 空)就会被 `!rows.is_empty()` 判成「一个实例都没有」,画出那句假话(复审 r1 P2-1)。 +pub(in crate::pages) fn detail_side<'a>( + ui: &'a Ui, + block: &'a Instances, + texts: Texts, +) -> Element<'a, Message> { + match block.scan { + ScanPhase::Scanning => scan_placeholder(ui, texts.scanning(), None), + ScanPhase::Stalled => scan_placeholder( + ui, + texts.scan_stalled(), + Some(buttons::filled( + ui, + Button::new( + texts.scan_retry(), + Message::Instances(InstanceMessage::RetryScan), + ), + )), + ), + ScanPhase::Ready => detail_empty(ui, texts, !block.rows.is_empty()), + } +} + +/// 首屏占位(M302):只画标题 + 可选的一条动作,**不**带空态 CTA。 +/// +/// 为什么单独一个函数而不是复用 [`empty_no_instances`]:那条支的语义是「盘上真的没有实例, +/// 所以请去导入」。「还在读盘」这件事跟它相反——用户可能什么都有,只是还没读完。 +fn scan_placeholder<'a>( + ui: &'a Ui, + title: &'a str, + action: Option>, +) -> Element<'a, Message> { + frame::empty_state( + ui, + IconKind::Instances, + title, + "", + action.into_iter().collect(), + ) +} diff --git a/crates/app-iced/src/pages/instances/state.rs b/crates/app-iced/src/pages/instances/state.rs index 29cb020..8840d4f 100644 --- a/crates/app-iced/src/pages/instances/state.rs +++ b/crates/app-iced/src/pages/instances/state.rs @@ -170,6 +170,11 @@ pub enum Message { ReloadSaves, /// 打开存档文件夹(M235):拉起哪个目录由 host 决定(`saves/` → `screenshots/` → 运行目录)。 OpenSavesFolder, + /// 重新扫描实例列表(M302):扫描超时那一屏的「重新扫描」。 + /// + /// 落点是 `AppCommand::RefreshInstances`(只重扫实例目录,不重拉版本清单)。这是**非模态** + /// 的恢复路径——没有它,线程真挂住时用户只能重启启动器。 + RetryScan, /// 开始**行内重命名**一个世界(M247):那一行就地换成输入框 + 确定/取消。 /// /// 为什么是行内而不是模态输入框:现成的模态队列有 `DialogKind::Input`,但 app 侧的 diff --git a/crates/app-iced/src/pages/instances/tests.rs b/crates/app-iced/src/pages/instances/tests.rs index 2574d7d..8d8b7d7 100644 --- a/crates/app-iced/src/pages/instances/tests.rs +++ b/crates/app-iced/src/pages/instances/tests.rs @@ -16,6 +16,9 @@ mod rename; /// M273 的**入口接线**(空态的「导入已有版本」⇒ `AdoptMessage::Open`)。 mod empty_entry; +/// M302 的**首屏三态**(占位 / 真空态 / 有实例:真控件树 + 文案 key 的源码断言)。 +mod cold_boot; + use super::super::layout::source; use super::super::layout::{frame_metrics, measure_cap_px, PaneKit, WidthClass}; diff --git a/crates/app-iced/src/pages/instances/tests/cold_boot.rs b/crates/app-iced/src/pages/instances/tests/cold_boot.rs new file mode 100644 index 0000000..d6d6c80 --- /dev/null +++ b/crates/app-iced/src/pages/instances/tests/cold_boot.rs @@ -0,0 +1,310 @@ +//! M302 · 冷启动首帧的**三态**(复审 r1 P2-1 的判据)。 +//! +//! ## 背景(为什么这组不是「措辞」而是缺陷) +//! +//! 扫盘搬进后台线程之后,`App::dispatch(RefreshAll)` 返回时快照还没到(`pump` 只抽队列、 +//! 不 `tick`),于是**首帧** `AppState::instances` 必然为空。改前那一帧画出的是: +//! 列头 `全部` + 计数 **0**、右栏「**还没有实例**」+「导入一个已经装在盘上的版本」**实心 CTA** +//! ——一句假话,而且那枚 CTA 是能点的。改后必须分成三态:**首个快照未到 / 确实零实例 / 有实例**。 +//! +//! ## 判据怎么落在「真控件树」上(iced 0.14 的 `Element` 没有文本内省) +//! +//! 真控件树能数的是**节点**,不是文字内容,所以这组按两条腿走: +//! +//! | 要断的事 | 用什么 | 抓不住什么 | +//! |---|---|---| +//! | 未就绪时**没有**空态 CTA | 真树里 `Button` 节点的条数:未就绪 = 有实例那一屏,只有「确实零实例」多一枚 | 哪一枚(按条数判,不按文案判) | +//! | 超时那支**有**重试按钮 | 同上:比未就绪多一枚 | 点下去派了什么(`app/update/instances.rs` 的用例管) | +//! | 列表项 = N | 每多一行多 2 条文字节点(名字 + 版本),用两条测量做差分 | 行里写的是什么(模型断言 / 出图) | +//! | 占位在、且**不是**「还没有实例」 | 源码断言:`Scanning` 那一支调 `scan_placeholder(.., texts.scanning(), ..)`,不碰 `empty_none_*` / `adopt_cta` | 屏幕上的那句字(同上) | +//! +//! 基线数字(实测,`ScanPhase` 三态 × 1180×760):未就绪 12 条文字 / 7 枚按钮;超时 13 / 8; +//! 已就绪零实例 14 / 8;已就绪三行 19 / 7。按钮 7 里有 5 枚来自 `scrollable` 自带的滚动条, +//! 所以断言一律写成**态与态之间的差分**,不钉绝对值——加一条页头动作不该让这组红。 + +use iced::advanced::widget::tree::Tag; +use iced::advanced::widget::Tree; +use iced::widget::text; +use iced::Element; + +use unlucky_design::color::SchemeFlavor; +use unlucky_design::typography::Platform; +use unlucky_presenter::dto::LaunchArgsView; +use unlucky_presenter::router::NavSection; + +use crate::app::components::buttons::{self, Button}; +use crate::pages::instances::LaunchForm; +use crate::pages::layout::source; +use crate::pages::model::{Instances, Row, ScanPhase}; +use crate::pages::{app, Action, Message, Model, Screen}; +use crate::theme::{Families, Ui}; + +const FAMILIES: Families = Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", +}; + +/// 一行实例在真控件树里贡献的**文字节点数**(headline 名字 + supporting 版本;`status: None` ⇒ 2)。 +/// +/// 为什么用「行的文字节点」数行数:`navigation::list_item` 的根是**通用容器**,它的 tree tag +/// 与页面上每个卡片相同(实测空列表也能数出 8 个),所以不能按 tag 数行。 +/// 这条不变式本身也是判据:谁给一行加/减一段文字,下面的差分断言立刻红,逼着一起改这里。 +const TEXTS_PER_ROW: usize = 2; + +fn ui() -> Ui { + Ui::with_platform(SchemeFlavor::Light, Platform::Windows, FAMILIES) +} + +fn form() -> LaunchForm { + LaunchForm::from_view(None, &LaunchArgsView::default()) +} + +/// 一屏实例页模型(宽窗 = list-detail,右侧没有选中项)。 +fn model(scan: ScanPhase, rows: Vec) -> Model { + let mut model = Model::new( + Screen::Instances, + 1180.0, + 760.0, + Some(NavSection::Instances), + ); + model.instances = Instances { + rows, + detail: None, + scan, + }; + model +} + +fn rows(count: usize) -> Vec { + (0..count) + .map(|index| Row { + id: format!("i{index}"), + name: format!("demo-{index}"), + supporting: "1.20.4".to_string(), + status: None, + }) + .collect() +} + +// ------------------------------------------------------------------ 真控件树取样 + +fn tree_of(el: &Element<'_, Message>) -> Tree { + Tree::new(el.as_widget()) +} + +/// 文字节点的标签(`text` 与包着它的 `container` 同 tag,见 iced 的 `container::tag` 委托)。 +fn text_tag() -> Tag { + let probe: Element<'static, Message> = text("probe").into(); + tree_of(&probe).tag +} + +/// 按钮节点的标签(页头两枚 `buttons::text`、空态那枚 `buttons::filled`、滚动条自带的按钮 +/// 都算:判据要的正是「这一屏一共有几枚可点的按钮」)。 +fn button_tag(ui: &Ui) -> Tag { + let probe = buttons::filled(ui, Button::new("probe", Message::Action(Action::Back))); + tree_of(&probe).tag +} + +fn count_tag(tree: &Tree, tag: Tag) -> usize { + usize::from(tree.tag == tag) + + tree + .children + .iter() + .map(|child| count_tag(child, tag)) + .sum::() +} + +/// 一屏的 `(文字节点数, 按钮数)`。 +fn counts(ui: &Ui, form: &LaunchForm, model: &Model) -> (usize, usize) { + let el = app(ui, model, form); + let tree = tree_of(&el); + ( + count_tag(&tree, text_tag()), + count_tag(&tree, button_tag(ui)), + ) +} + +// ------------------------------------------------------------------ ① 首帧(无快照) + +/// **首帧画的是占位,不是假空态**:没有空态 CTA、没有列表行、文案走 `scanning()`。 +/// +/// 抓得住:① `ScanPhase::Scanning` 那一支被删掉 / 落回 `detail_empty(.., false)`(复审判定的 +/// 那屏假空态:「0 个实例」+「还没有实例」+ 实心 CTA);② 未就绪时被塞了行(每条实例多 2 条 +/// 文字节点);③ 未就绪时计数位写了数字(多一条文字节点)。 +/// 抓不住:屏幕上那句字长什么样(`Element` 无内省 ⇒ 文案 key 由 [`the_scanning_branch_never_touches_the_empty_state_copy`] 钉)。 +#[test] +fn the_first_frame_is_a_placeholder_not_the_empty_state() { + let ui = ui(); + let form = form(); + + let (scanning_texts, scanning_buttons) = + counts(&ui, &form, &model(ScanPhase::Scanning, Vec::new())); + let (empty_texts, empty_buttons) = counts(&ui, &form, &model(ScanPhase::Ready, Vec::new())); + let (rows_texts, rows_buttons) = counts(&ui, &form, &model(ScanPhase::Ready, rows(3))); + + assert_eq!( + scanning_buttons, rows_buttons, + "未就绪那一屏必须与「有实例」同构:**没有**空态那枚 CTA(按钮数相同 = 少的那枚不存在)" + ); + assert_eq!( + empty_buttons, + scanning_buttons + 1, + "「确实零实例」比未就绪恰好多一枚按钮——那枚就是「导入已有版本」的 CTA" + ); + assert_eq!( + empty_texts, + scanning_texts + 2, + "已就绪空态比未就绪多两条文字(计数位 + 空态副文案),同时少了占位那两条里的… —— \ + 实际组合见文件头;差得不是 2 说明有一屏多/少了文字节点" + ); + assert!( + scanning_texts < rows_texts, + "未就绪那屏比「有三条实例」少(每行两条文字:{} vs {})", + scanning_texts, + rows_texts + ); +} + +// ------------------------------------------------------------------ ② 快照到达 + +/// **快照到达后占位消失、列表项 = N**。 +/// +/// 抓得住:`Ready` 分支没把行画出来;行被画成别的节点(`list_item` 一条 2 条文字的不变式被破坏)。 +/// 抓不住:行的文字内容与排序(模型断言 / 出图)。 +#[test] +fn a_landed_snapshot_replaces_the_placeholder_with_the_rows() { + let ui = ui(); + let form = form(); + + let (one, _) = counts(&ui, &form, &model(ScanPhase::Ready, rows(1))); + let (three, _) = counts(&ui, &form, &model(ScanPhase::Ready, rows(3))); + + assert_eq!( + three - one, + 2 * TEXTS_PER_ROW, + "三行比一行多 {} 条文字节点 ⇒ 每行 {TEXTS_PER_ROW} 条(名字 + 版本)。\ + 少了说明行没画出来,多了说明行里塞了别的东西", + 2 * TEXTS_PER_ROW + ); +} + +// ------------------------------------------------------------------ ③ 确实零实例 + +/// **真·空数据根(扫完、零实例)画的是空态引导,不是「扫描中」**。 +/// +/// 与 ① 的区别只在 `scan` 一位:`Ready + 零行` 才是「还没有实例」那句话的合法场合。 +/// 抓得住:把 `Ready` 也画成占位(用户永远看不到「去导入一个」的出路);把占位的文案接到空态 key 上 +/// (① 的文字差分与本条的按钮差分一起红)。 +/// 抓不住:`scan` 这一位在真后端里什么时候变成 `Ready`——那由 `app/update/instances.rs` 的 +/// app 级用例(快照/超时/空快照三种事件)与 presenter 的 `refresh_tests` 用真空盘钉。 +#[test] +fn a_finished_scan_with_zero_instances_shows_the_empty_guide() { + let ui = ui(); + let form = form(); + + let (_, scanning_buttons) = counts(&ui, &form, &model(ScanPhase::Scanning, Vec::new())); + let (_, empty_buttons) = counts(&ui, &form, &model(ScanPhase::Ready, Vec::new())); + let (_, rows_buttons) = counts(&ui, &form, &model(ScanPhase::Ready, rows(2))); + + assert_eq!( + empty_buttons, + rows_buttons + 1, + "零实例 + 已就绪 ⇒ 比「有实例」多一枚按钮:那枚「导入已有版本」的 CTA 是此刻唯一的出路" + ); + assert_eq!( + empty_buttons, + scanning_buttons + 1, + "同一枚 CTA 在未就绪时**不**存在(那是复审 P2-1 的假话:还没扫完就劝你去导入)" + ); +} + +// ------------------------------------------------------------------ ④ 超时那支(有界兜底) + +/// **超时那屏有一枚可点的「重新扫描」**(有界兜底:线程 panic / 卡在慢盘时用户还有出路)。 +/// +/// 抓得住:`Stalled` 那一支退化成普通占位(没有重试 ⇒ 用户只能重启启动器)。 +/// 抓不住:点下去派了什么(`app/update/instances.rs` 的 `retry_scan_dispatches_a_scan_only_refresh`)。 +#[test] +fn the_stalled_screen_offers_a_retry() { + let ui = ui(); + let form = form(); + + let (scanning_texts, scanning_buttons) = + counts(&ui, &form, &model(ScanPhase::Scanning, Vec::new())); + let (stalled_texts, stalled_buttons) = + counts(&ui, &form, &model(ScanPhase::Stalled, Vec::new())); + + assert_eq!( + stalled_buttons, + scanning_buttons + 1, + "超时那一屏必须多一枚按钮(「重新扫描」)" + ); + assert_eq!( + stalled_texts, + scanning_texts + 1, + "那枚按钮自带一条文字(「重新扫描」),其余与占位一致" + ); +} + +// ------------------------------------------------------------------ ⑤ 源码结构(文案 key) + +/// **扫描中那一支不许碰空态的文案与动作**(`Element` 无内省 ⇒ 文案身份只能读源码)。 +/// +/// 抓得住:`scan_placeholder` 里混进 `empty_none_*` / `adopt_cta` / `adopt_action`(把假话搬回来); +/// `detail_side` 把 `Ready` 以外的东西交给 `detail_empty`(未就绪又走回假空态); +/// 取词换到别的 key(占位会显示成空行或错文案)。 +/// 抓不住:真的画出来的像素(出图那张)。 +#[test] +fn the_scanning_branch_never_touches_the_empty_state_copy() { + // 分派与占位在 `detail/scan.rs`(从 `detail.rs` 拆出去守 400 行硬线) + let scan = include_str!("../detail/scan.rs"); + + let dispatch = source::code_text(source::function_body(scan, "fn detail_side")); + let scanning_at = dispatch.find("ScanPhase::Scanning").expect("扫描中那一支"); + let stalled_at = dispatch.find("ScanPhase::Stalled").expect("超时那一支"); + let ready_at = dispatch.find("ScanPhase::Ready").expect("就绪那一支"); + assert!( + scanning_at < stalled_at && stalled_at < ready_at, + "三态必须按 `scan` 分派(顺序 = 判据:未就绪先于「有没有行」):\n{dispatch}" + ); + assert!( + !dispatch[..ready_at].contains("detail_empty"), + "未就绪的两支**不许**落到 `detail_empty`(那会把「还没扫完」画成「还没有实例」):\n{dispatch}" + ); + assert!( + dispatch[ready_at..].contains("detail_empty"), + "`Ready` 那一支必须落到 `detail_empty`(零实例的引导在那里):\n{dispatch}" + ); + assert!( + dispatch[..ready_at].contains("InstanceMessage::RetryScan"), + "超时那一支必须接上「重新扫描」(否则那枚按钮点了没反应):\n{dispatch}" + ); + + let placeholder = source::code_text(source::function_body(scan, "fn scan_placeholder")); + for forbidden in [ + "empty_none_title", + "empty_none_body", + "adopt_cta", + "adopt_action", + ] { + assert!( + !placeholder.contains(forbidden), + "占位里混进了 `{forbidden}`(那属于「确实零实例」那一支):\n{placeholder}" + ); + } + + // 取词入口:扫描中/超时读的是 M302 新加的三个 key,不是空态那两个 + let texts = include_str!("../text.rs"); + for (method, key) in [ + ("fn scanning", "Key::InstancesScanning"), + ("fn scan_stalled", "Key::InstancesScanStalled"), + ("fn scan_retry", "Key::InstancesScanRetry"), + ] { + let body = source::code_text(source::function_body(texts, method)); + assert!( + body.contains(key), + "`{method}` 没读 `{key}`(占位会显示成空行或错文案):\n{body}" + ); + } +} diff --git a/crates/app-iced/src/pages/instances/tests/empty_entry.rs b/crates/app-iced/src/pages/instances/tests/empty_entry.rs index cae4f83..fe8a9bd 100644 --- a/crates/app-iced/src/pages/instances/tests/empty_entry.rs +++ b/crates/app-iced/src/pages/instances/tests/empty_entry.rs @@ -121,35 +121,46 @@ fn the_cta_lives_only_in_the_no_instances_branch() { ); } -/// **调用点**:实例页量「有没有实例」的那一行不许传反。 +/// **调用点**:实例页把「没选中时画什么」交给三态分派,宽窗 / 窄窗各一处。 /// -/// 这是 r1 复审的变异原样:把 `!block.rows.is_empty()` 改成 `block.rows.is_empty()`,上面的 -/// 分支断言照样绿(两个分支各自都还对),但**用户看到的是错的那一支**。所以判据必须落在 -/// 调用点上——宽窗与窄窗各有一条,两条都要有 `!`。 +/// 历史:M273 r1 复审的变异是把 `!block.rows.is_empty()` 改成 `block.rows.is_empty()`——分支 +/// 断言照样绿,但**用户看到的是错的那一支**,所以判据必须落在调用点上。M302 之后「有没有实例」 +/// 那一句从调用点挪进了 [`detail_side`],于是这条判据一分为二:**调用点**(两处、都传 `block`) +/// 与**分派点**(`Ready` 那一支自己带 `!`)。 /// -/// 抓得住:传参取反(`!` 被删);只改掉一处(宽窗对了、窄窗忘了);新增第三条调用点却传了别的 -/// 值(条数断言只认 2 条,多出来就红)。 +/// 抓得住:只改掉一处(宽窗接了、窄窗忘了);新增第三条调用点却传了别的值;`!` 从分派点丢掉。 /// 抓不住:等价写法(`block.rows.len() == 0`)——本仓构建链一行一条,判据按行首取。 #[test] -fn the_page_asks_the_empty_state_with_the_negated_row_count() { +fn the_page_asks_the_three_state_dispatch_from_both_call_sites() { let page = include_str!("../../instances.rs"); let calls: Vec = source::code_lines(page) .into_iter() - .filter(|line| line.contains("detail_empty(")) + .filter(|line| line.contains("detail_side(")) .map(|line| line.trim().to_string()) .collect(); assert_eq!( calls.len(), 2, - "实例页应当只有两条空态调用点(宽窗 + 窄窗),实际:{calls:?}" + "实例页应当只有两条「没选中时画什么」的调用点(宽窗 + 窄窗),实际:{calls:?}" ); for line in &calls { assert!( - line.contains("!block.rows.is_empty()"), - "调用点把「有没有实例」传反了(`!` 丢了 ⇒ 一个都没有时反而没有按钮):{line}" + line.contains("(ui, block, texts)"), + "调用点要把整块模型交给三态分派(自己判 `rows` 就又把三态塌成两态了):{line}" ); } + + // 分派点:「有没有实例」那一位带着 `!`,且只在 `Ready` 那一支 + let detail = source::code_text(source::function_body( + include_str!("../detail/scan.rs"), + "fn detail_side", + )); + assert!( + detail.contains("!block.rows.is_empty()"), + "`detail_side` 的 `Ready` 那一支必须带 `!`(取反丢了 ⇒ 一个都没有时反而没有按钮): +{detail}" + ); } /// `pages::Action::Import` → `AdoptMessage::Open`:跨层的这一跳不许被拆。 diff --git a/crates/app-iced/src/pages/instances/tests/i18n.rs b/crates/app-iced/src/pages/instances/tests/i18n.rs index 0b68232..e6830dd 100644 --- a/crates/app-iced/src/pages/instances/tests/i18n.rs +++ b/crates/app-iced/src/pages/instances/tests/i18n.rs @@ -54,9 +54,14 @@ const ALLOWED: &[&str] = &[ ]; /// 页面取词的六个源码文件(死 key 扫描与旧串扫描共用)。 -const PAGE_SOURCES: [(&str, &str); 12] = [ +const PAGE_SOURCES: [(&str, &str); 13] = [ ("instances.rs", include_str!("../../instances.rs")), ("instances/detail.rs", include_str!("../detail.rs")), + // M302:首屏三态的分派与占位从 detail.rs 拆出(那边越过 400 行)——新文件必须一起扫 + ( + "instances/detail/scan.rs", + include_str!("../detail/scan.rs"), + ), ("instances/demo.rs", include_str!("../demo.rs")), ("instances/text.rs", include_str!("../text.rs")), // M251:存档 / 截图一族从 text.rs 拆出(那边越过 400 行)——新文件必须一起扫, diff --git a/crates/app-iced/src/pages/instances/text.rs b/crates/app-iced/src/pages/instances/text.rs index 733768c..8745eb6 100644 --- a/crates/app-iced/src/pages/instances/text.rs +++ b/crates/app-iced/src/pages/instances/text.rs @@ -97,6 +97,20 @@ impl Texts { pub(super) fn adopt_cta(self) -> &'static str { self.text(Key::InstancesAdoptCta) } + /// 首屏占位(M302):首个实例快照还没到。 + /// + /// 与 [`Self::empty_none_title`] 是**两句话**:那句是「确实一个都没有」,这句是「还在读盘」。 + pub(super) fn scanning(self) -> &'static str { + self.text(Key::InstancesScanning) + } + /// 首屏占位(M302):扫描超时(线程没回来),配一个可点的重试。 + pub(super) fn scan_stalled(self) -> &'static str { + self.text(Key::InstancesScanStalled) + } + /// 超时那支的主行动(重新扫一遍实例目录)。 + pub(super) fn scan_retry(self) -> &'static str { + self.text(Key::InstancesScanRetry) + } /// 自动发现实例的运行目录规则(详情页常驻的一句话)。 pub(super) fn discovered_note(self) -> &'static str { self.text(Key::InstancesDiscoveredNote) diff --git a/crates/app-iced/src/pages/model.rs b/crates/app-iced/src/pages/model.rs index fbef7b9..25d4d63 100644 --- a/crates/app-iced/src/pages/model.rs +++ b/crates/app-iced/src/pages/model.rs @@ -91,6 +91,37 @@ pub struct LaunchFacts { pub options: Vec, } +/// 实例列表的**首屏三态**(M302):区分「还没扫完」和「确实一个都没有」。 +/// +/// 页面**不许**用 `rows.is_empty()` 猜状态(复审 r1 P2-1 的假空态就是这么来的:冷启动首帧 +/// 列表必然为空,猜出来就是「还没有实例」+ 一个导入 CTA,而那句话是假的)。 +/// 真源是 presenter 的 `dto::InstancePhase`,转换只有 [`ScanPhase::of`] 一处。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ScanPhase { + /// 首个快照还没到 ⇒ 画占位(不写计数、不画空态 CTA) + Scanning, + /// 超时兜底 ⇒ 占位 + 可点的「重新扫描」 + Stalled, + /// 收到过快照 ⇒ `rows` 就是真数据(可能是空,那才轮到「还没有实例」) + #[default] + Ready, +} + +impl ScanPhase { + /// presenter 的快照位 → 页面三态(**唯一**转换点,`app/view.rs` 只调它)。 + /// + /// 默认值 `Ready` 是故意的:演示数据 / 空壳模型(`Model::new`)没有「正在扫」这回事, + /// 它们必须保持改前的行为(`pages/tests.rs` 的构图用例都从空壳起步)。 + #[must_use] + pub fn of(phase: unlucky_presenter::dto::InstancePhase) -> Self { + match phase { + unlucky_presenter::dto::InstancePhase::Scanning => Self::Scanning, + unlucky_presenter::dto::InstancePhase::Stalled => Self::Stalled, + unlucky_presenter::dto::InstancePhase::Ready => Self::Ready, + } + } +} + /// 实例页要画的东西。 #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct Instances { @@ -98,6 +129,8 @@ pub struct Instances { pub rows: Vec, /// 当前选中的实例详情(`None` = 没选中 → 空态) pub detail: Option, + /// 首屏三态(M302):只有 `Ready` 才允许把「零行」解释成「还没有实例」 + pub scan: ScanPhase, } /// 一屏的视图模型。 diff --git a/crates/i18n/src/catalog/golden.rs b/crates/i18n/src/catalog/golden.rs index 3fd859b..75a2e82 100644 --- a/crates/i18n/src/catalog/golden.rs +++ b/crates/i18n/src/catalog/golden.rs @@ -163,6 +163,9 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[ ("InstancesDiscoveredRunShared", "Detected instance: it has no mods/ or saves/ of its own, so this launch uses the .minecraft folder (shared).", "自动发现的实例:它的版本目录里没有 mods/ 或 saves/,这次启动按共用目录运行(.minecraft 根目录)。"), ("InstancesDiscoveredRunIsolated", "Detected instance: it already has its own mods/ or saves/, so this launch uses its version folder (isolated).", "自动发现的实例:它的版本目录里已经有 mods/ 或 saves/,这次启动按独立目录运行(它自己的版本文件夹)。"), ("InstancesSavesSource", "Saves folder: {dir}", "存档目录:{dir}"), + ("InstancesScanning", "Scanning instances…", "正在扫描实例…"), + ("InstancesScanStalled", "The instance scan is taking longer than usual.", "扫描实例比平时久。"), + ("InstancesScanRetry", "Scan again", "重新扫描"), ]; /// 金表的**全量**行(本文件 → [`tail`] → [`tail_ai`] → [`tail2`],顺序即 `Key::ALL`)。 diff --git a/crates/i18n/src/catalog/instances.rs b/crates/i18n/src/catalog/instances.rs index 145ebb8..cf04404 100644 --- a/crates/i18n/src/catalog/instances.rs +++ b/crates/i18n/src/catalog/instances.rs @@ -236,6 +236,10 @@ macro_rules! instances_entries { // 两个根都可能有一个叫 `A` 的实例、各有一批世界。世界名本身分辨不出来源, // 所以这份列表把**绝对路径**摆出来:那是唯一不会说谎的来源标识(也是排障要的东西)。 InstancesSavesSource => ("Saves folder: {dir}", "存档目录:{dir}"), + // ── 首屏三态(M302):首个实例快照未到 / 超时 / 已就绪(就绪态沿用上面几条空态与列表文案) + InstancesScanning => ("Scanning instances…", "正在扫描实例…"), + InstancesScanStalled => ("The instance scan is taking longer than usual.", "扫描实例比平时久。"), + InstancesScanRetry => ("Scan again", "重新扫描"), } } }; } diff --git a/crates/presenter/src/dto/events.rs b/crates/presenter/src/dto/events.rs index 938ceb8..8d129a3 100644 --- a/crates/presenter/src/dto/events.rs +++ b/crates/presenter/src/dto/events.rs @@ -24,6 +24,7 @@ use crate::dto::{ DiagnosisReport, InstallProgressView, InstanceId, + InstancePhase, InstanceRuntime, InstanceSummary, JavaPageView, @@ -93,6 +94,13 @@ pub struct LaunchFinished { pub enum CoreEvent { /// 实例列表整体刷新 InstancesChanged(Vec), + /// 实例列表的首屏三态变化(M302)。 + /// + /// 只发「没就绪」的两态:`Scanning` = 新一轮扫描起跑(冷启动 / 点了「重新扫描」), + /// `Stalled` = 起跑后超过 `host::game::SCAN_STALL`(15 s)仍没快照。 + /// **`Ready` 不从这里发**:收到 [`CoreEvent::InstancesChanged`] 本身就是就绪的判据 + /// (一条通道一个含义,免得「快照到了但没就绪」这种不可能态被写出来)。 + InstancePhaseChanged(InstancePhase), /// 单个实例运行时状态变化(启动进度/运行中/失败) InstanceRuntimeChanged { /// 实例 id @@ -198,6 +206,11 @@ pub enum AppEvent { pub enum AppCommand { /// 重新扫描实例目录 + 全部页面快照(启动时与手改文件后) RefreshAll, + /// **只**重扫实例目录(M302:「扫描超时 · 重新扫描」按钮)。 + /// + /// 与 [`AppCommand::RefreshAll`] 的区别:不重拉版本清单(一条网络请求)、不重读账户 / + /// 设置 / Java / 更新快照——用户点的只是「把列表再扫一遍」。 + RefreshInstances, /// 启动实例 LaunchInstance(InstanceId), /// 停止实例(含取消启动中的管线) diff --git a/crates/presenter/src/dto/instances.rs b/crates/presenter/src/dto/instances.rs index ca53b89..179cc56 100644 --- a/crates/presenter/src/dto/instances.rs +++ b/crates/presenter/src/dto/instances.rs @@ -11,6 +11,8 @@ //! 路径对外**一个字没变**(`unlucky_presenter::dto::InstanceSummary` 等仍从这里取), //! 拆的是文件不是 API。 +use serde::{Deserialize, Serialize}; + mod create; mod loader; mod runtime; @@ -41,3 +43,33 @@ pub const DISCOVERED_ID_PREFIX: &str = "ext-"; pub fn is_discovered_instance(id: &str) -> bool { id.starts_with(DISCOVERED_ID_PREFIX) } + +/// 实例列表的**首屏三态**(M302):区分「还没扫完」和「确实一个都没有」。 +/// +/// ## 为什么必须要这一位(复审 r1 P2-1) +/// +/// 扫盘搬进后台线程之后,`App::dispatch(RefreshAll)` 返回时快照还没到(`pump` 只抽队列、 +/// 不 `tick`),所以**首帧**的 `AppState::instances` 是空的。用「`instances.is_empty()`」去猜 +/// 状态就必然把「还没扫完」画成「还没有实例」——一句假话,还配一个「导入已有版本」的实心 CTA +/// (用户可能真去点)。真源必须是 host 自己知道的那一位,不是列表长度。 +/// +/// ## 语义(三态都要能一句话说清) +/// +/// | 值 | 什么时候是它 | 界面 | +/// |---|---|---| +/// | [`Self::Scanning`] | **初值**:这一进程还没有任何一份实例快照落地 | 占位「正在扫描实例…」,不写计数、不画空态 CTA | +/// | [`Self::Stalled`] | 扫描起跑后超过 `host::game::SCAN_STALL` 仍没有快照(线程 panic / 卡在慢盘) | 占位 + 「重新扫描」按钮 | +/// | [`Self::Ready`] | 收到过至少一份 `CoreEvent::InstancesChanged` | 列表(空的也行——那就是真数据) | +/// +/// **`Ready` 不表示「列表非空」**,也**不表示「扫描成功」**(没有失败态:扫描不返回错误, +/// 只有「还没回来」):空列表 + `Ready` = 确实没有实例,那时才画「还没有实例」。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)] +pub enum InstancePhase { + /// 首个快照还没到(进程刚起来,或刚点过「重新扫描」) + #[default] + Scanning, + /// 首个快照迟迟没到(有界兜底,见 `host::game::SCAN_STALL`) + Stalled, + /// 收到过快照:列表内容就是真数据(可能是空) + Ready, +} diff --git a/crates/presenter/src/dto/mod.rs b/crates/presenter/src/dto/mod.rs index 277d745..3b82546 100644 --- a/crates/presenter/src/dto/mod.rs +++ b/crates/presenter/src/dto/mod.rs @@ -61,9 +61,9 @@ pub use install::{ InstallStage, }; pub use instances::{ - is_discovered_instance, CreateRequest, InstanceId, InstanceRuntime, InstanceSummary, - LaunchFailureCause, LaunchFailureStage, LaunchFailureView, LaunchStage, LoaderInfo, LoaderKind, - Progress, DISCOVERED_ID_PREFIX, + is_discovered_instance, CreateRequest, InstanceId, InstancePhase, InstanceRuntime, + InstanceSummary, LaunchFailureCause, LaunchFailureStage, LaunchFailureView, LaunchStage, + LoaderInfo, LoaderKind, Progress, DISCOVERED_ID_PREFIX, }; pub use java::{JavaChainOutcome, JavaChainStep, JavaPageView, JavaRuntimeView, JavaVendorView}; pub use launch_args::{LaunchArgsPatch, LaunchArgsView}; diff --git a/crates/presenter/src/host/dispatch.rs b/crates/presenter/src/host/dispatch.rs index 8d06f5d..9fd91ea 100644 --- a/crates/presenter/src/host/dispatch.rs +++ b/crates/presenter/src/host/dispatch.rs @@ -24,6 +24,8 @@ impl CoreHost { fn dispatch_inner(&mut self, command: AppCommand) { match command { AppCommand::RefreshAll => self.refresh_all(), + // M302:「扫描超时 · 重新扫描」只重扫实例目录(不重拉版本清单、不重读账户/设置) + AppCommand::RefreshInstances => self.refresh_instances(), AppCommand::LaunchInstance(id) => self.start_launch(&id), AppCommand::StopInstance(id) => match self.running_pid(&id) { Some(pid) => match launch::stop(pid) { diff --git a/crates/presenter/src/host/game/refresh.rs b/crates/presenter/src/host/game/refresh.rs index 0cbe925..d4f0e19 100644 --- a/crates/presenter/src/host/game/refresh.rs +++ b/crates/presenter/src/host/game/refresh.rs @@ -17,11 +17,29 @@ //! 提前掐断它省下的只是它自己那点 CPU,而掐断的代价是一个可能永远不回来的快照。 //! 于是一条路口:**不取消线程,只丢弃过期结果**(丢掉的是那一份,不是一个状态)。 -use std::time::Instant; +use std::time::{Duration, Instant}; + +use crate::dto::{CoreEvent, InstancePhase}; use super::super::job::{spawn, HostTask, JobSender}; use super::super::HostEnv; +/// 首个实例快照的等待上限(M302 的**有界兜底**)。 +/// +/// ## 取值依据(不是随手挑的) +/// +/// 仓库内最坏的一次实测扫盘是 **1.35 s**(官方根 200 个版本 + 10 份外部档案,M297/M274 r1); +/// 15 s ≈ 它的 11 倍,足够盖住慢盘 / 网络盘上的一次 `read_dir` 抖动,又不至于让用户在 +/// 「正在扫描实例…」上等到放弃。真正该防的不是「慢一点」,而是**线程 panic / 永远不回来**—— +/// 那种情况下没有兜底,占位永远不会消失。 +/// +/// ## 超时之后发生什么 +/// +/// **不取消扫描线程**(理由见模块文档):面板先翻到「扫描实例比平时久」+ 可点的「重新扫描」, +/// 而那一轮的线程仍在跑、迟到的结果照样落地(代次没变,`admit` 照放)⇒ 占位自己消失。 +/// 只有「永远不回来」那种才需要用户点重试。 +pub const SCAN_STALL: Duration = Duration::from_secs(15); + /// 一次刷新领到的代次(`0` 是「还没刷新过」的初值,第一次刷新从 `1` 开始)。 pub type Epoch = u64; @@ -35,8 +53,19 @@ pub struct Scan { epoch: Epoch, /// 最近一次被采纳的扫描 adopted: Option, - /// 被丢掉的**旧代次**结果条数(> 0 就证明「先发后到」真的发生过) + /// 被丢掉的**旧代次**结果条数(> 0 就说明「先发后到」真的发生过) + /// + /// **已知噪音(复审 N3)**:`CoreHost::build` 的 `refresh_all` 与 `on_boot` 的 + /// `AppCommand::RefreshAll` 各领一代 ⇒ **每次冷启动必然丢 1 份**。所以这个计数在人读日志上 + /// 必须配合「此前有没有快照落地过」一起看(`handle::apply_instances` 的丢弃日志已经把两种 + /// 成因分开写),单看「> 0」会误判成交错。 dropped: u64, + /// 本轮扫描的起跑时刻(超时兜底的唯一时钟来源;`None` = 结果已经落地 / 还没起跑) + started: Option, + /// 本轮是否已经报过超时(`tick` 每拍都会问,只报一次,否则面板/日志会被刷成噪音) + stalled: bool, + /// 本轮的超时上限(生产恒为 [`SCAN_STALL`];单测可以调小,不必等真实的 15 s) + budget: Duration, } /// 一次被采纳的扫描:代次、实例数、纯扫盘耗时(毫秒)。 @@ -56,10 +85,43 @@ impl Scan { /// 只增不减(`wrapping_add` 与 `install_epoch` 同款):**代次相等才是当代次**, /// 于是「上一轮还在飞的那份」无论什么时候回来都进不了门。 pub fn begin(&mut self) -> Epoch { + self.begin_with(SCAN_STALL) + } + + /// 同 [`Scan::begin`],但显式给上限(单测用;生产只有上面那一个入口)。 + pub fn begin_with(&mut self, budget: Duration) -> Epoch { self.epoch = self.epoch.wrapping_add(1); + self.started = Some(Instant::now()); + self.stalled = false; + self.budget = budget; self.epoch } + /// 单测专用:把**当前这一轮**的上限调小(生产路径的上限只由 [`SCAN_STALL`] 定)。 + /// + /// 存在的理由:超时路径要能在单测里真跑一遍(事件发出去 / 只发一次 / 迟到结果清掉它), + /// 而等 15 s 会让整个门禁慢十几秒。**调小不等于换阈值**:阈值本身仍只有 [`SCAN_STALL`]。 + #[cfg(test)] + pub fn shorten_budget(&mut self, budget: Duration) { + self.budget = budget; + } + + /// 这一轮**还要不要**报超时(有界兜底,判据是 [`SCAN_STALL`])。 + /// + /// `true` 只在「有扫描在飞 + 已经超过上限 + 这一轮还没报过」时返回一次;报过之后 + /// 每拍再问都是 `false`(`tick` 每帧都会问,重复报会把面板刷成噪音)。 + /// 结果落地([`Scan::adopt`])会停表,之后永远 `false`。 + pub fn stall(&mut self) -> bool { + let Some(started) = self.started else { + return false; + }; + if self.stalled || started.elapsed() < self.budget { + return false; + } + self.stalled = true; + true + } + /// 这份结果还是不是当代次的?不是的话记一笔丢包(可观测)并返回 `false`。 /// /// 判据是**相等**而不是「更大」:代次是唯一的命名,只有最新一轮发出去的那份作数。 @@ -73,6 +135,8 @@ impl Scan { /// 记下这一份(只在 [`Scan::admit`] 放行之后调)。 pub fn adopt(&mut self, epoch: Epoch, count: usize, elapsed_ms: u64) { + // 落地即停表:之后的 `tick` 不该再报超时 + self.started = None; self.adopted = Some(Adopted { epoch, count, @@ -114,8 +178,25 @@ pub fn spawn_scan(env: HostEnv, tx: JobSender, epoch: Epoch) { }); } +impl super::super::CoreHost { + /// 每拍看一眼「首个快照」有没有超时(有界兜底)。 + /// + /// 为什么要兜底:扫描线程若 panic 或卡在慢盘上,`ready` 就永远是 `false` ⇒ 用户永远看着 + /// 「正在扫描实例…」。这里只**报**超时(面板翻到可点的「重新扫描」),**不**掐线程—— + /// 判据与取值理由在 [`Scan::stall`] / [`SCAN_STALL`]。 + pub(crate) fn check_scan_stall(&mut self) { + if !self.instances_scan.stall() { + return; + } + eprintln!( + "[instances] 首个快照 {SCAN_STALL:?} 内没回来:报超时(扫描线程仍在跑,迟到的结果照样落地)" + ); + self.send(CoreEvent::InstancePhaseChanged(InstancePhase::Stalled)); + } +} + +/// 单测要 `tick` 抽通道,它的定义在 `crate::dto::Backend` 这条 trait 上。 #[cfg(test)] -// 单测要 `tick` 抽通道,它的定义在 `crate::dto::Backend` 这条 trait 上。 use crate::dto::Backend; /// 单测等待「这一代快照落地」的上限。 @@ -164,6 +245,10 @@ impl super::super::CoreHost { } } +#[cfg(test)] +#[path = "refresh_stall_tests.rs"] +mod stall_tests; + #[cfg(test)] #[path = "refresh_tests.rs"] mod tests; diff --git a/crates/presenter/src/host/game/refresh_stall_tests.rs b/crates/presenter/src/host/game/refresh_stall_tests.rs new file mode 100644 index 0000000..d84d153 --- /dev/null +++ b/crates/presenter/src/host/game/refresh_stall_tests.rs @@ -0,0 +1,91 @@ +//! 空数据根 / 超时兜底(M302 的第二组:`Scan` 的落地与 stall 判据)。 +//! +//! 从 `refresh_tests.rs` 拆出来守 400 行硬线(那边是三条**交错**用例 + 账本单测)。 +//! 这一组验的是另外两件事: +//! +//! | 用例 | 抓得住 | 抓不住 | +//! |---|---|---| +//! | [`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 super::tests::{host_with, next_scan, snapshots}; +use super::SCAN_STALL; +use crate::dto::{AppEvent, Backend, CoreEvent, InstancePhase}; + +// ------------------------------------------------------------------ 空数据根 / 超时兜底 + +/// **真·空数据根**:扫完就是一份**空快照**,而且这一代算「已经落地」。 +/// +/// 抓得住:把「零实例」误当成「没扫完」(`adopted()` 为空 ⇒ 界面永远停在「正在扫描」); +/// 或者反过来在没扫完时发一份空快照(那正是任务禁止的假空态)。 +/// 抓不住:界面上那两种状态怎么画(`pages/instances/tests/cold_boot.rs` 的控件树 + 出图)。 +#[test] +fn an_empty_root_publishes_an_empty_snapshot() { + let (mut host, _env, _ids, _guard) = host_with("empty-root", 0); + let _ = host.drain_events(); + + host.refresh_instances(); + host.pump_instances(); + + let published = snapshots(host.drain_events()); + assert_eq!( + published.len(), + 1, + "空根也要发一份快照(不发就永远不知道扫完了)" + ); + assert!(published[0].is_empty(), "零档案的根扫出来就是空"); + assert_eq!( + host.instances_scan.adopted().map(|adopted| adopted.count), + Some(0), + "账本要记「这一代落地了、0 条」——不是「没有落地」" + ); +} + +/// **超时兜底真的会发一条「超时」**,且只发一次;迟到的结果仍然落地(线程没被掐)。 +/// +/// 判据用 `shorten_budget` 把上限压到 0,不等真实的 15 s(阈值本身仍是 `SCAN_STALL`)。 +/// 抓得住:`tick` 没检查超时(用户永远看着「正在扫描实例…」);每拍都发(面板/日志被刷成噪音); +/// 报超时之后把在飞的结果丢掉(占位永远不消失)。 +/// 抓不住:真实的 15 s 阈值合不合适(那要真盘上压测,见 `SCAN_STALL` 的取值说明)。 +#[test] +fn a_stalled_scan_reports_once_and_a_late_snapshot_still_lands() { + // 阈值是**产品决定**(取值依据写在常量文档里):改它必须一起改那段话与这条断言 + assert_eq!(SCAN_STALL, Duration::from_secs(15)); + let (mut host, _env, _ids, _guard) = host_with("stall", 1); + let _ = host.drain_events(); + + host.refresh_instances(); + // 把这一轮的上限压到 0:下一次 `tick` 就该判超时 + host.instances_scan.shorten_budget(Duration::ZERO); + let task = next_scan(&host); + + 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:?}" + ); + + // 再拍两下:不许重复报 + 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 每拍都会问)" + ); + + // 迟到的结果照样落地(代次没变 ⇒ 不掐线程的代价是零) + host.handle(task); + let published = snapshots(host.drain_events()); + assert_eq!(published.len(), 1, "报过超时之后,迟到的快照仍然要落地"); + assert!(!published[0].is_empty()); +} diff --git a/crates/presenter/src/host/game/refresh_tests.rs b/crates/presenter/src/host/game/refresh_tests.rs index fd1843d..71e9301 100644 --- a/crates/presenter/src/host/game/refresh_tests.rs +++ b/crates/presenter/src/host/game/refresh_tests.rs @@ -16,11 +16,10 @@ //! | 启动收尾触发的第三次刷新 | [`a_launch_end_refresh_beats_the_two_that_were_in_flight`] | 收尾后列表不许被扫描期间的旧结果盖回「运行中」 | //! //! 三份都是**真构造**:真临时数据根、真 `profiles.json` 写入、真后台线程跑真 `game::instances`。 -//! 只有**交付顺序**是人为摆的(把 `rx` 里的任务按住、按想验的顺序喂给 host)——那正是要验的 -//! 「先发后到」,靠调度器碰运气是测不出确定结论的。 +//! 只有**交付顺序**是人为摆的(把 `rx` 里的任务按住、按想验的顺序喂给 host)——那正是要验的「先发后到」。 //! -//! 每条用例的「抓不住」写在各自文档里;共同的一条:**它们不量真实耗时**(扫盘在进程内可能只有 -//! 几百微秒),也不证明用户能感知的快慢——那要真盘上的 236 ms / 1.35 s 场景,见 M297 的实测口径。 +//! 每条用例的「抓不住」写在各自文档里;共同的一条:**不量真实耗时**(进程内可能只有几百微秒), +//! 也不证明用户能感知的快慢——那要真盘上的 236 ms / 1.35 s 场景(M297 的实测口径)。 use std::time::Duration; @@ -35,13 +34,12 @@ use crate::host::{CoreHost, HostEnv, HostTask, LiveLaunch}; use super::Scan; -/// 取任务的等待上限(真线程 + 临时根,毫秒级就回来;超时说明真的坏了)。 +/// 取任务的等待上限(真线程 + 临时根,毫秒级就回来;超时说明真的坏了) const WAIT: Duration = Duration::from_secs(5); - // ------------------------------------------------------------------ 夹具 /// 临时数据根守卫:测试结束(含断言 panic)时删掉目录。 -struct TempRoot(std::path::PathBuf); +pub(super) struct TempRoot(std::path::PathBuf); impl Drop for TempRoot { fn drop(&mut self) { @@ -97,7 +95,7 @@ fn ids(count: usize) -> Vec { } /// 建好 `count` 条档案的 host(headless:不跑 `refresh_all`,不起版本清单网络线程)。 -fn host_with(label: &str, count: usize) -> (CoreHost, HostEnv, Vec, TempRoot) { +pub(super) fn host_with(label: &str, count: usize) -> (CoreHost, HostEnv, Vec, TempRoot) { let (env, guard) = root(label); let ids = ids(count); seed(&env, &ids); @@ -107,7 +105,7 @@ fn host_with(label: &str, count: usize) -> (CoreHost, HostEnv, Vec, Temp // ------------------------------------------------------------------ 取件 /// 从通道里等下一份扫盘任务(**不经过 `tick`**:这里要自己按住顺序)。 -fn next_scan(host: &CoreHost) -> HostTask { +pub(super) fn next_scan(host: &CoreHost) -> HostTask { host.rx.recv_timeout(WAIT).expect("扫盘任务要在 5 s 内回来") } @@ -132,7 +130,7 @@ fn ids_of(task: &HostTask) -> Vec { } /// 事件队列里所有 `InstancesChanged` 快照(按先后)。 -fn snapshots(events: Vec) -> Vec> { +pub(super) fn snapshots(events: Vec) -> Vec> { events .into_iter() .filter_map(|event| match event { @@ -240,8 +238,10 @@ fn two_refreshes_in_flight_publish_only_the_newest() { /// → #2 先到、#1 后到。没有代次判据时,`InstancesChanged` 的整体替换会把被删的磁贴**放回来** /// (点进去必然报错,用户以为删失败了)。 /// -/// 抓得住:去掉/写错 `admit`(例如判成 `epoch >= self.epoch`),这条红;把丢掉旧代次那一步做成 -/// 「发一条空快照」也红(那正是任务里禁止的「清空再重填」)。 +/// 抓得住:把 `admit` 去掉(或改成恒真)——这条红,因为旧代次那份会被采纳、把被删的磁贴放回来; +/// 把丢掉旧代次那一步做成「发一条空快照」也红(那正是任务里禁止的「清空再重填」)。 +/// **不**抓:把 `admit` 的判据从 `==` 改成 `>=` —— 代次只增不减、在飞任务不可能带着**未来**代次, +/// 两种写法行为等价(复审 r1 实测变异③全绿,这条 docstring 原先声称会红,是错的)。 /// 抓不住:刷新 #1 与删除动作**同时**发生的窄窗口——本用例把两份扫描都等回来了再摆交付顺序, /// 所以它验的是「顺序」,不是「磁盘并发」。 #[test] @@ -350,7 +350,9 @@ fn a_launch_end_refresh_beats_the_two_that_were_in_flight() { assert_eq!( tile.runtime, InstanceRuntime::Idle, - "收尾之后的快照必须是 Idle(旧结果若盖回去会带着「运行中」的外观)" + "收尾之后磁贴必须是 Idle:运行态是**落地那刻**按 host 当前表盖的,收尾已清表,\ + 所以哪怕是过期快照落地也会是 Idle —— 这条断言只验「盖的结果对」,\ + 新旧之分靠上面的条数与代次断言(`published.len() == 1` / `adopted.epoch` / `dropped`)" ); assert_eq!( host.instances_scan.adopted().map(|adopted| adopted.epoch), @@ -364,7 +366,8 @@ fn a_launch_end_refresh_beats_the_two_that_were_in_flight() { /// `Scan` 的账目:换代 → 只认当代次 → 旧代次计数 → 记账。 /// -/// 抓得住:把 `admit` 写成 `>=`、把丢弃计数漏掉、把 `adopt` 记到别的代次上。 +/// 抓得住:把 `admit` 去掉(或改成恒真)、把丢弃计数漏掉、把 `adopt` 记到别的代次上。 +/// **不**抓:把 `==` 写成 `>=`(代次只增不减 ⇒ 两种写法等价,复审 r1 变异③实测全绿)。 /// 抓不住:真实交错的时序(那是上面三条用例的事)。 #[test] fn the_ledger_only_admits_the_current_epoch() { diff --git a/crates/presenter/src/host/handle.rs b/crates/presenter/src/host/handle.rs index 13fd0d8..090c8e3 100644 --- a/crates/presenter/src/host/handle.rs +++ b/crates/presenter/src/host/handle.rs @@ -275,11 +275,20 @@ impl CoreHost { elapsed_ms: u64, ) { if !self.instances_scan.admit(epoch) { - // 丢掉的那一份也记一行:交错真的发生时,光看「采纳」那一行是看不出来的 + // 丢掉的那一份也记一行:交错真的发生时,光看「采纳」那一行是看不出来的。 + // + // 两种成因必须分开写清楚(复审 N3):`CoreHost::build` 的 `refresh_all` 与 `on_boot` + // 的 `AppCommand::RefreshAll` 各领一代,所以**每次冷启动都必然丢 1 份**——那是既有 + // 双刷新的产物,不是交错。判据是「此前有没有任何快照落地过」。 eprintln!( - "[instances] 丢掉过期扫描(epoch {epoch} ≠ 当代次 {},累计丢 {} 份)", + "[instances] 丢弃旧代次扫描(epoch {epoch} ≠ 当代次 {},累计丢 {} 份{})", self.instances_scan.epoch(), - self.instances_scan.dropped() + self.instances_scan.dropped(), + if self.instances_scan.adopted().is_none() { + ";此前还没有任何快照落地 ⇒ 启动期双刷新,不是交错" + } else { + ";此前已有快照落地 ⇒ 这是真交错" + } ); return; } diff --git a/crates/presenter/src/host/mod.rs b/crates/presenter/src/host/mod.rs index d1768de..9df7d50 100644 --- a/crates/presenter/src/host/mod.rs +++ b/crates/presenter/src/host/mod.rs @@ -239,6 +239,13 @@ impl CoreHost { /// 掐断它省不下什么,却可能让一份快照永远不回来。 fn refresh_instances(&mut self) { let epoch = self.instances_scan.begin(); + // 还没有任何快照落地(冷启动 / 超时后点「重新扫描」)⇒ 面板回到「正在扫描实例…」。 + // **已就绪时绝不发这条**:那会让列表在每次刷新时退回占位——把卡顿换成闪屏,正是本 mission 禁止的。 + if self.instances_scan.adopted().is_none() { + self.send(CoreEvent::InstancePhaseChanged( + crate::dto::InstancePhase::Scanning, + )); + } game::spawn_scan(self.env.clone(), self.tx.clone(), epoch); } @@ -372,6 +379,8 @@ impl Backend for CoreHost { self.handle(task); } self.ticks += 1; + // M302 的有界兜底:首个快照太久没到就报超时(面板翻到「重新扫描」,线程不掐) + self.check_scan_stall(); if self.ticks.is_multiple_of(TAIL_POLL_TICKS) { self.poll_tail(); } diff --git a/crates/presenter/src/state.rs b/crates/presenter/src/state.rs index ce16277..7421dae 100644 --- a/crates/presenter/src/state.rs +++ b/crates/presenter/src/state.rs @@ -15,6 +15,7 @@ use crate::dto::{ DeviceLoginView, DiagnosisReport, InstallProgressView, + InstancePhase, InstanceRuntime, InstanceSummary, JavaPageView, @@ -68,6 +69,13 @@ pub struct AppState { pub router: Router, /// 实例列表快照 pub instances: Vec, + /// 实例列表的**首屏三态**(M302):`Scanning`(初值,首个快照还没到)/ `Stalled`(超时兜底)/ + /// `Ready`(收到过快照)。 + /// + /// 为什么不从 `instances.is_empty()` 推:扫盘搬进后台线程之后,首帧拿到的必然是一个空 + /// `Vec`——那和「确实零实例」长得一模一样,用它去猜就会把「还没扫完」画成「还没有实例」 + /// (复审 r1 P2-1 的假空态)。真源必须是 host 自己报的这一位。 + pub instances_phase: InstancePhase, /// 当前分类的版本列表 pub versions: Vec, /// 日志会话列表 @@ -161,6 +169,7 @@ impl AppState { prefs: UiPrefs::default(), router: Router::new(), instances: Vec::new(), + instances_phase: InstancePhase::Scanning, versions: Vec::new(), log_sessions: Vec::new(), log_lines: Vec::new(), @@ -255,7 +264,13 @@ impl AppState { /// core 事件。 pub fn apply_core(&mut self, event: CoreEvent) { match event { - CoreEvent::InstancesChanged(instances) => self.instances = instances, + CoreEvent::InstancesChanged(instances) => { + self.instances = instances; + // 收到快照 = 就绪(M302 三态的唯一就绪判据,见 `InstancePhase`) + self.instances_phase = InstancePhase::Ready; + } + // M302:只发「没就绪」的两态(扫盘起跑 / 超时兜底);`Ready` 由上面那条隐含 + CoreEvent::InstancePhaseChanged(phase) => self.instances_phase = phase, CoreEvent::InstanceRuntimeChanged { id, runtime } => { if let Some(instance) = self.instances.iter_mut().find(|item| item.id == id) { instance.runtime = runtime; From 23c7beee8df5f91d3e369ab3af5457c69e50cdb3 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Fri, 13 Nov 2026 19:36:33 +0800 Subject: [PATCH 3/3] =?UTF-8?q?test(instances):=20M302=20r2=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E5=9B=9B=E6=9D=A1=20P2=EF=BC=9A=E7=BA=BF=E7=A8=8B?= =?UTF-8?q?=E5=88=A4=E6=8D=AE=E7=9C=9F=E8=83=BD=E7=BA=A2=20+=20=E4=B8=A4?= =?UTF-8?q?=E5=A4=84=E7=9B=B8=E4=BD=8D=E5=AE=88=E5=8D=AB=20+=20=E3=80=8C?= =?UTF-8?q?=E5=8F=AA=E6=8A=A5=E4=B8=80=E6=AC=A1=E3=80=8D=E6=94=B9=E6=88=90?= =?UTF-8?q?=E5=8F=AF=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 守卫)都实测红,原文进报告。 --- .../src/app/update/instances/scan_phase.rs | 81 ++++++++ crates/presenter/src/host/game/refresh.rs | 142 +++++++++++++- .../src/host/game/refresh_stall_tests.rs | 173 ++++++++++++++++-- .../presenter/src/host/game/refresh_tests.rs | 11 +- 4 files changed, 372 insertions(+), 35 deletions(-) diff --git a/crates/app-iced/src/app/update/instances/scan_phase.rs b/crates/app-iced/src/app/update/instances/scan_phase.rs index 56fdefc..3b5b303 100644 --- a/crates/app-iced/src/app/update/instances/scan_phase.rs +++ b/crates/app-iced/src/app/update/instances/scan_phase.rs @@ -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::() + } + /// 一屏的 `(文字节点数, 按钮数)`。 + 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(), "列表也不许被清空"); +} diff --git a/crates/presenter/src/host/game/refresh.rs b/crates/presenter/src/host/game/refresh.rs index d4f0e19..787cbfa 100644 --- a/crates/presenter/src/host/game/refresh.rs +++ b/crates/presenter/src/host/game/refresh.rs @@ -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>, + /// 已经跑完几趟 + finished: std::sync::atomic::AtomicUsize, + /// 闸门:扫描体在这里等「放行」(带超时,免得 inline 变异把用例挂死) + gate: std::sync::Mutex>, + /// 闸门有没有被放行过 + released: std::sync::atomic::AtomicBool, +} + +#[cfg(test)] +static PROBE: std::sync::Mutex>> = 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 { + 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, 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> { + 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; } diff --git a/crates/presenter/src/host/game/refresh_stall_tests.rs b/crates/presenter/src/host/game/refresh_stall_tests.rs index d84d153..a5ef5e6 100644 --- a/crates/presenter/src/host/game/refresh_stall_tests.rs +++ b/crates/presenter/src/host/game/refresh_stall_tests.rs @@ -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(); +} diff --git a/crates/presenter/src/host/game/refresh_tests.rs b/crates/presenter/src/host/game/refresh_tests.rs index 71e9301..f4cbe33 100644 --- a/crates/presenter/src/host/game/refresh_tests.rs +++ b/crates/presenter/src/host/game/refresh_tests.rs @@ -149,12 +149,13 @@ fn sorted_ids(snapshot: &[InstanceSummary]) -> Vec { // ------------------------------------------------------------------ 判据 ①:真的不在 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);