fix(core): 老版本 Java 按版本距离选 + 启动看门狗有界等待 + 结束归属(M214 D4/D6/D10)

D4:`pick` 的排序键从「来源序」改成「版本距离 → 来源序」,全不满足时选最接近要求的那个
(托管 25 与 JAVA_HOME/扫盘 17 之间选 17,而不是按 rank 选托管)。新增 `select::decide`:
不符且**没有人显式指定 Java** 时 `ull launch` 直接报 `java_mismatch` 不 spawn(`--java` /
实例 `javaPath` 是豁免,只告警)——1.12.2 配 Java 17/25 必然起不来,警告没有信息量。
`JavaMismatch` + `check` 拆到 `mismatch.rs` 并加 `advice()`(mapping.rs 408→282 行)。

D6:`LaunchSession::finish` 的等进程改成**有界**。判据在 `startup_watch.rs`(纯逻辑):
①窗口从未出现且**连续 N 秒没有任何日志**(还在打日志就说明还在加载,慢加载不被判死);
②日志出现关闭标记后 N 秒仍未退出(实机「关窗后 java.exe 仍存活」的形状)。
到点只**停止等待、绝不杀进程**:写 `session_end(still_running)` + 索引 + `launcher.log`,
返回 `LaunchError::Watchdog`,CLI 翻成 `launch_timeout`。两个时长可配
(默认 300s/60s;`ULL_LAUNCH_STARTUP_TIMEOUT_SECS` / `ULL_LAUNCH_SHUTDOWN_GRACE_SECS`,
生效值每次启动写进 launcher.log 与人读输出)。

D10:把「被终止」与「应用错误」分开。①我们自己杀的:`tree_kill` **送达即登记**
`kill_registry`(pid 登记簿,取走语义、上限 64),覆盖 GUI 从别的线程 `kill_tree` 的路径,
`finish` 据此判 `killed`;②外部强杀:Windows 上「退出码 1 + 零错误输出 + 无崩溃/JVM 关键字」
判 `killed` 并标 `terminated_externally`(任务管理器与 `taskkill /F` 都是
`TerminateProcess(h, 1)`,应用真崩给的是 NTSTATUS 异常码)。

顺带:`SessionHandle` 拆 `session_handle.rs`(launch_session.rs 422→343 行)、
`NativesReport::to_json` 落 core(cli/launch.rs 腾出空间,仍 399 行)。
用例:select 距离/取舍、startup_watch 状态机、watchdog_cases(4 条真进程)、
exit_cases(外部终止与「我们杀的」区分)、pipeline 跨线程 kill。
This commit is contained in:
AskaEth committed 2026-11-09 17:07:22 +08:00
1 parent 5959a27add
commit ff78fe2cae
23 files changed
+1708 -250

No files matched your search

