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 守卫,跑完不留临时目录。
146 lines
6.3 KiB
Rust
146 lines
6.3 KiB
Rust
//! `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.pending.remove(&id);
|
|
self.failed.remove(&id);
|
|
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) => {
|
|
// 收尾可能发生在 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(),
|
|
}
|
|
};
|
|
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,
|
|
}));
|
|
}
|
|
}
|