fix(core,cli): M231 复审三条 P2 —— 恢复误删用例 / 收窄 --dry-run 文案 / 判定链去重
P2-1:`natives_cases.rs` 里 `missing_native_jar_becomes_missing_component` 是上一轮 批量截断时**误伤**掉的(我把「tree_snapshot 之后的所有内容」整段砍掉,连带这条与 dry-run 无关的用例),不是有意删。现已从 main 原样贴回,`MissingNativeJar` / natives 组件缺失这条用户可见失败路径恢复覆盖。 P2-2:help 的「也不写盘」是过度承诺 —— `commands/launch.rs` 的 `launcher_log::set_dir` /`record_start`/`record` 都在 `if args.dry_run` 之前,dry-run 仍会 append `<数据根>/logs/launcher.log`(连父目录 create_dir_all)。文案收窄成「不碰版本树 (不解压 natives、不建 natives 目录);启动器自身的运行日志照记」。core 的 `DryRun` 文档同步加了「本层承诺 / 调用方日志另算」的边界。 P2-3:`extract` 与 `preview` 的「无 jar / 戳记命中 / 重解」判定链合成 `inspect()` 一份, 返回 `DirStatus`(NoJars / Ready(files) / Stale)+ 源 jar 指纹;两个入口只在这里分叉。 另:`tree_snapshot_notices_a_transient_directory` 在全量并行跑时偶发红(本轮门禁抓到 1 次, 单跑 3 次全绿)——「建了立刻删」对父目录 mtime 的影响会被 NTFS 合并。换成确定性自检 `tree_snapshot_sees_new_nodes_and_mtime_changes`(新节点 + 同内容重写的 mtime 变化), 并把这条能力边界写进 `tree_snapshot` 的「抓不住」。
This commit is contained in:
1 parent
1fa30c4885
commit
c917bfa18f
5 files changed
+128
-44
No files matched your search
@@ -211,7 +211,8 @@ versions/<原版>-<加载器>-<版本>/<…>.json(inheritsFrom 父版本 + 主
|
|||||||
|
|
||||||
<instance> 可以是实例 id、实例名,或直接是版本 id。
|
<instance> 可以是实例 id、实例名,或直接是版本 id。
|
||||||
--dry-run(D18):只组装命令行并打印,不 spawn 进程、不写会话文件、不刷新令牌,
|
--dry-run(D18):只组装命令行并打印,不 spawn 进程、不写会话文件、不刷新令牌,
|
||||||
也不写盘——不解压 natives、不建 natives 目录(连缺的目录也不补),对版本树是纯读的。
|
也不碰版本树(不解压 natives、不建 natives 目录,连缺的目录也不补);启动器自身的
|
||||||
|
运行日志(<数据根>/logs/launcher.log)照记,这一点与真实启动一致。
|
||||||
账户:--account <name> 指定账户(玩家名或条目 id);不给时用唯一账户,一个都没有则回落
|
账户:--account <name> 指定账户(玩家名或条目 id);不给时用唯一账户,一个都没有则回落
|
||||||
离线占位(--username 或 Player),有多个账户会报错并列出候选。
|
离线占位(--username 或 Player),有多个账户会报错并列出候选。
|
||||||
accessToken 一律脱敏(输出里出现的是 <redacted:token>)。",
|
accessToken 一律脱敏(输出里出现的是 <redacted:token>)。",
|
||||||
|
|||||||
@@ -91,11 +91,13 @@ impl LauncherConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `--dry-run` 的输出(D18):完整命令行,但**不 spawn、不写会话文件、不落盘**。
|
/// `--dry-run` 的输出(D18):完整命令行,但**不 spawn、不写会话文件、不碰磁盘**。
|
||||||
///
|
///
|
||||||
/// 环境准备(第 6 段)在 dry-run 下走 [`crate::launch::natives::preview`]:只算「会解出
|
/// 「不碰磁盘」是本层(core 启动管线)的承诺:环境准备(第 6 段)在 dry-run 下走
|
||||||
/// 几个原生库 / natives 目录是不是已经就绪」,**不建目录、不写文件、不删文件**——
|
/// [`crate::launch::natives::preview`],只算「会解出几个原生库 / natives 目录是不是
|
||||||
/// `--dry-run` 对版本树(尤其是别人铺的 `versions/<id>/`)是纯读的。
|
/// 已经就绪」,**不建目录、不写文件、不删文件**——对版本树(尤其是别人铺的
|
||||||
|
/// `versions/<id>/`)是纯读的。调用方自己的运行日志(CLI 的 `<数据根>/logs/launcher.log`)
|
||||||
|
/// 不在此列。
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct DryRun {
|
pub struct DryRun {
|
||||||
/// 实际要执行的程序。
|
/// 实际要执行的程序。
|
||||||
|
|||||||
@@ -227,6 +227,34 @@ pub fn collect(
|
|||||||
natives
|
natives
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 目录状态的三种结论:两个入口共用一条判定链,别各写一份。
|
||||||
|
enum DirStatus {
|
||||||
|
/// 无需解压:1.19+ 没有 natives 分类器 jar,只要目录在。
|
||||||
|
NoJars,
|
||||||
|
/// 戳记与当前 jar 集一致:目录已就绪,上次解出 `usize` 个原生库。
|
||||||
|
Ready(usize),
|
||||||
|
/// 需要(重新)解压。
|
||||||
|
Stale,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 判定链 + 本次源 jar 指纹(真解压拿它写戳记,预览丢弃)。
|
||||||
|
///
|
||||||
|
/// `extract` 与 `preview` **只在这里**决定「要不要解压」:改判定只改这一处,
|
||||||
|
/// 两边不会漂。
|
||||||
|
fn inspect(dir: &Path, plan: &NativesPlan) -> (DirStatus, Vec<jar::JarStamp>) {
|
||||||
|
let stamps = jar_stamps(&plan.jars);
|
||||||
|
if plan.jars.is_empty() {
|
||||||
|
return (DirStatus::NoJars, stamps);
|
||||||
|
}
|
||||||
|
let ready = read_stamp(dir)
|
||||||
|
.filter(|stamp| stamp.matches(&stamps))
|
||||||
|
.map(|stamp| stamp.files());
|
||||||
|
match ready {
|
||||||
|
Some(files) => (DirStatus::Ready(files), stamps),
|
||||||
|
None => (DirStatus::Stale, stamps),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 解压一个计划(幂等:目录与源 jar 都没变时零写盘)。
|
/// 解压一个计划(幂等:目录与源 jar 都没变时零写盘)。
|
||||||
///
|
///
|
||||||
/// `--dry-run` 不走这里,走 [`preview`]:那里一个字节都不写。
|
/// `--dry-run` 不走这里,走 [`preview`]:那里一个字节都不写。
|
||||||
@@ -254,18 +282,18 @@ pub fn extract(dir: &Path, plan: &NativesPlan) -> Result<NativesReport, NativesE
|
|||||||
std::fs::create_dir_all(dir)
|
std::fs::create_dir_all(dir)
|
||||||
.map_err(|source| jar::io_error("create_natives_dir", dir, source))?;
|
.map_err(|source| jar::io_error("create_natives_dir", dir, source))?;
|
||||||
report.dir_exists = true;
|
report.dir_exists = true;
|
||||||
if plan.jars.is_empty() {
|
let (status, stamps) = inspect(dir, plan);
|
||||||
|
match status {
|
||||||
|
DirStatus::NoJars => {
|
||||||
report.up_to_date = true;
|
report.up_to_date = true;
|
||||||
return Ok(report);
|
return Ok(report);
|
||||||
}
|
}
|
||||||
|
DirStatus::Ready(files) => {
|
||||||
let stamps = jar_stamps(&plan.jars);
|
|
||||||
if let Some(stamp) = read_stamp(dir) {
|
|
||||||
if stamp.matches(&stamps) {
|
|
||||||
report.up_to_date = true;
|
report.up_to_date = true;
|
||||||
report.skipped = stamp.files();
|
report.skipped = files;
|
||||||
return Ok(report);
|
return Ok(report);
|
||||||
}
|
}
|
||||||
|
DirStatus::Stale => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 源变了:先清旧原生库(避免两代 dll 混在同一个 java.library.path 里),再全量解。
|
// 源变了:先清旧原生库(避免两代 dll 混在同一个 java.library.path 里),再全量解。
|
||||||
@@ -279,10 +307,10 @@ pub fn extract(dir: &Path, plan: &NativesPlan) -> Result<NativesReport, NativesE
|
|||||||
|
|
||||||
/// 预览一个计划:算出「真启动会怎么解压」,**不建目录、不写文件、不删文件**。
|
/// 预览一个计划:算出「真启动会怎么解压」,**不建目录、不写文件、不删文件**。
|
||||||
///
|
///
|
||||||
/// `--dry-run` 的唯一环境准备入口。与 [`extract`] 共用口径:
|
/// `--dry-run` 的唯一环境准备入口。与 [`extract`] 共用判定链([`inspect`])与条目
|
||||||
/// 无 jar(1.19+)= 无需解压;戳记与当前 jar 集一致 = 已就绪;否则读 jar 清点
|
/// 筛选口径:无 jar(1.19+)= 无需解压;戳记与当前 jar 集一致 = 已就绪;否则读 jar
|
||||||
/// 「将写下几个原生库」。区别只有「不落盘」:没有 `create_dir_all`、没有 `clear_natives`、
|
/// 清点「将写下几个原生库」。区别只有「不落盘」:没有 `create_dir_all`、没有
|
||||||
/// 没有 `write_stamp`,因此版本树的目录结构与父目录 mtime 都不动。
|
/// `clear_natives`、没有 `write_stamp`,因此版本树的目录结构与父目录 mtime 都不动。
|
||||||
///
|
///
|
||||||
/// jar 打不开 / 不是合法 zip 照样报错(与真启动同一条失败路径),预览不会藏坏包。
|
/// jar 打不开 / 不是合法 zip 照样报错(与真启动同一条失败路径),预览不会藏坏包。
|
||||||
///
|
///
|
||||||
@@ -305,18 +333,18 @@ pub fn preview(dir: &Path, plan: &NativesPlan) -> Result<NativesReport, NativesE
|
|||||||
planned: true,
|
planned: true,
|
||||||
dir_exists: dir.is_dir(),
|
dir_exists: dir.is_dir(),
|
||||||
};
|
};
|
||||||
if plan.jars.is_empty() {
|
let (status, _stamps) = inspect(dir, plan);
|
||||||
|
match status {
|
||||||
|
DirStatus::NoJars => {
|
||||||
report.up_to_date = true;
|
report.up_to_date = true;
|
||||||
return Ok(report);
|
return Ok(report);
|
||||||
}
|
}
|
||||||
|
DirStatus::Ready(files) => {
|
||||||
let stamps = jar_stamps(&plan.jars);
|
|
||||||
if let Some(stamp) = read_stamp(dir) {
|
|
||||||
if stamp.matches(&stamps) {
|
|
||||||
report.up_to_date = true;
|
report.up_to_date = true;
|
||||||
report.skipped = stamp.files();
|
report.skipped = files;
|
||||||
return Ok(report);
|
return Ok(report);
|
||||||
}
|
}
|
||||||
|
DirStatus::Stale => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
for native in &plan.jars {
|
for native in &plan.jars {
|
||||||
|
|||||||
@@ -176,3 +176,46 @@ fn natives_extracted_event_carries_report_fields() {
|
|||||||
.expect("dir 是字符串")
|
.expect("dir 是字符串")
|
||||||
.ends_with("natives"));
|
.ends_with("natives"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 缺 natives jar:适配层当成组件缺失(组装前显式失败),而不是等到 UnsatisfiedLinkError。
|
||||||
|
#[test]
|
||||||
|
fn missing_native_jar_becomes_missing_component() {
|
||||||
|
let (mut request, ctx) = legacy_forge_request();
|
||||||
|
request.natives = NativesPlan::new(vec![NativeJar::new(
|
||||||
|
"C:/mc/libraries/missing-natives-windows.jar",
|
||||||
|
"org.lwjgl:lwjgl:3.2.2:natives-windows",
|
||||||
|
)]);
|
||||||
|
// 直接验证适配层:给一个不存在的 natives jar,严格模式应产出 MissingNativeJar 告警。
|
||||||
|
let libraries: Vec<LibraryInput> = request
|
||||||
|
.classpath
|
||||||
|
.iter()
|
||||||
|
.map(|path| LibraryInput::new(path.clone(), "x"))
|
||||||
|
.collect();
|
||||||
|
let natives = request.natives.jars.clone();
|
||||||
|
let snapshot = SnapshotInput {
|
||||||
|
version_id: "1.16.5",
|
||||||
|
main_class: Some("net.minecraft.client.main.Main"),
|
||||||
|
libraries: &libraries,
|
||||||
|
natives: &natives,
|
||||||
|
..SnapshotInput::minimal("1.16.5", "net.minecraft.client.main.Main")
|
||||||
|
};
|
||||||
|
let options = AdapterOptions {
|
||||||
|
java: JavaSelection::new("C:/java/bin/java.exe", 17, 64),
|
||||||
|
..AdapterOptions::strict()
|
||||||
|
};
|
||||||
|
let output = build_request(&snapshot, options);
|
||||||
|
assert!(
|
||||||
|
output
|
||||||
|
.warnings
|
||||||
|
.iter()
|
||||||
|
.any(|warning| matches!(warning, AdapterWarning::MissingNativeJar { .. })),
|
||||||
|
"应有 MissingNativeJar 告警:{:?}",
|
||||||
|
output.warnings
|
||||||
|
);
|
||||||
|
assert_eq!(output.request.natives.era, NativesEra::Legacy);
|
||||||
|
// 组装按既有语义直接失败(missing_components 非空)。
|
||||||
|
assert!(matches!(
|
||||||
|
assemble(&output.request, &ctx).err(),
|
||||||
|
Some(LaunchError::Assembly(AssemblyError::MissingComponents(_)))
|
||||||
|
));
|
||||||
|
}
|
||||||
@@ -12,16 +12,16 @@ use crate::prelude::*;
|
|||||||
|
|
||||||
/// 版本树的「节点类型 + mtime(纳秒)+ 相对路径」快照,用来逐字比较 dry-run 前后。
|
/// 版本树的「节点类型 + mtime(纳秒)+ 相对路径」快照,用来逐字比较 dry-run 前后。
|
||||||
///
|
///
|
||||||
/// 抓得住:新增/删除任何节点(含 `natives/` 目录本身 —— 根节点的 mtime 就是父目录 mtime)、
|
/// 抓得住:新增/删除任何节点(含 `natives/` 目录本身)、任何已有节点被重写或 mtime 被
|
||||||
/// 任何已有节点被重写或 mtime 被更新。目录节点的 mtime 会因「建子目录」而变,所以
|
/// 更新;根节点(= 父目录 `versions/<id>/`)也在快照里,所以「建目录」会同时体现在
|
||||||
/// 「建了又删」这种瞬时写入也会被记到根节点上(只要快照跨过了那次操作)。
|
/// 节点集合与父目录 mtime 上。
|
||||||
/// 抓不住:内容变了但 mtime 被回填成原值(本仓没有这种写法);时间戳分辨率粗于操作间隔的
|
/// 抓不住:①内容变了但 mtime 被回填成原值(本仓没有这种写法);②**建了立刻删**这类
|
||||||
/// 文件系统(FAT/exFAT 的 2s 粒度)上「建了又删」可能落进同一格 —— 那种情况只剩
|
/// 瞬时写入只留父目录 mtime 一条线索,而 NTFS 会把该更新合并掉 —— 本仓实测:全量并行跑
|
||||||
/// 「`natives` 目录不存在」这条判据兜底,瞬时的建-删会漏。
|
/// 测试时这条偶发看不见(单跑又看得见),所以瞬时建删只剩「`natives` 目录不存在」兜底。
|
||||||
///
|
///
|
||||||
/// 时间戳口径:NTFS / ext4 的 mtime 精度是 100ns 级,快照取纳秒整数比较,所以同一秒内的
|
/// 时间戳口径:NTFS / ext4 的 mtime 精度是 100ns 级,快照取纳秒整数比较,同一秒内的
|
||||||
/// 多次操作(建目录 → 删目录)照样分辨得出来,不需要 `sleep` 把两次操作推到不同秒。
|
/// 两次操作(写文件 → 再写一次)分辨得出来,不需要 `sleep` 把操作推到不同秒。
|
||||||
/// 下面 `tree_snapshot_notices_a_transient_directory` 就是这条判据的自检(防假绿)。
|
/// 判据自检见 `tree_snapshot_sees_new_nodes_and_mtime_changes`(防假绿)。
|
||||||
fn tree_snapshot(root: &Path) -> Vec<String> {
|
fn tree_snapshot(root: &Path) -> Vec<String> {
|
||||||
fn line(meta: &std::fs::Metadata, relative: &str) -> String {
|
fn line(meta: &std::fs::Metadata, relative: &str) -> String {
|
||||||
let nanos = meta
|
let nanos = meta
|
||||||
@@ -206,30 +206,40 @@ fn dry_run_after_real_extract_reports_ready_without_writing() {
|
|||||||
assert_eq!(report.skipped, 1, "{report:?}");
|
assert_eq!(report.skipped, 1, "{report:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 判据自检(防假绿):快照必须看得见「建一个子目录又立刻删掉」留下的父目录 mtime 变化,
|
/// 判据自检(防假绿):快照必须看得见「新增节点」与「已有节点的 mtime 变化」,
|
||||||
/// 否则上面几条「快照逐字相等」等于没断言。
|
/// 否则上面几条「快照逐字相等」等于没断言。
|
||||||
|
///
|
||||||
|
/// 这里**不**用「建了立刻删」来探父目录 mtime:NTFS 会把瞬时建删对父目录 mtime 的影响
|
||||||
|
/// 合并掉(本仓实测:全量并行跑时偶发看不见、单跑看得见),拿它当门禁就是引入 flaky。
|
||||||
|
/// 改成两种确定性变化:新节点(结构维度)+ 同内容重写(时间维度,只有文件 mtime 变,
|
||||||
|
/// 不依赖父目录时间戳的合并行为)。
|
||||||
#[test]
|
#[test]
|
||||||
fn tree_snapshot_notices_a_transient_directory() {
|
fn tree_snapshot_sees_new_nodes_and_mtime_changes() {
|
||||||
let root = temp_dir("natives-snapshot-probe");
|
let root = temp_dir("natives-snapshot-probe");
|
||||||
let version_dir = root.join("versions/1.16.5");
|
let version_dir = root.join("versions/1.16.5");
|
||||||
std::fs::create_dir_all(&version_dir).expect("建版本目录");
|
std::fs::create_dir_all(&version_dir).expect("建版本目录");
|
||||||
std::fs::write(version_dir.join("1.16.5.json"), b"{}").expect("放清单占位");
|
std::fs::write(version_dir.join("1.16.5.json"), b"{}").expect("放清单占位");
|
||||||
let before = tree_snapshot(&version_dir);
|
let baseline = tree_snapshot(&version_dir);
|
||||||
|
|
||||||
// 模拟 `create_dir_all(natives)` 之后目录又被删掉:节点集合复原,只有父目录 mtime 变了。
|
// 1) 结构维度:多一个目录节点(正是 `create_dir_all(natives)` 留下的痕迹)。
|
||||||
let transient = version_dir.join("natives");
|
std::fs::create_dir(version_dir.join("natives")).expect("建目录");
|
||||||
std::fs::create_dir(&transient).expect("建目录");
|
let with_dir = tree_snapshot(&version_dir);
|
||||||
std::fs::remove_dir(&transient).expect("删目录");
|
assert!(
|
||||||
assert!(!transient.exists(), "目录应已删掉");
|
with_dir.len() > baseline.len(),
|
||||||
|
"快照必须看得见新目录:{baseline:?} vs {with_dir:?}"
|
||||||
|
);
|
||||||
|
assert_ne!(baseline, with_dir);
|
||||||
|
|
||||||
let after = tree_snapshot(&version_dir);
|
// 2) 时间维度:同一份内容重写一遍,节点集合不变、文件 mtime 必须变。
|
||||||
|
std::fs::write(version_dir.join("1.16.5.json"), b"{}").expect("重写清单占位");
|
||||||
|
let rewritten = tree_snapshot(&version_dir);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
before.len(),
|
with_dir.len(),
|
||||||
after.len(),
|
rewritten.len(),
|
||||||
"节点集合应恢复原样,差异只该在 mtime 上:{before:?} vs {after:?}"
|
"节点集合没变,差异只该在 mtime 上"
|
||||||
);
|
);
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
before, after,
|
with_dir, rewritten,
|
||||||
"快照必须看得见瞬时建删留下的父目录 mtime 变化,否则零落盘断言是假绿"
|
"快照必须看得见已有文件的 mtime 变化,否则零落盘断言是假绿"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user