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

+35
View File
@@ -104,6 +104,41 @@ fn posix_signals_are_recognized() {
);
}
#[test]
fn windows_terminated_code_with_no_output_is_not_an_application_error() {
// D10:1.12.2 挂死后被任务管理器 / `taskkill /F` 结束 —— 退出码 1,零错误输出。
let analysis = analyze(1, 60_000, &[], false);
assert_eq!(analysis.exit_type, ExitType::Killed);
assert!(analysis.terminated_externally);
assert!(
analysis.summary().contains("疑似被外部结束"),
"{}",
analysis.summary()
);
// 同样的退出码带错误输出 → 仍是应用错误(不能把所有 1 都洗成「被结束」)。
let lines = vec![line(
Stream::Stderr,
"Exception in thread \"main\" java.lang.NullPointerException",
)];
let analysis = analyze(1, 60_000, &lines, false);
assert_eq!(analysis.exit_type, ExitType::ApplicationError);
assert!(!analysis.terminated_externally);
// POSIX 上 1 不是信号痕迹:不套这条判据(137/143 才是)。
let analysis = analyze(1, 60_000, &[], true);
assert_eq!(analysis.exit_type, ExitType::ApplicationError);
assert!(!analysis.terminated_externally);
}
#[test]
fn a_deliberate_kill_is_reported_as_killed_not_suspected() {
// 「我们杀的」有登记作据,不该被说成「疑似」。
let mut signals = exit_analysis::ExitSignals::new(1, 60_000, &[]);
signals.killed = true;
let analysis = exit_analysis::analyze(&signals);
assert_eq!(analysis.exit_type, ExitType::Killed);
assert!(!analysis.terminated_externally);
}
#[test]
fn windows_ntstatus_is_explained() {
let analysis = analyze(-1_073_741_819, 60_000, &[], false);
+1
View File
@@ -14,3 +14,4 @@ mod pump_buffer;
mod session_events;
mod sink_cases;
mod tree_kill_cases;
mod watchdog_cases;
+37 -8
View File
@@ -77,15 +77,16 @@ fn echo_script() -> String {
}
}
fn long_running_script() -> String {
fn long_running_script(dir: &std::path::Path) -> String {
#[cfg(windows)]
{
"ping -n 30 127.0.0.1 >nul".to_string()
}
let (file, body) = (
dir.join("linger.bat"),
"@echo off\r\nping -n 30 127.0.0.1 >nul\r\n",
);
#[cfg(not(windows))]
{
"sleep 30".to_string()
}
let (file, body) = (dir.join("linger.sh"), "#!/bin/sh\nsleep 30\n");
std::fs::write(&file, body).expect("写长跑脚本");
file.display().to_string()
}
#[test]
@@ -191,7 +192,7 @@ fn pipeline_stop_records_killed_exit() {
let sessions = temp_sessions_root("killed");
let run_dir = temp_dir("killed-run");
let launcher = launcher(&sessions, None);
let (request, _) = shell_request(&long_running_script(), &run_dir);
let (request, _) = shell_request(&long_running_script(&run_dir), &run_dir);
let mut session = launcher.spawn(&request).expect("spawn 应成功");
assert!(session.pid() > 0);
@@ -211,6 +212,34 @@ fn pipeline_stop_records_killed_exit() {
assert_eq!(index[0].exit_type.as_deref(), Some("killed"));
}
#[test]
fn a_kill_from_another_thread_is_recorded_as_killed() {
// D10:GUI 的 `host::launch::stop(pid)` 是从别的线程直接 `kill_tree`,不经过
// `LaunchSession::stop()`。这条路以前会记成 `application_error`(Windows 退出码 1)。
let sessions = temp_sessions_root("external-kill");
let run_dir = temp_dir("external-kill-run");
let launcher = launcher(&sessions, None);
let (request, _) = shell_request(&long_running_script(&run_dir), &run_dir);
let session = launcher.spawn(&request).expect("spawn 应成功");
let pid = session.pid();
// `kill_tree` 的返回值不代表「进程还在」:温和阶段可能已经关掉了它(taskkill 报 128)。
let _ = unlucky_core::launch::tree_kill::kill_tree(pid, Duration::from_millis(200));
let handle = session.finish().expect("收尾应成功");
assert_eq!(
handle.exit.exit_type,
ExitType::Killed,
"我们杀的要判成 killed:{}",
handle.exit.summary()
);
assert!(
!handle.exit.terminated_externally,
"有登记作据就不该只是「疑似」:{}",
handle.exit.summary()
);
}
#[test]
fn pipeline_assembly_failure_still_writes_evidence() {
let sessions = temp_sessions_root("assembly-fail");
+311
View File
@@ -0,0 +1,311 @@
//! D6 看门狗:有界等待(真进程,不是 mock)。
//!
//! 四条用例各自钉一个判据:
//!
//! 1. 没窗口也没退出 → 立刻停止等待并报错,**进程还活着**(不杀);
//! 2. 窗口出现过 → 慢慢加载不会被判死,正常退出照常给结果;
//! 3. 出现关闭标记后仍不退出 → 判「卡在关闭」(实机 D6 的形态);
//! 4. 短命的正常退出 → 不会被超时误判成错误。
//!
//! 子进程一律是**脚本文件**(`.bat` / `.sh`),不把 `&` / 重定向塞进 `cmd /C` 的单个参数:
//! 那样 cmd 的引号剥离规则会让脚本变成另一条命令(第一版就是这么翻车的)。
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use crate::prelude::*;
use unlucky_core::launch::tree_kill;
use unlucky_core::launch::watchdog::WatchdogConfig;
fn launcher(sessions_root: &Path, startup: Duration, grace: Duration) -> Launcher {
let mut config = LauncherConfig::new(sessions_root);
config.platform = PlatformCtx::new(OsFamily::Windows, "x86_64");
config.process = ProcessConfig {
graceful_stop: Duration::from_millis(200),
// 泵的预算压到 200ms:放弃等待之后不该再为「等日志落盘」多等默认的 10 秒。
watchdog: WatchdogConfig {
pump_timeout: Duration::from_millis(200),
},
startup_timeout: startup,
shutdown_grace: grace,
..ProcessConfig::default()
};
Launcher::new(config)
}
/// 写一个伪装成 java 的脚本,返回它的路径。
fn script(dir: &Path, name: &str, lines: &[&str]) -> PathBuf {
#[cfg(windows)]
let (file, shebang) = (dir.join(format!("{name}.bat")), "@echo off");
#[cfg(not(windows))]
let (file, shebang) = (dir.join(format!("{name}.sh")), "#!/bin/sh");
let body = format!("{shebang}\n{}\n", lines.join("\n"));
std::fs::write(&file, body).expect("写测试脚本");
file
}
/// 用脚本文件当 java(命令行组装、事件流、退出判定都是真的,只有 JVM 是假的)。
fn request(script_path: &Path, run_dir: &Path) -> LaunchRequest {
let (mut request, _) = legacy_forge_request();
#[cfg(windows)]
{
request.wrapper = vec![
"cmd".to_string(),
"/C".to_string(),
script_path.display().to_string(),
];
}
#[cfg(not(windows))]
{
request.wrapper = vec!["/bin/sh".to_string(), script_path.display().to_string()];
}
request.java = JavaSelection::new("fake-java", 17, 64);
request.manifest.legacy_arguments = None;
request.dirs.game_dir = run_dir.to_path_buf();
request.dirs.natives_dir = run_dir.join("natives");
request.user.jvm = Vec::new();
request.user.game = Vec::new();
request
}
/// 进程是否还活着(看门狗「不杀进程」的判据)。
fn is_alive(pid: u32) -> bool {
#[cfg(windows)]
{
let output = std::process::Command::new("tasklist")
.args(["/FI", &format!("PID eq {pid}"), "/NH"])
.output()
.expect("tasklist 应可执行");
String::from_utf8_lossy(&output.stdout).contains(&pid.to_string())
}
#[cfg(not(windows))]
{
std::process::Command::new("kill")
.args(["-0", &pid.to_string()])
.status()
.map(|status| status.success())
.unwrap_or(false)
}
}
/// 一条「活着但什么也不干」的脚本(约 3 秒)。
fn linger_lines() -> Vec<&'static str> {
#[cfg(windows)]
{
vec!["ping -n 4 127.0.0.1 >nul"]
}
#[cfg(not(windows))]
{
vec!["sleep 3"]
}
}
/// 先打一行窗口标记,再活 1 秒左右。
fn window_then_exit_lines() -> Vec<&'static str> {
#[cfg(windows)]
{
vec!["echo LWJGL Version: 3.3.1", "ping -n 2 127.0.0.1 >nul"]
}
#[cfg(not(windows))]
{
vec!["echo 'LWJGL Version: 3.3.1'", "sleep 1"]
}
}
/// 每秒打一行、总共约 4 秒的脚本(**有**日志、**没有**窗口标记)。
#[cfg(windows)]
fn chatty_lines() -> Vec<&'static str> {
// `ping` 每秒打一行;不重定向就是为了让它真的输出。
vec!["ping -n 5 127.0.0.1"]
}
/// 同上(POSIX)。
#[cfg(not(windows))]
fn chatty_lines() -> Vec<&'static str> {
vec!["i=0; while [ $i -lt 5 ]; do echo \"tick $i\"; sleep 1; i=$((i+1)); done"]
}
#[test]
fn a_chatty_slow_load_is_not_mistaken_for_a_hang() {
// 一直没有窗口,但每秒都有一行日志:静默预算 1.5s,总时长 4s+ ⇒ 不能被判死。
let sessions = temp_sessions_root("watchdog-chatty");
let run_dir = temp_dir("watchdog-chatty-run");
let path = script(&run_dir, "chatty", &chatty_lines());
let launcher = launcher(
&sessions,
Duration::from_millis(1500),
Duration::from_secs(60),
);
let session = launcher
.spawn(&request(&path, &run_dir))
.expect("spawn 应成功");
let started = Instant::now();
let handle = session.finish().expect("还在打日志就不算卡死");
assert!(
started.elapsed() > Duration::from_millis(1500),
"这条用例要真的越过静默预算,否则什么都没验到:{:?}",
started.elapsed()
);
assert_eq!(handle.exit.code, 0);
assert_eq!(handle.exit.exit_type, ExitType::Normal);
assert!(
!handle
.index
.as_ref()
.is_some_and(|entry| entry.window_detected),
"这条脚本不该命中窗口启发式"
);
}
/// 先打关闭标记,再挂住不走(约 9 秒)。
fn shutdown_then_hang_lines() -> Vec<&'static str> {
#[cfg(windows)]
{
vec![
"echo [Client thread/INFO]: Stopping!",
"ping -n 10 127.0.0.1 >nul",
]
}
#[cfg(not(windows))]
{
vec!["echo '[Client thread/INFO]: Stopping!'", "sleep 9"]
}
}
#[test]
fn startup_timeout_stops_waiting_and_never_kills_the_process() {
let sessions = temp_sessions_root("watchdog-startup");
let run_dir = temp_dir("watchdog-startup-run");
let path = script(&run_dir, "linger", &linger_lines());
let launcher = launcher(
&sessions,
Duration::from_millis(300),
Duration::from_secs(60),
);
let session = launcher
.spawn(&request(&path, &run_dir))
.expect("spawn 应成功");
let pid = session.pid();
let log_path = session.log_path().to_path_buf();
let started = Instant::now();
let error = session
.finish()
.expect_err("没窗口也没退出:必须停止等待并报错,不能无限等");
let spent = started.elapsed();
assert!(
spent < Duration::from_secs(5),
"超时后应立即返回:{spent:?}"
);
match &error {
LaunchError::Watchdog {
pid: reported,
reason,
limit_ms,
..
} => {
assert_eq!(*reported, pid);
assert_eq!(*reason, "startup_timeout");
assert_eq!(*limit_ms, 300, "上限要如实回报(可配 + 可见)");
}
other => panic!("应报看门狗超时,实际 {other:?}"),
}
let text = error.to_string();
assert!(text.contains("没有被结束"), "{text}");
assert!(text.contains("ULL_LAUNCH_STARTUP_TIMEOUT_SECS"), "{text}");
// 会话照样收尾(历史里看得见这一局),原因是 still_running 而不是 exit。
let events = read_events(&log_path);
let last = events.last().expect("至少写了 session_start");
assert_eq!(last["kind"], "session_end");
assert_eq!(last["payload"]["reason"], "still_running");
// 关键断言:进程还活着 —— 看门狗只停止等待,绝不结束进程。
assert!(is_alive(pid), "看门狗不该结束进程(pid {pid} 已经不在了)");
let _ = tree_kill::force_kill(pid);
}
#[test]
fn a_seen_window_keeps_the_wait_alive_for_slow_loads() {
// 启动预算是 300ms,但窗口标记在几十毫秒内就到了:慢加载不是卡死。
let sessions = temp_sessions_root("watchdog-window");
let run_dir = temp_dir("watchdog-window-run");
let path = script(&run_dir, "windowed", &window_then_exit_lines());
let launcher = launcher(
&sessions,
Duration::from_millis(300),
Duration::from_secs(60),
);
let session = launcher
.spawn(&request(&path, &run_dir))
.expect("spawn 应成功");
let started = Instant::now();
let handle = session
.finish()
.expect("窗口出现过之后就该一直等到它自己退出");
assert!(
started.elapsed() > Duration::from_millis(300),
"这条用例要真的越过启动预算,否则什么都没验到"
);
assert_eq!(
handle.exit.exit_type,
ExitType::Normal,
"{}",
handle.summary()
);
assert_eq!(handle.exit.code, 0);
assert!(
handle
.index
.as_ref()
.is_some_and(|entry| entry.window_detected),
"窗口检测要落进索引(GUI/诊断都看这一位)"
);
}
#[test]
fn a_shutdown_marker_followed_by_hang_is_reported_as_stuck_on_close() {
// 实机 D6:关窗后 java.exe 仍存活。游戏自己打了「Stopping!」,之后就再没动静。
let sessions = temp_sessions_root("watchdog-shutdown");
let run_dir = temp_dir("watchdog-shutdown-run");
let path = script(&run_dir, "hanging", &shutdown_then_hang_lines());
let launcher = launcher(
&sessions,
Duration::from_secs(60),
Duration::from_millis(400),
);
let session = launcher
.spawn(&request(&path, &run_dir))
.expect("spawn 应成功");
let pid = session.pid();
let started = Instant::now();
let error = session
.finish()
.expect_err("关闭标记之后挂住:必须停止等待并报错");
assert!(
started.elapsed() < Duration::from_secs(6),
"宽限一到就要返回:{:?}",
started.elapsed()
);
match &error {
LaunchError::Watchdog { reason, .. } => assert_eq!(*reason, "shutdown_timeout"),
other => panic!("应报卡在关闭,实际 {other:?}"),
}
assert!(error.to_string().contains("已开始关闭"), "{error}");
let _ = tree_kill::force_kill(pid);
}
#[test]
fn a_quick_clean_exit_is_never_punished_by_the_watchdog() {
let sessions = temp_sessions_root("watchdog-quick");
let run_dir = temp_dir("watchdog-quick-run");
let path = script(&run_dir, "quick", &["echo hello"]);
let launcher = launcher(&sessions, Duration::from_secs(3), Duration::from_secs(3));
let session = launcher
.spawn(&request(&path, &run_dir))
.expect("spawn 应成功");
let handle = session.finish().expect("正常退出不该报错");
assert_eq!(handle.exit.code, 0);
assert_eq!(handle.exit.exit_type, ExitType::Normal);
assert!(!handle.exit.terminated_externally);
}