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

312 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.
//! 退出分析:退出码语义 + `exit 0` 静默失败检测。
//!
//! 平移自 `analyse/02` §6.3/§7.2(`ExitWaiter` 的三源判定),并按 §8.4-2 / §7.10-10 做两处升级:
//!
//! 1. **完整退出码表**:不只认 Linux 的 `137`,Windows 的 NTSTATUS
//! (`0xC0000005` 等)也要能说清是什么;
//! 2. **静默失败单独立标**:退出码 0 但存活 <30s 或 stderr 带错误特征时标记
//! `silent_failure`——HMCL 会判 NORMAL 放过(LaunchWrapper 吞异常后 `exit 0` 是
//! 真实存在的失败形态)。
use crate::launch::pump::LogLine;
/// 退出判定类型。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExitType {
/// 正常退出。
Normal,
/// 应用层崩溃(游戏起来了但崩了)。
ApplicationError,
/// JVM 都没起来(`-Xmx` 过大、JVM 参数非法等)。
JvmError,
/// 被 `SIGKILL` 结束(POSIX 137)。
SigKill,
/// 被 `SIGTERM` 结束(POSIX 143)。
SigTerm,
/// 被启动器主动结束(用户点取消 / 看门狗强制)。
Killed,
/// 等待被中断。
Interrupted,
}
impl ExitType {
/// 事件流里的字符串形式(与 M15 `ExitType` 刻意保持一致)。
pub fn as_str(self) -> &'static str {
match self {
Self::Normal => "normal",
Self::ApplicationError => "application_error",
Self::JvmError => "jvm_error",
Self::SigKill => "sigkill",
Self::SigTerm => "sigterm",
Self::Killed => "killed",
Self::Interrupted => "interrupted",
}
}
/// 是否属于“非正常退出”。
pub fn is_abnormal(self) -> bool {
self != Self::Normal
}
}
/// JVM 未能启动的三条关键字(`analyse/02` §7.2)。
pub const JVM_FAILURE_KEYWORDS: &[&str] = &[
"Could not create the Java Virtual Machine.",
"Error occurred during initialization of VM",
"A fatal exception has occurred. Program will exit.",
];
/// 应用层崩溃的五条关键字(`analyse/02` §7.2,覆盖三种崩法)。
pub const CRASH_KEYWORDS: &[&str] = &[
"Crash report saved to",
"Could not save crash report to",
"This crash report has been saved to:",
"Unable to launch",
"An exception was thrown, the game will display an error screen and halt.",
];
/// 静默失败的存活阈值(毫秒):<30s 视为“没真正跑起来”。
pub const SHORT_LIVED_MS: u64 = 30_000;
/// 「被外部结束」在 Windows 上的退出码。
///
/// 任务管理器「结束任务」与 `taskkill /F` 都走 `TerminateProcess(h, 1)`,进程侧只能看到 1;
/// 真正的应用崩溃在 Windows 上给的是 NTSTATUS 异常码(`0xC0000005` 等),不会落在 1。
pub const WINDOWS_TERMINATED_CODE: i32 = 1;
/// 退出分析的输入。
#[derive(Debug, Clone, Copy)]
pub struct ExitSignals<'a> {
/// 退出码。
pub code: i32,
/// 进程存活毫秒数。
pub uptime_ms: u64,
/// 环形缓冲里的日志行。
pub lines: &'a [LogLine],
/// 是否启动器主动结束。
pub killed: bool,
/// 等待是否被中断。
pub interrupted: bool,
/// 是否 POSIX(决定 137/143 是否按信号解读)。
pub is_posix: bool,
}
impl<'a> ExitSignals<'a> {
/// 构造。
pub fn new(code: i32, uptime_ms: u64, lines: &'a [LogLine]) -> Self {
Self {
code,
uptime_ms,
lines,
killed: false,
interrupted: false,
is_posix: cfg!(unix),
}
}
}
/// 静默失败标记。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SilentFailure {
/// 存活时间过短。
pub short_lived: bool,
/// stderr/日志里有错误特征。
pub error_output: bool,
}
impl SilentFailure {
/// 人读说明。
pub fn describe(&self) -> String {
match (self.short_lived, self.error_output) {
(true, true) => "退出码 0,但存活不足 30 秒且输出含错误特征".to_string(),
(true, false) => "退出码 0,但存活不足 30 秒".to_string(),
(false, true) => "退出码 0,但输出含错误特征".to_string(),
(false, false) => "退出码 0".to_string(),
}
}
}
/// 退出分析结果(`exit` 事件的 payload 来源)。
#[derive(Debug, Clone)]
pub struct ExitAnalysis {
/// 退出码。
pub code: i32,
/// 判定类型。
pub exit_type: ExitType,
/// 存活毫秒数。
pub uptime_ms: u64,
/// 错误级日志行数。
pub error_lines: u32,
/// 命中的崩溃关键字。
pub crash_keywords: Vec<String>,
/// 命中的 JVM 失败关键字。
pub jvm_keywords: Vec<String>,
/// 静默失败标记(仅 `Normal` 时可能非空)。
pub silent_failure: Option<SilentFailure>,
/// Windows NTSTATUS 解释(例 `0xC0000005 ACCESS_VIOLATION`)。
pub windows_status: Option<String>,
/// 是否**疑似被外部结束**(任务管理器 / 强杀),而不是应用自己崩了(D10)。
///
/// 判据见 [`looks_externally_terminated`]:Windows 上退出码 1 且零错误输出。
/// 与「我们杀的」区分开:那条路有 [`crate::launch::kill_registry`] 作据,是**确知**的。
pub terminated_externally: bool,
/// 是否应该进入诊断流水线(M15)。
pub needs_diagnosis: bool,
}
impl ExitAnalysis {
/// 一句话摘要(UI/agent 可直接展示)。
pub fn summary(&self) -> String {
let mut text = format!(
"退出码 {}({}),存活 {:.1}s,错误行 {}",
self.code,
self.exit_type.as_str(),
self.uptime_ms as f64 / 1000.0,
self.error_lines
);
if let Some(status) = &self.windows_status {
text.push_str(&format!(",{status}"));
}
if self.terminated_externally {
text.push_str(",零错误输出:疑似被外部结束(任务管理器 / 强杀)");
}
if !self.crash_keywords.is_empty() {
text.push_str(&format!(",崩溃关键字 {}", self.crash_keywords.len()));
}
if let Some(silent) = &self.silent_failure {
text.push_str(&format!(",{}", silent.describe()));
}
text
}
}
/// 做退出分析。
pub fn analyze(signals: &ExitSignals<'_>) -> ExitAnalysis {
let error_lines = signals.lines.iter().filter(|line| line.is_error()).count();
let crash_keywords = matched_keywords(signals.lines, CRASH_KEYWORDS);
let jvm_keywords = matched_keywords(signals.lines, JVM_FAILURE_KEYWORDS);
let error_output = signals
.lines
.iter()
.any(|line| line.is_error() && looks_like_failure(&line.text));
let exit_type = if signals.interrupted {
ExitType::Interrupted
} else if signals.killed {
ExitType::Killed
} else if signals.code != 0 && !jvm_keywords.is_empty() {
ExitType::JvmError
} else if signals.is_posix && signals.code == 137 {
ExitType::SigKill
} else if signals.is_posix && signals.code == 143 {
ExitType::SigTerm
} else if looks_externally_terminated(signals, error_lines, &crash_keywords, &jvm_keywords) {
// D10:任务管理器 / `taskkill /F` 给的就是「1 + 零错误输出」,不是应用错误。
ExitType::Killed
} else if signals.code != 0 || !crash_keywords.is_empty() {
ExitType::ApplicationError
} else {
ExitType::Normal
};
let silent_failure = if exit_type == ExitType::Normal {
let short_lived = signals.uptime_ms < SHORT_LIVED_MS;
if short_lived || error_output {
Some(SilentFailure {
short_lived,
error_output,
})
} else {
None
}
} else {
None
};
ExitAnalysis {
code: signals.code,
exit_type,
uptime_ms: signals.uptime_ms,
error_lines: u32::try_from(error_lines).unwrap_or(u32::MAX),
crash_keywords: crash_keywords.clone(),
jvm_keywords,
silent_failure,
windows_status: windows_status_name(signals.code),
terminated_externally: exit_type == ExitType::Killed
&& !signals.killed
&& !signals.interrupted,
needs_diagnosis: exit_type.is_abnormal()
|| silent_failure.is_some()
|| !crash_keywords.is_empty(),
}
}
/// 外部结束的判据(D10)。
///
/// 三条同时成立才算:
///
/// 1. **不是我们杀的**(`signals.killed`)——那条路有 [`crate::launch::kill_registry`] 作据;
/// 2. Windows 上退出码 [`WINDOWS_TERMINATED_CODE`](`TerminateProcess(h, 1)` 的痕迹);
/// 3. **零错误输出**:没有错误行、没有崩溃关键字、没有 JVM 失败关键字。
///
/// 「1 + 一句话的报错都没有」不属于应用崩溃:应用崩在 Windows 上给 NTSTATUS 异常码,
/// JVM 起不来会打 `Could not create the Java Virtual Machine.`,`System.exit(1)` 之前
/// 一般也有日志。剩下最可能的解释就是「被外面按了结束」——挂死之后任务管理器强杀正是这种形态。
#[must_use]
pub fn looks_externally_terminated(
signals: &ExitSignals<'_>,
error_lines: usize,
crash_keywords: &[String],
jvm_keywords: &[String],
) -> bool {
!signals.killed
&& !signals.interrupted
&& !signals.is_posix
&& signals.code == WINDOWS_TERMINATED_CODE
&& error_lines == 0
&& crash_keywords.is_empty()
&& jvm_keywords.is_empty()
}
/// Windows NTSTATUS → 解释。
pub fn windows_status_name(code: i32) -> Option<String> {
let (hex, name) = match code {
-1_073_741_819 => ("0xC0000005", "ACCESS_VIOLATION"),
-1_073_740_791 => ("0xC0000409", "STACK_BUFFER_OVERRUN"),
-1_073_741_515 => ("0xC0000135", "DLL_NOT_FOUND"),
-1_073_741_571 => ("0xC00000FD", "STACK_OVERFLOW"),
-1_073_741_510 => ("0xC000013A", "CONTROL_C_EXIT"),
_ => return None,
};
Some(format!("{hex} {name}"))
}
/// 退出码的人工提示(附录 C 速查表的裁剪版)。
pub fn exit_code_hint(code: i32, is_posix: bool) -> Option<&'static str> {
match code {
0 => Some("正常退出"),
1 => Some("通用失败(含 JVM 启动失败)"),
137 if is_posix => Some("SIGKILL:被强杀"),
143 if is_posix => Some("SIGTERM:被请求退出"),
_ => None,
}
}
fn matched_keywords(lines: &[LogLine], keywords: &[&str]) -> Vec<String> {
keywords
.iter()
.filter(|keyword| lines.iter().any(|line| line.text.contains(**keyword)))
.map(|keyword| (*keyword).to_string())
.collect()
}
/// “像失败的输出”:显式异常/错误特征(不是任何 stderr 输出都算)。
fn looks_like_failure(text: &str) -> bool {
let lower = text.to_ascii_lowercase();
lower.contains("exception")
|| lower.contains("caused by")
|| lower.contains("fatal")
|| lower.contains("error:")
|| lower.contains("failed")
}