fix(presenter): 回落日志两因并列 + 老 ext 档案覆盖 + 死根档案边界用例(M274 r3)

This commit is contained in:
AskaEth committed 2026-11-12 19:55:52 +08:00
1 parent 70c6aec6dd
commit 72137dc658
3 files changed
+149 -2

No files matched your search

+5 -1
View File
@@ -238,10 +238,14 @@ pub(in crate::host) fn game_for(env: &HostEnv, id: &str) -> GameLayout {
return game.clone();
}
if crate::dto::is_discovered_instance(id) {
// 两因并列,**不要**只写后一种:`ext-` 前缀在 M273 时代就有,那时 id 只哈希版本目录名
// (没有根),所以「M274 之前合成的老档案」**永远**反查不到——那是所有老用户的常态,
// 不是故障。只写「清单半写/被锁」会让每次点开详情页都往日志里加一条像是在报错的假警报
// (M274 r3 复审 P2-1)。
unlucky_core::launch::launcher_log::record(
"multiroot",
&format!(
"实例 {id} 反查不到所属根(版本此刻不可登记:清单半写/被锁/目录已不在)⇒ 按配置根 {} 解析",
"实例 {id} 反查不到所属根(老档案:M273 之前合成,id 不含根;或该版本此刻不可登记:清单半写/被锁/目录已不在)⇒ 按配置根 {} 解析",
env.game.root().display()
),
);
@@ -15,7 +15,7 @@
//! | [`the_same_version_in_two_roots_is_two_instances`] | 两个根里的同名版本 = **2 条**磁贴、id 不同、各自指向自己的根、名字分辨得出来 | 真实官方安装的目录形状(`launcher_profiles.json` / 加密的账户文件一概不读) |
//! | [`a_junction_to_the_configured_root_is_not_a_second_root`] | 同一路径的第二种写法(junction / 符号链接)**不许**让同一批实例列两遍 | 8.3 短名(用例盘上造不出来;判据靠 `canonicalize` 的长名展开,见 `roots::key_of` 的文档) |
//! | [`saves_come_from_the_instance_own_root`] | 每个实例的存档 / 截图列表只来自**它自己那个根**(不许把外部实例的存档当成当前根的) | 游戏进程正在跑时的文件锁 / 半写状态(未处理,见报告) |
//! | [`a_lazily_created_profile_stays_on_its_own_root`] | 懒创建档案(用户第一次改设置)之后 id → 根的反查不退化,且没把配置根的同名版本一并抹掉 | 档案里**没有**根字段这一事实本身(根只从 id 反查,见 `discovered::locate`) |
//! | [`a_lazily_created_profile_stays_on_its_own_root`] | 懒创建档案(用户第一次改设置)之后 id → 根的反查不退化,且没把配置根的同名版本一并抹掉 | 档案里**没有**根字段这一事实本身(根只从 id 反查,见 `discovered::Found`) |
//! | [`the_launch_reads_the_manifest_from_the_instance_root`] | 启动装配的清单 / 运行目录走实例自己的根 | 真的把游戏拉起来(要真 jvm / 账号 / 网络) |
use std::path::{Path, PathBuf};
@@ -161,3 +161,146 @@ fn both_same_named_instances_take_settings_in_either_order() {
assert_eq!(profiles.profiles.len(), 2, "两条实例各一份档案,不多不少");
}
}
// ------------------------------------------------- 反查回落(M274 r3 复审 P2-1 / ②)
/// M273 时代的 `ext-` id:**只哈希版本目录名**(不含根)。M274 之后这种 id 永远反查不到。
fn m273_external_id(version_id: &str) -> String {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for byte in version_id.as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
format!("ext-{hash:016x}")
}
/// 在**我们自己的日志目录**里数这个实例的回落行(重试到「登记窗口没被并行用例抢走」为止)。
///
/// `launcher_log` 的目录是**进程级全局**(`set_dir` 以最后一次为准),而 presenter 的用例并行跑、
/// 七十多处 `HostEnv::at_root` 都会重新登记。所以这里每次尝试换一个**新目录**:抢到了就能在
/// 一个全新文件里看到**恰好一行**;没抢到(文件不存在)就再试。抢不到 64 次视为用例失败(不当跳过)。
fn fallback_lines(env: &HostEnv, id: &str) -> Vec<String> {
let file = unlucky_core::launch::launcher_log::FILE_NAME;
let mut saw_file = false;
for attempt in 0..64 {
let dir = std::env::temp_dir().join(format!(
"ull-host-multiroot3-log-{}-{attempt}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|elapsed| elapsed.as_nanos())
.unwrap_or(0)
));
let _ = std::fs::remove_dir_all(&dir);
unlucky_core::launch::launcher_log::set_dir(&dir);
let _ = game_for(env, id);
let Ok(text) = std::fs::read_to_string(dir.join(file)) else {
// 文件没建出来:这一拍的登记窗口被并行用例抢走了(它们也会 `set_dir`),再试。
continue;
};
let lines: Vec<String> = text
.lines()
.filter(|line| line.contains(id))
.map(str::to_string)
.collect();
std::fs::remove_dir_all(&dir).ok();
if !lines.is_empty() {
return lines;
}
// 文件在、却没有我们的行:可能是别人的行占了这个文件,也可能这条路径**根本没写**。
// 再试几次,全都不中就当「0 行」交给断言去红(比在这里 panic 好读)。
saw_file = true;
}
if saw_file {
return Vec::new();
}
panic!("64 次都没看到日志文件:回落日志没写,或登记窗口每次都被并行用例抢走");
}
/// **老 `ext-` 档案**(M273 形状)反查不到 ⇒ 落配置根,并且日志里**恰好一行** `[multiroot]`,
/// 文案**两因并列**(老档案 ‖ 此刻不可登记)。
///
/// 这条路径此前零覆盖(M274 r3 复审指出):它是所有 M273 老用户升级后的**常态**,不是故障;
/// 文案只写「清单半写/被锁/目录已不在」会让每次点开详情页都往日志里加一条假警报。
///
/// 变异证明:把文案只留「此刻不可登记」那半句 ⇒ 下面的关键词断言红;把回落日志整段删掉 ⇒
/// 「恰好一行」红(0 行)。
#[test]
fn an_old_m273_instance_falls_back_with_one_two_cause_log_line() {
let (_root, env) = two_roots("ull-host-multiroot3-fallback");
write_version(&env.game, "A");
let old_id = m273_external_id("A");
assert!(
crate::dto::is_discovered_instance(&old_id),
"老 id 也带 `ext-` 前缀(这正是「永远反查不到」的来源)"
);
assert_eq!(
game_for(&env, &old_id).root(),
env.game.root(),
"老档案反查不到 ⇒ 只能按配置根解析"
);
let lines = fallback_lines(&env, &old_id);
assert_eq!(
lines.len(),
1,
"一次回落只该写一行(多了会把日志刷成噪声):{lines:?}"
);
let line = &lines[0];
assert!(line.contains("[multiroot]"), "分类名要可 grep:{line}");
assert!(
line.contains("老档案"),
"M273 老 id 是**常态**,文案必须并列这一因,不能只说「清单半写/被锁」:{line}"
);
assert!(
line.contains("不可登记"),
"另一因(官方启动器正在更新)也要在:{line}"
);
}
/// **已知边界**(不是设计意图):官方根的版本目录消失后,那条死档案会「吞掉」配置根的同名版本。
///
/// 现场(M274 r3 复审 ②):两根各一个 `A`,官方那条先懒创建;官方根的 `versions/` 随后不见
/// (官方启动器升级 / 清理 / 卸载 / 换盘)。此时:
/// - 列表只剩 **1 条**,名字还写着 `A · …\official\.minecraft`(指向官方根);
/// - 而 `version_present` / 读盘路径却全从**配置根**读出来(`game_for` 回落);
/// - ⇒ 真正的配置根那条 `A` **从列表消失**(被 `bound` 判成「已被档案占住」)。
///
/// 这是 r1 判 P1 的那种「显示与行为分叉」的反向形态。**这轮不改结构**:真正的修法是给
/// `InstanceProfile` 加「根」字段(`profiles.json` schema + 迁移),代价超出本 mission。
/// 所以这条用例钉的是**现状**——将来做 schema 迁移时它会红,届时要跟着改成「两条都在」。
#[test]
fn a_dead_official_root_is_swallowed_by_its_stale_profile() {
let (_root, env) = two_roots("ull-host-multiroot3-deadroot");
write_version(&env.game, "A");
write_version(&GameLayout::new(official(&env)), "A");
let official_id = tile_id(&env, |tile| tile.name.contains("official"));
set_isolation(&env, &official_id, "shared").expect("官方那条先懒创建档案");
// 官方启动器把那个版本清理掉了(目录整个不见)。
std::fs::remove_dir_all(GameLayout::new(official(&env)).versions_dir())
.expect("删官方 versions");
let snapshot = instances(&env);
assert_eq!(
snapshot.len(),
1,
"现状:只有档案那一条(配置根的同名版本被 bound 吞掉):{snapshot:?}"
);
let tile = &snapshot[0];
assert_eq!(tile.id, official_id);
assert!(
tile.name.contains("official"),
"名字还指着**已经不存在的**官方根:{tile:?}"
);
assert!(
tile.version_present && game_for(&env, &tile.id).root() == env.game.root(),
"而读盘/显示已经悄悄落回配置根(显示与行为分叉的反向形态):{tile:?}"
);
let dir = super::super::saves::run_dir(&env, tile).expect("派生运行目录");
assert_eq!(
dir,
env.game.root(),
"存档也落回配置根——这就是「死档案吞掉同名版本」的后果"
);
}