From 4b30bf07f054b60e765789d2fe7d8765ea346847 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 9 Nov 2026 13:35:30 +0800 Subject: [PATCH] =?UTF-8?q?fix(launch):=20=E4=BC=9A=E8=AF=9D=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=E6=B5=81=E8=B7=AF=E5=BE=84=E5=8F=AA=E7=95=99=E4=B8=80?= =?UTF-8?q?=E4=B8=AA=E7=9C=9F=E7=9B=B8=E6=BA=90=20+=20crash=5Ffound=20?= =?UTF-8?q?=E5=8F=AA=E8=AE=A4=E7=9C=9F=E5=AE=9E=E8=AF=81=E6=8D=AE=20+=20ul?= =?UTF-8?q?l=20launch=20=E4=B9=9F=E5=86=99=20launcher.log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M211(来自 M208 实机 e2e 的 D2/D3/D5): - D2「game/ 双写」:log_path 的契约原来有两种说法(字段文档写「相对 game/」,写入侧一直 写「相对会话根」的 game/.jsonl),于是两处读取点各自又多 join 一层 game/, ull logs / ull diag / GUI 日志页对每个真实会话都报「找不到事件流」。现在解析只走 core 的 session_index::event_file(CLI 与 presenter 共用),写入侧只走 session_index::log_path_for。方向选的是「读取侧不再 join」:存量索引里的 log_path 从第一版 起就是 game/.jsonl,改读取侧能把用户盘上已有的旧会话一起救回来(零迁移、零改写)。 - D2 夹具:cli/tests/support 与 presenter/host/logs_tests 的索引夹具改成生产形状 (game/.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(字段名与顺序只在一个地方定义),写的是同一份 /logs/launcher.log。 测试:core 新增 session_path_tests(写入形状 = 读取解析结果 = 真落点的往返)、crash_scan 的 干净退出用例、launcher_log 的行格式锁;CLI 新增 a_launch_attempt_writes_the_launcher_log。 --- crates/cli/src/commands/launch.rs | 15 +- crates/cli/src/commands/sessions.rs | 55 ++++++- crates/cli/tests/support/mod.rs | 8 +- crates/core/src/launch/crash_scan.rs | 143 +++++++++++++++++++ crates/core/src/launch/launch_session.rs | 18 +++ crates/core/src/launch/launcher_log.rs | 105 ++++++++++++++ crates/core/src/launch/session.rs | 18 ++- crates/core/src/launch/session_index.rs | 27 +++- crates/core/src/launch/session_path_tests.rs | 142 ++++++++++++++++++ crates/presenter/src/host/launch.rs | 30 ++-- crates/presenter/src/host/logs.rs | 13 +- crates/presenter/src/host/logs_tests.rs | 12 +- 12 files changed, 542 insertions(+), 44 deletions(-) create mode 100644 crates/core/src/launch/session_path_tests.rs diff --git a/crates/cli/src/commands/launch.rs b/crates/cli/src/commands/launch.rs index ba7e80a..00ff754 100644 --- a/crates/cli/src/commands/launch.rs +++ b/crates/cli/src/commands/launch.rs @@ -105,10 +105,18 @@ pub fn run(ctx: &Ctx, args: &LaunchArgs) -> Result { ..input }; - let plan = snapshot::prepare(ctx, &input)?; - // M188:命令行这一路也把启动器自己的运行日志落到 `/logs/launcher.log` - // (GUI 由 presenter 的 `HostEnv` 登记;两条路的落点必须是同一个)。 + // M188/M211:命令行这一路也把启动器日志写进 `/logs/launcher.log` + // (落点与格式的真相都在 core 的 `launcher_log`,GUI 写的是同一份)。 unlucky_core::launch::launcher_log::set_dir(ctx.logs_dir()); + unlucky_core::launch::launcher_log::record_start( + &input.instance_id, + &isolation.game_dir, + java.runtime + .as_ref() + .map(|runtime| (runtime.binary.as_path(), runtime.major.unwrap_or_default())), + input.max_memory, + ); + let plan = snapshot::prepare(ctx, &input)?; let config = LauncherConfig::new(ctx.sessions_root()) .with_launcher(super::meta::LAUNCHER_NAME, env!("CARGO_PKG_VERSION")); let launcher = Launcher::new(config); @@ -187,6 +195,7 @@ pub fn run(ctx: &Ctx, args: &LaunchArgs) -> Result { let handle = launcher .launch(&plan.request) .map_err(|error| map_launch_error(&error, ctx))?; + unlucky_core::launch::launcher_log::record_end(&input.instance_id, &handle); let data = json!({ "dry_run": false, "instance_found": instance_found, diff --git a/crates/cli/src/commands/sessions.rs b/crates/cli/src/commands/sessions.rs index d44fdf2..a2831ef 100644 --- a/crates/cli/src/commands/sessions.rs +++ b/crates/cli/src/commands/sessions.rs @@ -16,7 +16,6 @@ use unlucky_core::diagnose::collectors::{ExitSignal, SignalOrigin}; use unlucky_core::diagnose::engine::{Diagnoser, DiagnosisInput}; use unlucky_core::diagnose::loader::RuleLoader; use unlucky_core::launch::session_index::{self, SessionIndexEntry}; -use unlucky_core::platform::layout::GAME_LOG_DIR; use crate::cli::{DiagArgs, LogsArgs}; use crate::context::Ctx; @@ -241,14 +240,13 @@ fn find_entry(ctx: &Ctx, key: &str) -> Result { fn event_path(ctx: &Ctx, entry: &SessionIndexEntry) -> std::path::PathBuf { // M188:新会话在 `logs/game/`,旧会话在 `data/agent/sessions/game/`(只读兼容)。 // 先看新根,再回落旧家;两处都没有就把新根路径交出去(错误信息据此说「该去哪找」)。 - let primary = ctx.sessions_root().join(GAME_LOG_DIR).join(&entry.log_path); + // M211/D2:`log_path` 已经**相对会话根**(`game/.jsonl`),解析交给 core 里唯一的 + // `session_index::event_file`——这里不许再自己拼 `game/`,否则又是一份第三真相。 + let primary = session_index::event_file(&ctx.sessions_root(), &entry.log_path); if primary.is_file() { return primary; } - let legacy = ctx - .legacy_sessions_root() - .join(GAME_LOG_DIR) - .join(&entry.log_path); + let legacy = session_index::event_file(&ctx.legacy_sessions_root(), &entry.log_path); if legacy.is_file() { return legacy; } @@ -339,3 +337,48 @@ fn exit_label(entry: &SessionIndexEntry) -> String { }; format!("{code}/{exit_type}{silent}") } + +/// M211/D5:`ull launch` 也必须写 `/logs/launcher.log`(与 GUI 同一份文件、同一行格式)。 +/// +/// 为什么这条用例在这个文件里:CLI 侧本 mission 只允许改 `commands/launch.rs` 与 +/// `commands/sessions.rs`,而 `launch.rs` 已到 389 行、再加用例会破「文件 ≤400 行」—— +/// 用例放在同一个 `/logs` 层级的读取方(本文件打印「启动器日志:」)旁边。 +/// +/// 变异证明:删掉 `commands/launch.rs` 里的 `record_start(...)` ⇒ 本条红(文件根本不存在); +/// 把 `set_dir` 的入参换成别的目录 ⇒ 本条红。 +#[cfg(test)] +mod tests { + /// 一个最小实例档案(`profiles.json`):只要能让 `launch` 走到「准备启动」这一段。 + const PROFILES: &str = r#"{"schema_version":1,"profiles":[{"id":"11111111-2222-4333-8444-000000000001","name":"生存服","version_id":"1.20.1"}]}"#; + + #[test] + fn a_launch_attempt_writes_the_launcher_log() { + let root = std::env::temp_dir().join(format!("ull-m211-cli-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join(".ULL/config")).expect("建配置目录"); + std::fs::write(root.join(".ULL/config/profiles.json"), PROFILES).expect("写 profiles"); + + let args: Vec = vec![ + "launch".to_string(), + "生存服".to_string(), + "--data-dir".to_string(), + root.display().to_string(), + ]; + let (mut out, mut err) = (Vec::new(), Vec::new()); + // 版本本体不存在 ⇒ 这条命令会失败,但「启动前」那一行必须已经落盘 + // (事故现场最需要的正是「它打算用什么起的」)。 + let _ = crate::run(&args, &mut out, &mut err); + + let log = std::fs::read_to_string(root.join(".ULL/logs/launcher.log")) + .expect("`ull launch` 必须在 /logs/launcher.log 里留下启动器日志"); + assert!( + log.contains("[launch] instance=11111111-2222-4333-8444-000000000001"), + "{log}" + ); + assert!( + log.contains("game_dir=") && log.contains("memory="), + "{log}" + ); + std::fs::remove_dir_all(&root).ok(); + } +} diff --git a/crates/cli/tests/support/mod.rs b/crates/cli/tests/support/mod.rs index aa0befa..496b54f 100644 --- a/crates/cli/tests/support/mod.rs +++ b/crates/cli/tests/support/mod.rs @@ -225,6 +225,12 @@ pub fn write_profiles(data_root: &Path, profiles: &[(&str, &str)]) { } /// 写一份「退出码 1 + OOM」的会话索引与事件流,返回会话 id。 +/// +/// **形状必须与生产写入一致**(M211/D2):`log_path` 是**相对会话根**的 `game/.jsonl`, +/// 事件流真落点是 `<会话根>/game/.jsonl`。这里用的是旧家(`data/agent/sessions`)作会话根 +/// ——那是 M188 之前的生产落点,也是用户盘上存量会话的真实形状,读取侧必须还能读回它们。 +/// 夹具此前写的是不带 `game/` 的 `"{sid}.jsonl"`,于是「读取侧多 join 一层 `game/`」这个 bug +/// 在测试里永远看不见:夹具自己说了另一种形状。 pub fn write_session(data_root: &Path) -> String { let sid = "20261030T101500-abcdef"; let dir = data_root @@ -259,7 +265,7 @@ pub fn write_session(data_root: &Path) -> String { "silent_failure": false, "window_detected": false, "event_count": 3, - "log_path": format!("{sid}.jsonl"), + "log_path": unlucky_core::launch::session_index::log_path_for(sid), "crash_found": false, "diagnosis_top": null }); diff --git a/crates/core/src/launch/crash_scan.rs b/crates/core/src/launch/crash_scan.rs index 66da94b..d39772a 100644 --- a/crates/core/src/launch/crash_scan.rs +++ b/crates/core/src/launch/crash_scan.rs @@ -10,6 +10,8 @@ use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime}; +use crate::launch::exit_analysis::ExitAnalysis; + /// 崩溃现场文件来源。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CrashSource { @@ -30,6 +32,15 @@ impl CrashSource { Self::LatestLog => "latest_log", } } + + /// 这个来源本身能否**作为崩溃的证据**(M211/D3)。 + /// + /// `crash-reports/*.txt` 与 `hs_err_pid*.log` 只有崩溃时才会出现;`logs/latest.log` 是 + /// **每局都写**的普通日志,它的存在什么也证明不了——把「文件在」当崩溃判据,等于给每一局 + /// 都盖上「游戏崩溃」的章(M208 实机:连 `code 0 / error_lines 0` 的干净退出也被标成崩溃)。 + pub fn is_authoritative(self) -> bool { + !matches!(self, Self::LatestLog) + } } /// 一个现场文件。 @@ -49,6 +60,9 @@ pub const MAX_EXCERPT_CHARS: usize = 2000; /// 扫描一次运行目录,返回按修改时间倒序的现场文件(最多 `limit` 个)。 /// /// `since` 之前的文件视为上一局遗留,忽略;给 `since` 留 1 秒容差以吸收文件系统时间精度。 +/// +/// **`logs/latest.log` 只在已经找到权威现场文件时才被带上**(M211/D3):它是「崩溃报告/ +/// hs_err 都缺、只能看全文」时的补充证据,每局都会写,不能当崩溃判据。 pub fn scan(run_dir: &Path, since: SystemTime, limit: usize) -> Vec { let since = since.checked_sub(Duration::from_secs(1)).unwrap_or(since); let mut found: Vec<(SystemTime, CrashArtifact)> = Vec::new(); @@ -60,6 +74,9 @@ pub fn scan(run_dir: &Path, since: SystemTime, limit: usize) -> Vec Vec 0。 +/// +/// 干净退出(`code 0`、`error_lines 0`)落不进任何一条 ⇒ `false`。`logs/latest.log` 存在与否 +/// 不参与判定(每次启动都会写它)。 +pub fn has_crash_evidence(artifacts: &[CrashArtifact], exit: &ExitAnalysis) -> bool { + if artifacts.iter().any(|item| item.source.is_authoritative()) { + return true; + } + exit.code != 0 && exit.error_lines > 0 +} + fn collect_matching( dir: &Path, source: CrashSource, @@ -150,3 +182,114 @@ pub fn read_excerpt(path: &Path, max_chars: usize) -> Option { } Some(excerpt) } + +#[cfg(test)] +mod tests { + use super::{has_crash_evidence, scan, CrashSource}; + use crate::launch::exit_analysis::{self, ExitAnalysis, ExitSignals}; + use crate::launch::pump::LogLine; + use crate::launch::pump::Stream; + use std::path::{Path, PathBuf}; + use std::time::SystemTime; + + fn temp_dir(tag: &str) -> PathBuf { + let root = std::env::temp_dir().join(format!("ull-m211-scan-{}-{tag}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).expect("建夹具目录"); + root + } + + /// 每局都会写的普通日志:干净退出时它**也在**,所以「它在」什么也证明不了。 + fn write_latest_log(run_dir: &Path) { + let logs = run_dir.join("logs"); + std::fs::create_dir_all(&logs).expect("建 logs"); + std::fs::write(logs.join("latest.log"), "[10:15:00] [main/INFO]: hello\n") + .expect("写 latest.log"); + } + + fn analyze(code: i32, errors: usize) -> ExitAnalysis { + let lines: Vec = (0..errors) + .map(|index| { + LogLine::new( + Stream::Stderr, + format!("java.lang.RuntimeException: boom {index}"), + ) + }) + .collect(); + exit_analysis::analyze(&ExitSignals::new(code, 60_000, &lines)) + } + + /// D3 主用例(**改实现之前是红的**):干净退出 + 只写了 `logs/latest.log` ⇒ 没有现场文件。 + /// + /// 变异证明:把 `scan` 里 `if found.is_empty() { return Vec::new() }` 删掉(即恢复「 + /// latest.log 无条件当现场文件」)⇒ 本条拿到 1 个 `latest_log`,红。 + #[test] + fn a_clean_run_produces_no_crash_artifact() { + let run_dir = temp_dir("clean"); + write_latest_log(&run_dir); + + let artifacts = scan(&run_dir, SystemTime::now(), 8); + assert!( + artifacts.is_empty(), + "只有 latest.log 时不该报现场文件:{artifacts:?}" + ); + std::fs::remove_dir_all(&run_dir).ok(); + } + + /// 真有崩溃现场时,`latest.log` 才作为补充证据跟着走(它的诊断价值要保住)。 + /// + /// 变异证明:把 `scan` 里的 `latest_log(...)` 整段删掉 ⇒ 本条只剩 hs_err 一个,红。 + #[test] + fn latest_log_rides_along_only_when_a_crash_scene_exists() { + let run_dir = temp_dir("ride"); + write_latest_log(&run_dir); + std::fs::write(run_dir.join("hs_err_pid4242.log"), "SIGSEGV\n").expect("写 hs_err"); + + let artifacts = scan(&run_dir, SystemTime::now(), 8); + let sources: Vec = artifacts.iter().map(|item| item.source).collect(); + assert!( + sources.contains(&CrashSource::HsErr), + "hs_err 必须被认出来:{sources:?}" + ); + assert!( + sources.contains(&CrashSource::LatestLog), + "有权威现场时 latest.log 仍要作为补充带上:{sources:?}" + ); + std::fs::remove_dir_all(&run_dir).ok(); + } + + /// 崩溃结论的三条判据:权威现场 / 非 0 退出 + 错误行 / 其余一律不算。 + /// + /// 变异证明:把 `has_crash_evidence` 最后一行改成 `true`(或改成只看 + /// `!artifacts.is_empty()`)⇒ 干净退出那条断言红。 + #[test] + fn crash_evidence_needs_something_that_only_happens_on_a_crash() { + let hs_err = super::CrashArtifact { + source: CrashSource::HsErr, + path: PathBuf::from("hs_err_pid1.log"), + excerpt: None, + }; + let latest = super::CrashArtifact { + source: CrashSource::LatestLog, + path: PathBuf::from("logs/latest.log"), + excerpt: None, + }; + let clean = analyze(0, 0); + let crashed = analyze(1, 2); + + assert!( + !has_crash_evidence(&[latest.clone()], &clean), + "只有 latest.log + 干净退出 ⇒ 不是崩溃" + ); + assert!(!has_crash_evidence(&[], &clean), "干净退出 ⇒ 不是崩溃"); + assert!( + !has_crash_evidence(&[], &analyze(1, 0)), + "非 0 退出但没有错误行 ⇒ 证据不足,不算崩溃" + ); + assert!(has_crash_evidence(&[hs_err], &clean), "hs_err 就是崩溃证据"); + assert!( + has_crash_evidence(&[], &crashed), + "非 0 退出 + 错误行 ⇒ 崩溃" + ); + } +} diff --git a/crates/core/src/launch/launch_session.rs b/crates/core/src/launch/launch_session.rs index 529a406..7a1feeb 100644 --- a/crates/core/src/launch/launch_session.rs +++ b/crates/core/src/launch/launch_session.rs @@ -161,6 +161,9 @@ impl LaunchSession { let mut event_write_errors = Vec::new(); let mut diagnostics = None; let mut index = None; + // M211/D3:崩溃结论只认真实证据(权威现场文件 / 非 0 退出 + 错误行),不再由 + // 「有没有 latest.log」推出——那是每局都有的文件。 + let crash_evidence = crash_scan::has_crash_evidence(&artifacts, &exit); let end_reason = if self.killed { EndReason::Killed } else { @@ -180,6 +183,7 @@ impl LaunchSession { event_write_errors.push(err.to_string()); } } + writer.mark_crash(crash_evidence); if let Some(record) = diagnosis { if let Err(err) = writer.diagnosis(record.clone()) { event_write_errors.push(err.to_string()); @@ -307,6 +311,20 @@ impl SessionHandle { self.exit.needs_diagnosis } + /// 结局标签,与 GUI 的 `LogOutcome` 同词(`Ok` / `Crashed` / `SilentFail`)。 + /// + /// `launcher.log` 里两条路(GUI / `ull launch`)都写这一套词,见 + /// [`crate::launch::launcher_log::launch_end_line`]。 + pub fn outcome_label(&self) -> &'static str { + if self.exit.silent_failure.is_some() { + "SilentFail" + } else if self.succeeded() { + "Ok" + } else { + "Crashed" + } + } + /// 一句话摘要。 pub fn summary(&self) -> String { let mut text = format!("会话 {}:{}", self.session_id, self.exit.summary()); diff --git a/crates/core/src/launch/launcher_log.rs b/crates/core/src/launch/launcher_log.rs index 38fe218..0a02dab 100644 --- a/crates/core/src/launch/launcher_log.rs +++ b/crates/core/src/launch/launcher_log.rs @@ -29,6 +29,7 @@ use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::{Mutex, OnceLock}; +use super::launch_session::SessionHandle; use super::redact::Redactor; /// 日志文件名。 @@ -102,6 +103,83 @@ pub fn record(scope: &str, message: &str) { 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, +) -> 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, +) { + 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(), + ), + ); +} + /// 脱敏器:主目录换成 ``(同一份规则见 [`super::redact`])。 fn redactor() -> Redactor { let mut redactor = Redactor::new(); @@ -204,4 +282,31 @@ mod tests { 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" + ); + } } diff --git a/crates/core/src/launch/session.rs b/crates/core/src/launch/session.rs index 54c4989..a1dd048 100644 --- a/crates/core/src/launch/session.rs +++ b/crates/core/src/launch/session.rs @@ -316,12 +316,22 @@ impl SessionWriter { } /// `crash_found`:以 `path` 为权威字段(M15 自己读盘)。 + /// + /// 这条只是「找到了这个现场文件」的**事实**记录,不参与索引里的崩溃结论——结论由 + /// [`Self::mark_crash`] 按 `crash_scan::has_crash_evidence` 的判定写入(M211/D3)。 pub fn crash_found(&mut self, source: &str, path: &Path, excerpt: Option<&str>) -> Result<()> { - self.crash_found = true; let payload = payload::crash_found(source, path, excerpt, &self.redactor); self.record(EventKind::CrashFound, payload) } + /// 写入索引前的崩溃结论(`crash_scan::has_crash_evidence` 的结果)。 + /// + /// 索引里的 `crash_found` 不再等于「有现场文件」:`logs/latest.log` 每局都写,把它当判据 + /// 会让每一次干净退出都被标成「游戏崩溃」(M208 实机 D3)。 + pub fn mark_crash(&mut self, found: bool) { + self.crash_found = found; + } + /// `diagnosis`:M15 产出的结构化结论,由 launch 脱敏后落盘(单一真相源)。 /// /// M15 会按 `crash_found.path` 自己读盘再组 payload,崩溃报告/`latest.log` 里的 @@ -361,7 +371,7 @@ impl SessionWriter { silent_failure: self.silent_failure, window_detected: self.window_detected, event_count: self.event_count, - log_path: format!("game/{}.jsonl", self.session_id), + log_path: session_index::log_path_for(&self.session_id), crash_found: self.crash_found, diagnosis_top: self.diagnosis_top.clone(), }; @@ -373,3 +383,7 @@ impl SessionWriter { Ok(entry) } } + +#[cfg(test)] +#[path = "session_path_tests.rs"] +mod path_tests; diff --git a/crates/core/src/launch/session_index.rs b/crates/core/src/launch/session_index.rs index 4dfc47d..c9548ca 100644 --- a/crates/core/src/launch/session_index.rs +++ b/crates/core/src/launch/session_index.rs @@ -10,6 +10,8 @@ use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; +use crate::platform::layout::GAME_LOG_DIR; + /// 索引条目的 schema 版本。 pub const INDEX_VERSION: u8 = 1; @@ -43,7 +45,11 @@ pub struct SessionIndexEntry { pub window_detected: bool, /// 事件条数。 pub event_count: u64, - /// 相对 `game/` 的日志文件名。 + /// 事件流相对**会话根**的路径(生产形状 `game/.jsonl`)。 + /// + /// 契约里「相对谁」只有这一句,见 [`event_file`]。M211 之前这里写成「相对 `game/`」, + /// 而写入侧一直按「相对会话根」写 —— 两个说法各自成立,两处读取点于是都多 join 了一层 + /// `game/`,`ull logs` / `ull diag` 对每个真实会话都报「找不到事件流」。 pub log_path: String, /// 是否抓到崩溃报告/hs_err。 pub crash_found: bool, @@ -56,6 +62,25 @@ pub fn index_path(sessions_root: &Path) -> PathBuf { sessions_root.join("game").join("index.jsonl") } +/// 写入侧用的 `log_path`:**相对会话根**(``)的形状,`game/.jsonl`。 +/// +/// 写入侧([`crate::launch::session::SessionWriter`])与读取侧([`event_file`])都只认这一个 +/// 函数产出的形状,所以「存什么」与「怎么找回来」不可能再各说一套(M211/D2)。 +pub fn log_path_for(session_id: &str) -> String { + format!("{GAME_LOG_DIR}/{session_id}.jsonl") +} + +/// `log_path` → 事件流文件(相对**会话根**解析,M211/D2)。 +/// +/// 这是全仓唯一的解析点:CLI 的 `ull logs` / `ull diag` 与 presenter 的日志页都走这里, +/// 谁也不再自己往前拼一层 `game/`。 +/// +/// 存量数据不需要迁移:索引里的 `log_path` 从第一版起就是 `game/.jsonl`,正是本函数的 +/// 输入形状(`/game/.jsonl`),只是之前的读取侧又拼了一次 `game/` 才找不到。 +pub fn event_file(sessions_root: &Path, log_path: &str) -> PathBuf { + sessions_root.join(log_path) +} + /// 追加一行摘要。 pub fn append(index: &Path, entry: &SessionIndexEntry) -> std::io::Result<()> { if let Some(parent) = index.parent() { diff --git a/crates/core/src/launch/session_path_tests.rs b/crates/core/src/launch/session_path_tests.rs new file mode 100644 index 0000000..32fd9ae --- /dev/null +++ b/crates/core/src/launch/session_path_tests.rs @@ -0,0 +1,142 @@ +//! M211:写入侧与读取侧对**同一个相对路径**的往返用例(D2 / D3)。 +//! +//! 这个文件存在的理由:原来的夹具(CLI `tests/support`、presenter `logs_tests`)各自 +//! **手写**索引条目,于是「写入侧存 `game/.jsonl`、读取侧又多 join 一层 `game/`」这个 +//! bug 在测试里永远看不见——夹具自己说了第三种形状。这里跑**真的** [`SessionWriter`], +//! 断言它写出的形状与 `session_index::event_file` 解析出的路径是同一个文件。 +//! +//! 变异证明(把实现改回旧形状 ⇒ 本文件红): +//! - `session.rs` 的 `log_path` 改回 `format!("game/{}.jsonl", …)` 之类的手写形状 ⇒ +//! `the_written_log_path_is_the_one_the_readers_resolve` 的第①③条断言红; +//! - `session_index::event_file` 里补回 `.join(GAME_LOG_DIR)` ⇒ 同一条用例的第③条红。 + +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +use crate::launch::crash_scan; +use crate::launch::exit_analysis::{self, ExitSignals}; +use crate::launch::redact::Redactor; +use crate::launch::session::{EndReason, SessionMeta, SessionWriter}; +use crate::launch::session_index; + +fn temp_dir(tag: &str) -> PathBuf { + let root = std::env::temp_dir().join(format!("ull-m211-path-{}-{tag}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).expect("建夹具目录"); + root +} + +fn writer(sessions_root: &Path, session_id: &str) -> SessionWriter { + SessionWriter::create_with_id( + sessions_root, + session_id, + SessionMeta::new("inst-1", "实例", "1.20.1"), + Redactor::new(), + ) + .expect("建会话写入器") +} + +/// D2 主用例:写入侧存的形状 = 实际落点 = 读取侧解析出的路径(三者同一个文件)。 +#[test] +fn the_written_log_path_is_the_one_the_readers_resolve() { + let root = temp_dir("roundtrip"); + let mut sink = writer(&root, "s-round"); + sink.session_start().expect("写 session_start"); + let entry = sink.finish(EndReason::Exit).expect("收尾"); + + // ① 写入侧存进索引的形状:**相对会话根**,带一层 `game/`。 + assert_eq!( + entry.log_path, "game/s-round.jsonl", + "写入侧的 log_path 形状漂了(读取侧按这个形状回找)" + ); + // ② 事件流真的落在会话根下的 `game/`。 + let written = root.join("game").join("s-round.jsonl"); + assert!( + written.is_file(), + "事件流不在 {}:{}", + written.display(), + std::fs::read_dir(root.join("game")) + .map(|items| items.filter_map(Result::ok).count()) + .unwrap_or(0) + ); + // ③ 读取侧(CLI 的 `ull logs`/`ull diag` 与 presenter 的日志页共用)解析到**同一个文件**。 + assert_eq!( + session_index::event_file(&root, &entry.log_path), + written, + "读取侧解析出的路径与写入侧的真落点不一致(双写 `game/`?)" + ); + // ④ 真读一遍:读取侧拿到的路径里确实有事件。 + let text = std::fs::read_to_string(session_index::event_file(&root, &entry.log_path)) + .expect("读事件流"); + assert!(text.contains("\"session_start\""), "{text}"); + std::fs::remove_dir_all(&root).ok(); +} + +/// D3 主用例:干净退出(`code 0` / `error_lines 0`)⇒ 索引里 `crash_found` 必须是 `false`。 +/// +/// 夹具刻意按生产写:干净的一局也会写 `logs/latest.log`,所以「有 latest.log」不构成证据。 +#[test] +fn a_clean_exit_is_not_indexed_as_a_crash() { + let root = temp_dir("clean-index"); + let run_dir = temp_dir("clean-run"); + std::fs::create_dir_all(run_dir.join("logs")).expect("建 logs"); + std::fs::write( + run_dir.join("logs/latest.log"), + "[10:15:00] [main/INFO]: hi\n", + ) + .expect("写 latest.log"); + + let exit = exit_analysis::analyze(&ExitSignals::new(0, 60_000, &[])); + assert_eq!(exit.code, 0); + assert_eq!(exit.error_lines, 0, "夹具必须是干净退出"); + + let artifacts = crash_scan::scan(&run_dir, SystemTime::now(), 8); + let mut sink = writer(&root, "s-clean"); + sink.exit(&exit).expect("写 exit"); + for artifact in &artifacts { + sink.crash_found( + artifact.source.as_str(), + &artifact.path, + artifact.excerpt.as_deref(), + ) + .expect("写 crash_found"); + } + sink.mark_crash(crash_scan::has_crash_evidence(&artifacts, &exit)); + let entry = sink.finish(EndReason::Exit).expect("收尾"); + + assert!( + !entry.crash_found, + "干净退出(code 0 / error_lines 0)被标成了崩溃:artifacts={artifacts:?}" + ); + std::fs::remove_dir_all(&root).ok(); + std::fs::remove_dir_all(&run_dir).ok(); +} + +/// 对照用例:真有 `hs_err_pid*.log` 时仍然要标成崩溃(别把 D3 修成「永远 false」)。 +#[test] +fn a_real_crash_scene_is_still_indexed_as_a_crash() { + let root = temp_dir("crash-index"); + let run_dir = temp_dir("crash-run"); + std::fs::create_dir_all(run_dir.join("logs")).expect("建 logs"); + std::fs::write(run_dir.join("logs/latest.log"), "log\n").expect("写 latest.log"); + std::fs::write(run_dir.join("hs_err_pid77.log"), "SIGSEGV\n").expect("写 hs_err"); + + let exit = exit_analysis::analyze(&ExitSignals::new(0, 60_000, &[])); + let artifacts = crash_scan::scan(&run_dir, SystemTime::now(), 8); + let mut sink = writer(&root, "s-crash"); + sink.exit(&exit).expect("写 exit"); + for artifact in &artifacts { + sink.crash_found( + artifact.source.as_str(), + &artifact.path, + artifact.excerpt.as_deref(), + ) + .expect("写 crash_found"); + } + sink.mark_crash(crash_scan::has_crash_evidence(&artifacts, &exit)); + let entry = sink.finish(EndReason::Exit).expect("收尾"); + + assert!(entry.crash_found, "hs_err 在却没有标成崩溃:{artifacts:?}"); + std::fs::remove_dir_all(&root).ok(); + std::fs::remove_dir_all(&run_dir).ok(); +} diff --git a/crates/presenter/src/host/launch.rs b/crates/presenter/src/host/launch.rs index 9408799..68bc05c 100644 --- a/crates/presenter/src/host/launch.rs +++ b/crates/presenter/src/host/launch.rs @@ -259,11 +259,7 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) { }; report.run_dir = request.dirs.game_dir.clone(); report.version_json = Some(env.game.version_json(&target.version_id)); - report.java = format!( - "{} (major {})", - request.java.binary.display(), - request.java.major - ); + report.java = launcher_log::java_label(&request.java.binary, request.java.major); for warning in &warnings { let _ = tx.send(HostTask::Notice(Box::new(crate::dto::NoticeView::new( &format!("launch-warn:{}", target.id), @@ -273,14 +269,13 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) { )))); } // 命令行(脱敏版)进启动器日志:事故之后「到底用什么参数起的」是第一个要看的。 - let line = format!( - "instance={} game_dir={} java={} memory={}MB", - target.id, - report.run_dir.display(), - report.java, - request.memory.max_mb.unwrap_or(0) + // M211/D5:`ull launch` 写同一个文件的同一行(格式在 core 的 `launcher_log` 里)。 + launcher_log::record_start( + &target.id, + &report.run_dir, + Some((request.java.binary.as_path(), request.java.major)), + request.memory.max_mb, ); - launcher_log::record("launch", &line); let config = LauncherConfig::new(env.sessions_root()) .with_launcher(LAUNCHER_NAME, env!("CARGO_PKG_VERSION")); let session = match Launcher::new(config).spawn(&request) { @@ -317,16 +312,7 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) { match session.finish() { Ok(handle) => { let outcome = super::identity::outcome_of(&handle); - let line = format!( - "instance={} session={session_id} exit={} type={} uptime={}ms outcome={:?} artifacts={}", - target.id, - handle.exit.code, - handle.exit.exit_type.as_str(), - handle.uptime_ms, - outcome, - handle.artifacts.len() - ); - launcher_log::record("launch", &line); + launcher_log::record_end(&target.id, &handle); report.secrets.push(session_id.clone()); let lines: Vec = super::logs::read_lines(&handle.log_path) .into_iter() diff --git a/crates/presenter/src/host/logs.rs b/crates/presenter/src/host/logs.rs index 3b575a5..76a3c72 100644 --- a/crates/presenter/src/host/logs.rs +++ b/crates/presenter/src/host/logs.rs @@ -20,8 +20,7 @@ use serde_json::Value; use unlucky_core::diagnose::collectors::{ExitSignal, SignalOrigin}; use unlucky_core::diagnose::engine::{Diagnoser, DiagnosisInput}; use unlucky_core::diagnose::loader::RuleLoader; -use unlucky_core::launch::session_index::SessionIndexEntry; -use unlucky_core::platform::layout::GAME_LOG_DIR; +use unlucky_core::launch::session_index::{self, SessionIndexEntry}; use crate::dto::{ Diagnosis, DiagnosisReport, LogLevel, LogLine, LogOutcome, LogSession, SessionId, @@ -80,16 +79,20 @@ pub fn display_time(started_at: &str) -> String { format!("{date} {time}") } -/// 事件流文件路径(`/game/`;新根没有时回落到旧家)。 +/// 事件流文件路径(`/`;新根没有时回落到旧家)。 +/// +/// `log_path` 由写入侧按**相对会话根**的形状产出(`game/.jsonl`,见 +/// [`session_index::log_path_for`]),解析只走 [`session_index::event_file`] 一处—— +/// CLI 与 GUI 两个读取点共用它,谁也不许再自己拼一层 `game/`(M211/D2)。 /// /// 两处都找不到就返回**新根**的路径:调用方据此说出「该去哪找」,而不是一个旧目录路径。 pub fn event_path(env: &HostEnv, entry: &SessionIndexEntry) -> PathBuf { - let primary = root(env).join(GAME_LOG_DIR).join(&entry.log_path); + let primary = session_index::event_file(&root(env), &entry.log_path); if primary.is_file() { return primary; } if let Some(legacy) = legacy_root(env) { - let fallback = legacy.join(GAME_LOG_DIR).join(&entry.log_path); + let fallback = session_index::event_file(&legacy, &entry.log_path); if fallback.is_file() { return fallback; } diff --git a/crates/presenter/src/host/logs_tests.rs b/crates/presenter/src/host/logs_tests.rs index 37fa192..5db1fb3 100644 --- a/crates/presenter/src/host/logs_tests.rs +++ b/crates/presenter/src/host/logs_tests.rs @@ -95,6 +95,10 @@ fn temp_root(tag: &str) -> PathBuf { root } +/// 索引条目夹具。`log_path` **必须是生产写入的形状**(相对会话根:`game/.jsonl`)。 +/// +/// M211/D2:这里以前传不带 `game/` 的 `.jsonl`,于是「读取侧多 join 一层 `game/`」的 bug +/// 在夹具里永远看不见——夹具和生产各自自洽,两边都是绿的。改夹具比改路径更重要。 fn sample_entry(id: &str, file: &str, started_at: &str) -> SessionIndexEntry { SessionIndexEntry { v: 1, @@ -129,7 +133,7 @@ fn legacy_sessions_are_still_listed_and_openable() { std::fs::create_dir_all(&legacy_game).expect("建旧家目录"); let entry = sample_entry( "old-session", - "old-session.jsonl", + "game/old-session.jsonl", "2026-11-01T10:00:00.000Z", ); session_index::append( @@ -174,7 +178,7 @@ fn new_sessions_are_written_under_logs_game() { let index = session_index::index_path(&env.sessions_root()); assert_eq!(index, root.join(".ULL/logs/game/index.jsonl")); - let entry = sample_entry("fresh", "fresh.jsonl", "2026-11-08T09:00:00.000Z"); + let entry = sample_entry("fresh", "game/fresh.jsonl", "2026-11-08T09:00:00.000Z"); session_index::append(&index, &entry).expect("写新索引"); let file = env.sessions_root().join("game/fresh.jsonl"); std::fs::write( @@ -202,7 +206,7 @@ fn the_new_root_wins_when_both_indexes_know_the_same_id() { let env = HostEnv::at_root(&root); let legacy_game = env.legacy_sessions_root().join("game"); std::fs::create_dir_all(&legacy_game).expect("建旧家目录"); - let old = sample_entry("same", "same.jsonl", "2026-10-01T00:00:00.000Z"); + let old = sample_entry("same", "game/same.jsonl", "2026-10-01T00:00:00.000Z"); session_index::append( &session_index::index_path(&env.legacy_sessions_root()), &old, @@ -214,7 +218,7 @@ fn the_new_root_wins_when_both_indexes_know_the_same_id() { ) .expect("写旧事件流"); - let fresh = sample_entry("same", "same.jsonl", "2026-11-08T00:00:00.000Z"); + let fresh = sample_entry("same", "game/same.jsonl", "2026-11-08T00:00:00.000Z"); session_index::append(&session_index::index_path(&env.sessions_root()), &fresh) .expect("写新索引"); let new_file = env.sessions_root().join("game/same.jsonl");