复审 P2-1:扫盘搬后台之后,`App::dispatch(RefreshAll)` 返回时快照还没到(`pump` 只抽队列、
不 `tick`),**首帧必然**在快照之前。改前那一帧画的是「计数 0 + 还没有实例 + 实心导入 CTA」
——一句假话,而且 CTA 能点。改后三态分开:
- 真源放 presenter:`dto::InstancePhase { Scanning, Stalled, Ready }`,`AppState::instances_phase`
在 `CoreEvent::InstancesChanged` 落地时置 `Ready`;**不从 `instances.is_empty()` 推**(那就是 bug 形态)。
- `Scanning` = 首个快照未到:详情列画「正在扫描实例…」占位(新 key `InstancesScanning`,golden 两语),
列头**不写计数**(藏起来,不写 0 也不写「—」),**没有**空态 CTA。
- `Stalled` = 有界兜底:`host::game::SCAN_STALL = 15 s`(= 仓库内最坏实测 1.35 s 的约 11 倍,
理由在常量文档里)。超时只**报**一次、不掐线程;迟到的结果照样落地(代次没变)。
那一屏给一枚「重新扫描」→ `InstanceMessage::RetryScan` → 新命令 `AppCommand::RefreshInstances`
(只重扫实例目录,不重拉版本清单)。那一支在 `selected_instance_id()` 守卫**之前**——冷启动没有选中项。
- `Ready` = 收到过快照(空列表也算):这时才轮到 M273 的两种空态(没选中 / 一个都没有)。
- 已就绪时**不发** `Scanning`:否则每次刷新列表都退回占位(把卡顿换成闪屏)。
判据(都能红,变异原文见报告):
- `pages/instances/tests/cold_boot.rs`:真控件树(未就绪 7 枚按钮 = 有实例那一屏,零实例 = 8 枚,
多的那枚就是 CTA;每行 2 条文字节点的差分数行数)+ 源码结构(`Scanning`/`Stalled` 两支不碰
`detail_empty` 与空态文案、超时那支接 `RetryScan`、三个 key 真的被读)。
- `app/update/instances/scan_phase.rs`:首帧 = `Scanning`、空快照 = `Ready`、`Stalled` 经重试回到
`Scanning`、重试只派 `RefreshInstances`。夹具 `ULL_INSTANCE_PHASE` 只给出图(与 `ULL_SAVES_FIXTURE` 同一条纪律)。
顺手收 N1~N4(全是措辞 / 观测口径,不动行为):
- N1 两处 docstring 不再声称「`admit` 写成 `>=` 会红」(实测等价),改成「去掉或恒真才红」;
- N2 收尾用例那条 Idle 断言的失败消息改准(它不区分新旧快照,区分靠条数/代次断言);
- N3 boot 双刷新必然丢 1 份 ⇒ 丢弃日志分成「此前没有快照落地(启动期,非交错)」与「已有落地(真交错)」,
`Scan::dropped` 的文档登记这条已知噪音;
- N4 app 用例 docstring 收窄成「一拍无事件不清表、不钉时钟」(它绕过 `App::dispatch`,抓不住那 5 行)。
拆文件守 400 行:`detail/scan.rs`(分派 + 占位)、`refresh_stall_tests.rs`(空根 / 超时)、
`app/update/instances/scan_phase.rs`(app 级三态用例);`i18n` 的 `PAGE_SOURCES` 同步加上新页面文件。
340 lines
17 KiB
Rust
340 lines
17 KiB
Rust
//! `HostTask` → `CoreEvent` 的翻译(从 `host/mod.rs` 拆出,守 400 行硬限)。
|
||
//!
|
||
//! 父模块的私有字段对子模块可见,所以这里直接读写 `self.tx` / `self.launches` 等。
|
||
|
||
use crate::dto::{
|
||
AppEvent, CoreEvent, InstanceRuntime, InstanceSummary, LaunchFailureCause, LogOutcome,
|
||
PackEvent,
|
||
};
|
||
use crate::toast::ToastKind;
|
||
|
||
use super::{addon, logs, mods, 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}"));
|
||
}
|
||
// M302:实例扫描回程。**代次不符一律丢**(先发后到的扫描可能带着已被删掉的实例)。
|
||
HostTask::Instances {
|
||
epoch,
|
||
instances,
|
||
elapsed_ms,
|
||
} => self.apply_instances(epoch, instances, elapsed_ms),
|
||
HostTask::LoaderVersions {
|
||
kind,
|
||
mc_version,
|
||
result,
|
||
} => {
|
||
// 失败既要落进快照(页面禁用确认 + 显示具名原因),也要一条 Toast:
|
||
// 用户可能正盯着上面的版本列表,下拉那一行的红字容易被忽略。
|
||
if let Err(reason) = &result {
|
||
self.toast(
|
||
ToastKind::Warn,
|
||
format!("取不到 {} 的加载器版本:{reason}", kind.label()),
|
||
);
|
||
}
|
||
self.send(CoreEvent::LoaderVersions {
|
||
kind,
|
||
mc_version,
|
||
result,
|
||
});
|
||
}
|
||
// M109:内容库检索。`Err` 不弹 toast——页面自己有失败态(四态之一),
|
||
// toast 只做「顺手提一句」会把可重试的错误变成一次性噪音。
|
||
HostTask::Content(result) => match *result {
|
||
Ok(results) => self.send(CoreEvent::ContentResults(Box::new(results))),
|
||
Err(failure) => self.send(CoreEvent::ContentFailed(Box::new(failure))),
|
||
},
|
||
// M162:详情/版本列表(与检索同一套失败分类)
|
||
HostTask::ContentDetail { project, result } => {
|
||
self.send(CoreEvent::ContentDetail { project, result });
|
||
}
|
||
// M113: import/export —— 导入/导出事件带代次,取消后回来的旧事件一律丢掉
|
||
HostTask::Pack { event, epoch } => {
|
||
// pack 用**自己**的代次(复审 r1 P2-7):共用安装的计数器会让
|
||
// 「关一次导入面板」把下载页正在跑的安装静默丢掉。
|
||
if epoch != addon::pack_epoch(&self.env) {
|
||
// 唯一例外(M195 r3):**刚被取消的那一次**交上来的终态报告。取消会把代次
|
||
// +1,若连报告一起丢,界面就永远学不到"取消太晚、其实已经装完/回滚没做完"
|
||
// 这个真相——只剩一句无条件的"已停手并回滚"。
|
||
// 只放行 `Report`、且同一代次只放行一条;进度事件照旧按代次丢。
|
||
if !(matches!(&*event, PackEvent::Report(_))
|
||
&& addon::take_terminal_epoch(&self.env, epoch))
|
||
{
|
||
return;
|
||
}
|
||
}
|
||
// 导入成功可能登记了新实例:与 `InstallDone` 同一条刷新(复审 r1 P2-1),
|
||
// 否则新实例要等下一次无关的 `RefreshAll` 才出现在实例页与目标 chips 里。
|
||
if matches!(*event, PackEvent::Report(_)) {
|
||
self.refresh_instances();
|
||
}
|
||
self.send(CoreEvent::Pack(event));
|
||
}
|
||
// M105 的形状(代次丢包)为准:旧任务(已取消 / 被下一轮顶掉)的进度一律丢掉。
|
||
// 这是「点了取消没反应」的根因:取消时 `InstallProgress(None)` 刚把面板收回,
|
||
// 旧任务的下一条「下载中」事件立刻又把它点亮。
|
||
HostTask::Adopt { event, epoch } => self.handle_adopt(*event, epoch),
|
||
HostTask::Install { progress, epoch } => {
|
||
if epoch != self.install_epoch {
|
||
return;
|
||
}
|
||
self.install = Some(*progress.clone());
|
||
self.send(CoreEvent::InstallProgress(Some(*progress)));
|
||
}
|
||
HostTask::InstallDone {
|
||
progress,
|
||
result,
|
||
epoch,
|
||
} => {
|
||
// 收尾同样看代次:取消之后那次「已取消」的收尾不该再动面板(面板早就回弹了),
|
||
// 也不该清掉**新一轮**安装的取消句柄。
|
||
if epoch != self.install_epoch {
|
||
return;
|
||
}
|
||
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)));
|
||
}
|
||
// 只把**一句人读原因**弹出去:大类已经随 `progress.failure_kind` 进了 DTO,
|
||
// 面板/调用方按大类决定怎么画(离线 vs 查无此项)。
|
||
Err(failure) => self.toast(ToastKind::Error, failure.detail),
|
||
}
|
||
}
|
||
HostTask::Accounts(accounts) => {
|
||
self.accounts = accounts.clone();
|
||
self.send(CoreEvent::AccountsChanged(accounts));
|
||
self.refresh_instances();
|
||
}
|
||
HostTask::Login(view) => self.send(CoreEvent::DeviceLoginChanged(*view)),
|
||
HostTask::Authlib(view) => self.send(CoreEvent::AuthlibLoginChanged(*view)),
|
||
HostTask::Avatar { id, skin } => {
|
||
// 抓到/没抓到都记下来(`None` = 试过了,不再重试),再把列表盖好发出去
|
||
self.skins.insert(id, skin);
|
||
self.attach_skins();
|
||
self.send(CoreEvent::AccountsChanged(self.accounts.clone()));
|
||
}
|
||
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
|
||
.layout
|
||
.game_logs()
|
||
.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 盖掉。
|
||
//
|
||
// 原因**按形状**组装(复审 P1-1):`failure` 有值 = 进程没起来(阶段失败);
|
||
// 没值 = 跑起来了,那就必须看 `outcome`(崩溃 / 疑似静默失败),
|
||
// 不许再拿「启动游戏进程失败」去顶——那句话在进程明明起来时说不过去。
|
||
let cause = LaunchFailureCause::of(
|
||
finished.outcome,
|
||
finished.failure.as_ref(),
|
||
finished.exit_code,
|
||
);
|
||
self.failed.insert(finished.id.clone(), cause.clone());
|
||
InstanceRuntime::Failed { cause }
|
||
};
|
||
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),
|
||
// M159:更新回报(检查结论 / 应用结局)→ 快照整体替换
|
||
HostTask::Update(report) => {
|
||
self.update.adopt(*report);
|
||
self.publish_update();
|
||
}
|
||
// M224:本地 Mod 列表 / 启停。列表成功 ⇒ 整份快照替换;失败 ⇒ 具名原因给 app 求文案。
|
||
// 启停**不做乐观回显**:开关跟着这一份快照翻(与启动/停止同一条纪律)。
|
||
HostTask::Mods(report) => {
|
||
let mods::Report {
|
||
instance,
|
||
list,
|
||
toggled,
|
||
} = *report;
|
||
if let Some(toggled) = toggled {
|
||
// 只留一行日志:开关自己会跟着快照翻,再弹 Toast 是噪音。
|
||
eprintln!(
|
||
"[mods] {instance}: {} → {} ({})",
|
||
toggled.from,
|
||
toggled.to,
|
||
if toggled.enabled {
|
||
"enabled"
|
||
} else {
|
||
"disabled"
|
||
}
|
||
);
|
||
}
|
||
self.send(CoreEvent::Mods(Box::new(list)));
|
||
}
|
||
// M235:实例存档 / 截图的只读列表。成功 ⇒ 整份快照;失败 ⇒ 具名原因给 app 求文案。
|
||
// 「打开文件夹」也是这条:拉起文件管理器之后再回一份新鲜快照(用户在那边删了世界,
|
||
// 切回来这一拍就跟上)。
|
||
HostTask::Saves(report) => {
|
||
self.send(CoreEvent::Saves(Box::new(report.list)));
|
||
}
|
||
HostTask::Toast(toast) => self.events.push_back(AppEvent::Toast(*toast)),
|
||
}
|
||
}
|
||
|
||
/// 把一份**当代次**的扫盘结果落地:丢旧代次 → 盖运行时态 → 记账 → 发快照。
|
||
///
|
||
/// ## 运行时态为什么在这里盖(M302)
|
||
///
|
||
/// `game::instances` 只读盘,每条都是 `Idle`;在跑的实例必须按 host 自己的启动表覆写
|
||
/// `Running{pid}`/`Busy`/`Failed`,否则任意一次刷新(装版本成功、加删账户、另一实例收尾)
|
||
/// 都会把磁贴打回 Idle:停止键与「运行中」条消失,进程还在跑却再也停不下来。
|
||
///
|
||
/// 盖的时机是**结果回来那一刻**而不是「发请求那一刻」:扫描期间发生的启动/收尾已经落在
|
||
/// host 上了,这一读就是最新的(老实现同步扫,两件事本来就是同一刻)。所以异步化没有改变
|
||
/// 这条结论——`launch_state_tests` 的 Running/Busy/Failed 三条照旧成立。
|
||
///
|
||
/// ## 代次
|
||
///
|
||
/// 不符就整份丢掉并计数:那份结果的盘面已经过期,采纳它会让「删掉的实例又回来」。
|
||
/// 丢完就 return,**不发事件**——界面上的旧快照原样留着(清空再重填只是把卡顿换成白屏)。
|
||
fn apply_instances(
|
||
&mut self,
|
||
epoch: u64,
|
||
mut instances: Vec<InstanceSummary>,
|
||
elapsed_ms: u64,
|
||
) {
|
||
if !self.instances_scan.admit(epoch) {
|
||
// 丢掉的那一份也记一行:交错真的发生时,光看「采纳」那一行是看不出来的。
|
||
//
|
||
// 两种成因必须分开写清楚(复审 N3):`CoreHost::build` 的 `refresh_all` 与 `on_boot`
|
||
// 的 `AppCommand::RefreshAll` 各领一代,所以**每次冷启动都必然丢 1 份**——那是既有
|
||
// 双刷新的产物,不是交错。判据是「此前有没有任何快照落地过」。
|
||
eprintln!(
|
||
"[instances] 丢弃旧代次扫描(epoch {epoch} ≠ 当代次 {},累计丢 {} 份{})",
|
||
self.instances_scan.epoch(),
|
||
self.instances_scan.dropped(),
|
||
if self.instances_scan.adopted().is_none() {
|
||
";此前还没有任何快照落地 ⇒ 启动期双刷新,不是交错"
|
||
} else {
|
||
";此前已有快照落地 ⇒ 这是真交错"
|
||
}
|
||
);
|
||
return;
|
||
}
|
||
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(cause) = self.failed.get(&instance.id) {
|
||
instance.runtime = InstanceRuntime::Failed {
|
||
cause: cause.clone(),
|
||
};
|
||
}
|
||
}
|
||
let count = instances.len();
|
||
self.instances_scan.adopt(epoch, count, elapsed_ms);
|
||
// M302 的可观测口径:一次刷新一行(前缀与 `[mods]` / `[auth]` 同一种形状,好 grep)。
|
||
// 「扫了几条、扫了多久、丢过几份」三样都在这一行里——回归时它比"感觉快了"硬。
|
||
eprintln!(
|
||
"[instances] {count} 个实例 · 扫盘 {elapsed_ms} ms · epoch {epoch} · 丢旧代次 {} 份",
|
||
self.instances_scan.dropped()
|
||
);
|
||
self.send(CoreEvent::InstancesChanged(instances));
|
||
}
|
||
|
||
/// 尾随运行中会话(每 `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,
|
||
}));
|
||
}
|
||
}
|