Files
Unlucky-Launcher/crates/core/src/launch/launcher_log.rs
T
AskaEth 4b30bf07f0 fix(launch): 会话事件流路径只留一个真相源 + crash_found 只认真实证据 + ull launch 也写 launcher.log
M211(来自 M208 实机 e2e 的 D2/D3/D5):

- D2「game/ 双写」:log_path 的契约原来有两种说法(字段文档写「相对 game/」,写入侧一直
  写「相对会话根」的 game/<sid>.jsonl),于是两处读取点各自又多 join 一层 game/,
  ull logs / ull diag / GUI 日志页对每个真实会话都报「找不到事件流」。现在解析只走 core 的
  session_index::event_file(CLI 与 presenter 共用),写入侧只走
  session_index::log_path_for。方向选的是「读取侧不再 join」:存量索引里的 log_path 从第一版
  起就是 game/<id>.jsonl,改读取侧能把用户盘上已有的旧会话一起救回来(零迁移、零改写)。
- D2 夹具:cli/tests/support 与 presenter/host/logs_tests 的索引夹具改成生产形状
  (game/<id>.jsonl)。夹具此前自己说了第三种形状,所以这个 bug 在测试里永远看不见——
  修夹具比修路径更重要。
- D3「crash_found 恒为 true」:logs/latest.log 每局都写,不再被当现场文件(只在已经找到
  权威现场时作为补充证据)。崩溃结论改由 crash_scan::has_crash_evidence 判定(权威现场文件
  / 非 0 退出 + 错误行),索引旗标由 SessionWriter::mark_crash 写入;crash_found 事件恢复
  成纯事实记录。
- D5「ull launch 从不写 launcher.log」:现在与 GUI 共用
  launcher_log::record_start / record_end(字段名与顺序只在一个地方定义),写的是同一份
  <data>/logs/launcher.log。

测试:core 新增 session_path_tests(写入形状 = 读取解析结果 = 真落点的往返)、crash_scan 的
干净退出用例、launcher_log 的行格式锁;CLI 新增 a_launch_attempt_writes_the_launcher_log。
2026-11-09 14:12:23 +08:00

