From 50b321ec64f26ed02aa81499594dc5c4f6b7bfe4 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sat, 31 Oct 2026 22:14:24 +0800 Subject: [PATCH] =?UTF-8?q?fix(app):=20LaunchEnd=20=E4=B9=9F=E6=B8=85=20pe?= =?UTF-8?q?nding=20=E5=B9=B6=E8=AE=B0=E5=A4=B1=E8=B4=A5=E5=8E=9F=E5=9B=A0?= =?UTF-8?q?=EF=BC=8C=E8=A1=A5=E6=97=A9=E5=A4=B1=E8=B4=A5=E5=9B=9E=E5=BD=92?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=EF=BC=88M43=20r3=20=E4=B8=A4=E6=9D=A1=20P2?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2-1(本轮新引入的回归):`pending` 只在 LaunchStarted 清除,但 `launch::start` 的两条早失败出口(build_request 失败 / spawn 失败——没装 Java、版本 JSON 坏就是 这两条)只发 LaunchEnd → pending 永久残留 → `in_flight` 恒真 → 该实例本会话再也 启动不了,磁贴锁在「启动中」。修:LaunchEnd 分支先 `pending.remove`,早失败与 正常收尾都释放。 P2-2(failed 零 insert):上一轮只在 note 里声称「失败原因进 failed 表」,代码没 落地(唯一 insert 缺失,refresh 的 Failed 分支是死代码,test 6 还靠手动塞表绕过)。 修:LaunchEnd 非 Ok 时 `failed.insert(id, summary)`、Ok 时 remove;refresh_instances 的 Failed 分支从此由真实路径喂数据。 测试(123 → 124,全部走真实入口): - 新增 `launch_end_before_launch_started_still_releases_the_instance`:mark_pending → handle(LaunchEnd Crashed) → pending 空、in_flight 假、refresh 后磁贴是 Failed{reason}(同时钉住 P2-1 与 P2-2)。 - test 6 重写:Busy 断言改走 `mark_pending`(start_launch 用的同一入口),Failed 断言改走 `handle(LaunchEnd Crashed)`,不再手动塞 failed 表。 - 所有测试的数据根加 `TempRoot` Drop 守卫,跑完不留临时目录。 --- crates/app/src/host/handle.rs | 9 +++ crates/app/src/host/launch_state_tests.rs | 84 +++++++++++++++++++---- crates/app/src/host/mod.rs | 11 ++- 3 files changed, 87 insertions(+), 17 deletions(-) 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); }