Files
Unlucky-Launcher/crates/core/tests/launch/exit_cases.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

205 lines
7.1 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.
//! 退出分析测试:退出码语义 + `exit 0` 静默失败(`analyse/02` §6.3 §7.2 §7.10-10)。
use crate::prelude::*;
fn line(stream: Stream, text: &str) -> LogLine {
LogLine::new(stream, text)
}
fn analyze(
code: i32,
uptime_ms: u64,
lines: &[LogLine],
posix: bool,
) -> unlucky_core::launch::exit_analysis::ExitAnalysis {
let mut signals = exit_analysis::ExitSignals::new(code, uptime_ms, lines);
signals.is_posix = posix;
exit_analysis::analyze(&signals)
}
#[test]
fn clean_long_run_is_normal() {
let lines = vec![line(
Stream::Stdout,
"[12:00:00] [Render thread/INFO]: started",
)];
let analysis = analyze(0, 120_000, &lines, true);
assert_eq!(analysis.exit_type, ExitType::Normal);
assert!(analysis.silent_failure.is_none());
assert!(!analysis.needs_diagnosis);
}
#[test]
fn exit_zero_short_lived_is_silent_failure() {
let lines = vec![line(Stream::Stdout, "Stopping!")];
let analysis = analyze(0, 8_000, &lines, true);
assert_eq!(analysis.exit_type, ExitType::Normal);
let silent = analysis.silent_failure.expect("应标记静默失败");
assert!(silent.short_lived);
assert!(!silent.error_output);
assert!(analysis.needs_diagnosis, "静默失败必须进诊断流水线");
}
#[test]
fn exit_zero_with_error_output_is_silent_failure() {
let lines = vec![
line(Stream::Stdout, "[12:00:00] [main/INFO]: loading"),
line(
Stream::Stderr,
"java.lang.RuntimeException: mixin apply failed",
),
];
let analysis = analyze(0, 90_000, &lines, true);
assert_eq!(analysis.exit_type, ExitType::Normal);
let silent = analysis.silent_failure.expect("应标记静默失败");
assert!(silent.error_output);
assert!(!silent.short_lived);
assert!(silent.describe().contains("错误特征"));
assert_eq!(analysis.error_lines, 1);
}
#[test]
fn jvm_keywords_take_precedence() {
let lines = vec![line(
Stream::Stderr,
"Error occurred during initialization of VM",
)];
let analysis = analyze(1, 500, &lines, true);
assert_eq!(analysis.exit_type, ExitType::JvmError);
assert_eq!(analysis.jvm_keywords.len(), 1);
assert!(analysis.needs_diagnosis);
}
#[test]
fn nonzero_without_keywords_is_application_error() {
let lines = vec![line(Stream::Stdout, "[12:00:00] [main/INFO]: hi")];
let analysis = analyze(1, 40_000, &lines, true);
assert_eq!(analysis.exit_type, ExitType::ApplicationError);
assert!(analysis.needs_diagnosis);
}
#[test]
fn crash_keywords_promote_exit_zero() {
let lines = vec![line(
Stream::Stdout,
"[12:00:00] [Render thread/ERROR]: Crash report saved to ./crash-reports/crash-1.txt",
)];
let analysis = analyze(0, 60_000, &lines, true);
assert_eq!(analysis.exit_type, ExitType::ApplicationError);
assert_eq!(analysis.crash_keywords.len(), 1);
assert!(
analysis.silent_failure.is_none(),
"已判异常就不再是静默失败"
);
}
#[test]
fn posix_signals_are_recognized() {
assert_eq!(analyze(137, 60_000, &[], true).exit_type, ExitType::SigKill);
assert_eq!(analyze(143, 60_000, &[], true).exit_type, ExitType::SigTerm);
// Windows 上 137 没有信号语义,按应用错误处理。
assert_eq!(
analyze(137, 60_000, &[], false).exit_type,
ExitType::ApplicationError
);
}
#[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);
assert_eq!(analysis.exit_type, ExitType::ApplicationError);
assert_eq!(
analysis.windows_status.as_deref(),
Some("0xC0000005 ACCESS_VIOLATION")
);
let dll = analyze(-1_073_741_515, 60_000, &[], false);
assert_eq!(
dll.windows_status.as_deref(),
Some("0xC0000135 DLL_NOT_FOUND")
);
// 0xC000013A = STATUS_CONTROL_C_EXIT(控制台 Ctrl+C / 关窗口),十进制 -1073741510。
let control_c = analyze(-1_073_741_510, 60_000, &[], false);
assert_eq!(
control_c.windows_status.as_deref(),
Some("0xC000013A CONTROL_C_EXIT")
);
// 回归:-1073741743 其实是 0xC0000051,不得被当成 CONTROL_C_EXIT。
assert!(analyze(-1_073_741_743, 60_000, &[], false)
.windows_status
.is_none());
}
#[test]
fn killed_and_interrupted_override_exit_code() {
let mut signals = exit_analysis::ExitSignals::new(1, 5_000, &[]);
signals.killed = true;
assert_eq!(exit_analysis::analyze(&signals).exit_type, ExitType::Killed);
let mut signals = exit_analysis::ExitSignals::new(1, 5_000, &[]);
signals.interrupted = true;
assert_eq!(
exit_analysis::analyze(&signals).exit_type,
ExitType::Interrupted
);
}
#[test]
fn exit_type_strings_match_contract() {
// 与 M15 刻意保持一致的字符串集合(`launch-接口约定.md` §3)。
assert_eq!(ExitType::Normal.as_str(), "normal");
assert_eq!(ExitType::ApplicationError.as_str(), "application_error");
assert_eq!(ExitType::JvmError.as_str(), "jvm_error");
assert_eq!(ExitType::SigKill.as_str(), "sigkill");
assert_eq!(ExitType::SigTerm.as_str(), "sigterm");
assert_eq!(ExitType::Killed.as_str(), "killed");
assert_eq!(ExitType::Interrupted.as_str(), "interrupted");
assert!(!ExitType::Normal.is_abnormal());
assert!(ExitType::JvmError.is_abnormal());
}
#[test]
fn summary_is_human_readable() {
let analysis = analyze(-1_073_741_819, 61_500, &[], false);
let summary = analysis.summary();
assert!(summary.contains("-1073741819"));
assert!(summary.contains("61.5s"));
assert!(summary.contains("ACCESS_VIOLATION"));
}