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。
312 lines
11 KiB
Rust
312 lines
11 KiB
Rust
//! 退出分析:退出码语义 + `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")
|
||
}
|