fix(launch): M304 r1 —— 根不见了必须拒绝 + 运行时态落进快照 + 真 host 端到端同拍判据

三条 r1 复审 P2,全部按代码修(不是只改注释)。

**P2-1(唯一实质问题)**:`ext-` 实例「所属根消失 / 改名」被静默放行 ⇒ 可能起错副本。
`live.rs::discovered` 反查落空时原来一律 `Ok(())`;而 `roots::all` 对非配置根是
`if !extra.is_dir() { continue; }`,于是官方根被删 / 改名 / 盘没挂上时反查必然落空 ——
放行之后 `build_request` → `game_for` 会退回配置根,配置根里恰好有同名 `version_id`
就把**另一份**拉起来(gameDir / 存档都在另一边),相对改前(同步扫盘 ⇒ 扫不出来 ⇒ 拒绝)
是回归。改法:把 M273 的「只哈希版本目录名」搬进 `discovered.rs`
(`legacy_external_instance_id`,与 `external_instance_id` 共用同一个多段 FNV),反查落空时
**老档案放行、其余一律拒绝**并给具名原因(+ `stale` 触发重扫)。
另加 `game::tests::instance_id_hashes_are_the_released_ones`:两个哈希公式的字面量钉死
——这次把哈希抽成公共函数,公式一漂就会误杀所有存量 `ext-` 档案。

**P2-2**:`send()` 只认 `InstancesChanged`,而 app 侧 `state.rs:274` 把
`InstanceRuntimeChanged` **就地**写进自己的列表 ⇒ `runtime` 这一位必定分叉,而
`accept::running_pid` 的兜底读的正是它。改:`send()` 同时把运行时态落到 `published`
对应条目,并加用例 `runtime_events_land_in_the_published_snapshot`。

**P2-3**:补真 `CoreHost` + 真 app 的端到端判据(`app/update/instances/scan_phase.rs`):
临时数据根 + 真 host,`advance` 到首份快照落地后点启动,**`advance` 之前**详情页必须已
`can_stop`。变异(把 Busy 挪进启动线程、只有回程才发)实测必红。

判据(新增 3 条 + 1 条钉公式):`revalidate_tests`(根不见了 ⇒ 拒绝)、
`target_tests`(运行时态落快照)、`scan_phase`(真 host 同一拍)、`game::tests`(哈希公式)。
用例夹具按 400 行硬线拆到 `launch/fixtures.rs`。

门禁:`cargo test -p unlucky-presenter -p unlucky-app-iced --all-targets` 全绿
(426 + 1076)· `clippy --workspace --all-targets -- -D warnings` 0 ·
`fmt --all --check` 0 · 行数全 ≤400(最大 366;未碰的 `app/update/instances.rs` 399)。
This commit is contained in:
AskaEth committed 2026-11-13 20:46:00 +08:00
1 parent 583a6cf24b
commit 2fb61d8006
9 files changed
+449 -126

No files matched your search

+25 -6
View File
@@ -19,10 +19,21 @@
//! | 档案实例(id 不是 `ext-`) | `profiles.json` 里还有这条 id,且 `version_id` 没被改过 | 一次小文件读 |
//! | 自动发现的(`ext-`) | 逐个根重算 id(哈希)定位到 (根, 版本),再用 core 的 `adopt::inspect` 判这一个版本 | #根 次哈希 + 一次单版本检查 |
//!
//! 反查不到根的 `ext-` id(M273 时代的老档案,哈希里没有根)**不在这里判死**:那种 id 的语义
//! 本来就是「按配置根解析」,真没版本时 `build_request` 会给出具名阶段失败(`VersionManifest` +
//! 路径),一个进程都没起来。改前那条同步扫盘也不会拦它——`version_present: false` 的磁贴在
//! 旧实现里照样能点、照样走到同一条具名失败。
//! ## 反查落空的两种情形,必须分开(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`] 上
//!
@@ -37,7 +48,7 @@ use unlucky_core::game::install::instance::read_profiles;
use crate::dto::InstanceSummary;
use super::super::super::HostEnv;
use super::super::discovered::external_instance_id;
use super::super::discovered::{external_instance_id, legacy_external_instance_id};
use super::super::roots;
impl super::Scan {
@@ -105,5 +116,13 @@ fn discovered(env: &HostEnv, instance: &InstanceSummary) -> Result<(), String> {
)
});
}
Ok(())
// 反查落空:老档案放行,**根不见了就拒绝**(理由见模块文档——放行会让 `game_for` 退回配置根,
// 把另一个根里同名的那个版本拉起来)。
if legacy_external_instance_id(&instance.version_id) == instance.id {
return Ok(());
}
Err(format!(
"实例 `{}` 所属的游戏目录已不在(被移走 / 改名 / 盘没挂上),列表已过期",
instance.name
))
}