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:
1 parent
583a6cf24b
commit
2fb61d8006
9 files changed
+449
-126
No files matched your search
@@ -103,6 +103,31 @@ pub(in crate::host) fn external_versions(env: &HostEnv, game: &GameLayout) -> Ve
|
||||
.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 当实例 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。
|
||||
///
|
||||
/// **M274**:`root_key` 也进哈希(`fnv(root || 0 || version)`)——两个根里的 `1.20.4` 因此是两个
|
||||
/// 实例。分隔符用 `0`:路径里不可能有 NUL,所以 `("ab","c")` 与 `("a","bc")` 不会撞。
|
||||
/// **M274**:`root_key` 也进哈希(`fnv(root || 0 || version)`,见 [`instance_id_from`])——两个根里
|
||||
/// 的 `1.20.4` 因此是两个实例。
|
||||
pub(in crate::host) fn external_instance_id(root_key: &str, version_id: &str) -> String {
|
||||
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
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)
|
||||
instance_id_from(&[root_key, version_id])
|
||||
}
|
||||
|
||||
/// 磁贴的显示名:配置根里的版本就是版本目录名;**别的根**里的版本带上根路径。
|
||||
|
||||
@@ -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
|
||||
))
|
||||
}
|
||||
@@ -196,3 +196,30 @@ fn isolation_write_then_read_round_trips() {
|
||||
assert!(set_isolation(&env, &id, "nope").is_err());
|
||||
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- 档案一律反查不到)"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user