+9 -103
View File
@@ -1,4 +1,6 @@
//! MC 版本 ↔ Java 版本映射(平移 HMCL 约束体系)+ 不符检查。
//! MC 版本 ↔ Java 版本映射(平移 HMCL 约束体系);不符判定与文案见
//! [`crate::game::java::mismatch`](其中的 `check` / `JavaMismatch` 仍从本模块转出,
//! 调用方的 `mapping::check` 写法不变)。
//!
//! 依据 `DevDocs/analyse/02-launch-pipeline.md` §5.4(HMCL `GameJavaVersion.getMinimumJavaVersion`)
//! 与 §3.1 的强制约束清单(`JavaVersionConstraint` 的 `isMandatory` 项)。两条权威叠加:
@@ -25,6 +27,8 @@ use super::super::inherit::resolve;
use super::super::layout::GameLayout;
use super::super::manifest::{JavaVersion, VersionJson};
pub use super::mismatch::{check, JavaMismatch};
/// 要求来源(`--json` 的 `requirement_source`)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RequirementSource {
@@ -90,66 +94,6 @@ impl JavaRequirement {
}
}
/// 不符的两种形态(D13:只提示,不阻断)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JavaMismatch {
/// Java 太低。
TooOld {
/// 需要的最低主版本。
required: u32,
/// 实际主版本。
actual: u32,
},
/// Java 太高(老版本只能用 8)。
TooNew {
/// 允许的最高主版本。
maximum: u32,
/// 实际主版本。
actual: u32,
},
}
impl JavaMismatch {
/// 稳定字符串 id(`--json` 的 `mismatch.code`)。
#[must_use]
pub const fn code(self) -> &'static str {
match self {
Self::TooOld { .. } => "java_too_old",
Self::TooNew { .. } => "java_too_new",
}
}
/// 人读说明(GUI 弹窗 P1 后直接复用这句)。
#[must_use]
pub fn message(self) -> String {
match self {
Self::TooOld { required, actual } => format!(
"该版本需要 Java {required} 及以上,当前用的是 Java {actual}(大概率起不来)"
),
Self::TooNew { maximum, actual } => format!(
"该版本只能用 Java {maximum} 及以下,当前用的是 Java {actual}(老加载器吃不了新 class 文件)"
),
}
}
}
/// 判需求与实际是否相符(`None` = 相符)。
#[must_use]
pub fn check(actual: u32, required: &JavaRequirement) -> Option<JavaMismatch> {
if actual < required.minimum {
return Some(JavaMismatch::TooOld {
required: required.minimum,
actual,
});
}
if let Some(maximum) = required.maximum {
if actual > maximum {
return Some(JavaMismatch::TooNew { maximum, actual });
}
}
None
}
/// 静态表:MC 版本 → Java 要求。
///
/// 无法解析的版本(周快照 `20w14a`、魔改 id)只给下限 `8`、不设上限——
@@ -281,7 +225,7 @@ fn at_least(parts: &[u32], floor: &[u32]) -> bool {
#[cfg(test)]
mod tests {
use super::{check, recommended_major, requirement_for, JavaMismatch, JavaRequirement};
use super::{recommended_major, requirement_for, JavaRequirement};
#[test]
fn table_covers_the_hmcl_boundaries() {
@@ -330,47 +274,9 @@ mod tests {
}
#[test]
fn check_reports_both_directions() {
let modern = JavaRequirement::table(21, None);
assert_eq!(check(21, &modern), None);
assert_eq!(check(25, &modern), None);
assert_eq!(
check(17, &modern),
Some(JavaMismatch::TooOld {
required: 21,
actual: 17
})
);
let legacy = JavaRequirement::table(8, Some(8));
assert_eq!(check(8, &legacy), None);
assert_eq!(
check(21, &legacy),
Some(JavaMismatch::TooNew {
maximum: 8,
actual: 21
})
);
assert_eq!(legacy.describe(), "Java 8");
assert_eq!(modern.describe(), "Java 21+");
fn describe_spells_out_the_interval() {
assert_eq!(requirement_for("1.12.2").describe(), "Java 8");
assert_eq!(requirement_for("1.20.5").describe(), "Java 21+");
assert_eq!(JavaRequirement::table(8, Some(16)).describe(), "Java 8–16");
}
#[test]
fn mismatch_messages_name_both_sides() {
let message = JavaMismatch::TooOld {
required: 21,
actual: 8,
}
.message();
assert!(message.contains("Java 21"));
assert!(message.contains("Java 8"));
assert_eq!(
JavaMismatch::TooNew {
maximum: 8,
actual: 21
}
.code(),
"java_too_new"
);
}
}
+167
View File
@@ -0,0 +1,167 @@
//! Java 与目标版本**不符**的判定与文案(从 `mapping` 拆出:那边只管版本表与清单推导)。
//!
//! 两种形态(D13 引入,D4 起参与启动决策):
//!
//! - [`JavaMismatch::TooOld`]:新版本配了太老的 Java(1.20.5+ 需要 21,手里只有 17);
//! - [`JavaMismatch::TooNew`]:老版本配了新 Java(1.12.2 只能用 8,托管里只有 21/25)。
//!
//! 这里只产出数据与人读文案;**拦不拦**由调用方决定(CLI 见
//! [`crate::game::java::select::decide`]:不符且用户没显式指定 Java 就拦住)。
use super::mapping::JavaRequirement;
/// 不符的两种形态。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JavaMismatch {
/// Java 太低。
TooOld {
/// 需要的最低主版本。
required: u32,
/// 实际主版本。
actual: u32,
},
/// Java 太高(老版本只能用 8)。
TooNew {
/// 允许的最高主版本。
maximum: u32,
/// 实际主版本。
actual: u32,
},
}
impl JavaMismatch {
/// 稳定字符串 id(`--json` 的 `mismatch.code`)。
#[must_use]
pub const fn code(self) -> &'static str {
match self {
Self::TooOld { .. } => "java_too_old",
Self::TooNew { .. } => "java_too_new",
}
}
/// 人读说明(GUI 弹窗 P1 后直接复用这句)。
#[must_use]
pub fn message(self) -> String {
match self {
Self::TooOld { required, actual } => format!(
"该版本需要 Java {required} 及以上,当前用的是 Java {actual}(大概率起不来)"
),
Self::TooNew { maximum, actual } => format!(
"该版本只能用 Java {maximum} 及以下,当前用的是 Java {actual}(老加载器吃不了新 class 文件)"
),
}
}
/// 一句**可操作**的建议(D4:拦下来之后必须告诉用户怎么继续)。
///
/// 两个出口:装一个对版本的 JDK,或用 `--java` 明确指定要试的那个(显式指定不再被拦)。
#[must_use]
pub fn advice(self) -> String {
let wanted = match self {
Self::TooOld { required, .. } => required,
Self::TooNew { maximum, .. } => maximum,
};
format!(
"装一个 Java {wanted}(`java install {wanted}`),或用 `--java <路径>` 指定要试的那个"
)
}
/// 实际主版本号。
#[must_use]
pub const fn actual(self) -> u32 {
match self {
Self::TooOld { actual, .. } | Self::TooNew { actual, .. } => actual,
}
}
}
/// 判需求与实际是否相符(`None` = 相符)。
#[must_use]
pub fn check(actual: u32, required: &JavaRequirement) -> Option<JavaMismatch> {
if actual < required.minimum {
return Some(JavaMismatch::TooOld {
required: required.minimum,
actual,
});
}
if let Some(maximum) = required.maximum {
if actual > maximum {
return Some(JavaMismatch::TooNew { maximum, actual });
}
}
None
}
#[cfg(test)]
mod tests {
use super::{check, JavaMismatch};
use crate::game::java::mapping::JavaRequirement;
#[test]
fn check_reports_both_directions() {
let modern = JavaRequirement::table(21, None);
assert_eq!(check(21, &modern), None);
assert_eq!(check(25, &modern), None);
assert_eq!(
check(17, &modern),
Some(JavaMismatch::TooOld {
required: 21,
actual: 17
})
);
let legacy = JavaRequirement::table(8, Some(8));
assert_eq!(check(8, &legacy), None);
assert_eq!(
check(21, &legacy),
Some(JavaMismatch::TooNew {
maximum: 8,
actual: 21
})
);
}
#[test]
fn messages_name_both_sides_and_codes_are_stable() {
let message = JavaMismatch::TooOld {
required: 21,
actual: 8,
}
.message();
assert!(message.contains("Java 21"));
assert!(message.contains("Java 8"));
assert_eq!(
JavaMismatch::TooNew {
maximum: 8,
actual: 21
}
.code(),
"java_too_new"
);
assert_eq!(
JavaMismatch::TooNew {
maximum: 8,
actual: 21
}
.actual(),
21
);
}
#[test]
fn advice_names_the_major_and_the_escape_hatch() {
let advice = JavaMismatch::TooNew {
maximum: 8,
actual: 17,
}
.advice();
assert!(advice.contains("Java 8"), "{advice}");
assert!(advice.contains("java install 8"), "{advice}");
assert!(advice.contains("--java"), "{advice}");
assert!(JavaMismatch::TooOld {
required: 21,
actual: 17
}
.advice()
.contains("java install 21"));
}
}
+8 -4
View File
@@ -15,15 +15,18 @@
//! extract zip / tar.gz 解压 + 顶层目录剥离 + 幂等戳记 + zip-slip 防护
//! probe java -version 探测、托管目录扫描(两种布局都认)
//! discover 标准安装位置扫描(Program Files 系 / 官方启动器 runtime / 注册表 / /usr/lib/jvm)+ 去重
//! select 启动选 Java 的**纯**优先链(满足要求优先,再按来源)
//! mapping MC↔Java 静态表(HMCL `GameJavaVersion`)+ 版本 JSON 优先 + 不符检查
//! select 启动选 Java 的**纯**优先链(满足要求优先,全不满足时按版本距离,再按来源)
//! mapping MC↔Java 静态表(HMCL `GameJavaVersion`)+ 版本 JSON 优先
//! mismatch 不符判定(太老 / 太新)+ 说明与可操作建议
//! ```
//!
//! # 三条边界
//!
//! 1. **不碰网络细节**:包下载一律交 `core::download` 的引擎,本模块不写重试循环
//! (`MirrorTable::expand` 负责 Temurin 的镜像展开;Oracle 的目录解析在 `oracle`);
//! 2. **不弹窗**:不符检查只产出数据([`JavaMismatch`]),提示交给 CLI 的告警行与 GUI;
//! 2. **不弹窗**:不符检查只产出数据([`JavaMismatch`])与取舍结论([`LaunchVerdict`]),
//! 提示与拦截都交给调用方(D4:不符且用户没显式指定 Java 时 `ull launch` 直接报错,
//! 显式 `--java` 则只告警);
//! 3. **不改启动策略**:「用哪个 Java 启动」是启动期的派生结果,不落盘;落盘的是**装到哪**
//! ([`JavaInstallRoot`],由 `java.installRoot` + 数据目录模式决定)。
//!
@@ -49,6 +52,7 @@ pub mod location;
pub mod manual;
pub mod mapping;
pub mod microsoft;
pub mod mismatch;
pub mod net;
pub mod oracle;
pub mod probe;
@@ -92,7 +96,7 @@ pub use self::probe::{
resolve_on_path, system_candidates, JavaOrigin, JavaRuntime, DEFAULT_MAJOR,
};
pub use self::release::parse_sha256_text;
pub use self::select::{pick, resolve, JavaResolution};
pub use self::select::{decide, distance, pick, resolve, JavaResolution, LaunchVerdict};
pub use self::source::{ChainLog, VendorAttempt};
pub use self::target::AdoptiumTarget;
pub use self::vendor::{order as vendor_order, JdkVendor, VENDOR_IDS};
+142 -10
View File
@@ -7,9 +7,11 @@
//! 挑选规则(比「机械按来源排序」更贴近用户预期):
//!
//! 1. **满足要求的候选优先**——1.12.2 只能用 Java 8,托管的 21 再新也不能选;
//! 2. 同档内按来源:托管 > `JAVA_HOME` > `PATH`(D13:托管目录是启动器的地盘,
//! 用户 PATH 上的 java 可能是另一个 JDK 的残留);
//! 3. 全都不满足时仍给一个(按来源优先取最好那个),并报不符——**提示而不是拒绝**。
//! 2. 全都不满足时按**版本距离**([`distance`])取最接近的那个,同距离再按来源:
//! 托管 > `JAVA_HOME` > `PATH`(D13:托管目录是启动器的地盘,用户 PATH 上的 java
//! 可能是另一个 JDK 的残留)。距离优先而非来源优先是 D4:托管里只有 25、`JAVA_HOME`
//! 是 21 时,1.12.2 该被推上更接近的 21,而不是机械地选「来源排名更高」的托管 25;
//! 3. 挑完还要决定**要不要拦**([`decide`]):不符且用户没显式指定 Java 就不启动。
//!
//! 抽成纯函数是为了可测:测试注入「托管 21 + PATH 8」这类组合,不必真装 JDK。
@@ -43,21 +45,70 @@ impl JavaResolution {
}
}
/// 从候选里挑一个:满足要求的优先,同档内按来源优先序。
/// 候选主版本号与要求区间的**距离**(`0` = 满足要求)。
///
/// D4:主版本号是离散档位,用户装的 `17` 与 `25` 对「只能用 8」的 1.12.2 都是错的,
/// 但 `17` 只差 9 档、`25` 差 17 档——真机上「离得近」的那个起得来的概率明显更高
/// (老版本对新 class 文件的容忍度随版本差递减)。
#[must_use]
pub fn distance(major: u32, required: &JavaRequirement) -> u32 {
if major < required.minimum {
return required.minimum - major;
}
match required.maximum {
Some(maximum) if major > maximum => major - maximum,
_ => 0,
}
}
/// 从候选里挑一个:先按「离要求多远」,同距离再按来源优先序。
///
/// 探不到主版本号的候选排最后(不知道就不能算“近”)。
#[must_use]
pub fn pick<'a>(
candidates: &'a [JavaRuntime],
required: &JavaRequirement,
) -> Option<&'a JavaRuntime> {
candidates.iter().min_by_key(|runtime| {
let ok = runtime
let distance = runtime
.major
.is_some_and(|major| required.satisfied_by(major));
// 先排「不满足」(`ok=false` 的 key 大),再排来源优先序。
(!ok, runtime.origin.rank())
.map_or(u32::MAX, |major| distance(major, required));
// 先排「距离远」,再排来源优先序(托管 > JAVA_HOME > PATH)。
(distance, runtime.origin.rank())
})
}
/// 启动前的取舍(D4)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LaunchVerdict {
/// 照常启动(满足要求,或用户显式指定了 Java——那是他的明确选择)。
Proceed,
/// 拦下:不满足要求且没人明确指定,起不来是必然的,那句警告没有信息量。
Block(JavaMismatch),
}
impl LaunchVerdict {
/// 需要拦下时返回不符说明。
#[must_use]
pub fn block(self) -> Option<JavaMismatch> {
match self {
Self::Proceed => None,
Self::Block(mismatch) => Some(mismatch),
}
}
}
/// 不满足要求时是「只告警」还是「拦住」(D4 的取舍,理由见 [`LaunchVerdict`])。
///
/// 判据只有两条:不符的方向不重要(两个方向都必然起不来),**是不是用户自己选的**才重要。
#[must_use]
pub fn decide(mismatch: Option<JavaMismatch>, explicitly_chosen: bool) -> LaunchVerdict {
match mismatch {
Some(mismatch) if !explicitly_chosen => LaunchVerdict::Block(mismatch),
_ => LaunchVerdict::Proceed,
}
}
/// 挑 + 判不符。
#[must_use]
pub fn resolve(candidates: &[JavaRuntime], required: &JavaRequirement) -> JavaResolution {
@@ -75,8 +126,8 @@ pub fn resolve(candidates: &[JavaRuntime], required: &JavaRequirement) -> JavaRe
#[cfg(test)]
mod tests {
use super::resolve;
use crate::game::java::mapping::{JavaRequirement, RequirementSource};
use super::{decide, distance, resolve, LaunchVerdict};
use crate::game::java::mapping::{JavaMismatch, JavaRequirement, RequirementSource};
use crate::game::java::probe::{JavaOrigin, JavaRuntime, DEFAULT_MAJOR};
fn runtime(major: u32, origin: JavaOrigin) -> JavaRuntime {
@@ -149,6 +200,36 @@ mod tests {
assert!(picked.is_satisfied());
}
#[test]
fn unsatisfiable_candidates_are_ranked_by_distance_not_by_origin() {
// 1.12.2(min=max=8):托管的 25 与 JAVA_HOME 的 17 都不满足要求。
// 按来源排会选托管 25(rank 1 < 2),按版本距离应选 17(距离 9 vs 17)。
let required = JavaRequirement::new(8, Some(8), RequirementSource::Table);
let candidates = [
runtime(25, JavaOrigin::Managed),
runtime(17, JavaOrigin::JavaHome),
];
assert_eq!(
resolve(&candidates, &required).major(),
17,
"全不满足时应按版本距离选 17,而不是按来源选托管的 25"
);
}
#[test]
fn too_old_candidates_are_ranked_by_distance() {
// 需要 21+:托管 8 与 JAVA_HOME 17 都太老(距离 13 vs 4)。
let candidates = [
runtime(8, JavaOrigin::Managed),
runtime(17, JavaOrigin::JavaHome),
];
assert_eq!(
resolve(&candidates, &at_least(21)).major(),
17,
"都太老时应选最接近的那个,而不是托管目录里最老的那个"
);
}
#[test]
fn mismatch_is_reported_when_nothing_fits() {
let candidates = [runtime(8, JavaOrigin::Path)];
@@ -157,4 +238,55 @@ mod tests {
let mismatch = picked.mismatch.expect("应报不符");
assert_eq!(mismatch.code(), "java_too_old");
}
#[test]
fn distance_is_zero_inside_the_interval_and_positive_outside() {
let legacy = JavaRequirement::new(8, Some(8), RequirementSource::Table);
assert_eq!(distance(8, &legacy), 0);
assert_eq!(distance(17, &legacy), 9);
assert_eq!(distance(25, &legacy), 17);
let modern = at_least(21);
assert_eq!(distance(21, &modern), 0);
assert_eq!(distance(25, &modern), 0);
assert_eq!(distance(17, &modern), 4);
// 区间 8–16(假想):16 与 9 都满足,只有 17 超出一档。
let band = JavaRequirement::new(8, Some(16), RequirementSource::Table);
assert_eq!(distance(16, &band), 0);
assert_eq!(distance(17, &band), 1);
}
#[test]
fn unknown_majors_rank_last() {
let mut unknown = runtime(17, JavaOrigin::Managed);
unknown.major = None;
let candidates = [unknown, runtime(25, JavaOrigin::JavaHome)];
let picked = resolve(&candidates, &at_least(21));
assert_eq!(
picked.runtime.map(|runtime| runtime.major),
Some(Some(25)),
"探不到主版本号的候选不能因为来源排名高就胜出"
);
}
#[test]
fn mismatch_blocks_only_when_nobody_chose_that_java() {
let mismatch = JavaMismatch::TooNew {
maximum: 8,
actual: 17,
};
assert_eq!(
decide(Some(mismatch), false),
LaunchVerdict::Block(mismatch)
);
assert_eq!(decide(Some(mismatch), true), LaunchVerdict::Proceed);
assert_eq!(decide(None, false), LaunchVerdict::Proceed);
assert_eq!(LaunchVerdict::Proceed.block(), None);
assert_eq!(LaunchVerdict::Block(mismatch).block(), Some(mismatch));
// 太老同样拦:装错方向不会改变「起不来」这个结局。
let too_old = JavaMismatch::TooOld {
required: 21,
actual: 17,
};
assert_eq!(decide(Some(too_old), false).block(), Some(too_old));
}
}