Files
Unlucky-Launcher/crates/core/src/diagnose/signals.rs
T
AskaEth bc41dfcf7d
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Unlucky Launcher v0.0.1-alpha
Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent),
MCP server 接入外部 Agent 工具,GUI 开发中。
2026-10-31 21:54:10 +08:00

397 lines
13 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.
//! 采集到的原子信号:日志环形缓冲 + 退出信号。
//!
//! 五类信号里的「退出码」和「日志环形缓冲」两种(analyse/02 §7.1 信号 A/B)。
//! 环境快照(信号 E)与 mod 清单在 [`crate::diagnose::collectors`] 里,因为它带采集动作。
//!
//! 环形缓冲相对 HMCL `Log.java`(固定 2000 行、一律淘汰)的升级:
//! 命中 `Exception|Error|Caused by|OutOfMemory` 的行**永久保留**,
//! 解决「崩在启动早期、根因被后续输出挤出缓冲区」(analyse/02 §7.10-3)。
use std::collections::VecDeque;
use std::fmt;
/// 一条日志文本的来源(诊断输出的 `evidence[]` 前缀也是它)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum SignalOrigin {
/// 游戏进程 stdout。
Stdout,
/// 游戏进程 stderr。
Stderr,
/// `logs/latest.log`(内存缓冲不够长时的兜底)。
LatestLog,
/// `crash-reports/crash-*.txt`。
CrashReport,
/// `hs_err_pid*.log` 的摘要文本。
HsErr,
/// 启动器自身日志 / 结构化事件(`cmd_built`、`assembly_failed` 等)。
Launcher,
}
impl SignalOrigin {
/// 稳定字符串(evidence 前缀 / 序列化用)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Stdout => "stdout",
Self::Stderr => "stderr",
Self::LatestLog => "latest_log",
Self::CrashReport => "crash_report",
Self::HsErr => "hs_err",
Self::Launcher => "launcher",
}
}
/// `launch` 侧 `LineSink` 的 `stream` 字段映射(`stdout` / `stderr`)。
#[must_use]
pub fn from_stream_name(stream: &str) -> Self {
match stream.trim().to_ascii_lowercase().as_str() {
"stderr" => Self::Stderr,
_ => Self::Stdout,
}
}
/// 取证优先级:同一规则在多个来源命中时保留 rank 最小者(崩溃报告 > hs_err > 日志)。
#[must_use]
pub const fn evidence_rank(self) -> u8 {
match self {
Self::CrashReport => 0,
Self::HsErr => 1,
Self::Launcher => 2,
Self::LatestLog => 3,
Self::Stdout => 4,
Self::Stderr => 5,
}
}
}
impl fmt::Display for SignalOrigin {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
/// 环形缓冲里的一行。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LogLine {
/// 来源。
pub origin: SignalOrigin,
/// 行文本(不含行尾换行)。
pub text: String,
}
impl LogLine {
/// 构造一行。
#[must_use]
pub fn new(origin: SignalOrigin, text: impl Into<String>) -> Self {
Self {
origin,
text: text.into(),
}
}
}
/// 是否「关键行」:不随环形淘汰(HMCL 局限 3 的正面修正)。
#[must_use]
pub fn is_key_line(text: &str) -> bool {
const MARKERS: [&str; 4] = ["Exception", "Error", "Caused by", "OutOfMemory"];
MARKERS.iter().any(|marker| text.contains(marker))
}
/// 日志环形缓冲:最近 N 行 + 被判为关键行的历史永久保留。
#[derive(Debug, Clone)]
pub struct LogBuffer {
capacity: usize,
key_capacity: usize,
recent: VecDeque<LogLine>,
/// 已从环形区淘汰、但因为是关键行而留下来的行(按时间顺序)。
kept: VecDeque<LogLine>,
pushed: usize,
dropped: usize,
}
impl Default for LogBuffer {
fn default() -> Self {
Self::new(2000)
}
}
impl LogBuffer {
/// 默认容量 2000 行(与 HMCL 的 `Log` 一致)。
#[must_use]
pub fn new(capacity: usize) -> Self {
Self {
capacity: capacity.max(1),
key_capacity: capacity.max(1),
recent: VecDeque::new(),
kept: VecDeque::new(),
pushed: 0,
dropped: 0,
}
}
/// 关键行保留上限(默认 = 环形容量)。
#[must_use]
pub fn with_key_capacity(mut self, key_capacity: usize) -> Self {
self.key_capacity = key_capacity.max(1);
self
}
/// 记一行(`launch` 的 stdout/stderr 事件逐行喂进来)。
pub fn push(&mut self, origin: SignalOrigin, line: &str) {
let text = line.trim_end_matches(['\r', '\n']).to_owned();
self.pushed += 1;
if self.recent.len() >= self.capacity {
if let Some(evicted) = self.recent.pop_front() {
self.dropped += 1;
if is_key_line(&evicted.text) {
if self.kept.len() >= self.key_capacity {
self.kept.pop_front();
}
self.kept.push_back(evicted);
}
}
}
self.recent.push_back(LogLine::new(origin, text));
}
/// 一次性喂多行(导入日志、`latest.log` 兜底)。
pub fn extend<'a, I>(&mut self, origin: SignalOrigin, lines: I)
where
I: IntoIterator<Item = &'a str>,
{
for line in lines {
self.push(origin, line);
}
}
/// 按时间顺序返回全部保留行(关键行历史在前,最近 N 行在后)。
pub fn lines(&self) -> impl Iterator<Item = &LogLine> {
self.kept.iter().chain(self.recent.iter())
}
/// 拼成可供规则匹配的文本(`\n` 连接)。
#[must_use]
pub fn text(&self) -> String {
let mut out = String::with_capacity(self.byte_len());
for line in self.lines() {
out.push_str(&line.text);
out.push('\n');
}
out
}
/// 当前保留行数。
#[must_use]
pub fn len(&self) -> usize {
self.kept.len() + self.recent.len()
}
/// 是否一行都没有。
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
/// 一共推入多少行。
#[must_use]
pub const fn pushed(&self) -> usize {
self.pushed
}
/// 被淘汰多少行(不含被永久保留的关键行)。
#[must_use]
pub const fn dropped(&self) -> usize {
self.dropped
}
/// 因关键行规则而保留下来的历史行数。
#[must_use]
pub fn kept_key_lines(&self) -> usize {
self.kept.len()
}
fn byte_len(&self) -> usize {
self.lines().map(|line| line.text.len() + 1).sum()
}
}
/// 退出类型。字符串值与 `launch-接口约定.md` §3 `exit.exit_type` 完全一致。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
pub enum ExitType {
/// 正常退出(但可能是静默失败,见 [`ExitSignal::silent_failure`])。
#[default]
Normal,
/// 应用层崩溃(有崩溃关键字 / 非 0 退出码)。
ApplicationError,
/// JVM 没起来(`-Xmx` 过大、参数非法、JVM 致命错误)。
JvmError,
/// 被 SIGKILL(Linux 137 / 系统 OOM killer)。
Sigkill,
/// 被 SIGTERM(143)。
Sigterm,
/// 被启动器主动停止(用户取消 / watchdog)。
Killed,
/// 等待被中断(会话被丢弃)。
Interrupted,
}
impl ExitType {
/// 稳定字符串(与 launch 约定同值)。
#[must_use]
pub const 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",
}
}
/// 从 launch 约定字符串反解(未知值回落 `ApplicationError`,绝不 panic)。
#[must_use]
pub fn from_name(name: &str) -> Self {
match name.trim().to_ascii_lowercase().as_str() {
"normal" => Self::Normal,
"jvm_error" => Self::JvmError,
"sigkill" => Self::Sigkill,
"sigterm" => Self::Sigterm,
"killed" => Self::Killed,
"interrupted" => Self::Interrupted,
_ => Self::ApplicationError,
}
}
/// 是否异常结束(非 `Normal`)。
#[must_use]
pub const fn is_abnormal(self) -> bool {
!matches!(self, Self::Normal)
}
}
impl fmt::Display for ExitType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
/// JVM 没起来的三个签名(analyse/02 §7.2,`JVM_ERROR` 判定依据)。
pub const JVM_ERROR_KEYWORDS: [&str; 3] = [
"Could not create the Java Virtual Machine",
"Error occurred during initialization of VM",
"A fatal exception has occurred. Program will exit.",
];
/// 应用层崩溃关键字(HMCL `ExitWaiter` 的 5 条 + 我们能拿到的补充)。
pub const CRASH_KEYWORDS: [&str; 5] = [
"Game crashed! Crash report saved to",
"Exception in thread",
"Unhandled exception",
"Unable to launch",
"An exception was thrown, the game will display an error screen and halt",
];
/// Windows 常见退出码(`0xC0000005` 等按 `i32` 表示,analyse/02 §8.4-2)。
pub const EXIT_ACCESS_VIOLATION: i32 = -1_073_741_819;
/// `0xC0000409` STACK_BUFFER_OVERRUN。
pub const EXIT_STACK_BUFFER_OVERRUN: i32 = -1_073_740_791;
/// `0xC0000135` DLL_NOT_FOUND。
pub const EXIT_DLL_NOT_FOUND: i32 = -1_073_741_515;
/// POSIX SIGKILL(128 + 9)。
pub const EXIT_SIGKILL: i32 = 137;
/// POSIX SIGTERM(128 + 15)。
pub const EXIT_SIGTERM: i32 = 143;
/// 进程退出信号(`launch` 的 `exit` 事件映射过来;离线诊断时由 [`ExitSignal::classify`] 反推)。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ExitSignal {
/// 退出码(被信号杀死时为负或 128+N)。
pub code: Option<i32>,
/// 退出类型。
pub exit_type: ExitType,
/// 进程存活毫秒数。
pub uptime_ms: Option<u64>,
/// stderr/错误行条数。
pub error_lines: u32,
/// 退出码 0 但疑似静默失败(存活 <30s 或 stderr 有错误特征)。
pub silent_failure: bool,
/// Windows 状态码原始串(`0xc0000005`)。
pub windows_status: Option<String>,
/// 命中的崩溃关键字。
pub crash_keywords: Vec<String>,
}
impl ExitSignal {
/// 从原始退出事实推断类型(离线诊断 / 导入日志用;launch 侧有权威版本)。
#[must_use]
pub fn classify(
code: Option<i32>,
uptime_ms: Option<u64>,
error_lines: u32,
error_text: &str,
) -> Self {
let has = |keywords: &[&str]| keywords.iter().any(|kw| error_text.contains(kw));
let mut crash_keywords = Vec::new();
for keyword in CRASH_KEYWORDS {
if error_text.contains(keyword) {
crash_keywords.push(keyword.to_owned());
}
}
let exit_type = match code {
Some(EXIT_SIGKILL) => ExitType::Sigkill,
Some(EXIT_SIGTERM) => ExitType::Sigterm,
// 与 HMCL `ExitType` 一致:只有「非 0 退出码 + JVM 关键字」才算 JVM_ERROR。
// exit 0 却打出这些关键字的场景归到靜默失败(silent_failure)而不是 JVM 错误。
Some(0) => ExitType::Normal,
Some(_) if has(&JVM_ERROR_KEYWORDS) => ExitType::JvmError,
Some(_) => ExitType::ApplicationError,
None => ExitType::ApplicationError,
};
let short_lived = uptime_ms.is_some_and(|ms| ms < 30_000);
let silent_failure = exit_type == ExitType::Normal
&& (short_lived || error_lines > 0 || has(&CRASH_KEYWORDS));
Self {
code,
exit_type,
uptime_ms,
error_lines,
silent_failure,
windows_status: code.and_then(hex_status),
crash_keywords,
}
}
/// Windows 状态码的十六进制串(如 `0xc0000005`)。
#[must_use]
pub fn status_hex(&self) -> Option<String> {
self.windows_status
.clone()
.or_else(|| self.code.and_then(hex_status))
}
/// 退出码是否是 Windows 访问违例(`0xC0000005`)。
#[must_use]
pub fn is_access_violation(&self) -> bool {
self.code == Some(EXIT_ACCESS_VIOLATION)
}
/// 一句话摘要(写进诊断报告 / 证据行)。
#[must_use]
pub fn summary(&self) -> String {
let code = self
.code
.map_or_else(|| "未知".to_owned(), |code| code.to_string());
let uptime = self
.uptime_ms
.map_or_else(String::new, |ms| format!(",存活 {} ms", ms));
format!("退出码 {code}({}){uptime}", self.exit_type)
}
}
fn hex_status(code: i32) -> Option<String> {
// 只有 Windows 的 NTSTATUS 形态(负数高位为 1)才有意义。
(code < 0).then(|| format!("0x{:08x}", code as u32))
}