From ecded9bef06bb19dce239cfae4196de2d1a9a709 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sat, 31 Oct 2026 21:49:05 +0800 Subject: [PATCH] =?UTF-8?q?fix(app):=20=E5=81=9C=E6=AD=A2=E5=AE=9E?= =?UTF-8?q?=E4=BE=8B=E6=8C=89=E5=AE=9E=E4=BE=8B=20id=20=E6=89=BE=20pid?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E5=8F=91=E5=90=AF=E5=8A=A8=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E6=9D=80=E9=94=99=E8=BF=9B=E7=A8=8B=E6=A0=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reviewer-guiwire r1 的 P2-1:`running_pid` 只记「最近一次启动」的单槽 pid, 并发启动 A、B 后点 A 的停止键会 tree_kill 掉 B;且实时尾随在 B 启动时被抢占, A 的日志再也没人跟。兜底分支也永远不命中(`game::instances` 每次重建都把 runtime 置 Idle)。 改法: - 单槽 `launch_pid` → `launches: Vec`, 停止/收尾/尾随判定都按实例 id 或会话 id 精确匹配; - 同一实例重复点启动直接拒绝(「已经在启动/运行中」),不再起第二个进程; - 收尾只拆自己那场的尾随,并发时另一实例的实时日志不被掐断; - 实时尾随仍是单窗口(一次只跟一场,另一场收尾后可从 JSONL 回放)——这是 有意的 UI 取舍,不是漏判。 --- crates/app/src/host/handle.rs | 134 +++++++++++++++++++++++++++++++ crates/app/src/host/mod.rs | 146 +++++++--------------------------- 2 files changed, 161 insertions(+), 119 deletions(-) create mode 100644 crates/app/src/host/handle.rs diff --git a/crates/app/src/host/handle.rs b/crates/app/src/host/handle.rs new file mode 100644 index 0000000..1df0bec --- /dev/null +++ b/crates/app/src/host/handle.rs @@ -0,0 +1,134 @@ +//! `HostTask` → `CoreEvent` 的翻译(从 `host/mod.rs` 拆出,守 400 行硬限)。 +//! +//! 父模块的私有字段对子模块可见,所以这里直接读写 `self.tx` / `self.launches` 等。 + +use crate::dto::{AppEvent, CoreEvent, InstanceRuntime, LogOutcome}; +use crate::toast::ToastKind; + +use super::{logs, CoreHost, HostTask, LiveLaunch}; + +impl CoreHost { + /// 后台消息 → 事件。 + pub(super) fn handle(&mut self, task: HostTask) { + match task { + HostTask::Versions(Ok(versions)) => self.send(CoreEvent::VersionListChanged(versions)), + HostTask::Versions(Err(reason)) => { + self.toast(ToastKind::Warn, format!("拉取版本清单失败:{reason}")); + } + HostTask::Install(progress) => { + self.install = Some(*progress.clone()); + self.send(CoreEvent::InstallProgress(Some(*progress))); + } + HostTask::InstallDone { progress, result } => { + self.install_cancel = None; + self.install = Some(*progress.clone()); + self.send(CoreEvent::InstallProgress(Some(*progress))); + match result { + Ok(_) => { + self.refresh_instances(); + self.send(CoreEvent::LogSessionsChanged(logs::sessions(&self.env))); + } + Err(reason) => self.toast(ToastKind::Error, reason), + } + } + HostTask::Accounts(accounts) => { + self.accounts = accounts.clone(); + self.send(CoreEvent::AccountsChanged(accounts)); + self.refresh_instances(); + } + HostTask::Login(view) => self.send(CoreEvent::DeviceLoginChanged(*view)), + HostTask::Java(page) => self.send(CoreEvent::JavaChanged(*page)), + HostTask::Logs { + sessions, + lines, + diagnosis, + } => { + self.send(CoreEvent::LogSessionsChanged(sessions)); + self.send(CoreEvent::LogLinesChanged(lines)); + if let Some(report) = diagnosis { + self.send(CoreEvent::DiagnosisReady(report)); + } + } + HostTask::Sessions(sessions) => self.send(CoreEvent::LogSessionsChanged(sessions)), + HostTask::Tail(batch) => { + if batch.running { + self.send(CoreEvent::LogLinesChanged(batch.lines)); + } else { + self.send(CoreEvent::LogAppended(*batch)); + } + } + HostTask::LaunchStarted { + id, + session_id, + pid, + } => { + self.launches.retain(|live| live.instance != id); + self.launches.push(LiveLaunch { + instance: id.clone(), + session: session_id.clone(), + pid, + }); + // 实时尾随一次只跟一场(下载页/日志页共用一个滚动窗口); + // 已有别的实例在尾随时不抢占——它的日志仍在 JSONL 里,收尾后可回放。 + if self.tail.session().is_none() { + self.tail.attach( + self.env + .sessions_root() + .join("game") + .join(format!("{session_id}.jsonl")), + &session_id, + ); + } + self.send(CoreEvent::InstanceRuntimeChanged { + id, + runtime: InstanceRuntime::Running { pid }, + }); + } + HostTask::Runtime { id, runtime } => { + self.send(CoreEvent::InstanceRuntimeChanged { id, runtime }); + } + HostTask::LaunchEnd(finished) => { + self.launches.retain(|live| live.instance != finished.id); + let runtime = if finished.outcome == LogOutcome::Ok { + InstanceRuntime::Idle + } else { + InstanceRuntime::Failed { + reason: finished.summary.clone(), + } + }; + self.send(CoreEvent::InstanceRuntimeChanged { + id: finished.id.clone(), + runtime, + }); + self.send(CoreEvent::LaunchFinished((*finished).clone())); + // 只拆自己那场的尾随:并发时另一实例的实时日志不该被这次收尾掐掉。 + if self.tail.session() == Some(finished.session.as_str()) { + self.tail.detach(); + } + self.send(CoreEvent::LogSessionsChanged(logs::sessions(&self.env))); + self.refresh_instances(); + } + HostTask::Crash(report) => { + self.send(CoreEvent::DiagnosisReady(*report)); + } + HostTask::Notice(notice) => self.push_notice(*notice), + HostTask::Toast(toast) => self.events.push_back(AppEvent::Toast(*toast)), + } + } + + /// 尾随运行中会话(每 `TAIL_POLL_TICKS` 拍一次)。 + pub(super) fn poll_tail(&mut self) { + let Some((session, lines)) = self.tail.poll() else { + return; + }; + if lines.is_empty() { + return; + } + let running = self.launches.iter().any(|live| live.session == session); + self.send(CoreEvent::LogAppended(crate::dto::LiveLogBatch { + session, + lines, + running, + })); + } +} diff --git a/crates/app/src/host/mod.rs b/crates/app/src/host/mod.rs index 8c1dee0..76f105c 100644 --- a/crates/app/src/host/mod.rs +++ b/crates/app/src/host/mod.rs @@ -17,6 +17,7 @@ mod dispatch; mod engine; pub mod env; mod game; +mod handle; mod identity; mod install; mod java; @@ -32,7 +33,7 @@ use std::time::Duration; use crate::dto::{ AccountView, AppCommand, AppEvent, Backend, CoreEvent, DiagnosisReport, InstallProgressView, - InstanceRuntime, JavaChainStep, LogOutcome, NoticeView, + InstanceRuntime, JavaChainStep, NoticeView, }; use crate::mock::MockBackend; use crate::toast::{Toast, ToastKind}; @@ -47,6 +48,14 @@ const MAX_TASKS_PER_TICK: usize = 64; /// 实时日志尾随间隔(拍数,33ms/拍 → 约 600ms 一次)。 const TAIL_POLL_TICKS: u64 = 18; +/// 一次在跑的启动(并发启动时按实例 id 区分,停止/尾随都不会串到别的实例)。 +#[derive(Debug, Clone, PartialEq, Eq)] +struct LiveLaunch { + instance: String, + session: String, + pid: u32, +} + /// 真实 host。 pub struct CoreHost { env: HostEnv, @@ -63,8 +72,8 @@ pub struct CoreHost { java_message: String, install: Option, notices: Vec, - /// 最近一次启动的 pid(停止按钮用) - launch_pid: Option, + /// 在跑的启动(实例 → 会话 → pid;支持多个实例同时启动,停止按 id 找正确的 pid) + launches: Vec, /// 实时日志尾随器 tail: logs::Tail, /// AI 对话的临时后端(agent GUI host 落地前的占位) @@ -97,7 +106,7 @@ impl CoreHost { java_message: String::new(), install: None, notices: Vec::new(), - launch_pid: None, + launches: Vec::new(), tail: logs::Tail::default(), chat: MockBackend::default(), install_cancel: None, @@ -202,6 +211,14 @@ impl CoreHost { return; }; let instance_id = target.id.clone(); + if self + .launches + .iter() + .any(|live| live.instance == instance_id) + { + self.toast(ToastKind::Warn, "这个实例已经在启动/运行中"); + return; + } if let Some(notice) = game::mode_change_notice(&self.env, &instance_id) { self.push_notice(notice); } @@ -215,10 +232,13 @@ impl CoreHost { launch::start(self.env.clone(), self.tx.clone(), target); } - /// 一台在跑的实例的 pid(从快照或最近一次启动里取)。 + /// 某实例在跑的 pid(按实例 id 查启动表;不再有「最近一次启动」这种单槽)。 + /// + /// 兜底分支读实例快照的 `Running { pid }`:host 自己发的运行时事件会写回快照, + /// 但快照是重建的(`game::instances` 每次 Idle),所以这层只是给将来留的口。 fn running_pid(&self, id: &str) -> Option { - if let Some(pid) = self.launch_pid { - return Some(pid); + if let Some(live) = self.launches.iter().find(|live| live.instance == id) { + return Some(live.pid); } game::instances(&self.env) .into_iter() @@ -228,118 +248,6 @@ impl CoreHost { _ => None, }) } - - /// 后台消息 → 事件。 - fn handle(&mut self, task: HostTask) { - match task { - HostTask::Versions(Ok(versions)) => self.send(CoreEvent::VersionListChanged(versions)), - HostTask::Versions(Err(reason)) => { - self.toast(ToastKind::Warn, format!("拉取版本清单失败:{reason}")); - } - HostTask::Install(progress) => { - self.install = Some(*progress.clone()); - self.send(CoreEvent::InstallProgress(Some(*progress))); - } - HostTask::InstallDone { progress, result } => { - self.install_cancel = None; - self.install = Some(*progress.clone()); - self.send(CoreEvent::InstallProgress(Some(*progress))); - match result { - Ok(_) => { - self.refresh_instances(); - self.send(CoreEvent::LogSessionsChanged(logs::sessions(&self.env))); - } - Err(reason) => self.toast(ToastKind::Error, reason), - } - } - HostTask::Accounts(accounts) => { - self.accounts = accounts.clone(); - self.send(CoreEvent::AccountsChanged(accounts)); - self.refresh_instances(); - } - HostTask::Login(view) => self.send(CoreEvent::DeviceLoginChanged(*view)), - HostTask::Java(page) => self.send(CoreEvent::JavaChanged(*page)), - HostTask::Logs { - sessions, - lines, - diagnosis, - } => { - self.send(CoreEvent::LogSessionsChanged(sessions)); - self.send(CoreEvent::LogLinesChanged(lines)); - if let Some(report) = diagnosis { - self.send(CoreEvent::DiagnosisReady(report)); - } - } - HostTask::Sessions(sessions) => self.send(CoreEvent::LogSessionsChanged(sessions)), - HostTask::Tail(batch) => { - if batch.running { - self.send(CoreEvent::LogLinesChanged(batch.lines)); - } else { - self.send(CoreEvent::LogAppended(*batch)); - } - } - HostTask::LaunchStarted { - id, - session_id, - pid, - } => { - self.launch_pid = Some(pid); - self.tail.attach( - self.env - .sessions_root() - .join("game") - .join(format!("{session_id}.jsonl")), - &session_id, - ); - self.send(CoreEvent::InstanceRuntimeChanged { - id, - runtime: InstanceRuntime::Running { pid }, - }); - } - HostTask::Runtime { id, runtime } => { - self.send(CoreEvent::InstanceRuntimeChanged { id, runtime }); - } - HostTask::LaunchEnd(finished) => { - self.launch_pid = None; - let runtime = if finished.outcome == LogOutcome::Ok { - InstanceRuntime::Idle - } else { - InstanceRuntime::Failed { - reason: finished.summary.clone(), - } - }; - self.send(CoreEvent::InstanceRuntimeChanged { - id: finished.id.clone(), - runtime, - }); - self.send(CoreEvent::LaunchFinished((*finished).clone())); - self.tail.detach(); - self.send(CoreEvent::LogSessionsChanged(logs::sessions(&self.env))); - self.refresh_instances(); - } - HostTask::Crash(report) => { - self.send(CoreEvent::DiagnosisReady(*report)); - } - HostTask::Notice(notice) => self.push_notice(*notice), - HostTask::Toast(toast) => self.events.push_back(AppEvent::Toast(*toast)), - } - } - - /// 尾随运行中会话(每 `TAIL_POLL_TICKS` 拍一次)。 - fn poll_tail(&mut self) { - let Some((session, lines)) = self.tail.poll() else { - return; - }; - if lines.is_empty() { - return; - } - let running = self.launch_pid.is_some(); - self.send(CoreEvent::LogAppended(crate::dto::LiveLogBatch { - session, - lines, - running, - })); - } } impl Backend for CoreHost {