feat(host): M302 实例列表刷新搬出 UI 线程(扫盘进后台 + 代次丢弃过期结果)

`refresh_instances()` 以前把「扫盘」与「盖回运行时态」写在一起,而 `dispatch(RefreshAll)`
那一支是唯一没走 `job::spawn` 的(邻座 ListVersions / ListLoaderVersions 都走了)⇒ 同步跑在
UI 线程:官方根 200 版本约 236 ms、叠加 10 份外部档案 1.35 s(仓库内旧实测 M297)。

两半分家:
- `game::instances(&env)` 是纯 env 函数 ⇒ 搬进 `job::spawn("instances", …)`,回程是新载荷
  `HostTask::Instances { epoch, instances, elapsed_ms }`;
- 「按 `launches/pending/failed` 覆写 Running/Busy/Failed」要读 host 自己的表 ⇒ 留在 host 线程,
  挪到结果回来那一刻(`handle::apply_instances`),结论与之前逐字一致。

顺序:每次刷新领一个只增不减的代次(`game::Scan`),只采纳当代次那份。并发两次刷新 / 扫描途中
删实例 / 收尾触发第三次这三种交错下,先发后到的扫描一律丢(不发事件:清空再重填只是把卡顿换成
白屏)。不做线程取消 —— 扫描是纯读,没有值得取消的东西,丢结果比掐线程安全。

可观测:每次采纳打一行 `[instances] N 个实例 · 扫盘 X ms · epoch E · 丢旧代次 D 份`,
丢弃也各打一行;账本 `game::Scan` 可直接断言(实例数 / 耗时 / 丢包数)。

判据(都真构造:真临时根 + 真 profiles.json + 真线程,只有交付顺序是摆的):
- `a_refresh_returns_before_the_scan_is_done`(改回 inline 即红)
- `two_refreshes_in_flight_publish_only_the_newest`
- `an_older_scan_cannot_resurrect_what_a_newer_one_saw_deleted`
- `a_launch_end_refresh_beats_the_two_that_were_in_flight`
- app 侧 `a_refresh_in_flight_keeps_the_list_and_does_not_pin_the_clock`(不清空 + M298 的
  view_dirty 不永久置真)
- `launch_state_tests` 的 5 处 `refresh_instances()` 后补一行 `pump_instances()`(断言一字未动)
This commit is contained in:
AskaEth committed 2026-11-13 17:58:59 +08:00
1 parent 38fbd2a778
commit a0413a5f94
8 files changed
+753 -28

No files matched your search

+70 -1
View File
@@ -2,7 +2,10 @@
//!
//! 父模块的私有字段对子模块可见,所以这里直接读写 `self.tx` / `self.launches` 等。
use crate::dto::{AppEvent, CoreEvent, InstanceRuntime, LaunchFailureCause, LogOutcome, PackEvent};
use crate::dto::{
AppEvent, CoreEvent, InstanceRuntime, InstanceSummary, LaunchFailureCause, LogOutcome,
PackEvent,
};
use crate::toast::ToastKind;
use super::{addon, logs, mods, CoreHost, HostTask, LiveLaunch};
@@ -15,6 +18,12 @@ impl CoreHost {
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,
@@ -243,6 +252,66 @@ impl CoreHost {
}
}
/// 把一份**当代次**的扫盘结果落地:丢旧代次 → 盖运行时态 → 记账 → 发快照。
///
/// ## 运行时态为什么在这里盖(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) {
// 丢掉的那一份也记一行:交错真的发生时,光看「采纳」那一行是看不出来的
eprintln!(
"[instances] 丢掉过期扫描(epoch {epoch} ≠ 当代次 {},累计丢 {} 份)",
self.instances_scan.epoch(),
self.instances_scan.dropped()
);
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 {