From 583a6cf24bf7e7a2ed8a7e018705d4728ea4abe7 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Fri, 13 Nov 2026 20:14:37 +0800 Subject: [PATCH] =?UTF-8?q?perf(launch):=20M304=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E7=82=B9=E5=90=AF=E5=8A=A8/=E5=81=9C=E6=AD=A2=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E5=9C=A8=20UI=20=E7=BA=BF=E7=A8=8B=E6=89=AB=E7=9B=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M302 把 `refresh_instances` 的扫盘搬进了后台线程,但「点 Play」那条路上还留着两次 `game::instances(&self.env)`(`launch_target` 与 `running_pid`):官方根 200 个版本 236 ms、叠加 10 份外部档案 1.35 s,全部落在 UI 线程上。 改法:两条查询都读**已发布快照**(`CoreHost::send` 发 `InstancesChanged` 时留一份), 并在受理那一刻补一道**单实例**复核(`game::Scan::revalidate`),于是 - 调用方线程一次盘都不扫(一次内存查表 + 微秒级复核); - 快照过期不会拉错东西:档案实例查 `profiles.json` 是否还在、版本有没有被改; 自动发现的 `ext-` 实例用「逐个根重算 id」定位 (根, 版本),再让 `adopt::inspect` 只判这一个版本(不扫 `versions/`)。复核不过 ⇒ 拒绝 + 具名原因 + 立刻重扫。 - 同名不猜:名字兜底只在唯一命中时用,两个同名实例直接拒绝(多根之后很常见)。 - 「点下去立刻有反馈」不变:`Busy` 仍在 `dispatch` 返回前发出(`launch::accept`)。 拆分:`launch_target` / `in_flight` / `mark_pending` / `start_launch` / `running_pid` 从 `host/mod.rs`(原 395/400 行)搬进 `host/launch/accept.rs`;解析与拒绝形状在 `host/launch/target.rs`,单实例复核在 `host/game/refresh/live.rs`。 判据(`host/launch/target_tests.rs`,5 条): - 同一拍进 Busy(无 tick、无后台回程),且受理不派扫描(代次不变); - 快照落地后**新增**的实例点启动必须被拒绝(解析改回现场扫盘 ⇒ 红,实测); - 快照落地后**删掉**的实例点启动必须拒绝并给具名原因(去掉复核 ⇒ 红,实测); - 两个同名实例不猜;停止读快照里的 pid。 门禁:`cargo test -p unlucky-presenter -p unlucky-app-iced --all-targets`(423 + 1075 全绿)、 `cargo clippy --workspace --all-targets -- -D warnings`(0)、`cargo fmt --all --check`(0)、 文件行数全 ≤400(`host/mod.rs` 340、`refresh.rs` 382、`launch/` 90/114/242/280)。 --- crates/presenter/src/host/game/refresh.rs | 6 + .../presenter/src/host/game/refresh/live.rs | 109 +++++++ crates/presenter/src/host/launch.rs | 6 + crates/presenter/src/host/launch/accept.rs | 90 ++++++ crates/presenter/src/host/launch/target.rs | 114 +++++++ .../presenter/src/host/launch/target_tests.rs | 280 ++++++++++++++++++ crates/presenter/src/host/mod.rs | 95 ++---- 7 files changed, 625 insertions(+), 75 deletions(-) create mode 100644 crates/presenter/src/host/game/refresh/live.rs create mode 100644 crates/presenter/src/host/launch/accept.rs create mode 100644 crates/presenter/src/host/launch/target.rs create mode 100644 crates/presenter/src/host/launch/target_tests.rs diff --git a/crates/presenter/src/host/game/refresh.rs b/crates/presenter/src/host/game/refresh.rs index 787cbfa..dd2a0e3 100644 --- a/crates/presenter/src/host/game/refresh.rs +++ b/crates/presenter/src/host/game/refresh.rs @@ -24,6 +24,12 @@ use crate::dto::{CoreEvent, InstancePhase}; use super::super::job::{spawn, HostTask, JobSender}; use super::super::HostEnv; +/// M304:快照条目的「现在」——这一条**还**在不在盘上(判据见 `live` 的模块文档)。 +/// +/// 它能在 `game` 外面被调到,是因为挂成了 [`Scan`] 的关联函数:`host` 其它地方只够得着 +/// `game` 转出的那几个名字(`Scan` / `instances` / `game_for` …),私有子模块够不着。 +mod live; + /// 首个实例快照的等待上限(M302 的**有界兜底**)。 /// /// ## 取值依据(不是随手挑的) diff --git a/crates/presenter/src/host/game/refresh/live.rs b/crates/presenter/src/host/game/refresh/live.rs new file mode 100644 index 0000000..1e464b6 --- /dev/null +++ b/crates/presenter/src/host/game/refresh/live.rs @@ -0,0 +1,109 @@ +//! 快照条目的**现在**:这一条还在不在盘上(M304)。 +//! +//! ## 为什么需要 +//! +//! M304 把「点启动」的实例解析从「同步扫一遍 [`super::super::instances`]」改成「查已发布快照」。 +//! 快照是**过去**的一拍:用户可能在别处(文件管理器 / 另一个启动器 / 同一会话的另一条命令) +//! 把这个实例删了、或者把它绑的版本改了。照旧解析就会「按快照把它拉起来」——拉的是**已经不 +//! 存在的东西**,最坏的一种是 `game_for` 反查不到那个根、退回配置根,于是**启动的是另一个根里 +//! 同名版本**(用户看着 A,起来的却是 B)。 +//! +//! ## 判据(单实例,不扫 `versions/`) +//! +//! 一次只判**这一条**,所以绝不能再用 `instances()`(那是全量扫盘:官方根 200 个版本 236 ms, +//! 叠加外部档案 1.35 s)——那等于把「点了就受理」换成「点了等一扫」。两条判据各用最便宜的 +//! 那个事实: +//! +//! | 实例 | 判据 | 成本 | +//! |---|---|---| +//! | 档案实例(id 不是 `ext-`) | `profiles.json` 里还有这条 id,且 `version_id` 没被改过 | 一次小文件读 | +//! | 自动发现的(`ext-`) | 逐个根重算 id(哈希)定位到 (根, 版本),再用 core 的 `adopt::inspect` 判这一个版本 | #根 次哈希 + 一次单版本检查 | +//! +//! 反查不到根的 `ext-` id(M273 时代的老档案,哈希里没有根)**不在这里判死**:那种 id 的语义 +//! 本来就是「按配置根解析」,真没版本时 `build_request` 会给出具名阶段失败(`VersionManifest` + +//! 路径),一个进程都没起来。改前那条同步扫盘也不会拦它——`version_present: false` 的磁贴在 +//! 旧实现里照样能点、照样走到同一条具名失败。 +//! +//! ## 为什么挂在 [`super::Scan`] 上 +//! +//! [`super::super::discovered`] / [`super::super::roots`] 是 `game` 的私有子模块,`host` 其它地方 +//! 只够得着 `game` 转出的那几个名字(`Scan` / `instances` / `game_for` …)。`impl` 块可以写在任何 +//! 模块里,于是这一层用关联函数挂在 [`super::Scan`] 上——语义也对:被判的正是「这份扫描账里的 +//! 那一条现在还成不成立」。 + +use unlucky_core::game::adopt; +use unlucky_core::game::install::instance::read_profiles; + +use crate::dto::InstanceSummary; + +use super::super::super::HostEnv; +use super::super::discovered::external_instance_id; +use super::super::roots; + +impl super::Scan { + /// 这条快照条目**现在**还在不在盘上;不在就给一句给用户看的具名原因。 + /// + /// 只判这一条(成本见模块文档的表格),**不重扫 `versions/`**:调用方是「点启动」那条同步 + /// 路径,它必须便宜到能在同一拍里给出 Busy。 + /// + /// # Errors + /// + /// 返回的字符串是**给人读的原因**(presenter 没有 i18n 依赖,文案在这里拼,与 `toast` 同一种 + /// 形状):实例清单里没了 / 版本被改过 / 版本目录已不可用。调用方负责拒绝受理并重扫一遍。 + pub(in crate::host) fn revalidate( + env: &HostEnv, + instance: &InstanceSummary, + ) -> Result<(), String> { + if crate::dto::is_discovered_instance(&instance.id) { + return discovered(env, instance); + } + profile(env, instance) + } +} + +/// 档案实例:`profiles.json` 里还有这条 id,且绑的版本没被改过。 +/// +/// 「版本被改过」也算过期:用户改的是**同一个档案**,但屏幕上那条磁贴写着旧版本、启动链也会按 +/// 旧版本装配——那不是他刚才看中的那一条。 +fn profile(env: &HostEnv, instance: &InstanceSummary) -> Result<(), String> { + let profiles = + read_profiles(&env.layout).map_err(|error| format!("读实例清单失败:{error}"))?; + let Some(profile) = profiles + .profiles + .iter() + .find(|profile| profile.id.as_str() == instance.id) + else { + return Err(format!( + "实例 `{}` 已不在实例清单里(可能刚在别处被删掉),列表已过期", + instance.name + )); + }; + if profile.version_id != instance.version_id { + return Err(format!( + "实例 `{}` 绑的版本已被改成 `{}`(列表里还是 `{}`),列表已过期", + instance.name, profile.version_id, instance.version_id + )); + } + Ok(()) +} + +/// 自动发现的实例:id 是 (根, 版本) 的哈希,逐个根重算一遍定位,再判这一个版本。 +/// +/// 反查不到根 ⇒ 放行(M273 老档案,理由见模块文档):不猜、也不在这里改成「拒绝」, +/// 否则所有老用户的 `ext-` 磁贴会集体点不动。 +fn discovered(env: &HostEnv, instance: &InstanceSummary) -> Result<(), String> { + for root in roots::all(env) { + if external_instance_id(root.key(), &instance.version_id) != instance.id { + continue; + } + return adopt::inspect(root.game(), &env.layout, &instance.version_id) + .map(|_| ()) + .map_err(|error| { + format!( + "实例 `{}` 的版本 `{}` 已不可用({error}),列表已过期", + instance.name, instance.version_id + ) + }); + } + Ok(()) +} diff --git a/crates/presenter/src/host/launch.rs b/crates/presenter/src/host/launch.rs index 64f50c5..8c7f23b 100644 --- a/crates/presenter/src/host/launch.rs +++ b/crates/presenter/src/host/launch.rs @@ -24,12 +24,15 @@ use crate::dto::{ use super::env::HostEnv; +mod accept; mod build; mod java; +mod target; use super::job::{HostTask, JobSender}; pub use build::build_request; pub use java::pick_java; +pub(super) use target::{resolve, Rejection}; /// 启动器名(与 CLI `meta::LAUNCHER_NAME` 同值)。 pub const LAUNCHER_NAME: &str = "Unlucky Launcher"; @@ -232,5 +235,8 @@ pub fn isolation_label(mode: IsolationMode) -> &'static str { mode.as_str() } +#[cfg(test)] +mod target_tests; + #[cfg(test)] mod tests; diff --git a/crates/presenter/src/host/launch/accept.rs b/crates/presenter/src/host/launch/accept.rs new file mode 100644 index 0000000..419cd39 --- /dev/null +++ b/crates/presenter/src/host/launch/accept.rs @@ -0,0 +1,90 @@ +//! 一次启动的**受理**与停止的实例解析(M304:读已发布快照,不在调用方线程扫盘)。 +//! +//! 从 `host/mod.rs` 拆出来只为两件事:守住 400 行硬线,以及让「点启动这条状态机」与 +//! `launch` 模块的其余部分(装配 / 起进程 / 收尾)放在一起。判断顺序见 [`CoreHost::start_launch`]。 +//! +//! 四个方法的可见性是 `pub(in crate::host)` 而不是 `pub(super)`:`host::launch_state_tests` +//! 是 `host` 的子模块、不是 `launch` 的子模块,它要直接调这几个入口(测真实状态流转)。 + +use crate::dto::{CoreEvent, InstanceRuntime, LaunchStage, Progress}; +use crate::toast::ToastKind; + +use super::super::{game, launch, CoreHost}; + +impl CoreHost { + /// 某实例是否已在启动/运行(`pending` 与 `launches` 的并集)。 + /// + /// 只查 `launches` 不够:`launch::start` 到后台线程回报 `LaunchStarted` 之间有窗口, + /// 这期间重复点启动会起第二个进程、并把旧 pid 在启动表里顶掉(旧进程变孤儿)。 + pub(in crate::host) fn in_flight(&self, key: &str) -> bool { + self.pending.contains(key) || self.launches.iter().any(|live| live.instance == key) + } + + /// 标记「已受理、等后台线程回报」(`launch::start` 之后的窗口)。 + /// + /// 单测也走这个入口而不是直接改 `pending`,保证测的是真实状态流转。 + pub(in crate::host) fn mark_pending(&mut self, id: &str) { + self.pending.insert(id.to_string()); + self.failed.remove(id); + } + + /// 启动受理(M304:解析读**已发布快照**,调用方线程一次盘都不扫)。 + /// + /// 顺序不能换:① 在飞/重复点先挡(一次内存判,最便宜)② 解析 + 单实例复核(微秒级) + /// ③ 标 `pending` + 发 `Busy` ④ 起后台线程。③ 在 ④ 之前 ⇒ 「按钮进 Busy」**不依赖**后台线程 + /// 回来,这正是「点下去没反应」的判据(`host::launch::target_tests`)。 + pub(in crate::host) fn start_launch(&mut self, id: &str) { + if self.in_flight(id) { + self.toast(ToastKind::Warn, "这个实例已经在启动/运行中"); + return; + } + let target = + match launch::resolve(&self.env, &self.published, id, self.active_account.clone()) { + Ok(target) => target, + Err(launch::Rejection { reason, stale }) => { + // 拒绝要**具名**:用户点的是磁贴,磁贴与盘面对不上必须说出来是哪一条、为什么。 + self.toast(ToastKind::Error, reason); + // 过期 ⇒ 立刻重扫(磁贴自己会消失 / 换成新版本)。「还在扫描中」不重扫:那会把 + // 在飞那一轮的代次顶掉、结果被 `apply_instances` 丢掉,占位反而更久。 + if stale { + self.refresh_instances(); + } + return; + } + }; + let instance_id = target.id.clone(); + if self.in_flight(&instance_id) { + self.toast(ToastKind::Warn, "这个实例已经在启动/运行中"); + return; + } + self.mark_pending(&instance_id); + if let Some(notice) = game::mode_change_notice(&self.env, &instance_id) { + self.push_notice(notice); + } + self.send(CoreEvent::InstanceRuntimeChanged { + id: instance_id.clone(), + runtime: InstanceRuntime::Busy { + stage: LaunchStage::Launch, + progress: Progress { done: 0, total: 1 }, + }, + }); + launch::start(self.env.clone(), self.tx.clone(), target); + } + + /// 某实例在跑的 pid(按实例 id 查启动表;不再有「最近一次启动」这种单槽)。 + /// + /// 兜底分支读**已发布快照**的 `Running { pid }`(M304):host 自己发的运行时事件会经 + /// `handle::apply_instances` 写回快照,而快照就是这里读的那一份——「点停止」不再为此扫一次盘。 + pub(in crate::host) fn running_pid(&self, id: &str) -> Option { + if let Some(live) = self.launches.iter().find(|live| live.instance == id) { + return Some(live.pid); + } + self.published + .iter() + .find(|instance| instance.id == id) + .and_then(|instance| match instance.runtime { + InstanceRuntime::Running { pid } => Some(pid), + _ => None, + }) + } +} diff --git a/crates/presenter/src/host/launch/target.rs b/crates/presenter/src/host/launch/target.rs new file mode 100644 index 0000000..766dc29 --- /dev/null +++ b/crates/presenter/src/host/launch/target.rs @@ -0,0 +1,114 @@ +//! 启动受理用的实例解析(M304):**从已发布快照里查**,不在调用方线程扫盘。 +//! +//! ## 改的是什么 +//! +//! M302 把 `refresh_instances` 的扫盘搬进了后台线程,但「点启动」这条路上还留着两次全量扫盘 +//! (`launch_target` 与 `running_pid` 各自 `game::instances(&env)`):官方根 200 个版本 236 ms、 +//! 叠加 10 份外部档案 1.35 s,全部落在 UI 线程上。用户侧的表现就是「点了 Play,界面先僵一下」。 +//! +//! 现在读**已发布快照**(`CoreHost::send` 把每份 `InstancesChanged` 留一份): +//! +//! - 成本从「#根 次 `versions/` 全扫」降到一次内存查表 + 一次**单实例**复核 +//! (`game::Scan::revalidate`); +//! - 语义也更准:用户在列表里点的**就是**快照里的那一条。同步扫盘拿的是「此刻的盘」, +//! 它可能已经和屏幕上那条不是同一个实例了(两次刷新之间盘变了)。 +//! +//! ## 为什么不是「异步解析」 +//! +//! 异步会把「点下去没反应」换个形状:受理(按钮进 Busy)得等解析回来。本 mission 的硬要求是 +//! **同一拍**给出可见反馈,所以解析必须同步且足够便宜——便宜的手段是查快照(+ 单实例复核), +//! 不是再起一条线程等它回来。 +//! +//! ## 快照过期时会发生什么(这条路的真风险) +//! +//! | 情形 | 判据落点 | 结果 | +//! |---|---|---| +//! | 实例在别处被删 / 绑的版本被改 | `Scan::revalidate` | 拒绝 + 具名原因 + 立刻重扫 | +//! | 快照里还没有内容(冷启动第一拍) | [`resolve`] 的空快照分支 | 拒绝 +「还在扫描中」 | +//! | 名字撞上两个实例 | [`find`] 的唯一命中判据 | 拒绝 +「不能替你猜」 | +//! | `versions/` 被删 | `Scan::revalidate`(`adopt::inspect`) | 拒绝 + 具名原因 | +//! +//! 「拒绝」是**一字节都不碰**:不进 `pending`、不发 Busy、不起线程、不碰盘上的任何东西 +//! (除了那一次只读复核)。 + +use crate::dto::InstanceSummary; + +use super::super::game; +use super::super::HostEnv; +use super::LaunchTarget; + +/// 拒绝受理:给用户看的一句具名原因 + 要不要顺手重扫一遍实例目录。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Rejection { + /// 人读原因(presenter 没有 i18n 依赖,文案在与 `toast` 同一层拼好) + pub reason: String, + /// 快照明确落后于盘面(实例没了 / 版本改了 / 版本目录没了)⇒ 值得立刻重扫一遍; + /// 「还在扫描中」与「名字撞了」都不是过期,重扫也解决不了,所以是 `false`。 + pub stale: bool, +} + +/// 在快照里把用户点的那个实例解析成启动目标。 +/// +/// `key` 是界面传来的实例 id(磁贴带的就是它);名字兜底只为兼容按名字调用的老路径。 +/// +/// # Errors +/// +/// 见模块文档那张表:空快照 / 快照里没有 / 名字撞多个 / 盘面已过期([`Rejection::stale`])。 +pub fn resolve( + env: &HostEnv, + snapshot: &[InstanceSummary], + key: &str, + account: Option, +) -> Result { + let Some(instance) = find(snapshot, key)? else { + // 空快照 = 首次扫描还没落地(M302 的占位那一态)。用户此刻其实是**看不到**可点的磁贴的, + // 但这条路径在事件回流前也可能被点到(快捷键 / 双击),所以拒绝也要有话说,且**不装死**。 + return Err(if snapshot.is_empty() { + Rejection { + reason: "实例列表还在扫描中,稍等一下再点(正在扫描实例…)".to_string(), + stale: false, + } + } else { + Rejection { + reason: format!("实例列表里没有 `{key}`(可能刚在别处被改过)"), + stale: true, + } + }); + }; + game::Scan::revalidate(env, instance).map_err(|reason| Rejection { + reason, + stale: true, + })?; + Ok(LaunchTarget { + id: instance.id.clone(), + name: instance.name.clone(), + version_id: instance.version_id.clone(), + account, + }) +} + +/// id 精确优先;名字兜底**只在唯一命中时**用。 +/// +/// 为什么名字撞了要拒绝而不是取第一个:两个同名实例(多根之后很常见:两个根里都叫 `A`)取第一个 +/// 就是在**猜**,而猜错的代价是「用户看着 A,起来的却是 B」——启动是这条路上唯一不可撤销的动作。 +fn find<'a>( + snapshot: &'a [InstanceSummary], + key: &str, +) -> Result, Rejection> { + if let Some(exact) = snapshot.iter().find(|instance| instance.id == key) { + return Ok(Some(exact)); + } + let mut by_name = snapshot.iter().filter(|instance| instance.name == key); + let first = by_name.next(); + if by_name.next().is_some() { + let count = snapshot + .iter() + .filter(|instance| instance.name == key) + .count(); + return Err(Rejection { + reason: format!("有 {count} 个实例都叫 `{key}`,不能替你猜——请从列表里点那一条"), + stale: false, + }); + } + Ok(first) +} diff --git a/crates/presenter/src/host/launch/target_tests.rs b/crates/presenter/src/host/launch/target_tests.rs new file mode 100644 index 0000000..c022ea5 --- /dev/null +++ b/crates/presenter/src/host/launch/target_tests.rs @@ -0,0 +1,280 @@ +//! M304 的判据:启动/停止的实例解析读**已发布快照**,而不是在调用方线程扫一遍盘。 +//! +//! 三条判据各打一个具体的谎: +//! +//! | 判据 | 抓得住什么 | +//! |---|---| +//! | ① 点下去**同一拍**进 Busy(无 tick、无后台回程) | 把受理做成「等解析回来才发 Busy」 | +//! | ② 快照落地后**在盘上新增**一条实例 ⇒ 点它必须被拒绝,不是被扫出来 | 解析改回**现场扫盘**(`game::instances`)| +//! | ③ 快照落地后**删掉**这个实例 ⇒ 点它必须拒绝并给具名原因 | 去掉受理那一刻的单实例复核 | +//! +//! ②与③是一对:只做②(纯快照、不复核)会把已删实例拉起来;只做③(只复核、读盘)会退回扫盘。 +//! 两条都靠**盘面在快照之后变了**来判,不靠计时——计时在 CI 上只会假绿。 +//! +//! 另外两条小的:同名不猜(两个实例同名时拒绝而不是取第一个)、停止读快照里的 pid(不为此扫盘)。 + +use std::time::Duration; + +use unlucky_core::game::install::instance::write_profiles; +use unlucky_core::game::{InstanceProfile, ProfileId, ProfileList}; + +use crate::dto::{AppCommand, AppEvent, Backend, CoreEvent, InstanceRuntime}; +use crate::toast::ToastKind; + +use super::super::{CoreHost, HostEnv}; + +/// 临时数据根守卫:用例结束(含断言 panic)时删掉,不留空壳。 +struct TempRoot(std::path::PathBuf); + +impl Drop for TempRoot { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// 一条实例档案(自建实例:id 与名字由用例给)。 +fn profile(name: &str, version: &str) -> InstanceProfile { + InstanceProfile { + id: ProfileId::generate(), + name: name.to_string(), + version_id: version.to_string(), + game_dir: None, + running_dir: None, + java_path: None, + created_at: None, + last_played_at: None, + extra: std::collections::BTreeMap::new(), + } +} + +/// 把实例清单**写进盘**(`profiles.json` 是「盘上有没有这个实例」的档案侧真相)。 +/// +/// 增删实例都走它:用例改的就是 host 看不见的那份盘面,快照因此真的会过期。 +fn write(env: &HostEnv, profiles: &[InstanceProfile]) { + let list = ProfileList { + profiles: profiles.to_vec(), + ..ProfileList::default() + }; + write_profiles(&env.layout, &list).expect("写 profiles.json"); +} + +/// 建一个只有这几条档案的数据根,返回 `(host, 守卫)`。 +fn host(label: &str, profiles: &[InstanceProfile]) -> (CoreHost, TempRoot) { + let dir = std::env::temp_dir().join(format!( + "ull-m304-{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 目录"); + write(&env, profiles); + (CoreHost::headless(env), TempRoot(dir)) +} + +/// 让第一份快照落地(M302 的后台扫盘),并把这一批事件读走。 +fn land_snapshot(host: &mut CoreHost) { + host.refresh_instances(); + host.pump_instances(); + let _ = host.drain_events(); +} + +/// 有没有发过「运行时变了」(受理成功 = 一条 `Busy`)。 +fn runtime_event_emitted(events: &[AppEvent]) -> bool { + events.iter().any(|event| { + matches!( + event, + AppEvent::Core(CoreEvent::InstanceRuntimeChanged { .. }) + ) + }) +} + +/// 事件里唯一那条错误 Toast 的原文(拒绝必须是**具名**的,所以这里要求它存在)。 +fn error_toast(events: &[AppEvent]) -> String { + events + .iter() + .find_map(|event| match event { + AppEvent::Toast(toast) if toast.kind == ToastKind::Error => Some(toast.text.clone()), + _ => None, + }) + .unwrap_or_else(|| panic!("拒绝受理必须给一条错误提示(具名原因),实际事件:{events:?}")) +} + +/// 等启动后台线程把结局交回来(本文件的实例都没有版本本体 ⇒ 装配必然失败)。 +/// +/// 不只是收尾:它顺手证明**受理不依赖这条线程**——Busy 的断言都在它之前做完了。 +fn settle(host: &mut CoreHost) { + for _ in 0..2_000 { + if host.pending.is_empty() { + return; + } + host.tick(Duration::from_millis(1)); + std::thread::sleep(Duration::from_millis(1)); + } + panic!("启动线程没在 2 s 内收尾(pending 还剩 {:?})", host.pending); +} + +/// **判据①**:点启动 ⇒ `dispatch` 返回时 Busy 已经在事件队列里(不需要 `tick`、不需要后台回程)。 +/// +/// 抓得住:把解析做成异步(「先起线程,等它回来说受理了」)——那时这里读完事件队列还没有 Busy。 +#[test] +fn clicking_launch_shows_busy_in_the_same_beat() { + let demo = profile("demo", "1.20.1"); + let id = demo.id.as_str().to_string(); + let (mut host, _root) = host("same-beat", std::slice::from_ref(&demo)); + land_snapshot(&mut host); + let epoch = host.instances_scan.epoch(); + + host.dispatch(AppCommand::LaunchInstance(id.clone())); + + let events = host.drain_events(); + assert!( + runtime_event_emitted(&events), + "点下去必须**同一拍**进 Busy(不许等解析/扫盘回来):{events:?}" + ); + assert!(host.in_flight(&id), "受理要立刻落进 pending"); + assert_eq!( + host.instances_scan.epoch(), + epoch, + "受理这条路不许顺手派一轮扫描(代次变了就说明它扫盘了)" + ); + + settle(&mut host); +} + +/// **判据②(变异:解析改回现场扫盘 ⇒ 这条红)**:快照落地之后盘上**多了**一条实例。 +/// +/// 点它必须被拒绝(快照里没有),而不是被「现场扫一遍盘」扫出来直接拉起来。用户看见的列表里 +/// 根本没有这一条——把不在屏幕上的实例启动起来,正是「缓存/盘面两套真相」最坏的形状。 +/// +/// 拒绝之后 host 会自己重扫(`Rejection::stale`)⇒ 重扫完成再点,就必须受理。 +#[test] +fn an_instance_that_appeared_after_the_snapshot_is_refused_not_launched() { + let demo = profile("demo", "1.20.1"); + let late = profile("late", "1.21"); + let late_id = late.id.as_str().to_string(); + let (mut host, _root) = host("late", std::slice::from_ref(&demo)); + land_snapshot(&mut host); + + // 快照之后才写进盘的第二条:host 手里的快照里没有它 + write(host.env(), &[demo, late]); + + host.dispatch(AppCommand::LaunchInstance(late_id.clone())); + let events = host.drain_events(); + assert!( + !runtime_event_emitted(&events), + "解析必须读快照:快照里没有的实例不许被拉起来(现场扫盘就会拉起来):{events:?}" + ); + assert!(host.pending.is_empty(), "拒绝受理 ⇒ 一个字节都不进 pending"); + assert!( + error_toast(&events).contains("没有"), + "要具名说清「列表里没有这条」" + ); + + // 拒绝不是死路:自动重扫之后它就出现在列表里,再点就受理 + host.pump_instances(); + assert!( + host.published.iter().any(|instance| instance.id == late_id), + "过期快照应被重扫替换(拒绝之后要自己跟上盘面)" + ); + let _ = host.drain_events(); + host.dispatch(AppCommand::LaunchInstance(late_id.clone())); + assert!( + runtime_event_emitted(&host.drain_events()), + "重扫之后同一个 id 必须能受理" + ); + + settle(&mut host); +} + +/// **判据③(变异:去掉单实例复核 ⇒ 这条红)**:快照落地之后删掉这个实例。 +/// +/// 点它必须**拒绝并给具名原因**,而不是按快照把它拉起来。这条是本 mission 的真风险—— +/// 性能只影响手感,拿过期快照启动影响的是「起错东西」。 +#[test] +fn an_instance_deleted_after_the_snapshot_is_refused_with_a_named_reason() { + let demo = profile("demo", "1.20.1"); + let id = demo.id.as_str().to_string(); + let (mut host, _root) = host("deleted", std::slice::from_ref(&demo)); + land_snapshot(&mut host); + + // 快照之后才删:盘上真的没了(档案表重写成空表) + write(host.env(), &[]); + + host.dispatch(AppCommand::LaunchInstance(id.clone())); + let events = host.drain_events(); + assert!( + !runtime_event_emitted(&events), + "已删的实例不许被「按快照」拉起来(那会起错东西/给不出原因):{events:?}" + ); + assert!(host.pending.is_empty(), "拒绝受理 ⇒ 一个字节都不进 pending"); + let message = error_toast(&events); + assert!( + message.contains("demo") && message.contains("实例清单"), + "具名原因要说出是哪个实例、为什么(现在是:{message})" + ); + + // 拒绝之后列表自己跟上:重扫把这条磁贴抹掉 + host.pump_instances(); + assert!( + host.published.is_empty(), + "重扫之后已删实例不该还留在快照里:{:?}", + host.published + ); +} + +/// 两个实例同名时**不许猜**:拒绝并说清有几个,按 id 点(界面的真实路径)照常受理。 +/// +/// 多根之后「两个根里都叫 `A`」是常见布局,取第一个就是在赌用户点的是哪一条。 +#[test] +fn two_instances_sharing_a_name_are_never_guessed() { + let first = profile("同名", "1.20.1"); + let second = profile("同名", "1.21"); + let (mut host, _root) = host("same-name", &[first.clone(), second]); + land_snapshot(&mut host); + + host.dispatch(AppCommand::LaunchInstance("同名".to_string())); + let events = host.drain_events(); + assert!( + !runtime_event_emitted(&events), + "两个同名实例不许猜一个来启动:{events:?}" + ); + assert!(host.pending.is_empty()); + assert!( + error_toast(&events).contains("都叫"), + "要说清为什么不能替你选" + ); + + // 按 id(磁贴真正带的值)点:照常受理 + host.dispatch(AppCommand::LaunchInstance(first.id.as_str().to_string())); + assert!( + runtime_event_emitted(&host.drain_events()), + "按 id 点必须受理(这条路径不受同名影响)" + ); + + settle(&mut host); +} + +/// 停止读的是**快照里的 pid**(host 的启动表里没有它时也一样),不为此扫盘。 +/// +/// 抓得住:`running_pid` 的兜底分支改回 `game::instances(&env)`——那时盘上这条是 `Idle`, +/// 这里会拿到 `None`(状态说「在跑」、点停止却回「没有在跑」)。 +#[test] +fn stop_reads_the_pid_from_the_published_snapshot_not_the_disk() { + let demo = profile("demo", "1.20.1"); + let id = demo.id.as_str().to_string(); + let (mut host, _root) = host("pid", std::slice::from_ref(&demo)); + land_snapshot(&mut host); + + for instance in &mut host.published { + if instance.id == id { + instance.runtime = InstanceRuntime::Running { pid: 4_242 }; + } + } + assert!(host.launches.is_empty(), "host 自己的启动表里没有这一场"); + assert_eq!( + host.running_pid(&id), + Some(4_242), + "停止要读快照里的 pid,不要为此再扫一次盘" + ); +} diff --git a/crates/presenter/src/host/mod.rs b/crates/presenter/src/host/mod.rs index 9df7d50..f5fdecd 100644 --- a/crates/presenter/src/host/mod.rs +++ b/crates/presenter/src/host/mod.rs @@ -45,11 +45,16 @@ use std::time::Duration; use crate::dto::{ AccountView, AppCommand, AppEvent, Backend, CoreEvent, DiagnosisReport, InstallProgressView, - InstanceRuntime, JavaChainStep, LaunchFailureCause, NoticeView, SkinImage, + InstanceSummary, JavaChainStep, LaunchFailureCause, NoticeView, SkinImage, }; use crate::mock::MockBackend; use crate::toast::{Toast, ToastKind}; +// M304:`running_pid` 搬进了 `launch::accept`,本文件不再用它;但 `launch_state_tests`(`use super::*`) +// 仍然拿 `InstanceRuntime` 写断言,所以只在单测里引一次(与 `game::refresh` 里 `Backend` 同一个写法)。 +#[cfg(test)] +use crate::dto::InstanceRuntime; + use accounts::LoginJob; pub use env::HostEnv; use job::{HostTask, JobReceiver, JobSender}; @@ -122,6 +127,14 @@ pub struct CoreHost { /// 实例、漏掉刚装好的),采纳它等于把新快照盖回旧的样子。判据在 `handle::apply_instances`, /// 账目(实例数 / 扫盘耗时 / 丢了几份)在 [`game::Scan`] 里,单测直接读它。 instances_scan: game::Scan, + /// 最近一次**发给界面**的实例快照(M304:启动/停止的实例解析读它,不再扫盘)。 + /// + /// 由 [`CoreHost::send`] 发 `InstancesChanged` 时顺手留一份——于是「host 认为的列表」与 + /// 「界面上的列表」永远是同一份,不会再出现「扫盘那一刻的列表」这种第三份真相。 + /// + /// 它可能**落后于盘面**(用户在两拍之间删了实例 / 改了版本):落点的正确性由 + /// `game::Scan::revalidate` 在受理那一刻复核单条实例,理由与判据见 `launch::target` 模块文档。 + published: Vec, } impl CoreHost { @@ -177,6 +190,7 @@ impl CoreHost { adopt_epoch: 0, update, instances_scan: game::Scan::default(), + published: Vec::new(), }; if refresh { host.refresh_all(); @@ -192,6 +206,11 @@ impl CoreHost { } fn send(&mut self, event: CoreEvent) { + // M304:**已发布快照**在这里留一份(启动/停止的实例解析读它,不再扫盘)。 + // 选这里而不是扫描回程:这里发出去的就是界面上的那一份,两者不可能分叉。 + if let CoreEvent::InstancesChanged(instances) = &event { + self.published.clone_from(instances); + } self.events.push_back(AppEvent::Core(event)); } @@ -282,80 +301,6 @@ impl CoreHost { }, ))); } - - /// 由实例名/版本 id 找启动目标。 - fn launch_target(&self, id: &str) -> Option { - let instance = game::instances(&self.env) - .into_iter() - .find(|instance| instance.id == id || instance.name == id)?; - Some(launch::LaunchTarget { - id: instance.id, - name: instance.name, - version_id: instance.version_id, - account: self.active_account.clone(), - }) - } - - /// 某实例是否已在启动/运行(`pending` 与 `launches` 的并集)。 - /// - /// 只查 `launches` 不够:`launch::start` 到后台线程回报 `LaunchStarted` 之间有窗口, - /// 这期间重复点启动会起第二个进程、并把旧 pid 在启动表里顶掉(旧进程变孤儿)。 - fn in_flight(&self, key: &str) -> bool { - self.pending.contains(key) || self.launches.iter().any(|live| live.instance == key) - } - - /// 标记「已受理、等后台线程回报」(`launch::start` 之后的窗口)。 - /// - /// 单测也走这个入口而不是直接改 `pending`,保证测的是真实状态流转。 - fn mark_pending(&mut self, id: &str) { - self.pending.insert(id.to_string()); - self.failed.remove(id); - } - - fn start_launch(&mut self, id: &str) { - if self.in_flight(id) { - self.toast(ToastKind::Warn, "这个实例已经在启动/运行中"); - return; - } - let Some(target) = self.launch_target(id) else { - self.toast(ToastKind::Error, format!("找不到实例 {id}")); - return; - }; - let instance_id = target.id.clone(); - if self.in_flight(&instance_id) { - self.toast(ToastKind::Warn, "这个实例已经在启动/运行中"); - return; - } - self.mark_pending(&instance_id); - if let Some(notice) = game::mode_change_notice(&self.env, &instance_id) { - self.push_notice(notice); - } - self.send(CoreEvent::InstanceRuntimeChanged { - id: instance_id.clone(), - runtime: InstanceRuntime::Busy { - stage: crate::dto::LaunchStage::Launch, - progress: crate::dto::Progress { done: 0, total: 1 }, - }, - }); - launch::start(self.env.clone(), self.tx.clone(), target); - } - - /// 某实例在跑的 pid(按实例 id 查启动表;不再有「最近一次启动」这种单槽)。 - /// - /// 兜底分支读实例快照的 `Running { pid }`:host 自己发的运行时事件会写回快照, - /// 但快照是重建的(`game::instances` 每次 Idle),所以这层只是给将来留的口。 - fn running_pid(&self, id: &str) -> Option { - if let Some(live) = self.launches.iter().find(|live| live.instance == id) { - return Some(live.pid); - } - game::instances(&self.env) - .into_iter() - .find(|instance| instance.id == id) - .and_then(|instance| match instance.runtime { - InstanceRuntime::Running { pid } => Some(pid), - _ => None, - }) - } } impl Backend for CoreHost {