313 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.
//! 启动器**自己的**运行日志(M187):`<.ULL>/logs/launcher.log`。
//!
//! ## 为什么要有这个文件
//!
//! 用户实测(2026-11-08):「游戏崩溃后只有一个安装日志,没有崩溃日志」。核查结论是:
//! `logs/` 分区里此前只有 `install.log`(安装流水)与 auth journal——**启动器自己没有运行期
//! 日志**。双击启动的 GUI 进程没有 stderr 去向,所有 `eprintln!` 诊断当场蒸发,于是
//! 「启动器视角发生了什么」在事故之后无法复原。
//!
//! 本模块只做一件事:把一行行 `[scope] message` 追加到一个文件里,**落盘即脱敏**。
//!
//! ## 三条约束(都是有意的)
//!
//! 1. **best-effort**:写不进去就静默放过。日志失败不该让启动器崩、也不该弹提示。
//! 2. **不阻塞**:不建后台线程、不做异步队列。调用点都是「一次启动 / 一次导出」这种低频事件,
//! 每次一行 `write_all` 的代价可以忽略;引入通道反而多一处要收尾的生命周期。
//! 3. **有上限**:超过 [`MAX_BYTES`] 就滚成 `launcher.1.log`(只保留一份历史)。日志文件
//! 无限长会让「导出诊断包」变成搬砖。
//!
//! ## 目录从哪来
//!
//! 目录**不是**本模块自己解析的:`.ULL` 的落点由 `platform::data_dir` 的三模式决定(导出的
//! exe 目录 / 标准目录 / 自定义根),解析它需要读配置——那是宿主(presenter / app)已经做过
//! 的事。所以这里是「**先到者登记**」:知道真相的调用方用 [`set_dir`] 登记,
//! 登记之前 [`record`] 的行进内存缓冲(最多 [`MAX_BUFFERED`] 行),登记时一次刷出去。
//! 这样启动早期(字体 / GPU / 路由)那几行也不会丢。
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use super::launch_session::SessionHandle;
use super::redact::Redactor;
/// 日志文件名。
pub const FILE_NAME: &str = "launcher.log";
/// 单个文件上限(超过就滚成 `launcher.1.log`,只留一份历史)。
pub const MAX_BYTES: u64 = 1024 * 1024;
/// 登记目录之前最多缓冲多少行。
pub const MAX_BUFFERED: usize = 200;
#[derive(Debug, Default)]
struct State {
dir: Option<PathBuf>,
buffer: Vec<String>,
}
fn state() -> &'static Mutex<State> {
static STATE: OnceLock<Mutex<State>> = OnceLock::new();
STATE.get_or_init(|| Mutex::new(State::default()))
}
/// 登记日志目录(`<.ULL>/logs`)。重复登记时以**最后一次**为准(数据根可能被 `redirect.json` 改)。
pub fn set_dir(logs_dir: impl Into<PathBuf>) {
let dir = logs_dir.into();
let mut guard = match state().lock() {
Ok(guard) => guard,
Err(_) => return,
};
if guard.dir.as_ref() == Some(&dir) {
return;
}
guard.dir = Some(dir);
let buffered = std::mem::take(&mut guard.buffer);
let Some(directory) = guard.dir.clone() else {
return;
};
drop(guard);
for line in buffered {
append(&directory, &line);
}
}
/// 当前日志文件路径(`None` = 还没登记目录)。
pub fn path() -> Option<PathBuf> {
let guard = state().lock().ok()?;
guard.dir.as_ref().map(|dir| dir.join(FILE_NAME))
}
/// 记一行(`[scope] message`)。scope 是稳定短的英文标签(`boot` / `launch` / `dialog`)。
///
/// 时间戳走 `launch::time::now()`(与事件流同一份时钟口径),所以日志行与 JSONL 能对上。
pub fn record(scope: &str, message: &str) {
let line = format!(
"{} [{scope}] {message}",
super::time::format_rfc3339_millis(super::time::now())
);
let dir = {
let mut guard = match state().lock() {
Ok(guard) => guard,
Err(_) => return,
};
match guard.dir.clone() {
Some(dir) => dir,
None => {
if guard.buffer.len() < MAX_BUFFERED {
guard.buffer.push(line);
}
return;
}
}
};
append(&dir, &line);
}
/// 一次启动的「启动前」一行:`instance=… game_dir=… java=… memory=…MB`(M211/D5)。
///
/// GUI(`presenter::host::launch`)与 CLI(`ull launch`)写的是**同一份** `launcher.log`,
/// 所以字段名/顺序只在这里定义一次——两边各写一句 `format!` 就等于迟早漂成两种形状。
fn start_line(
instance_id: &str,
game_dir: &Path,
java: Option<(&Path, u32)>,
memory_mb: Option<u32>,
) -> String {
let java = java.map_or_else(
|| "未找到".to_string(),
|(binary, major)| java_label(binary, major),
);
format!(
"instance={instance_id} game_dir={} java={java} memory={}MB",
game_dir.display(),
memory_mb.unwrap_or(0)
)
}
/// `java=` 字段的形状:`<二进制> (major <主版本>)`。
pub fn java_label(binary: &Path, major: u32) -> String {
format!("{} (major {major})", binary.display())
}
/// 一次启动的「收尾」一行:`instance=… session=… exit=… type=… uptime=…ms outcome=… artifacts=…`。
///
/// `outcome` 与 GUI 的 `LogOutcome` 同词(`Ok` / `Crashed` / `SilentFail`),见
/// [`SessionHandle::outcome_label`]。
#[allow(clippy::too_many_arguments)]
fn end_line(
instance_id: &str,
session_id: &str,
exit_code: i32,
exit_type: &str,
uptime_ms: u64,
outcome: &str,
artifacts: usize,
) -> String {
format!(
"instance={instance_id} session={session_id} exit={exit_code} type={exit_type} \
uptime={uptime_ms}ms outcome={outcome} artifacts={artifacts}"
)
}
/// 记「启动前」一行(`instance` / `game_dir` / `java` / `memory`)。
///
/// 装配失败也要留下这一行:事故之后「到底打算用什么起的」是第一个要看的。
pub fn record_start(
instance_id: &str,
game_dir: &Path,
java: Option<(&Path, u32)>,
memory_mb: Option<u32>,
) {
record(
"launch",
&start_line(instance_id, game_dir, java, memory_mb),
);
}
/// 记「收尾」一行(`session` / `exit` / `type` / `uptime` / `outcome` / `artifacts`)。
pub fn record_end(instance_id: &str, handle: &SessionHandle) {
record(
"launch",
&end_line(
instance_id,
&handle.session_id,
handle.exit.code,
handle.exit.exit_type.as_str(),
handle.uptime_ms,
handle.outcome_label(),
handle.artifacts.len(),
),
);
}
/// 脱敏器:主目录换成 `<home>`(同一份规则见 [`super::redact`])。
fn redactor() -> Redactor {
let mut redactor = Redactor::new();
for key in ["USERPROFILE", "HOME"] {
if let Ok(home) = std::env::var(key) {
if !home.trim().is_empty() {
redactor = redactor.with_home_str(home);
}
}
}
redactor
}
fn append(dir: &Path, line: &str) {
let path = dir.join(FILE_NAME);
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
// 滚动:只在超过上限时做一次 rename,平时的路径只有「打开 → 追加 → 关」。
if std::fs::metadata(&path).is_ok_and(|meta| meta.len() > MAX_BYTES) {
let _ = std::fs::rename(&path, dir.join("launcher.1.log"));
}
let scrubbed = redactor().scrub(line).into_owned();
if let Ok(mut file) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
{
let _ = file.write_all(scrubbed.as_bytes());
let _ = file.write_all(b"\n");
}
}
#[cfg(test)]
fn state_reset() {
if let Ok(mut guard) = state().lock() {
guard.dir = None;
guard.buffer.clear();
}
}
#[cfg(test)]
mod tests {
use super::MAX_BUFFERED;
use std::path::PathBuf;
/// 测试之间共享全局状态,必须串行(`cargo test` 默认多线程)。
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn temp_dir(tag: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!("ull-m187-log-{}-{tag}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("建夹具目录");
root
}
#[test]
fn buffered_lines_are_flushed_once_the_dir_is_known() {
let _guard = LOCK.lock().unwrap_or_else(|error| error.into_inner());
super::state_reset();
let dir = temp_dir("flush");
super::record("boot", "before the dir was known");
assert!(super::path().is_none(), "没登记目录时不该报出路径");
super::set_dir(&dir);
super::record("launch", "after");
let text = std::fs::read_to_string(dir.join(super::FILE_NAME)).expect("读日志");
assert!(text.contains("before the dir was known"), "{text}");
assert!(text.contains("[launch] after"), "{text}");
}
#[test]
fn home_paths_are_scrubbed_on_write() {
let _guard = LOCK.lock().unwrap_or_else(|error| error.into_inner());
super::state_reset();
let dir = temp_dir("scrub");
super::set_dir(&dir);
let home = std::env::var("USERPROFILE").or_else(|_| std::env::var("HOME"));
let Ok(home) = home else {
return;
};
super::record("launch", &format!("argv --nativesDir {home}/natives"));
let text = std::fs::read_to_string(dir.join(super::FILE_NAME)).expect("读日志");
assert!(text.contains("<home>"), "{text}");
assert!(!text.contains(home.as_str()), "家目录原文还在:{text}");
}
#[test]
fn buffer_is_bounded_before_the_dir_is_known() {
let _guard = LOCK.lock().unwrap_or_else(|error| error.into_inner());
super::state_reset();
for index in 0..(MAX_BUFFERED + 50) {
super::record("boot", &format!("line {index}"));
}
let dir = temp_dir("bounded");
super::set_dir(&dir);
let text = std::fs::read_to_string(dir.join(super::FILE_NAME)).expect("读日志");
let lines = text.lines().count();
assert!(lines <= MAX_BUFFERED, "缓冲没有上限:{lines} 行");
assert!(lines > 0);
}
/// M211/D5:GUI 与 `ull launch` 写**同一份** `launcher.log`,所以形状只能有一套。
///
/// 变异证明:把 `start_line`/`end_line` 里的键名或顺序改掉(例如 `memory` → `mem`)⇒
/// 本条的字符串相等断言红——这正是「两条路各自 `format!`」时会发生的事。
#[test]
fn launch_lines_have_one_shape_for_both_entries() {
use std::path::Path;
assert_eq!(
super::start_line(
"inst",
Path::new("C:/mc"),
Some((Path::new("C:/jdk/bin/java.exe"), 17)),
Some(4096),
),
"instance=inst game_dir=C:/mc java=C:/jdk/bin/java.exe (major 17) memory=4096MB"
);
assert_eq!(
super::start_line("inst", Path::new("C:/mc"), None, None),
"instance=inst game_dir=C:/mc java=未找到 memory=0MB"
);
assert_eq!(
super::end_line("inst", "sid", 1, "application_error", 1234, "Crashed", 2),
"instance=inst session=sid exit=1 type=application_error \
uptime=1234ms outcome=Crashed artifacts=2"
);
}
}