diff --git a/crates/app/src/host/handle.rs b/crates/app/src/host/handle.rs index adba48e..e6b135e 100644 --- a/crates/app/src/host/handle.rs +++ b/crates/app/src/host/handle.rs @@ -90,10 +90,19 @@ impl CoreHost { self.send(CoreEvent::InstanceRuntimeChanged { id, runtime }); } HostTask::LaunchEnd(finished) => { + // 收尾可能发生在 LaunchStarted 之前(build_request / spawn 失败只发 + // LaunchEnd):pending 必须在这里也清掉,否则 in_flight 恒真、该实例 + // 本会话内再也起不来。 + self.pending.remove(&finished.id); self.launches.retain(|live| live.instance != finished.id); let runtime = if finished.outcome == LogOutcome::Ok { + self.failed.remove(&finished.id); InstanceRuntime::Idle } else { + // 记住失败原因:随后的 refresh_instances 会重建快照, + // 不记的话磁贴的「失败/重试」外观会被 Idle 盖掉。 + self.failed + .insert(finished.id.clone(), finished.summary.clone()); InstanceRuntime::Failed { reason: finished.summary.clone(), } diff --git a/crates/app/src/host/launch_state_tests.rs b/crates/app/src/host/launch_state_tests.rs index b303790..bbb230d 100644 --- a/crates/app/src/host/launch_state_tests.rs +++ b/crates/app/src/host/launch_state_tests.rs @@ -12,8 +12,17 @@ use super::*; use crate::dto::{LaunchFinished, LogOutcome}; use crate::toast::ToastKind; -/// 建一个只有一条实例档案的数据根,返回 `(host, instance_id)`。 -fn host_with_instance(label: &str) -> (CoreHost, String) { +/// 临时数据根守卫:测试结束(含断言 panic)时把目录删掉,不留空壳。 +struct TempRoot(std::path::PathBuf); + +impl Drop for TempRoot { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// 建一个只有一条实例档案的数据根,返回 `(host, instance_id, 守卫)`。 +fn host_with_instance(label: &str) -> (CoreHost, String, TempRoot) { let dir = std::env::temp_dir().join(format!( "ull-host-launch-{label}-{}-{}", std::process::id(), @@ -40,7 +49,7 @@ fn host_with_instance(label: &str) -> (CoreHost, String) { ..ProfileList::default() }; write_profiles(&env.layout, &profiles).expect("写 profiles"); - (CoreHost::headless(env), id) + (CoreHost::headless(env), id, TempRoot(dir)) } fn live(instance: &str, session: &str, pid: u32) -> LiveLaunch { @@ -53,7 +62,7 @@ fn live(instance: &str, session: &str, pid: u32) -> LiveLaunch { #[test] fn running_pid_is_per_instance_not_a_single_slot() { - let (mut host, id) = host_with_instance("pid"); + let (mut host, id, _root) = host_with_instance("pid"); host.launches.push(live("a", "s-a", 111)); host.launches.push(live("b", "s-b", 222)); @@ -76,7 +85,7 @@ fn running_pid_is_per_instance_not_a_single_slot() { #[test] fn duplicate_launch_is_rejected_before_spawning() { - let (mut host, id) = host_with_instance("dup"); + let (mut host, id, _root) = host_with_instance("dup"); let _ = host.drain_events(); host.launches.push(live(&id, "s", 111)); @@ -101,9 +110,9 @@ fn duplicate_launch_is_rejected_before_spawning() { #[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()); + let (mut host, id, _root) = host_with_instance("pending"); + // 走 start_launch 用的同一个入口,模拟「已受理、后台线程还没回报 LaunchStarted」。 + host.mark_pending(&id); assert!(host.in_flight(&id)); let _ = host.drain_events(); @@ -121,8 +130,8 @@ fn pending_window_also_blocks_a_second_launch() { #[test] fn launch_started_registers_and_launch_end_clears() { - let (mut host, id) = host_with_instance("cycle"); - host.pending.insert(id.clone()); + let (mut host, id, _root) = host_with_instance("cycle"); + host.mark_pending(&id); host.handle(HostTask::LaunchStarted { id: id.clone(), @@ -145,7 +154,7 @@ fn launch_started_registers_and_launch_end_clears() { #[test] fn refresh_keeps_running_instances_running() { - let (mut host, id) = host_with_instance("refresh-run"); + let (mut host, id, _root) = host_with_instance("refresh-run"); host.launches.push(live(&id, "s", 4_242)); let _ = host.drain_events(); @@ -164,9 +173,10 @@ fn refresh_keeps_running_instances_running() { #[test] fn refresh_keeps_pending_as_busy_and_failed_as_failed() { - let (mut host, id) = host_with_instance("refresh-busy"); + let (mut host, id, _root) = host_with_instance("refresh-busy"); - host.pending.insert(id.clone()); + // Busy:走 start_launch 用的同一个入口(mark_pending),而不是直接改字段。 + host.mark_pending(&id); let pending = instances_from_events({ host.refresh_instances(); host.drain_events() @@ -182,8 +192,16 @@ fn refresh_keeps_pending_as_busy_and_failed_as_failed() { } )); - host.pending.remove(&id); - host.failed.insert(id.clone(), "崩溃(exit 1)".to_string()); + // Failed:走唯一真实写入路径 LaunchEnd(不是手动塞 failed 表)。 + host.handle(HostTask::LaunchEnd(Box::new(LaunchFinished { + id: id.clone(), + session: "20261031T120000-dead01".to_string(), + outcome: LogOutcome::Crashed, + summary: "崩溃(exit 1)".to_string(), + }))); + assert!(!host.pending.contains(&id), "LaunchEnd 必须清 pending"); + assert!(!host.in_flight(&id), "收尾后该实例要能被重新启动"); + let failed = instances_from_events({ host.refresh_instances(); host.drain_events() @@ -200,6 +218,42 @@ fn refresh_keeps_pending_as_busy_and_failed_as_failed() { ); } +#[test] +fn launch_end_before_launch_started_still_releases_the_instance() { + let (mut host, id, _root) = host_with_instance("fail-early"); + // build_request / spawn 失败的现场:start_launch 已标 pending、后台线程只发 LaunchEnd。 + host.mark_pending(&id); + assert!(host.in_flight(&id)); + + host.handle(HostTask::LaunchEnd(Box::new(LaunchFinished { + id: id.clone(), + session: String::new(), + outcome: LogOutcome::Crashed, + summary: "启动失败:找不到 Java".to_string(), + }))); + + assert!( + !host.pending.contains(&id), + "LaunchEnd 早于 LaunchStarted 时也必须清 pending(否则该实例本会话再也起不来)" + ); + assert!(!host.in_flight(&id), "失败后要能重试"); + + 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: "启动失败:找不到 Java".to_string() + }, + "早失败也要在磁贴上留下「失败/重试」,不能被 Idle 盖掉" + ); +} + /// 从事件队列里取最后一次 `InstancesChanged`。 fn instances_from_events(events: Vec) -> Vec { events diff --git a/crates/app/src/host/mod.rs b/crates/app/src/host/mod.rs index a75d82e..26e89a8 100644 --- a/crates/app/src/host/mod.rs +++ b/crates/app/src/host/mod.rs @@ -255,6 +255,14 @@ impl CoreHost { 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, "这个实例已经在启动/运行中"); @@ -269,8 +277,7 @@ impl CoreHost { self.toast(ToastKind::Warn, "这个实例已经在启动/运行中"); return; } - self.pending.insert(instance_id.clone()); - self.failed.remove(&instance_id); + self.mark_pending(&instance_id); if let Some(notice) = game::mode_change_notice(&self.env, &instance_id) { self.push_notice(notice); }