From 7b2ff62ddc2d5d3c8991f4cad11006908653d651 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sat, 31 Oct 2026 22:09:47 +0800 Subject: [PATCH] =?UTF-8?q?fix(app):=20=E8=BF=90=E8=A1=8C=E6=97=B6?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E5=9B=9E=E5=BD=92=E7=9C=9F=E5=AE=9E=E6=9D=A5?= =?UTF-8?q?=E6=BA=90=20+=20=E5=90=AF=E5=8A=A8=E7=8A=B6=E6=80=81=E6=9C=BA?= =?UTF-8?q?=E8=A1=A5=E5=9B=9E=E5=BD=92=E6=B5=8B=E8=AF=95=EF=BC=88M43=20r2?= =?UTF-8?q?=20=E4=B8=A4=E6=9D=A1=20P2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2-1(修复零测试):新增 host/launch_state_tests.rs,6 条单测钉住启动状态机: - running_pid 按实例 id 取 pid(并发 A/B 后停 A 不会拿到 B 的 pid,A 收尾不动 B); - 重复启动守卫:launches 已落盘时 start_launch不发 Busy、只给 warn(不起第二个进程); - pending 窗口守卫:后台线程还没回报 LaunchStarted 时同样挡住重复启动; - LaunchStarted 清 pending 并登记 pid;LaunchEnd 清启动表与失败记录; - refresh 后在跑实例仍是 Running{pid};pending 是 Busy;失败记录是 Failed(保住 磁贴的停止键/运行条/重试外观)。 P2-2(运行时状态未派生):refresh_instances 重建快照后按 host 的启动表覆写运行时 (Running{pid} / Busy / Failed),不再被 game::instances 的硬编码 Idle 打回原形—— 装版本成功、加删账户、别的实例收尾等任意刷新都不再让「在跑的实例」丢掉停止入口。 顺带加固(reviewer 建议的 pending 窗口):单槽守卫改为 pending ∪ launches 并集, launch::start 到 LaunchStarted 之间的重复 dispatch 不会起第二个进程;失败原因也 记进 failed 表,避免收尾后的刷新把「失败/重试」覆盖成 Idle。 --- crates/app/src/host/handle.rs | 2 + crates/app/src/host/launch_state_tests.rs | 213 ++++++++++++++++++++++ crates/app/src/host/mod.rs | 72 +++++++- 3 files changed, 279 insertions(+), 8 deletions(-) create mode 100644 crates/app/src/host/launch_state_tests.rs diff --git a/crates/app/src/host/handle.rs b/crates/app/src/host/handle.rs index 1df0bec..adba48e 100644 --- a/crates/app/src/host/handle.rs +++ b/crates/app/src/host/handle.rs @@ -62,6 +62,8 @@ impl CoreHost { session_id, pid, } => { + self.pending.remove(&id); + self.failed.remove(&id); self.launches.retain(|live| live.instance != id); self.launches.push(LiveLaunch { instance: id.clone(), diff --git a/crates/app/src/host/launch_state_tests.rs b/crates/app/src/host/launch_state_tests.rs new file mode 100644 index 0000000..b303790 --- /dev/null +++ b/crates/app/src/host/launch_state_tests.rs @@ -0,0 +1,213 @@ +//! host 启动状态机单测(P2-1/P2-2 的回归网)。 +//! +//! 覆盖三件事: +//! 1. `running_pid` 按实例 id 取 pid(并发启动不会把 A 的停止键指到 B); +//! 2. 重复启动守卫(`launches` 已落盘 + `pending` 窗口两态都要挡); +//! 3. `refresh_instances` 重建快照后仍保留真实运行时(Running / Busy / Failed)。 + +use unlucky_core::game::install::instance::write_profiles; +use unlucky_core::game::{InstanceProfile, ProfileId, ProfileList}; + +use super::*; +use crate::dto::{LaunchFinished, LogOutcome}; +use crate::toast::ToastKind; + +/// 建一个只有一条实例档案的数据根,返回 `(host, instance_id)`。 +fn host_with_instance(label: &str) -> (CoreHost, String) { + let dir = std::env::temp_dir().join(format!( + "ull-host-launch-{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 目录"); + + let profile = InstanceProfile { + id: ProfileId::generate(), + name: "demo".to_string(), + 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(), + }; + let id = profile.id.as_str().to_string(); + let profiles = ProfileList { + profiles: vec![profile], + ..ProfileList::default() + }; + write_profiles(&env.layout, &profiles).expect("写 profiles"); + (CoreHost::headless(env), id) +} + +fn live(instance: &str, session: &str, pid: u32) -> LiveLaunch { + LiveLaunch { + instance: instance.to_string(), + session: session.to_string(), + pid, + } +} + +#[test] +fn running_pid_is_per_instance_not_a_single_slot() { + let (mut host, id) = host_with_instance("pid"); + host.launches.push(live("a", "s-a", 111)); + host.launches.push(live("b", "s-b", 222)); + + assert_eq!(host.running_pid("a"), Some(111)); + assert_eq!(host.running_pid("b"), Some(222)); + assert_eq!( + host.running_pid("c"), + None, + "没启动过的实例不该拿到别人的 pid" + ); + + // A 收尾后只影响 A:这正是 review r1 P2-1 的场景(旧实现共用一个槽位)。 + host.launches.retain(|entry| entry.instance != "a"); + assert_eq!(host.running_pid("a"), None); + assert_eq!(host.running_pid("b"), Some(222)); + + // 落盘实例默认是 Idle,兜底分支不误报。 + assert_eq!(host.running_pid(&id), None); +} + +#[test] +fn duplicate_launch_is_rejected_before_spawning() { + let (mut host, id) = host_with_instance("dup"); + let _ = host.drain_events(); + host.launches.push(live(&id, "s", 111)); + + host.start_launch(&id); + + let events = host.drain_events(); + assert!( + !events.iter().any(|event| matches!( + event, + AppEvent::Core(CoreEvent::InstanceRuntimeChanged { .. }) + )), + "已在运行/启动中的实例不该再发 Busy(那会起第二个进程)" + ); + assert!( + events + .iter() + .any(|event| matches!(event, AppEvent::Toast(toast) if toast.kind == ToastKind::Warn)), + "至少要给用户一条「已经在启动/运行中」的提示" + ); + assert_eq!(host.launches.len(), 1, "启动表不该被重复启动污染"); +} + +#[test] +fn pending_window_also_blocks_a_second_launch() { + let (mut host, id) = host_with_instance("pending"); + // 模拟「launch::start 已调用、后台线程还没回报 LaunchStarted」的窗口。 + host.pending.insert(id.clone()); + assert!(host.in_flight(&id)); + + let _ = host.drain_events(); + host.start_launch(&id); + let events = host.drain_events(); + assert!( + !events.iter().any(|event| matches!( + event, + AppEvent::Core(CoreEvent::InstanceRuntimeChanged { .. }) + )), + "pending 窗口内重复点启动必须被挡住" + ); + assert_eq!(host.pending.len(), 1); +} + +#[test] +fn launch_started_registers_and_launch_end_clears() { + let (mut host, id) = host_with_instance("cycle"); + host.pending.insert(id.clone()); + + host.handle(HostTask::LaunchStarted { + id: id.clone(), + session_id: "20261031T120000-abcd12".to_string(), + pid: 4_242, + }); + assert!(!host.pending.contains(&id), "回报后 pending 要清掉"); + assert_eq!(host.running_pid(&id), Some(4_242)); + + host.handle(HostTask::LaunchEnd(Box::new(LaunchFinished { + id: id.clone(), + session: "20261031T120000-abcd12".to_string(), + outcome: LogOutcome::Ok, + summary: "正常退出".to_string(), + }))); + assert!(host.launches.is_empty(), "收尾后启动表要清空"); + assert!(!host.failed.contains_key(&id)); + assert_eq!(host.running_pid(&id), None); +} + +#[test] +fn refresh_keeps_running_instances_running() { + let (mut host, id) = host_with_instance("refresh-run"); + host.launches.push(live(&id, "s", 4_242)); + let _ = host.drain_events(); + + host.refresh_instances(); + + let instance = instances_from_events(host.drain_events()) + .into_iter() + .find(|instance| instance.id == id) + .expect("刷新后应有该实例"); + assert_eq!( + instance.runtime, + InstanceRuntime::Running { pid: 4_242 }, + "刷新(装版本/账户变更/别的实例收尾)不能把在跑实例打回 Idle" + ); +} + +#[test] +fn refresh_keeps_pending_as_busy_and_failed_as_failed() { + let (mut host, id) = host_with_instance("refresh-busy"); + + host.pending.insert(id.clone()); + let pending = instances_from_events({ + host.refresh_instances(); + host.drain_events() + }) + .into_iter() + .find(|instance| instance.id == id) + .expect("实例存在"); + assert!(matches!( + pending.runtime, + InstanceRuntime::Busy { + stage: crate::dto::LaunchStage::Launch, + .. + } + )); + + host.pending.remove(&id); + host.failed.insert(id.clone(), "崩溃(exit 1)".to_string()); + let failed = instances_from_events({ + host.refresh_instances(); + host.drain_events() + }) + .into_iter() + .find(|instance| instance.id == id) + .expect("实例存在"); + assert_eq!( + failed.runtime, + InstanceRuntime::Failed { + reason: "崩溃(exit 1)".to_string() + }, + "失败原因要经得住一次刷新(否则磁贴的「重试」外观会被 Idle 盖掉)" + ); +} + +/// 从事件队列里取最后一次 `InstancesChanged`。 +fn instances_from_events(events: Vec) -> Vec { + events + .into_iter() + .rev() + .find_map(|event| match event { + AppEvent::Core(CoreEvent::InstancesChanged(instances)) => Some(instances), + _ => None, + }) + .unwrap_or_default() +} diff --git a/crates/app/src/host/mod.rs b/crates/app/src/host/mod.rs index 76f105c..a75d82e 100644 --- a/crates/app/src/host/mod.rs +++ b/crates/app/src/host/mod.rs @@ -28,7 +28,7 @@ mod meter; mod settings; mod versions; -use std::collections::VecDeque; +use std::collections::{BTreeMap, BTreeSet, VecDeque}; use std::time::Duration; use crate::dto::{ @@ -74,6 +74,10 @@ pub struct CoreHost { notices: Vec, /// 在跑的启动(实例 → 会话 → pid;支持多个实例同时启动,停止按 id 找正确的 pid) launches: Vec, + /// 已受理但后台线程还没回报 `LaunchStarted` 的实例(防「启动中重复点启动」起第二个进程) + pending: BTreeSet, + /// 上次启动失败的实例与原因(刷新快照时保住磁贴的「失败/重试」外观) + failed: BTreeMap, /// 实时日志尾随器 tail: logs::Tail, /// AI 对话的临时后端(agent GUI host 落地前的占位) @@ -90,6 +94,16 @@ impl CoreHost { /// 用给定环境装配(截图/单测通道)。 pub fn new(env: HostEnv) -> Self { + Self::build(env, true) + } + + /// 单测用:不跑 `refresh_all`(不起版本清单网络线程),其余装配一致。 + #[cfg(test)] + pub(crate) fn headless(env: HostEnv) -> Self { + Self::build(env, false) + } + + fn build(env: HostEnv, refresh: bool) -> Self { let (tx, rx) = job::channel(); game::ensure_root(&env); let mut host = Self { @@ -107,11 +121,15 @@ impl CoreHost { install: None, notices: Vec::new(), launches: Vec::new(), + pending: BTreeSet::new(), + failed: BTreeMap::new(), tail: logs::Tail::default(), chat: MockBackend::default(), install_cancel: None, }; - host.refresh_all(); + if refresh { + host.refresh_all(); + } host } @@ -143,8 +161,32 @@ impl CoreHost { versions::spawn_list(self.env.clone(), self.tx.clone()); } + /// 重建实例快照,并把**真实运行时**盖回去。 + /// + /// `game::instances` 只读盘(每条都是 `Idle`),所以在跑的实例必须在重建后按 + /// host 自己的启动表覆写 `Running{pid}`/`Busy`,否则任意一次刷新(装版本成功、 + /// 加删账户、另一实例收尾)都会把磁贴打回 Idle:停止键与「运行中」条消失, + /// 进程还在跑却再也停不下来。 fn refresh_instances(&mut self) { - let instances = game::instances(&self.env); + 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(reason) = self.failed.get(&instance.id) { + instance.runtime = InstanceRuntime::Failed { + reason: reason.clone(), + }; + } + } self.send(CoreEvent::InstancesChanged(instances)); } @@ -205,20 +247,30 @@ impl CoreHost { }) } + /// 某实例是否已在启动/运行(`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) + } + 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 - .launches - .iter() - .any(|live| live.instance == instance_id) - { + if self.in_flight(&instance_id) { self.toast(ToastKind::Warn, "这个实例已经在启动/运行中"); return; } + self.pending.insert(instance_id.clone()); + self.failed.remove(&instance_id); if let Some(notice) = game::mode_change_notice(&self.env, &instance_id) { self.push_notice(notice); } @@ -276,3 +328,7 @@ impl Backend for CoreHost { } } } + +#[cfg(test)] +#[path = "launch_state_tests.rs"] +mod launch_state_tests;