Files
Unlucky-Launcher/crates/core/src/game/java/select.rs
T
AskaEth ff78fe2cae 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。
2026-11-09 17:07:22 +08:00

293 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 「启动选哪个 Java」的**纯**优先链(D13)。
//!
//! 优先级:实例覆盖(`--java` / 档案 `javaPath`)> 托管目录里**满足要求**的 JDK >
//! `JAVA_HOME` > `PATH`。实例覆盖由调用方单独判(用户明确指定了就不参与打分),
//! 这里只处理「一堆已探测的候选 + 一个要求」的挑选。
//!
//! 挑选规则(比「机械按来源排序」更贴近用户预期):
//!
//! 1. **满足要求的候选优先**——1.12.2 只能用 Java 8,托管的 21 再新也不能选;
//! 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。
use super::mapping::{check, JavaMismatch, JavaRequirement};
use super::probe::{JavaRuntime, DEFAULT_MAJOR};
/// 一次启动的 Java 选择结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JavaResolution {
/// 选中的运行时(一个候选都没有时 `None`)。
pub runtime: Option<JavaRuntime>,
/// 目标版本对 Java 的要求。
pub required: JavaRequirement,
/// 不符说明(`None` = 满足要求,或压根没探测到 Java)。
pub mismatch: Option<JavaMismatch>,
}
impl JavaResolution {
/// 生效的主版本号(没有运行时按缺省值)。
#[must_use]
pub fn major(&self) -> u32 {
self.runtime
.as_ref()
.map_or(DEFAULT_MAJOR, JavaRuntime::major_or_default)
}
/// 是否满足要求(没有运行时视为「不知道」,返回 `true`)。
#[must_use]
pub fn is_satisfied(&self) -> bool {
self.mismatch.is_none()
}
}
/// 候选主版本号与要求区间的**距离**(`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 distance = runtime
.major
.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 {
let runtime = pick(candidates, required).cloned();
let mismatch = runtime
.as_ref()
.and_then(|runtime| runtime.major)
.and_then(|major| check(major, required));
JavaResolution {
runtime,
required: required.clone(),
mismatch,
}
}
#[cfg(test)]
mod tests {
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 {
JavaRuntime {
binary: std::path::PathBuf::from(format!("java{major}")),
origin,
version: Some(format!("openjdk version \"{major}.0.1\"")),
major: Some(major),
bits: 64,
vendor: None,
}
}
fn at_least(minimum: u32) -> JavaRequirement {
JavaRequirement::new(minimum, None, RequirementSource::Table)
}
#[test]
fn satisfying_managed_runtime_beats_mismatching_system_java() {
let candidates = [
runtime(8, JavaOrigin::Path),
runtime(21, JavaOrigin::Managed),
];
let picked = resolve(&candidates, &at_least(17));
assert_eq!(picked.major(), 21);
assert!(picked.is_satisfied());
}
#[test]
fn satisfying_system_java_beats_mismatching_managed() {
// 1.12.2 只能 Java 8:托管的 21 不满足,PATH 上的 8 满足 → 选 8。
let required = JavaRequirement::new(8, Some(8), RequirementSource::Table);
let candidates = [
runtime(21, JavaOrigin::Managed),
runtime(8, JavaOrigin::Path),
];
let picked = resolve(&candidates, &required);
assert_eq!(picked.major(), 8);
assert!(picked.is_satisfied());
}
#[test]
fn managed_wins_when_both_satisfy() {
let candidates = [
runtime(21, JavaOrigin::Path),
runtime(17, JavaOrigin::JavaHome),
runtime(17, JavaOrigin::Managed),
];
assert_eq!(resolve(&candidates, &at_least(17)).major(), 17);
}
#[test]
fn java_home_beats_path_when_both_satisfy() {
let candidates = [
runtime(21, JavaOrigin::Path),
runtime(21, JavaOrigin::JavaHome),
];
let picked = resolve(&candidates, &at_least(21));
assert_eq!(
picked.runtime.map(|runtime| runtime.origin),
Some(JavaOrigin::JavaHome)
);
}
#[test]
fn no_candidate_keeps_the_default_and_reports_no_mismatch() {
let picked = resolve(&[], &at_least(21));
assert_eq!(picked.major(), DEFAULT_MAJOR);
assert!(picked.runtime.is_none());
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)];
let picked = resolve(&candidates, &at_least(21));
assert_eq!(picked.major(), 8);
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));
}
}