Merge branch 'feat/m304-launchscan-launch-path-stops-sync-s'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
GPU evidence / GPU 证据 — 真实产物判据 (push) Canceled after 0s
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Canceled after 0s
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Canceled after 0s
Dev Guards / GPU 证据门禁(同 main 口径;无 GPU 只跑自测) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
GPU evidence / GPU 证据 — 真实产物判据 (push) Canceled after 0s
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Canceled after 0s
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Canceled after 0s
Dev Guards / GPU 证据门禁(同 main 口径;无 GPU 只跑自测) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
This commit is contained in:
commit
b9536c5833
12 files changed
+962
-89
No files matched your search
@@ -8,6 +8,7 @@
|
|||||||
//! | [`an_empty_snapshot_still_counts_as_ready`] | 空快照被当成「未就绪」(用户永远看着「正在扫描」) | 空快照从哪来(presenter 的 `refresh_tests` 用真空盘钉) |
|
//! | [`an_empty_snapshot_still_counts_as_ready`] | 空快照被当成「未就绪」(用户永远看着「正在扫描」) | 空快照从哪来(presenter 的 `refresh_tests` 用真空盘钉) |
|
||||||
//! | [`a_stalled_scan_goes_back_to_scanning_on_retry`] | 超时之后没有出路(`Stalled` 不会被新一轮扫描收回 `Scanning`) | 15 s 的阈值本身(`host::game::SCAN_STALL`,单测不等真实时间) |
|
//! | [`a_stalled_scan_goes_back_to_scanning_on_retry`] | 超时之后没有出路(`Stalled` 不会被新一轮扫描收回 `Scanning`) | 15 s 的阈值本身(`host::game::SCAN_STALL`,单测不等真实时间) |
|
||||||
//! | [`retry_scan_dispatches_a_scan_only_refresh`] | 重试按钮派错命令 / 被「必须有选中实例」的守卫挡住(冷启动时根本没有选中项) | 命令真的到达 host(`host::dispatch` 的 `RefreshInstances` 分支由 presenter 的穷尽 match 守) |
|
//! | [`retry_scan_dispatches_a_scan_only_refresh`] | 重试按钮派错命令 / 被「必须有选中实例」的守卫挡住(冷启动时根本没有选中项) | 命令真的到达 host(`host::dispatch` 的 `RefreshInstances` 分支由 presenter 的穷尽 match 守) |
|
||||||
|
//! | [`a_real_host_flips_the_detail_button_in_the_same_beat`] | 真 host + 真 app:点启动后**同一拍**(`advance` 之前)没进运行态(把 `Busy` 拖到回程才发) | 像素;受理之后那条后台线程的成败(不在本条判据里) |
|
||||||
|
|
||||||
//! M302 的**首屏三态接线**:presenter 的 `InstancePhase` → 页面三态,以及「重新扫描」的落点。
|
//! M302 的**首屏三态接线**:presenter 的 `InstancePhase` → 页面三态,以及「重新扫描」的落点。
|
||||||
//!
|
//!
|
||||||
@@ -261,3 +262,105 @@ fn a_refresh_after_ready_keeps_the_list_and_the_tree() {
|
|||||||
);
|
);
|
||||||
assert!(!app.view.instances.rows.is_empty(), "列表也不许被清空");
|
assert!(!app.view.instances.rows.is_empty(), "列表也不许被清空");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **M304 的端到端判据**:真 `CoreHost` + 真数据根 + 真 app —— 点启动 ⇒ **同一拍**(`advance` 之前)
|
||||||
|
/// 详情页的按钮已经翻成「可停止」。
|
||||||
|
///
|
||||||
|
/// 判据落在**状态**上:`app.dispatch(LaunchInstance)` 只做「派发 + 抽干**当下**已有的事件」,之后
|
||||||
|
/// 一次 `advance`(= `backend.tick()`)都不调。所以这条绿的意思是「`Busy` 在 host 的 `dispatch`
|
||||||
|
/// 返回前就发出来了」,而不是「等某条后台线程回报」。
|
||||||
|
///
|
||||||
|
/// 抓得住:把 host 那条 `InstanceRuntimeChanged{Busy}` 挪进启动线程(= 只有回程才发)——那时抽干
|
||||||
|
/// 到的是空队列,`can_stop` 仍是 false,这条红(presenter 侧的同款判据在
|
||||||
|
/// `host::launch::target_tests::clicking_launch_shows_busy_in_the_same_beat`)。
|
||||||
|
/// 抓不住:真鼠标点击(从 `AppCommand` 起跑);受理之后装配/启动的成败(这一条只判「同一拍」)。
|
||||||
|
#[test]
|
||||||
|
fn a_real_host_flips_the_detail_button_in_the_same_beat() {
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use unlucky_core::game::install::instance::write_profiles;
|
||||||
|
use unlucky_core::game::{InstanceProfile, ProfileId, ProfileList};
|
||||||
|
use unlucky_presenter::host::{CoreHost, HostEnv};
|
||||||
|
use unlucky_presenter::router::Route;
|
||||||
|
|
||||||
|
let dir = std::env::temp_dir().join(format!("ull-m304-launch-beat-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(dir.join(".ULL/config")).expect("config 目录");
|
||||||
|
|
||||||
|
let env = HostEnv::at_root(&dir);
|
||||||
|
// 一条实例档案。**不造版本本体**:装配会失败——但那是**受理之后**的事,本条判的是受理那一拍。
|
||||||
|
let profile = InstanceProfile {
|
||||||
|
id: ProfileId::generate(),
|
||||||
|
name: "demo".to_string(),
|
||||||
|
version_id: "1.20.1".to_string(),
|
||||||
|
game_dir: None,
|
||||||
|
running_dir: None,
|
||||||
|
java_path: None,
|
||||||
|
created_at: None,
|
||||||
|
last_played_at: None,
|
||||||
|
extra: std::collections::BTreeMap::new(),
|
||||||
|
};
|
||||||
|
let id = profile.id.as_str().to_string();
|
||||||
|
write_profiles(
|
||||||
|
&env.layout,
|
||||||
|
&ProfileList {
|
||||||
|
profiles: vec![profile],
|
||||||
|
..ProfileList::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("写 profiles.json");
|
||||||
|
|
||||||
|
let mut app = App::with_presenter(
|
||||||
|
Options {
|
||||||
|
data_dir: Some(dir.clone()),
|
||||||
|
..Options::default()
|
||||||
|
},
|
||||||
|
FAMILIES,
|
||||||
|
SchemeFlavor::Light,
|
||||||
|
Box::new(CoreHost::new(env)),
|
||||||
|
SettingsBridge::with_path(unlucky_presenter::backend::config_path(&dir)),
|
||||||
|
);
|
||||||
|
app.dispatch(AppCommand::RefreshAll);
|
||||||
|
// 先 `advance` 到**第一份快照落地**(M302 之后扫盘在后台线程,这一拍必须等)
|
||||||
|
for _ in 0..200 {
|
||||||
|
if app.state.tiles().iter().any(|cell| cell.instance.id == id) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
app.advance(Duration::from_millis(33));
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
app.state.tiles().iter().any(|cell| cell.instance.id == id),
|
||||||
|
"前提:快照里必须已经有这条实例(等了 200 拍)"
|
||||||
|
);
|
||||||
|
app.state.navigate(Route::Detail(id.clone()));
|
||||||
|
app.refresh_view();
|
||||||
|
let before = app.view.instances.detail.clone().expect("有选中项");
|
||||||
|
assert!(
|
||||||
|
before.can_launch && !before.can_stop,
|
||||||
|
"前提:空闲态可启动不可停止({before:?})"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 点 Play:只派发 + 抽干**当下**的事件,不 tick、不 advance
|
||||||
|
app.dispatch(AppCommand::LaunchInstance(id.clone()));
|
||||||
|
|
||||||
|
let after = app.view.instances.detail.clone().expect("有选中项");
|
||||||
|
assert!(
|
||||||
|
after.can_stop && !after.can_launch,
|
||||||
|
"点下去必须**同一拍**进运行态(advance 之前):{after:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 收尾:让那条后台线程把失败交回来(不造版本本体 ⇒ 装配失败),再把临时根删干净
|
||||||
|
for _ in 0..100 {
|
||||||
|
app.advance(Duration::from_millis(33));
|
||||||
|
if app
|
||||||
|
.view
|
||||||
|
.instances
|
||||||
|
.detail
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|detail| !detail.can_stop)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
@@ -103,6 +103,31 @@ pub(in crate::host) fn external_versions(env: &HostEnv, game: &GameLayout) -> Ve
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **M273 时代的 `ext-` id**:只哈希版本目录名(不含根)。M274 之后这种 id 永远反查不到根。
|
||||||
|
///
|
||||||
|
/// 存在的唯一理由:`host/game/refresh/live.rs` 判「反查落空的 `ext-` id」时要用它把两种情形分开——
|
||||||
|
/// 老档案(这条 ⇒ 放行,按配置根解析)与**根不见了**(不是这条 ⇒ 拒绝,否则会起错副本)。
|
||||||
|
pub(in crate::host) fn legacy_external_instance_id(version_id: &str) -> String {
|
||||||
|
instance_id_from(&[version_id])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// FNV-1a 64 的多段哈希:段之间插一个 `0` 分隔符(路径里不可能有 NUL,所以 `("ab","c")` 与
|
||||||
|
/// `("a","bc")` 不会撞)。
|
||||||
|
fn instance_id_from(parts: &[&str]) -> String {
|
||||||
|
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
|
||||||
|
for (index, part) in parts.iter().enumerate() {
|
||||||
|
if index > 0 {
|
||||||
|
// 分隔符那一个 `0` 字节的 `hash ^= 0` 是恒等,留下的痕迹只有这一次乘法
|
||||||
|
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
|
||||||
|
}
|
||||||
|
for byte in part.as_bytes() {
|
||||||
|
hash ^= u64::from(*byte);
|
||||||
|
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
format!("{}{hash:016x}", crate::dto::DISCOVERED_ID_PREFIX)
|
||||||
|
}
|
||||||
|
|
||||||
/// 外部实例的视图 id(= 懒创建时写进 `profiles.json` 的档案 id)。
|
/// 外部实例的视图 id(= 懒创建时写进 `profiles.json` 的档案 id)。
|
||||||
///
|
///
|
||||||
/// **不能直接拿版本 id 当实例 id**:`core::platform::layout::is_valid_instance_id` 只收
|
/// **不能直接拿版本 id 当实例 id**:`core::platform::layout::is_valid_instance_id` 只收
|
||||||
@@ -112,21 +137,10 @@ pub(in crate::host) fn external_versions(env: &HostEnv, game: &GameLayout) -> Ve
|
|||||||
///
|
///
|
||||||
/// 前缀 + 稳定哈希 ⇒ 同一个 (根, 版本) 每次拿到同一个 id(懒创建因此幂等),且是合法实例 id。
|
/// 前缀 + 稳定哈希 ⇒ 同一个 (根, 版本) 每次拿到同一个 id(懒创建因此幂等),且是合法实例 id。
|
||||||
///
|
///
|
||||||
/// **M274**:`root_key` 也进哈希(`fnv(root || 0 || version)`)——两个根里的 `1.20.4` 因此是两个
|
/// **M274**:`root_key` 也进哈希(`fnv(root || 0 || version)`,见 [`instance_id_from`])——两个根里
|
||||||
/// 实例。分隔符用 `0`:路径里不可能有 NUL,所以 `("ab","c")` 与 `("a","bc")` 不会撞。
|
/// 的 `1.20.4` 因此是两个实例。
|
||||||
pub(in crate::host) fn external_instance_id(root_key: &str, version_id: &str) -> String {
|
pub(in crate::host) fn external_instance_id(root_key: &str, version_id: &str) -> String {
|
||||||
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
|
instance_id_from(&[root_key, version_id])
|
||||||
let bytes = root_key
|
|
||||||
.as_bytes()
|
|
||||||
.iter()
|
|
||||||
.copied()
|
|
||||||
.chain(std::iter::once(0))
|
|
||||||
.chain(version_id.as_bytes().iter().copied());
|
|
||||||
for byte in bytes {
|
|
||||||
hash ^= u64::from(byte);
|
|
||||||
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
|
|
||||||
}
|
|
||||||
format!("{}{hash:016x}", crate::dto::DISCOVERED_ID_PREFIX)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 磁贴的显示名:配置根里的版本就是版本目录名;**别的根**里的版本带上根路径。
|
/// 磁贴的显示名:配置根里的版本就是版本目录名;**别的根**里的版本带上根路径。
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ use crate::dto::{CoreEvent, InstancePhase};
|
|||||||
use super::super::job::{spawn, HostTask, JobSender};
|
use super::super::job::{spawn, HostTask, JobSender};
|
||||||
use super::super::HostEnv;
|
use super::super::HostEnv;
|
||||||
|
|
||||||
|
/// M304:快照条目的「现在」——这一条**还**在不在盘上(判据见 `live` 的模块文档)。
|
||||||
|
///
|
||||||
|
/// 它能在 `game` 外面被调到,是因为挂成了 [`Scan`] 的关联函数:`host` 其它地方只够得着
|
||||||
|
/// `game` 转出的那几个名字(`Scan` / `instances` / `game_for` …),私有子模块够不着。
|
||||||
|
mod live;
|
||||||
|
|
||||||
/// 首个实例快照的等待上限(M302 的**有界兜底**)。
|
/// 首个实例快照的等待上限(M302 的**有界兜底**)。
|
||||||
///
|
///
|
||||||
/// ## 取值依据(不是随手挑的)
|
/// ## 取值依据(不是随手挑的)
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
//! 快照条目的**现在**:这一条还在不在盘上(M304)。
|
||||||
|
//!
|
||||||
|
//! ## 为什么需要
|
||||||
|
//!
|
||||||
|
//! M304 把「点启动」的实例解析从「同步扫一遍 [`super::super::instances`]」改成「查已发布快照」。
|
||||||
|
//! 快照是**过去**的一拍:用户可能在别处(文件管理器 / 另一个启动器 / 同一会话的另一条命令)
|
||||||
|
//! 把这个实例删了、或者把它绑的版本改了。照旧解析就会「按快照把它拉起来」——拉的是**已经不
|
||||||
|
//! 存在的东西**,最坏的一种是 `game_for` 反查不到那个根、退回配置根,于是**启动的是另一个根里
|
||||||
|
//! 同名版本**(用户看着 A,起来的却是 B)。
|
||||||
|
//!
|
||||||
|
//! ## 判据(单实例,不扫 `versions/`)
|
||||||
|
//!
|
||||||
|
//! 一次只判**这一条**,所以绝不能再用 `instances()`(那是全量扫盘:官方根 200 个版本 236 ms,
|
||||||
|
//! 叠加外部档案 1.35 s)——那等于把「点了就受理」换成「点了等一扫」。两条判据各用最便宜的
|
||||||
|
//! 那个事实:
|
||||||
|
//!
|
||||||
|
//! | 实例 | 判据 | 成本 |
|
||||||
|
//! |---|---|---|
|
||||||
|
//! | 档案实例(id 不是 `ext-`) | `profiles.json` 里还有这条 id,且 `version_id` 没被改过 | 一次小文件读 |
|
||||||
|
//! | 自动发现的(`ext-`) | 逐个根重算 id(哈希)定位到 (根, 版本),再用 core 的 `adopt::inspect` 判这一个版本 | #根 次哈希 + 一次单版本检查 |
|
||||||
|
//!
|
||||||
|
//! ## 反查落空的两种情形,必须分开(r1 复审 P2-1)
|
||||||
|
//!
|
||||||
|
//! `roots::all` 对非配置根是 `if !extra.is_dir() { continue; }`(只有配置根无条件入列)。于是
|
||||||
|
//! 「官方根被删 / 改名 / 盘没挂上 / `canonicalize` 变了」时,那个 `ext-` id 一个根都对不上——
|
||||||
|
//! 若照旧放行,`build_request` → `game_for`(`discovered::game_for` 的回落)会退回**配置根**,
|
||||||
|
//! 配置根里恰好有同名 `version_id` 就把**另一份**拉起来(gameDir / 存档都在另一边)。这是相对
|
||||||
|
//! 改前(同步扫盘 ⇒ 扫不出来 ⇒ 拒绝「找不到实例」)的回归,所以必须分开判:
|
||||||
|
//!
|
||||||
|
//! - **M273 老档案**(id 只哈希版本目录名 ⇒ `legacy_external_instance_id` 命中):**放行**——
|
||||||
|
//! 那种 id 的语义本来就是「按配置根解析」,真没版本时 `build_request` 给具名阶段失败;
|
||||||
|
//! - **所属根不见了**(删 / 改名 / 盘没挂上 / `canonicalize` 变了):**拒绝** + 具名原因,并由
|
||||||
|
//! 调用方立刻重扫(`Rejection::stale`)。
|
||||||
|
//!
|
||||||
|
//! 放行老档案不是偷懒:改前那条同步扫盘也不会拦它——`version_present: false` 的磁贴在旧实现里
|
||||||
|
//! 照样能点、照样走到同一条具名失败(`VersionManifest` + 路径),进程一个都没起来。
|
||||||
|
//!
|
||||||
|
//! ## 为什么挂在 [`super::Scan`] 上
|
||||||
|
//!
|
||||||
|
//! [`super::super::discovered`] / [`super::super::roots`] 是 `game` 的私有子模块,`host` 其它地方
|
||||||
|
//! 只够得着 `game` 转出的那几个名字(`Scan` / `instances` / `game_for` …)。`impl` 块可以写在任何
|
||||||
|
//! 模块里,于是这一层用关联函数挂在 [`super::Scan`] 上——语义也对:被判的正是「这份扫描账里的
|
||||||
|
//! 那一条现在还成不成立」。
|
||||||
|
|
||||||
|
use unlucky_core::game::adopt;
|
||||||
|
use unlucky_core::game::install::instance::read_profiles;
|
||||||
|
|
||||||
|
use crate::dto::InstanceSummary;
|
||||||
|
|
||||||
|
use super::super::super::HostEnv;
|
||||||
|
use super::super::discovered::{external_instance_id, legacy_external_instance_id};
|
||||||
|
use super::super::roots;
|
||||||
|
|
||||||
|
impl super::Scan {
|
||||||
|
/// 这条快照条目**现在**还在不在盘上;不在就给一句给用户看的具名原因。
|
||||||
|
///
|
||||||
|
/// 只判这一条(成本见模块文档的表格),**不重扫 `versions/`**:调用方是「点启动」那条同步
|
||||||
|
/// 路径,它必须便宜到能在同一拍里给出 Busy。
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// 返回的字符串是**给人读的原因**(presenter 没有 i18n 依赖,文案在这里拼,与 `toast` 同一种
|
||||||
|
/// 形状):实例清单里没了 / 版本被改过 / 版本目录已不可用。调用方负责拒绝受理并重扫一遍。
|
||||||
|
pub(in crate::host) fn revalidate(
|
||||||
|
env: &HostEnv,
|
||||||
|
instance: &InstanceSummary,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if crate::dto::is_discovered_instance(&instance.id) {
|
||||||
|
return discovered(env, instance);
|
||||||
|
}
|
||||||
|
profile(env, instance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 档案实例:`profiles.json` 里还有这条 id,且绑的版本没被改过。
|
||||||
|
///
|
||||||
|
/// 「版本被改过」也算过期:用户改的是**同一个档案**,但屏幕上那条磁贴写着旧版本、启动链也会按
|
||||||
|
/// 旧版本装配——那不是他刚才看中的那一条。
|
||||||
|
fn profile(env: &HostEnv, instance: &InstanceSummary) -> Result<(), String> {
|
||||||
|
let profiles =
|
||||||
|
read_profiles(&env.layout).map_err(|error| format!("读实例清单失败:{error}"))?;
|
||||||
|
let Some(profile) = profiles
|
||||||
|
.profiles
|
||||||
|
.iter()
|
||||||
|
.find(|profile| profile.id.as_str() == instance.id)
|
||||||
|
else {
|
||||||
|
return Err(format!(
|
||||||
|
"实例 `{}` 已不在实例清单里(可能刚在别处被删掉),列表已过期",
|
||||||
|
instance.name
|
||||||
|
));
|
||||||
|
};
|
||||||
|
if profile.version_id != instance.version_id {
|
||||||
|
return Err(format!(
|
||||||
|
"实例 `{}` 绑的版本已被改成 `{}`(列表里还是 `{}`),列表已过期",
|
||||||
|
instance.name, profile.version_id, instance.version_id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 自动发现的实例:id 是 (根, 版本) 的哈希,逐个根重算一遍定位,再判这一个版本。
|
||||||
|
///
|
||||||
|
/// 反查不到根 ⇒ 放行(M273 老档案,理由见模块文档):不猜、也不在这里改成「拒绝」,
|
||||||
|
/// 否则所有老用户的 `ext-` 磁贴会集体点不动。
|
||||||
|
fn discovered(env: &HostEnv, instance: &InstanceSummary) -> Result<(), String> {
|
||||||
|
for root in roots::all(env) {
|
||||||
|
if external_instance_id(root.key(), &instance.version_id) != instance.id {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return adopt::inspect(root.game(), &env.layout, &instance.version_id)
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"实例 `{}` 的版本 `{}` 已不可用({error}),列表已过期",
|
||||||
|
instance.name, instance.version_id
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 反查落空:老档案放行,**根不见了就拒绝**(理由见模块文档——放行会让 `game_for` 退回配置根,
|
||||||
|
// 把另一个根里同名的那个版本拉起来)。
|
||||||
|
if legacy_external_instance_id(&instance.version_id) == instance.id {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(format!(
|
||||||
|
"实例 `{}` 所属的游戏目录已不在(被移走 / 改名 / 盘没挂上),列表已过期",
|
||||||
|
instance.name
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -196,3 +196,30 @@ fn isolation_write_then_read_round_trips() {
|
|||||||
assert!(set_isolation(&env, &id, "nope").is_err());
|
assert!(set_isolation(&env, &id, "nope").is_err());
|
||||||
std::fs::remove_dir_all(&dir).ok();
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **实例 id 的哈希公式钉死**(M304 r1:把「老档案放行」那条判据挂在这两个函数上,公式一漂就会
|
||||||
|
/// 误杀所有存量实例)。
|
||||||
|
///
|
||||||
|
/// 两个字面量是 FNV-1a 64 的独立复算结果(`ext-` 前缀 + 16 位小写十六进制):
|
||||||
|
///
|
||||||
|
/// | 输入 | 期望 |
|
||||||
|
/// |---|---|
|
||||||
|
/// | `legacy_external_instance_id("1.20.4")` | `ext-52d6191436663b4e`(= M273 的「只哈希版本目录名」)|
|
||||||
|
/// | `external_instance_id("c:/mc/.minecraft", "1.20.4")` | `ext-1e6c24c50b4a95c2`(= root + `0` + version)|
|
||||||
|
///
|
||||||
|
/// 抓得住:把分隔符那一次乘法漏掉 / 把 `0` 字节也过一遍 `xor`(两种都改哈希)——那时**所有**存量
|
||||||
|
/// `ext-` id 都会换一个值(`profiles.json` 里旧的 `ext-` 档案一律反查不到)。M304 把哈希抽成
|
||||||
|
/// [`super::discovered`] 里的一个多段函数,这条就是那次重构的回归网。
|
||||||
|
#[test]
|
||||||
|
fn instance_id_hashes_are_the_released_ones() {
|
||||||
|
assert_eq!(
|
||||||
|
super::discovered::legacy_external_instance_id("1.20.4"),
|
||||||
|
"ext-52d6191436663b4e",
|
||||||
|
"M273 老档案的 id 公式变了(存量实例会被判成「根不见了」)"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
super::discovered::external_instance_id("c:/mc/.minecraft", "1.20.4"),
|
||||||
|
"ext-1e6c24c50b4a95c2",
|
||||||
|
"M274 的 id 公式变了(存量 ext- 档案一律反查不到)"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,12 +24,15 @@ use crate::dto::{
|
|||||||
|
|
||||||
use super::env::HostEnv;
|
use super::env::HostEnv;
|
||||||
|
|
||||||
|
mod accept;
|
||||||
mod build;
|
mod build;
|
||||||
mod java;
|
mod java;
|
||||||
|
mod target;
|
||||||
|
|
||||||
use super::job::{HostTask, JobSender};
|
use super::job::{HostTask, JobSender};
|
||||||
pub use build::build_request;
|
pub use build::build_request;
|
||||||
pub use java::pick_java;
|
pub use java::pick_java;
|
||||||
|
pub(super) use target::{resolve, Rejection};
|
||||||
|
|
||||||
/// 启动器名(与 CLI `meta::LAUNCHER_NAME` 同值)。
|
/// 启动器名(与 CLI `meta::LAUNCHER_NAME` 同值)。
|
||||||
pub const LAUNCHER_NAME: &str = "Unlucky Launcher";
|
pub const LAUNCHER_NAME: &str = "Unlucky Launcher";
|
||||||
@@ -232,5 +235,14 @@ pub fn isolation_label(mode: IsolationMode) -> &'static str {
|
|||||||
mode.as_str()
|
mode.as_str()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod fixtures;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod revalidate_tests;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod target_tests;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
//! 一次启动的**受理**与停止的实例解析(M304:读已发布快照,不在调用方线程扫盘)。
|
||||||
|
//!
|
||||||
|
//! 从 `host/mod.rs` 拆出来只为两件事:守住 400 行硬线,以及让「点启动这条状态机」与
|
||||||
|
//! `launch` 模块的其余部分(装配 / 起进程 / 收尾)放在一起。判断顺序见 [`CoreHost::start_launch`]。
|
||||||
|
//!
|
||||||
|
//! 四个方法的可见性是 `pub(in crate::host)` 而不是 `pub(super)`:`host::launch_state_tests`
|
||||||
|
//! 是 `host` 的子模块、不是 `launch` 的子模块,它要直接调这几个入口(测真实状态流转)。
|
||||||
|
|
||||||
|
use crate::dto::{CoreEvent, InstanceRuntime, LaunchStage, Progress};
|
||||||
|
use crate::toast::ToastKind;
|
||||||
|
|
||||||
|
use super::super::{game, launch, CoreHost};
|
||||||
|
|
||||||
|
impl CoreHost {
|
||||||
|
/// 某实例是否已在启动/运行(`pending` 与 `launches` 的并集)。
|
||||||
|
///
|
||||||
|
/// 只查 `launches` 不够:`launch::start` 到后台线程回报 `LaunchStarted` 之间有窗口,
|
||||||
|
/// 这期间重复点启动会起第二个进程、并把旧 pid 在启动表里顶掉(旧进程变孤儿)。
|
||||||
|
pub(in crate::host) fn in_flight(&self, key: &str) -> bool {
|
||||||
|
self.pending.contains(key) || self.launches.iter().any(|live| live.instance == key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 标记「已受理、等后台线程回报」(`launch::start` 之后的窗口)。
|
||||||
|
///
|
||||||
|
/// 单测也走这个入口而不是直接改 `pending`,保证测的是真实状态流转。
|
||||||
|
pub(in crate::host) fn mark_pending(&mut self, id: &str) {
|
||||||
|
self.pending.insert(id.to_string());
|
||||||
|
self.failed.remove(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 启动受理(M304:解析读**已发布快照**,调用方线程一次盘都不扫)。
|
||||||
|
///
|
||||||
|
/// 顺序不能换:① 在飞/重复点先挡(一次内存判,最便宜)② 解析 + 单实例复核(微秒级)
|
||||||
|
/// ③ 标 `pending` + 发 `Busy` ④ 起后台线程。③ 在 ④ 之前 ⇒ 「按钮进 Busy」**不依赖**后台线程
|
||||||
|
/// 回来,这正是「点下去没反应」的判据(`host::launch::target_tests`)。
|
||||||
|
pub(in crate::host) fn start_launch(&mut self, id: &str) {
|
||||||
|
if self.in_flight(id) {
|
||||||
|
self.toast(ToastKind::Warn, "这个实例已经在启动/运行中");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let target =
|
||||||
|
match launch::resolve(&self.env, &self.published, id, self.active_account.clone()) {
|
||||||
|
Ok(target) => target,
|
||||||
|
Err(launch::Rejection { reason, stale }) => {
|
||||||
|
// 拒绝要**具名**:用户点的是磁贴,磁贴与盘面对不上必须说出来是哪一条、为什么。
|
||||||
|
self.toast(ToastKind::Error, reason);
|
||||||
|
// 过期 ⇒ 立刻重扫(磁贴自己会消失 / 换成新版本)。「还在扫描中」不重扫:那会把
|
||||||
|
// 在飞那一轮的代次顶掉、结果被 `apply_instances` 丢掉,占位反而更久。
|
||||||
|
if stale {
|
||||||
|
self.refresh_instances();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let instance_id = target.id.clone();
|
||||||
|
if self.in_flight(&instance_id) {
|
||||||
|
self.toast(ToastKind::Warn, "这个实例已经在启动/运行中");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.mark_pending(&instance_id);
|
||||||
|
if let Some(notice) = game::mode_change_notice(&self.env, &instance_id) {
|
||||||
|
self.push_notice(notice);
|
||||||
|
}
|
||||||
|
self.send(CoreEvent::InstanceRuntimeChanged {
|
||||||
|
id: instance_id.clone(),
|
||||||
|
runtime: InstanceRuntime::Busy {
|
||||||
|
stage: LaunchStage::Launch,
|
||||||
|
progress: Progress { done: 0, total: 1 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
launch::start(self.env.clone(), self.tx.clone(), target);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 某实例在跑的 pid(按实例 id 查启动表;不再有「最近一次启动」这种单槽)。
|
||||||
|
///
|
||||||
|
/// 兜底分支读**已发布快照**的 `Running { pid }`(M304):host 自己发的运行时事件会经
|
||||||
|
/// `handle::apply_instances` 写回快照,而快照就是这里读的那一份——「点停止」不再为此扫一次盘。
|
||||||
|
pub(in crate::host) fn running_pid(&self, id: &str) -> Option<u32> {
|
||||||
|
if let Some(live) = self.launches.iter().find(|live| live.instance == id) {
|
||||||
|
return Some(live.pid);
|
||||||
|
}
|
||||||
|
self.published
|
||||||
|
.iter()
|
||||||
|
.find(|instance| instance.id == id)
|
||||||
|
.and_then(|instance| match instance.runtime {
|
||||||
|
InstanceRuntime::Running { pid } => Some(pid),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
//! M304 用例的**共用夹具**(临时数据根 / 实例档案 / 首份快照落地 / 事件读取)。
|
||||||
|
//!
|
||||||
|
//! 拆出来的理由同仓里其它 `tests*` 的拆分:单文件 400 行硬线。判据本身在
|
||||||
|
//! [`super::target_tests`](快照解析 / 同一拍 / 同名不猜 / 停止读 pid)与
|
||||||
|
//! [`super::revalidate_tests`](所属根不见了 / 运行时态落进快照)。
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use unlucky_core::game::install::instance::write_profiles;
|
||||||
|
use unlucky_core::game::{InstanceProfile, ProfileId, ProfileList};
|
||||||
|
|
||||||
|
use crate::dto::{AppEvent, Backend, CoreEvent};
|
||||||
|
use crate::toast::ToastKind;
|
||||||
|
|
||||||
|
use super::super::{CoreHost, HostEnv};
|
||||||
|
|
||||||
|
/// 临时数据根守卫:用例结束(含断言 panic)时删掉,不留空壳。
|
||||||
|
pub(super) struct TempRoot(pub(super) std::path::PathBuf);
|
||||||
|
|
||||||
|
impl Drop for TempRoot {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = std::fs::remove_dir_all(&self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一条实例档案(自建实例:id 与名字由用例给)。
|
||||||
|
pub(super) fn profile(name: &str, version: &str) -> InstanceProfile {
|
||||||
|
InstanceProfile {
|
||||||
|
id: ProfileId::generate(),
|
||||||
|
name: name.to_string(),
|
||||||
|
version_id: version.to_string(),
|
||||||
|
game_dir: None,
|
||||||
|
running_dir: None,
|
||||||
|
java_path: None,
|
||||||
|
created_at: None,
|
||||||
|
last_played_at: None,
|
||||||
|
extra: std::collections::BTreeMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把实例清单**写进盘**(`profiles.json` 是「盘上有没有这个实例」的档案侧真相)。
|
||||||
|
///
|
||||||
|
/// 增删实例都走它:用例改的就是 host 看不见的那份盘面,快照因此真的会过期。
|
||||||
|
pub(super) fn write(env: &HostEnv, profiles: &[InstanceProfile]) {
|
||||||
|
let list = ProfileList {
|
||||||
|
profiles: profiles.to_vec(),
|
||||||
|
..ProfileList::default()
|
||||||
|
};
|
||||||
|
write_profiles(&env.layout, &list).expect("写 profiles.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 建一个只有这几条档案的数据根,返回 `(host, 守卫)`。
|
||||||
|
pub(super) fn host(label: &str, profiles: &[InstanceProfile]) -> (CoreHost, TempRoot) {
|
||||||
|
let dir = temp_dir(label);
|
||||||
|
let env = HostEnv::at_root(&dir);
|
||||||
|
std::fs::create_dir_all(env.layout.config()).expect("config 目录");
|
||||||
|
write(&env, profiles);
|
||||||
|
(CoreHost::headless(env), TempRoot(dir))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **两个根**的数据根:配置根里几条档案 + 官方根(`<data>/official/.minecraft`)里一个能登记的版本。
|
||||||
|
///
|
||||||
|
/// `HostEnv::at_root` 默认**不做多根发现**(见 `env.rs::at_root` 的文档:截图/用例要确定性),所以
|
||||||
|
/// 这里在交给 host 之前显式把 `standard_game` 指过去——用例要的正是「实例属于**另一个**根」。
|
||||||
|
pub(super) fn host_with_official(
|
||||||
|
label: &str,
|
||||||
|
profiles: &[InstanceProfile],
|
||||||
|
version: &str,
|
||||||
|
) -> (CoreHost, TempRoot, std::path::PathBuf) {
|
||||||
|
let dir = temp_dir(label);
|
||||||
|
let mut env = HostEnv::at_root(&dir);
|
||||||
|
std::fs::create_dir_all(env.layout.config()).expect("config 目录");
|
||||||
|
write(&env, profiles);
|
||||||
|
let official = dir.join("official").join(".minecraft");
|
||||||
|
let version_dir = official.join("versions").join(version);
|
||||||
|
std::fs::create_dir_all(&version_dir).expect("官方根的版本目录");
|
||||||
|
std::fs::write(
|
||||||
|
version_dir.join(format!("{version}.json")),
|
||||||
|
format!(r#"{{"id":"{version}","mainClass":"net.minecraft.client.main.Main"}}"#),
|
||||||
|
)
|
||||||
|
.expect("写版本 JSON");
|
||||||
|
env.standard_game = Some(official.clone());
|
||||||
|
(CoreHost::headless(env), TempRoot(dir), official)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一次性临时目录(`line!()` 让同一个文件里的两条用例不会撞)。
|
||||||
|
fn temp_dir(label: &str) -> std::path::PathBuf {
|
||||||
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
"ull-m304-{label}-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
line!()
|
||||||
|
));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 让第一份快照落地(M302 的后台扫盘),并把这一批事件读走。
|
||||||
|
pub(super) fn land_snapshot(host: &mut CoreHost) {
|
||||||
|
host.refresh_instances();
|
||||||
|
host.pump_instances();
|
||||||
|
let _ = host.drain_events();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 有没有发过「运行时变了」(受理成功 = 一条 `Busy`)。
|
||||||
|
pub(super) fn runtime_event_emitted(events: &[AppEvent]) -> bool {
|
||||||
|
events.iter().any(|event| {
|
||||||
|
matches!(
|
||||||
|
event,
|
||||||
|
AppEvent::Core(CoreEvent::InstanceRuntimeChanged { .. })
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 事件里唯一那条错误 Toast 的原文(拒绝必须是**具名**的,所以这里要求它存在)。
|
||||||
|
pub(super) fn error_toast(events: &[AppEvent]) -> String {
|
||||||
|
events
|
||||||
|
.iter()
|
||||||
|
.find_map(|event| match event {
|
||||||
|
AppEvent::Toast(toast) if toast.kind == ToastKind::Error => Some(toast.text.clone()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| panic!("拒绝受理必须给一条错误提示(具名原因),实际事件:{events:?}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 等启动后台线程把结局交回来(夹具里的实例都没有版本本体 ⇒ 装配必然失败)。
|
||||||
|
///
|
||||||
|
/// 不只是收尾:它顺手证明**受理不依赖这条线程**——Busy 的断言都在它之前做完了。
|
||||||
|
pub(super) fn settle(host: &mut CoreHost) {
|
||||||
|
for _ in 0..2_000 {
|
||||||
|
if host.pending.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
host.tick(Duration::from_millis(1));
|
||||||
|
std::thread::sleep(Duration::from_millis(1));
|
||||||
|
}
|
||||||
|
panic!("启动线程没在 2 s 内收尾(pending 还剩 {:?})", host.pending);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
//! M304 r1 复审的两条判据:**所属根不见了必须拒绝**、**运行时态事件要落进已发布快照**。
|
||||||
|
//!
|
||||||
|
//! 前半条是真风险(复审 P2-1):`revalidate` 对 `ext-` 实例是「逐个根重算 id 定位」,而
|
||||||
|
//! `roots::all` 对非配置根是 `if !extra.is_dir() { continue; }`。根被删 / 改名 / 盘没挂上 ⇒ 一个都
|
||||||
|
//! 对不上 ⇒ 若照旧放行,`build_request` → `game_for` 退回**配置根**,配置根里恰好有同名版本就把
|
||||||
|
//! **另一份**拉起来(gameDir / 存档都在另一边)。改前的同步扫盘扫不出来 ⇒ 拒绝,所以放行是回归。
|
||||||
|
//!
|
||||||
|
//! 夹具见 [`super::fixtures`],其余判据见 [`super::target_tests`]。
|
||||||
|
|
||||||
|
use crate::dto::{AppCommand, AppEvent, Backend, CoreEvent, InstanceRuntime};
|
||||||
|
|
||||||
|
use super::super::CoreHost;
|
||||||
|
use super::fixtures::{error_toast, host_with_official, land_snapshot, profile};
|
||||||
|
|
||||||
|
/// 从事件里挑出一条「运行时变成 Busy」的 id(受理成功的证据)。
|
||||||
|
fn busy_id(events: &[AppEvent]) -> Option<String> {
|
||||||
|
events.iter().find_map(|event| match event {
|
||||||
|
AppEvent::Core(CoreEvent::InstanceRuntimeChanged {
|
||||||
|
id,
|
||||||
|
runtime: InstanceRuntime::Busy { .. },
|
||||||
|
}) => Some(id.clone()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **P2-1**:实例属于另一个根,快照落地之后那个根不见了 ⇒ 点它必须**拒绝并具名**。
|
||||||
|
///
|
||||||
|
/// 抓得住:`revalidate` 在反查落空时直接 `Ok(())`(= 复审的现场,这条立即红)。
|
||||||
|
/// 抓不住:M273 老档案那条**放行**分支——它由 presenter 的 `game::tests_multiroot3`(老 id 的
|
||||||
|
/// 反查回落)与 `host::game::refresh` 的 `legacy_external_instance_id` 共用同一份哈希守。
|
||||||
|
#[test]
|
||||||
|
fn an_instance_whose_root_disappeared_is_refused_with_a_named_reason() {
|
||||||
|
let demo = profile("demo", "1.20.1");
|
||||||
|
let (mut host, _root, official) =
|
||||||
|
host_with_official("root-gone", std::slice::from_ref(&demo), "1.20.9");
|
||||||
|
land_snapshot(&mut host);
|
||||||
|
let tile = tile_of_version(&host, "1.20.9");
|
||||||
|
|
||||||
|
// 快照之后根没了(盘掉线 / 被移走 / 改名)
|
||||||
|
std::fs::remove_dir_all(&official).expect("删掉官方根");
|
||||||
|
|
||||||
|
host.dispatch(AppCommand::LaunchInstance(tile));
|
||||||
|
let events = host.drain_events();
|
||||||
|
assert!(
|
||||||
|
busy_id(&events).is_none(),
|
||||||
|
"所属根不见了不许受理(放行会让 game_for 退回配置根,把另一份拉起来):{events:?}"
|
||||||
|
);
|
||||||
|
assert!(host.pending.is_empty(), "拒绝受理 ⇒ 不进 pending");
|
||||||
|
let message = error_toast(&events);
|
||||||
|
assert!(
|
||||||
|
message.contains("游戏目录已不在") && message.contains("1.20.9"),
|
||||||
|
"具名原因要说出是哪个实例、为什么(现在是:{message})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 快照里那个版本的磁贴 id(`ext-` 前缀 = 自动发现的实例)。
|
||||||
|
fn tile_of_version(host: &CoreHost, version: &str) -> String {
|
||||||
|
host.published
|
||||||
|
.iter()
|
||||||
|
.find(|instance| instance.version_id == version)
|
||||||
|
.map(|instance| instance.id.clone())
|
||||||
|
.unwrap_or_else(|| panic!("快照里没有版本 `{version}` 的磁贴:{:?}", host.published))
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
//! 启动受理用的实例解析(M304):**从已发布快照里查**,不在调用方线程扫盘。
|
||||||
|
//!
|
||||||
|
//! ## 改的是什么
|
||||||
|
//!
|
||||||
|
//! M302 把 `refresh_instances` 的扫盘搬进了后台线程,但「点启动」这条路上还留着两次全量扫盘
|
||||||
|
//! (`launch_target` 与 `running_pid` 各自 `game::instances(&env)`):官方根 200 个版本 236 ms、
|
||||||
|
//! 叠加 10 份外部档案 1.35 s,全部落在 UI 线程上。用户侧的表现就是「点了 Play,界面先僵一下」。
|
||||||
|
//!
|
||||||
|
//! 现在读**已发布快照**(`CoreHost::send` 把每份 `InstancesChanged` 留一份):
|
||||||
|
//!
|
||||||
|
//! - 成本从「#根 次 `versions/` 全扫」降到一次内存查表 + 一次**单实例**复核
|
||||||
|
//! (`game::Scan::revalidate`);
|
||||||
|
//! - 语义也更准:用户在列表里点的**就是**快照里的那一条。同步扫盘拿的是「此刻的盘」,
|
||||||
|
//! 它可能已经和屏幕上那条不是同一个实例了(两次刷新之间盘变了)。
|
||||||
|
//!
|
||||||
|
//! ## 为什么不是「异步解析」
|
||||||
|
//!
|
||||||
|
//! 异步会把「点下去没反应」换个形状:受理(按钮进 Busy)得等解析回来。本 mission 的硬要求是
|
||||||
|
//! **同一拍**给出可见反馈,所以解析必须同步且足够便宜——便宜的手段是查快照(+ 单实例复核),
|
||||||
|
//! 不是再起一条线程等它回来。
|
||||||
|
//!
|
||||||
|
//! ## 快照过期时会发生什么(这条路的真风险)
|
||||||
|
//!
|
||||||
|
//! | 情形 | 判据落点 | 结果 |
|
||||||
|
//! |---|---|---|
|
||||||
|
//! | 实例在别处被删 / 绑的版本被改 | `Scan::revalidate` | 拒绝 + 具名原因 + 立刻重扫 |
|
||||||
|
//! | 快照里还没有内容(冷启动第一拍) | [`resolve`] 的空快照分支 | 拒绝 +「还在扫描中」 |
|
||||||
|
//! | 名字撞上两个实例 | [`find`] 的唯一命中判据 | 拒绝 +「不能替你猜」 |
|
||||||
|
//! | `versions/<id>` 被删 | `Scan::revalidate`(`adopt::inspect`) | 拒绝 + 具名原因 |
|
||||||
|
//!
|
||||||
|
//! 「拒绝」是**一字节都不碰**:不进 `pending`、不发 Busy、不起线程、不碰盘上的任何东西
|
||||||
|
//! (除了那一次只读复核)。
|
||||||
|
|
||||||
|
use crate::dto::InstanceSummary;
|
||||||
|
|
||||||
|
use super::super::game;
|
||||||
|
use super::super::HostEnv;
|
||||||
|
use super::LaunchTarget;
|
||||||
|
|
||||||
|
/// 拒绝受理:给用户看的一句具名原因 + 要不要顺手重扫一遍实例目录。
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Rejection {
|
||||||
|
/// 人读原因(presenter 没有 i18n 依赖,文案在与 `toast` 同一层拼好)
|
||||||
|
pub reason: String,
|
||||||
|
/// 快照明确落后于盘面(实例没了 / 版本改了 / 版本目录没了)⇒ 值得立刻重扫一遍;
|
||||||
|
/// 「还在扫描中」与「名字撞了」都不是过期,重扫也解决不了,所以是 `false`。
|
||||||
|
pub stale: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 在快照里把用户点的那个实例解析成启动目标。
|
||||||
|
///
|
||||||
|
/// `key` 是界面传来的实例 id(磁贴带的就是它);名字兜底只为兼容按名字调用的老路径。
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// 见模块文档那张表:空快照 / 快照里没有 / 名字撞多个 / 盘面已过期([`Rejection::stale`])。
|
||||||
|
pub fn resolve(
|
||||||
|
env: &HostEnv,
|
||||||
|
snapshot: &[InstanceSummary],
|
||||||
|
key: &str,
|
||||||
|
account: Option<String>,
|
||||||
|
) -> Result<LaunchTarget, Rejection> {
|
||||||
|
let Some(instance) = find(snapshot, key)? else {
|
||||||
|
// 空快照 = 首次扫描还没落地(M302 的占位那一态)。用户此刻其实是**看不到**可点的磁贴的,
|
||||||
|
// 但这条路径在事件回流前也可能被点到(快捷键 / 双击),所以拒绝也要有话说,且**不装死**。
|
||||||
|
return Err(if snapshot.is_empty() {
|
||||||
|
Rejection {
|
||||||
|
reason: "实例列表还在扫描中,稍等一下再点(正在扫描实例…)".to_string(),
|
||||||
|
stale: false,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Rejection {
|
||||||
|
reason: format!("实例列表里没有 `{key}`(可能刚在别处被改过)"),
|
||||||
|
stale: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
game::Scan::revalidate(env, instance).map_err(|reason| Rejection {
|
||||||
|
reason,
|
||||||
|
stale: true,
|
||||||
|
})?;
|
||||||
|
Ok(LaunchTarget {
|
||||||
|
id: instance.id.clone(),
|
||||||
|
name: instance.name.clone(),
|
||||||
|
version_id: instance.version_id.clone(),
|
||||||
|
account,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// id 精确优先;名字兜底**只在唯一命中时**用。
|
||||||
|
///
|
||||||
|
/// 为什么名字撞了要拒绝而不是取第一个:两个同名实例(多根之后很常见:两个根里都叫 `A`)取第一个
|
||||||
|
/// 就是在**猜**,而猜错的代价是「用户看着 A,起来的却是 B」——启动是这条路上唯一不可撤销的动作。
|
||||||
|
fn find<'a>(
|
||||||
|
snapshot: &'a [InstanceSummary],
|
||||||
|
key: &str,
|
||||||
|
) -> Result<Option<&'a InstanceSummary>, Rejection> {
|
||||||
|
if let Some(exact) = snapshot.iter().find(|instance| instance.id == key) {
|
||||||
|
return Ok(Some(exact));
|
||||||
|
}
|
||||||
|
let mut by_name = snapshot.iter().filter(|instance| instance.name == key);
|
||||||
|
let first = by_name.next();
|
||||||
|
if by_name.next().is_some() {
|
||||||
|
let count = snapshot
|
||||||
|
.iter()
|
||||||
|
.filter(|instance| instance.name == key)
|
||||||
|
.count();
|
||||||
|
return Err(Rejection {
|
||||||
|
reason: format!("有 {count} 个实例都叫 `{key}`,不能替你猜——请从列表里点那一条"),
|
||||||
|
stale: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(first)
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
//! M304 的判据:启动/停止的实例解析读**已发布快照**,而不是在调用方线程扫一遍盘。
|
||||||
|
//!
|
||||||
|
//! | 判据 | 抓得住什么 |
|
||||||
|
//! |---|---|
|
||||||
|
//! | ① 点下去**同一拍**进 Busy(无 tick、无后台回程) | 把受理做成「等解析回来才发 Busy」 |
|
||||||
|
//! | ② 快照落地后**在盘上新增**一条实例 ⇒ 点它必须被拒绝,不是被扫出来 | 解析改回**现场扫盘**(`game::instances`)|
|
||||||
|
//! | ③ 快照落地后**删掉**这个实例 ⇒ 点它必须拒绝并给具名原因 | 去掉受理那一刻的单实例复核 |
|
||||||
|
//! | ④ 两个同名实例 ⇒ 按名字点必须拒绝(不猜) | 名字兜底取第一个 |
|
||||||
|
//! | ⑤ 停止读快照里的 pid | `running_pid` 兜底改回 `game::instances()` |
|
||||||
|
//!
|
||||||
|
//! ②与③是一对:只做②(纯快照、不复核)会把已删实例拉起来;只做③(只复核、读盘)会退回扫盘。
|
||||||
|
//! 两条都靠**盘面在快照之后变了**来判,不靠计时——计时在 CI 上只会假绿。
|
||||||
|
//!
|
||||||
|
//! 另外两条在 [`super::revalidate_tests`]:所属根不见了必须拒绝(r1 P2-1)、运行时态事件也要落进
|
||||||
|
//! 已发布快照(r1 P2-2)。夹具在 [`super::fixtures`]。
|
||||||
|
|
||||||
|
use crate::dto::{AppCommand, Backend, InstanceRuntime};
|
||||||
|
|
||||||
|
use super::super::job::HostTask;
|
||||||
|
use super::super::CoreHost;
|
||||||
|
use super::fixtures::{
|
||||||
|
error_toast, host, land_snapshot, profile, runtime_event_emitted, settle, write,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// **判据①**:点启动 ⇒ `dispatch` 返回时 Busy 已经在事件队列里(不需要 `tick`、不需要后台回程)。
|
||||||
|
///
|
||||||
|
/// 抓得住:把解析做成异步(「先起线程,等它回来说受理了」)——那时这里读完事件队列还没有 Busy。
|
||||||
|
#[test]
|
||||||
|
fn clicking_launch_shows_busy_in_the_same_beat() {
|
||||||
|
let demo = profile("demo", "1.20.1");
|
||||||
|
let id = demo.id.as_str().to_string();
|
||||||
|
let (mut host, _root) = host("same-beat", std::slice::from_ref(&demo));
|
||||||
|
land_snapshot(&mut host);
|
||||||
|
let epoch = host.instances_scan.epoch();
|
||||||
|
|
||||||
|
host.dispatch(AppCommand::LaunchInstance(id.clone()));
|
||||||
|
|
||||||
|
let events = host.drain_events();
|
||||||
|
assert!(
|
||||||
|
runtime_event_emitted(&events),
|
||||||
|
"点下去必须**同一拍**进 Busy(不许等解析/扫盘回来):{events:?}"
|
||||||
|
);
|
||||||
|
assert!(host.in_flight(&id), "受理要立刻落进 pending");
|
||||||
|
assert_eq!(
|
||||||
|
host.instances_scan.epoch(),
|
||||||
|
epoch,
|
||||||
|
"受理这条路不许顺手派一轮扫描(代次变了就说明它扫盘了)"
|
||||||
|
);
|
||||||
|
|
||||||
|
settle(&mut host);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **判据②(变异:解析改回现场扫盘 ⇒ 这条红)**:快照落地之后盘上**多了**一条实例。
|
||||||
|
///
|
||||||
|
/// 点它必须被拒绝(快照里没有),而不是被「现场扫一遍盘」扫出来直接拉起来。用户看见的列表里
|
||||||
|
/// 根本没有这一条——把不在屏幕上的实例启动起来,正是「两套真相」最坏的形状。
|
||||||
|
///
|
||||||
|
/// 拒绝之后 host 会自己重扫(`Rejection::stale`)⇒ 重扫完成再点,就必须受理。
|
||||||
|
#[test]
|
||||||
|
fn an_instance_that_appeared_after_the_snapshot_is_refused_not_launched() {
|
||||||
|
let demo = profile("demo", "1.20.1");
|
||||||
|
let late = profile("late", "1.21");
|
||||||
|
let late_id = late.id.as_str().to_string();
|
||||||
|
let (mut host, _root) = host("late", std::slice::from_ref(&demo));
|
||||||
|
land_snapshot(&mut host);
|
||||||
|
|
||||||
|
// 快照之后才写进盘的第二条:host 手里的快照里没有它
|
||||||
|
write(host.env(), &[demo, late]);
|
||||||
|
|
||||||
|
host.dispatch(AppCommand::LaunchInstance(late_id.clone()));
|
||||||
|
let events = host.drain_events();
|
||||||
|
assert!(
|
||||||
|
!runtime_event_emitted(&events),
|
||||||
|
"解析必须读快照:快照里没有的实例不许被拉起来(现场扫盘就会拉起来):{events:?}"
|
||||||
|
);
|
||||||
|
assert!(host.pending.is_empty(), "拒绝受理 ⇒ 一个字节都不进 pending");
|
||||||
|
assert!(
|
||||||
|
error_toast(&events).contains("没有"),
|
||||||
|
"要具名说清「列表里没有这条」"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 拒绝不是死路:自动重扫之后它就出现在列表里,再点就受理
|
||||||
|
host.pump_instances();
|
||||||
|
assert!(
|
||||||
|
host.published.iter().any(|instance| instance.id == late_id),
|
||||||
|
"过期快照应被重扫替换(拒绝之后要自己跟上盘面)"
|
||||||
|
);
|
||||||
|
let _ = host.drain_events();
|
||||||
|
host.dispatch(AppCommand::LaunchInstance(late_id.clone()));
|
||||||
|
assert!(
|
||||||
|
runtime_event_emitted(&host.drain_events()),
|
||||||
|
"重扫之后同一个 id 必须能受理"
|
||||||
|
);
|
||||||
|
|
||||||
|
settle(&mut host);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **判据③(变异:去掉单实例复核 ⇒ 这条红)**:快照落地之后删掉这个实例。
|
||||||
|
///
|
||||||
|
/// 点它必须**拒绝并给具名原因**,而不是按快照把它拉起来。这条是本 mission 的真风险——
|
||||||
|
/// 性能只影响手感,拿过期快照启动影响的是「起错东西」。
|
||||||
|
#[test]
|
||||||
|
fn an_instance_deleted_after_the_snapshot_is_refused_with_a_named_reason() {
|
||||||
|
let demo = profile("demo", "1.20.1");
|
||||||
|
let id = demo.id.as_str().to_string();
|
||||||
|
let (mut host, _root) = host("deleted", std::slice::from_ref(&demo));
|
||||||
|
land_snapshot(&mut host);
|
||||||
|
|
||||||
|
// 快照之后才删:盘上真的没了(档案表重写成空表)
|
||||||
|
write(host.env(), &[]);
|
||||||
|
|
||||||
|
host.dispatch(AppCommand::LaunchInstance(id.clone()));
|
||||||
|
let events = host.drain_events();
|
||||||
|
assert!(
|
||||||
|
!runtime_event_emitted(&events),
|
||||||
|
"已删的实例不许被「按快照」拉起来(那会起错东西/给不出原因):{events:?}"
|
||||||
|
);
|
||||||
|
assert!(host.pending.is_empty(), "拒绝受理 ⇒ 一个字节都不进 pending");
|
||||||
|
let message = error_toast(&events);
|
||||||
|
assert!(
|
||||||
|
message.contains("demo") && message.contains("实例清单"),
|
||||||
|
"具名原因要说出是哪个实例、为什么(现在是:{message})"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 拒绝之后列表自己跟上:重扫把这条磁贴抹掉
|
||||||
|
host.pump_instances();
|
||||||
|
assert!(
|
||||||
|
host.published.is_empty(),
|
||||||
|
"重扫之后已删实例不该还留在快照里:{:?}",
|
||||||
|
host.published
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 两个实例同名时**不许猜**:拒绝并说清有几个,按 id 点(界面的真实路径)照常受理。
|
||||||
|
///
|
||||||
|
/// 多根之后「两个根里都叫 `A`」是常见布局,取第一个就是在赌用户点的是哪一条。
|
||||||
|
#[test]
|
||||||
|
fn two_instances_sharing_a_name_are_never_guessed() {
|
||||||
|
let first = profile("同名", "1.20.1");
|
||||||
|
let second = profile("同名", "1.21");
|
||||||
|
let (mut host, _root) = host("same-name", &[first.clone(), second]);
|
||||||
|
land_snapshot(&mut host);
|
||||||
|
|
||||||
|
host.dispatch(AppCommand::LaunchInstance("同名".to_string()));
|
||||||
|
let events = host.drain_events();
|
||||||
|
assert!(
|
||||||
|
!runtime_event_emitted(&events),
|
||||||
|
"两个同名实例不许猜一个来启动:{events:?}"
|
||||||
|
);
|
||||||
|
assert!(host.pending.is_empty());
|
||||||
|
assert!(
|
||||||
|
error_toast(&events).contains("都叫"),
|
||||||
|
"要说清为什么不能替你选"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 按 id(磁贴真正带的值)点:照常受理
|
||||||
|
host.dispatch(AppCommand::LaunchInstance(first.id.as_str().to_string()));
|
||||||
|
assert!(
|
||||||
|
runtime_event_emitted(&host.drain_events()),
|
||||||
|
"按 id 点必须受理(这条路径不受同名影响)"
|
||||||
|
);
|
||||||
|
|
||||||
|
settle(&mut host);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 停止读的是**快照里的 pid**(host 的启动表里没有它时也一样),不为此扫盘。
|
||||||
|
///
|
||||||
|
/// 抓得住:`running_pid` 的兜底分支改回 `game::instances(&env)`——那时盘上这条是 `Idle`,
|
||||||
|
/// 这里会拿到 `None`(状态说「在跑」、点停止却回「没有在跑」)。
|
||||||
|
#[test]
|
||||||
|
fn stop_reads_the_pid_from_the_published_snapshot_not_the_disk() {
|
||||||
|
let demo = profile("demo", "1.20.1");
|
||||||
|
let id = demo.id.as_str().to_string();
|
||||||
|
let (mut host, _root) = host("pid", std::slice::from_ref(&demo));
|
||||||
|
land_snapshot(&mut host);
|
||||||
|
|
||||||
|
for instance in &mut host.published {
|
||||||
|
if instance.id == id {
|
||||||
|
instance.runtime = InstanceRuntime::Running { pid: 4_242 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(host.launches.is_empty(), "host 自己的启动表里没有这一场");
|
||||||
|
assert_eq!(
|
||||||
|
host.running_pid(&id),
|
||||||
|
Some(4_242),
|
||||||
|
"停止要读快照里的 pid,不要为此再扫一次盘"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **判据⑥(r1 复审 P2-2)**:运行时态事件(`InstanceRuntimeChanged`)也必须落进已发布快照。
|
||||||
|
///
|
||||||
|
/// app 侧 `apply` 把它**就地**写进自己的实例列表(只认扫描回程就会分叉),而 `running_pid` 的
|
||||||
|
/// 兜底读的正是快照里那一格 —— 今天不炸只因 `launches` 先命中。
|
||||||
|
///
|
||||||
|
/// 抓得住:`send()` 只认 `InstancesChanged`(快照停在 Idle,这条红)。
|
||||||
|
#[test]
|
||||||
|
fn runtime_events_land_in_the_published_snapshot() {
|
||||||
|
let demo = profile("demo", "1.20.1");
|
||||||
|
let id = demo.id.as_str().to_string();
|
||||||
|
let (mut host, _root) = host("published-runtime", std::slice::from_ref(&demo));
|
||||||
|
land_snapshot(&mut host);
|
||||||
|
let runtime_of = |host: &CoreHost| {
|
||||||
|
host.published
|
||||||
|
.iter()
|
||||||
|
.find(|instance| instance.id == id)
|
||||||
|
.map(|instance| instance.runtime.clone())
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
runtime_of(&host),
|
||||||
|
Some(InstanceRuntime::Idle),
|
||||||
|
"前提:快照里是 Idle"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 唯一真实写入路径:后台线程回报「起来了」
|
||||||
|
host.handle(HostTask::LaunchStarted {
|
||||||
|
id: id.clone(),
|
||||||
|
session_id: "20261113T120000-m304a".to_string(),
|
||||||
|
pid: 4_242,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
runtime_of(&host),
|
||||||
|
Some(InstanceRuntime::Running { pid: 4_242 }),
|
||||||
|
"运行时态事件必须同时落进已发布快照(否则界面那份比快照新)"
|
||||||
|
);
|
||||||
|
assert_eq!(host.running_pid(&id), Some(4_242));
|
||||||
|
}
|
||||||
@@ -45,11 +45,16 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use crate::dto::{
|
use crate::dto::{
|
||||||
AccountView, AppCommand, AppEvent, Backend, CoreEvent, DiagnosisReport, InstallProgressView,
|
AccountView, AppCommand, AppEvent, Backend, CoreEvent, DiagnosisReport, InstallProgressView,
|
||||||
InstanceRuntime, JavaChainStep, LaunchFailureCause, NoticeView, SkinImage,
|
InstanceSummary, JavaChainStep, LaunchFailureCause, NoticeView, SkinImage,
|
||||||
};
|
};
|
||||||
use crate::mock::MockBackend;
|
use crate::mock::MockBackend;
|
||||||
use crate::toast::{Toast, ToastKind};
|
use crate::toast::{Toast, ToastKind};
|
||||||
|
|
||||||
|
// M304:`running_pid` 搬进了 `launch::accept`,本文件不再用它;但 `launch_state_tests`(`use super::*`)
|
||||||
|
// 仍然拿 `InstanceRuntime` 写断言,所以只在单测里引一次(与 `game::refresh` 里 `Backend` 同一个写法)。
|
||||||
|
#[cfg(test)]
|
||||||
|
use crate::dto::InstanceRuntime;
|
||||||
|
|
||||||
use accounts::LoginJob;
|
use accounts::LoginJob;
|
||||||
pub use env::HostEnv;
|
pub use env::HostEnv;
|
||||||
use job::{HostTask, JobReceiver, JobSender};
|
use job::{HostTask, JobReceiver, JobSender};
|
||||||
@@ -122,6 +127,14 @@ pub struct CoreHost {
|
|||||||
/// 实例、漏掉刚装好的),采纳它等于把新快照盖回旧的样子。判据在 `handle::apply_instances`,
|
/// 实例、漏掉刚装好的),采纳它等于把新快照盖回旧的样子。判据在 `handle::apply_instances`,
|
||||||
/// 账目(实例数 / 扫盘耗时 / 丢了几份)在 [`game::Scan`] 里,单测直接读它。
|
/// 账目(实例数 / 扫盘耗时 / 丢了几份)在 [`game::Scan`] 里,单测直接读它。
|
||||||
instances_scan: game::Scan,
|
instances_scan: game::Scan,
|
||||||
|
/// 最近一次**发给界面**的实例快照(M304:启动/停止的实例解析读它,不再扫盘)。
|
||||||
|
///
|
||||||
|
/// 由 [`CoreHost::send`] 发 `InstancesChanged` 时顺手留一份——于是「host 认为的列表」与
|
||||||
|
/// 「界面上的列表」永远是同一份,不会再出现「扫盘那一刻的列表」这种第三份真相。
|
||||||
|
///
|
||||||
|
/// 它可能**落后于盘面**(用户在两拍之间删了实例 / 改了版本):落点的正确性由
|
||||||
|
/// `game::Scan::revalidate` 在受理那一刻复核单条实例,理由与判据见 `launch::target` 模块文档。
|
||||||
|
published: Vec<InstanceSummary>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CoreHost {
|
impl CoreHost {
|
||||||
@@ -177,6 +190,7 @@ impl CoreHost {
|
|||||||
adopt_epoch: 0,
|
adopt_epoch: 0,
|
||||||
update,
|
update,
|
||||||
instances_scan: game::Scan::default(),
|
instances_scan: game::Scan::default(),
|
||||||
|
published: Vec::new(),
|
||||||
};
|
};
|
||||||
if refresh {
|
if refresh {
|
||||||
host.refresh_all();
|
host.refresh_all();
|
||||||
@@ -192,6 +206,18 @@ impl CoreHost {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn send(&mut self, event: CoreEvent) {
|
fn send(&mut self, event: CoreEvent) {
|
||||||
|
// M304:**已发布快照**在这里留一份(启动/停止的实例解析读它,不再扫盘)。
|
||||||
|
// 选这里而不是扫描回程:这里发出去的就是界面上的那一份,两者不可能分叉——所以运行时态事件
|
||||||
|
// 也必须同时落进快照(app 侧 `apply` 会把它**就地**写进自己的列表,只认扫描回程就会分叉)。
|
||||||
|
match &event {
|
||||||
|
CoreEvent::InstancesChanged(instances) => self.published.clone_from(instances),
|
||||||
|
CoreEvent::InstanceRuntimeChanged { id, runtime } => {
|
||||||
|
if let Some(instance) = self.published.iter_mut().find(|entry| &entry.id == id) {
|
||||||
|
instance.runtime.clone_from(runtime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
self.events.push_back(AppEvent::Core(event));
|
self.events.push_back(AppEvent::Core(event));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,80 +308,6 @@ impl CoreHost {
|
|||||||
},
|
},
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 由实例名/版本 id 找启动目标。
|
|
||||||
fn launch_target(&self, id: &str) -> Option<launch::LaunchTarget> {
|
|
||||||
let instance = game::instances(&self.env)
|
|
||||||
.into_iter()
|
|
||||||
.find(|instance| instance.id == id || instance.name == id)?;
|
|
||||||
Some(launch::LaunchTarget {
|
|
||||||
id: instance.id,
|
|
||||||
name: instance.name,
|
|
||||||
version_id: instance.version_id,
|
|
||||||
account: self.active_account.clone(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 某实例是否已在启动/运行(`pending` 与 `launches` 的并集)。
|
|
||||||
///
|
|
||||||
/// 只查 `launches` 不够:`launch::start` 到后台线程回报 `LaunchStarted` 之间有窗口,
|
|
||||||
/// 这期间重复点启动会起第二个进程、并把旧 pid 在启动表里顶掉(旧进程变孤儿)。
|
|
||||||
fn in_flight(&self, key: &str) -> bool {
|
|
||||||
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, "这个实例已经在启动/运行中");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let Some(target) = self.launch_target(id) else {
|
|
||||||
self.toast(ToastKind::Error, format!("找不到实例 {id}"));
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let instance_id = target.id.clone();
|
|
||||||
if self.in_flight(&instance_id) {
|
|
||||||
self.toast(ToastKind::Warn, "这个实例已经在启动/运行中");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.mark_pending(&instance_id);
|
|
||||||
if let Some(notice) = game::mode_change_notice(&self.env, &instance_id) {
|
|
||||||
self.push_notice(notice);
|
|
||||||
}
|
|
||||||
self.send(CoreEvent::InstanceRuntimeChanged {
|
|
||||||
id: instance_id.clone(),
|
|
||||||
runtime: InstanceRuntime::Busy {
|
|
||||||
stage: crate::dto::LaunchStage::Launch,
|
|
||||||
progress: crate::dto::Progress { done: 0, total: 1 },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
launch::start(self.env.clone(), self.tx.clone(), target);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 某实例在跑的 pid(按实例 id 查启动表;不再有「最近一次启动」这种单槽)。
|
|
||||||
///
|
|
||||||
/// 兜底分支读实例快照的 `Running { pid }`:host 自己发的运行时事件会写回快照,
|
|
||||||
/// 但快照是重建的(`game::instances` 每次 Idle),所以这层只是给将来留的口。
|
|
||||||
fn running_pid(&self, id: &str) -> Option<u32> {
|
|
||||||
if let Some(live) = self.launches.iter().find(|live| live.instance == id) {
|
|
||||||
return Some(live.pid);
|
|
||||||
}
|
|
||||||
game::instances(&self.env)
|
|
||||||
.into_iter()
|
|
||||||
.find(|instance| instance.id == id)
|
|
||||||
.and_then(|instance| match instance.runtime {
|
|
||||||
InstanceRuntime::Running { pid } => Some(pid),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Backend for CoreHost {
|
impl Backend for CoreHost {
|
||||||
|
|||||||
Reference in new issue
Block a user