Merge branch 'feat/m211-logreach-session-jsonl-path-double'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
This commit is contained in:
commit
994ffda626
12 files changed
+542
-44
No files matched your search
@@ -105,10 +105,18 @@ pub fn run(ctx: &Ctx, args: &LaunchArgs) -> Result<Report, CliError> {
|
|||||||
..input
|
..input
|
||||||
};
|
};
|
||||||
|
|
||||||
let plan = snapshot::prepare(ctx, &input)?;
|
// M188/M211:命令行这一路也把启动器日志写进 `<data>/logs/launcher.log`
|
||||||
// M188:命令行这一路也把启动器自己的运行日志落到 `<data>/logs/launcher.log`
|
// (落点与格式的真相都在 core 的 `launcher_log`,GUI 写的是同一份)。
|
||||||
// (GUI 由 presenter 的 `HostEnv` 登记;两条路的落点必须是同一个)。
|
|
||||||
unlucky_core::launch::launcher_log::set_dir(ctx.logs_dir());
|
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())
|
let config = LauncherConfig::new(ctx.sessions_root())
|
||||||
.with_launcher(super::meta::LAUNCHER_NAME, env!("CARGO_PKG_VERSION"));
|
.with_launcher(super::meta::LAUNCHER_NAME, env!("CARGO_PKG_VERSION"));
|
||||||
let launcher = Launcher::new(config);
|
let launcher = Launcher::new(config);
|
||||||
@@ -187,6 +195,7 @@ pub fn run(ctx: &Ctx, args: &LaunchArgs) -> Result<Report, CliError> {
|
|||||||
let handle = launcher
|
let handle = launcher
|
||||||
.launch(&plan.request)
|
.launch(&plan.request)
|
||||||
.map_err(|error| map_launch_error(&error, ctx))?;
|
.map_err(|error| map_launch_error(&error, ctx))?;
|
||||||
|
unlucky_core::launch::launcher_log::record_end(&input.instance_id, &handle);
|
||||||
let data = json!({
|
let data = json!({
|
||||||
"dry_run": false,
|
"dry_run": false,
|
||||||
"instance_found": instance_found,
|
"instance_found": instance_found,
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ use unlucky_core::diagnose::collectors::{ExitSignal, SignalOrigin};
|
|||||||
use unlucky_core::diagnose::engine::{Diagnoser, DiagnosisInput};
|
use unlucky_core::diagnose::engine::{Diagnoser, DiagnosisInput};
|
||||||
use unlucky_core::diagnose::loader::RuleLoader;
|
use unlucky_core::diagnose::loader::RuleLoader;
|
||||||
use unlucky_core::launch::session_index::{self, SessionIndexEntry};
|
use unlucky_core::launch::session_index::{self, SessionIndexEntry};
|
||||||
use unlucky_core::platform::layout::GAME_LOG_DIR;
|
|
||||||
|
|
||||||
use crate::cli::{DiagArgs, LogsArgs};
|
use crate::cli::{DiagArgs, LogsArgs};
|
||||||
use crate::context::Ctx;
|
use crate::context::Ctx;
|
||||||
@@ -241,14 +240,13 @@ fn find_entry(ctx: &Ctx, key: &str) -> Result<SessionIndexEntry, CliError> {
|
|||||||
fn event_path(ctx: &Ctx, entry: &SessionIndexEntry) -> std::path::PathBuf {
|
fn event_path(ctx: &Ctx, entry: &SessionIndexEntry) -> std::path::PathBuf {
|
||||||
// M188:新会话在 `logs/game/`,旧会话在 `data/agent/sessions/game/`(只读兼容)。
|
// 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/<id>.jsonl`),解析交给 core 里唯一的
|
||||||
|
// `session_index::event_file`——这里不许再自己拼 `game/`,否则又是一份第三真相。
|
||||||
|
let primary = session_index::event_file(&ctx.sessions_root(), &entry.log_path);
|
||||||
if primary.is_file() {
|
if primary.is_file() {
|
||||||
return primary;
|
return primary;
|
||||||
}
|
}
|
||||||
let legacy = ctx
|
let legacy = session_index::event_file(&ctx.legacy_sessions_root(), &entry.log_path);
|
||||||
.legacy_sessions_root()
|
|
||||||
.join(GAME_LOG_DIR)
|
|
||||||
.join(&entry.log_path);
|
|
||||||
if legacy.is_file() {
|
if legacy.is_file() {
|
||||||
return legacy;
|
return legacy;
|
||||||
}
|
}
|
||||||
@@ -339,3 +337,48 @@ fn exit_label(entry: &SessionIndexEntry) -> String {
|
|||||||
};
|
};
|
||||||
format!("{code}/{exit_type}{silent}")
|
format!("{code}/{exit_type}{silent}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// M211/D5:`ull launch` 也必须写 `<data>/logs/launcher.log`(与 GUI 同一份文件、同一行格式)。
|
||||||
|
///
|
||||||
|
/// 为什么这条用例在这个文件里:CLI 侧本 mission 只允许改 `commands/launch.rs` 与
|
||||||
|
/// `commands/sessions.rs`,而 `launch.rs` 已到 389 行、再加用例会破「文件 ≤400 行」——
|
||||||
|
/// 用例放在同一个 `<data>/logs` 层级的读取方(本文件打印「启动器日志:<dir>」)旁边。
|
||||||
|
///
|
||||||
|
/// 变异证明:删掉 `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<String> = 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` 必须在 <data>/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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -225,6 +225,12 @@ pub fn write_profiles(data_root: &Path, profiles: &[(&str, &str)]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 写一份「退出码 1 + OOM」的会话索引与事件流,返回会话 id。
|
/// 写一份「退出码 1 + OOM」的会话索引与事件流,返回会话 id。
|
||||||
|
///
|
||||||
|
/// **形状必须与生产写入一致**(M211/D2):`log_path` 是**相对会话根**的 `game/<sid>.jsonl`,
|
||||||
|
/// 事件流真落点是 `<会话根>/game/<sid>.jsonl`。这里用的是旧家(`data/agent/sessions`)作会话根
|
||||||
|
/// ——那是 M188 之前的生产落点,也是用户盘上存量会话的真实形状,读取侧必须还能读回它们。
|
||||||
|
/// 夹具此前写的是不带 `game/` 的 `"{sid}.jsonl"`,于是「读取侧多 join 一层 `game/`」这个 bug
|
||||||
|
/// 在测试里永远看不见:夹具自己说了另一种形状。
|
||||||
pub fn write_session(data_root: &Path) -> String {
|
pub fn write_session(data_root: &Path) -> String {
|
||||||
let sid = "20261030T101500-abcdef";
|
let sid = "20261030T101500-abcdef";
|
||||||
let dir = data_root
|
let dir = data_root
|
||||||
@@ -259,7 +265,7 @@ pub fn write_session(data_root: &Path) -> String {
|
|||||||
"silent_failure": false,
|
"silent_failure": false,
|
||||||
"window_detected": false,
|
"window_detected": false,
|
||||||
"event_count": 3,
|
"event_count": 3,
|
||||||
"log_path": format!("{sid}.jsonl"),
|
"log_path": unlucky_core::launch::session_index::log_path_for(sid),
|
||||||
"crash_found": false,
|
"crash_found": false,
|
||||||
"diagnosis_top": null
|
"diagnosis_top": null
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::{Duration, SystemTime};
|
use std::time::{Duration, SystemTime};
|
||||||
|
|
||||||
|
use crate::launch::exit_analysis::ExitAnalysis;
|
||||||
|
|
||||||
/// 崩溃现场文件来源。
|
/// 崩溃现场文件来源。
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum CrashSource {
|
pub enum CrashSource {
|
||||||
@@ -30,6 +32,15 @@ impl CrashSource {
|
|||||||
Self::LatestLog => "latest_log",
|
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` 个)。
|
/// 扫描一次运行目录,返回按修改时间倒序的现场文件(最多 `limit` 个)。
|
||||||
///
|
///
|
||||||
/// `since` 之前的文件视为上一局遗留,忽略;给 `since` 留 1 秒容差以吸收文件系统时间精度。
|
/// `since` 之前的文件视为上一局遗留,忽略;给 `since` 留 1 秒容差以吸收文件系统时间精度。
|
||||||
|
///
|
||||||
|
/// **`logs/latest.log` 只在已经找到权威现场文件时才被带上**(M211/D3):它是「崩溃报告/
|
||||||
|
/// hs_err 都缺、只能看全文」时的补充证据,每局都会写,不能当崩溃判据。
|
||||||
pub fn scan(run_dir: &Path, since: SystemTime, limit: usize) -> Vec<CrashArtifact> {
|
pub fn scan(run_dir: &Path, since: SystemTime, limit: usize) -> Vec<CrashArtifact> {
|
||||||
let since = since.checked_sub(Duration::from_secs(1)).unwrap_or(since);
|
let since = since.checked_sub(Duration::from_secs(1)).unwrap_or(since);
|
||||||
let mut found: Vec<(SystemTime, CrashArtifact)> = Vec::new();
|
let mut found: Vec<(SystemTime, CrashArtifact)> = Vec::new();
|
||||||
@@ -60,6 +74,9 @@ pub fn scan(run_dir: &Path, since: SystemTime, limit: usize) -> Vec<CrashArtifac
|
|||||||
&mut found,
|
&mut found,
|
||||||
);
|
);
|
||||||
collect_matching(run_dir, CrashSource::HsErr, since, &mut found);
|
collect_matching(run_dir, CrashSource::HsErr, since, &mut found);
|
||||||
|
if found.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
if let Some(artifact) = latest_log(run_dir, since) {
|
if let Some(artifact) = latest_log(run_dir, since) {
|
||||||
let modified = artifact_time(&artifact.path).unwrap_or(since);
|
let modified = artifact_time(&artifact.path).unwrap_or(since);
|
||||||
found.push((modified, artifact));
|
found.push((modified, artifact));
|
||||||
@@ -73,6 +90,21 @@ pub fn scan(run_dir: &Path, since: SystemTime, limit: usize) -> Vec<CrashArtifac
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 这一局是否**有真实崩溃证据**(M211/D3:索引里的 `crash_found` 只认这个结论)。
|
||||||
|
///
|
||||||
|
/// 判据只有两类,都要么是崩溃专属产物、要么是「异常退出 + 日志里真有错误行」:
|
||||||
|
/// 1. 现场文件里有权威来源(`crash-reports/*.txt` / `hs_err_pid*.log`);
|
||||||
|
/// 2. 非 0 退出码且错误级日志行数 > 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(
|
fn collect_matching(
|
||||||
dir: &Path,
|
dir: &Path,
|
||||||
source: CrashSource,
|
source: CrashSource,
|
||||||
@@ -150,3 +182,114 @@ pub fn read_excerpt(path: &Path, max_chars: usize) -> Option<String> {
|
|||||||
}
|
}
|
||||||
Some(excerpt)
|
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<LogLine> = (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<CrashSource> = 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(std::slice::from_ref(&latest), &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 退出 + 错误行 ⇒ 崩溃"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -161,6 +161,9 @@ impl LaunchSession {
|
|||||||
let mut event_write_errors = Vec::new();
|
let mut event_write_errors = Vec::new();
|
||||||
let mut diagnostics = None;
|
let mut diagnostics = None;
|
||||||
let mut index = 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 {
|
let end_reason = if self.killed {
|
||||||
EndReason::Killed
|
EndReason::Killed
|
||||||
} else {
|
} else {
|
||||||
@@ -180,6 +183,7 @@ impl LaunchSession {
|
|||||||
event_write_errors.push(err.to_string());
|
event_write_errors.push(err.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
writer.mark_crash(crash_evidence);
|
||||||
if let Some(record) = diagnosis {
|
if let Some(record) = diagnosis {
|
||||||
if let Err(err) = writer.diagnosis(record.clone()) {
|
if let Err(err) = writer.diagnosis(record.clone()) {
|
||||||
event_write_errors.push(err.to_string());
|
event_write_errors.push(err.to_string());
|
||||||
@@ -307,6 +311,20 @@ impl SessionHandle {
|
|||||||
self.exit.needs_diagnosis
|
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 {
|
pub fn summary(&self) -> String {
|
||||||
let mut text = format!("会话 {}:{}", self.session_id, self.exit.summary());
|
let mut text = format!("会话 {}:{}", self.session_id, self.exit.summary());
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ use std::io::Write;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::{Mutex, OnceLock};
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
|
||||||
|
use super::launch_session::SessionHandle;
|
||||||
use super::redact::Redactor;
|
use super::redact::Redactor;
|
||||||
|
|
||||||
/// 日志文件名。
|
/// 日志文件名。
|
||||||
@@ -102,6 +103,83 @@ pub fn record(scope: &str, message: &str) {
|
|||||||
append(&dir, &line);
|
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`])。
|
/// 脱敏器:主目录换成 `<home>`(同一份规则见 [`super::redact`])。
|
||||||
fn redactor() -> Redactor {
|
fn redactor() -> Redactor {
|
||||||
let mut redactor = Redactor::new();
|
let mut redactor = Redactor::new();
|
||||||
@@ -204,4 +282,31 @@ mod tests {
|
|||||||
assert!(lines <= MAX_BUFFERED, "缓冲没有上限:{lines} 行");
|
assert!(lines <= MAX_BUFFERED, "缓冲没有上限:{lines} 行");
|
||||||
assert!(lines > 0);
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -316,12 +316,22 @@ impl SessionWriter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// `crash_found`:以 `path` 为权威字段(M15 自己读盘)。
|
/// `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<()> {
|
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);
|
let payload = payload::crash_found(source, path, excerpt, &self.redactor);
|
||||||
self.record(EventKind::CrashFound, payload)
|
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 脱敏后落盘(单一真相源)。
|
/// `diagnosis`:M15 产出的结构化结论,由 launch 脱敏后落盘(单一真相源)。
|
||||||
///
|
///
|
||||||
/// M15 会按 `crash_found.path` 自己读盘再组 payload,崩溃报告/`latest.log` 里的
|
/// M15 会按 `crash_found.path` 自己读盘再组 payload,崩溃报告/`latest.log` 里的
|
||||||
@@ -361,7 +371,7 @@ impl SessionWriter {
|
|||||||
silent_failure: self.silent_failure,
|
silent_failure: self.silent_failure,
|
||||||
window_detected: self.window_detected,
|
window_detected: self.window_detected,
|
||||||
event_count: self.event_count,
|
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,
|
crash_found: self.crash_found,
|
||||||
diagnosis_top: self.diagnosis_top.clone(),
|
diagnosis_top: self.diagnosis_top.clone(),
|
||||||
};
|
};
|
||||||
@@ -373,3 +383,7 @@ impl SessionWriter {
|
|||||||
Ok(entry)
|
Ok(entry)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "session_path_tests.rs"]
|
||||||
|
mod path_tests;
|
||||||
@@ -10,6 +10,8 @@ use std::path::{Path, PathBuf};
|
|||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::platform::layout::GAME_LOG_DIR;
|
||||||
|
|
||||||
/// 索引条目的 schema 版本。
|
/// 索引条目的 schema 版本。
|
||||||
pub const INDEX_VERSION: u8 = 1;
|
pub const INDEX_VERSION: u8 = 1;
|
||||||
|
|
||||||
@@ -43,7 +45,11 @@ pub struct SessionIndexEntry {
|
|||||||
pub window_detected: bool,
|
pub window_detected: bool,
|
||||||
/// 事件条数。
|
/// 事件条数。
|
||||||
pub event_count: u64,
|
pub event_count: u64,
|
||||||
/// 相对 `game/` 的日志文件名。
|
/// 事件流相对**会话根**的路径(生产形状 `game/<session-id>.jsonl`)。
|
||||||
|
///
|
||||||
|
/// 契约里「相对谁」只有这一句,见 [`event_file`]。M211 之前这里写成「相对 `game/`」,
|
||||||
|
/// 而写入侧一直按「相对会话根」写 —— 两个说法各自成立,两处读取点于是都多 join 了一层
|
||||||
|
/// `game/`,`ull logs` / `ull diag` 对每个真实会话都报「找不到事件流」。
|
||||||
pub log_path: String,
|
pub log_path: String,
|
||||||
/// 是否抓到崩溃报告/hs_err。
|
/// 是否抓到崩溃报告/hs_err。
|
||||||
pub crash_found: bool,
|
pub crash_found: bool,
|
||||||
@@ -56,6 +62,25 @@ pub fn index_path(sessions_root: &Path) -> PathBuf {
|
|||||||
sessions_root.join("game").join("index.jsonl")
|
sessions_root.join("game").join("index.jsonl")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 写入侧用的 `log_path`:**相对会话根**(`<sessions_root>`)的形状,`game/<id>.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/<id>.jsonl`,正是本函数的
|
||||||
|
/// 输入形状(`<root>/game/<id>.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<()> {
|
pub fn append(index: &Path, entry: &SessionIndexEntry) -> std::io::Result<()> {
|
||||||
if let Some(parent) = index.parent() {
|
if let Some(parent) = index.parent() {
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
//! M211:写入侧与读取侧对**同一个相对路径**的往返用例(D2 / D3)。
|
||||||
|
//!
|
||||||
|
//! 这个文件存在的理由:原来的夹具(CLI `tests/support`、presenter `logs_tests`)各自
|
||||||
|
//! **手写**索引条目,于是「写入侧存 `game/<id>.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();
|
||||||
|
}
|
||||||
@@ -259,11 +259,7 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
|
|||||||
};
|
};
|
||||||
report.run_dir = request.dirs.game_dir.clone();
|
report.run_dir = request.dirs.game_dir.clone();
|
||||||
report.version_json = Some(env.game.version_json(&target.version_id));
|
report.version_json = Some(env.game.version_json(&target.version_id));
|
||||||
report.java = format!(
|
report.java = launcher_log::java_label(&request.java.binary, request.java.major);
|
||||||
"{} (major {})",
|
|
||||||
request.java.binary.display(),
|
|
||||||
request.java.major
|
|
||||||
);
|
|
||||||
for warning in &warnings {
|
for warning in &warnings {
|
||||||
let _ = tx.send(HostTask::Notice(Box::new(crate::dto::NoticeView::new(
|
let _ = tx.send(HostTask::Notice(Box::new(crate::dto::NoticeView::new(
|
||||||
&format!("launch-warn:{}", target.id),
|
&format!("launch-warn:{}", target.id),
|
||||||
@@ -273,14 +269,13 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
|
|||||||
))));
|
))));
|
||||||
}
|
}
|
||||||
// 命令行(脱敏版)进启动器日志:事故之后「到底用什么参数起的」是第一个要看的。
|
// 命令行(脱敏版)进启动器日志:事故之后「到底用什么参数起的」是第一个要看的。
|
||||||
let line = format!(
|
// M211/D5:`ull launch` 写同一个文件的同一行(格式在 core 的 `launcher_log` 里)。
|
||||||
"instance={} game_dir={} java={} memory={}MB",
|
launcher_log::record_start(
|
||||||
target.id,
|
&target.id,
|
||||||
report.run_dir.display(),
|
&report.run_dir,
|
||||||
report.java,
|
Some((request.java.binary.as_path(), request.java.major)),
|
||||||
request.memory.max_mb.unwrap_or(0)
|
request.memory.max_mb,
|
||||||
);
|
);
|
||||||
launcher_log::record("launch", &line);
|
|
||||||
let config = LauncherConfig::new(env.sessions_root())
|
let config = LauncherConfig::new(env.sessions_root())
|
||||||
.with_launcher(LAUNCHER_NAME, env!("CARGO_PKG_VERSION"));
|
.with_launcher(LAUNCHER_NAME, env!("CARGO_PKG_VERSION"));
|
||||||
let session = match Launcher::new(config).spawn(&request) {
|
let session = match Launcher::new(config).spawn(&request) {
|
||||||
@@ -317,16 +312,7 @@ pub fn start(env: HostEnv, tx: JobSender, target: LaunchTarget) {
|
|||||||
match session.finish() {
|
match session.finish() {
|
||||||
Ok(handle) => {
|
Ok(handle) => {
|
||||||
let outcome = super::identity::outcome_of(&handle);
|
let outcome = super::identity::outcome_of(&handle);
|
||||||
let line = format!(
|
launcher_log::record_end(&target.id, &handle);
|
||||||
"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);
|
|
||||||
report.secrets.push(session_id.clone());
|
report.secrets.push(session_id.clone());
|
||||||
let lines: Vec<String> = super::logs::read_lines(&handle.log_path)
|
let lines: Vec<String> = super::logs::read_lines(&handle.log_path)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|||||||
@@ -20,8 +20,7 @@ use serde_json::Value;
|
|||||||
use unlucky_core::diagnose::collectors::{ExitSignal, SignalOrigin};
|
use unlucky_core::diagnose::collectors::{ExitSignal, SignalOrigin};
|
||||||
use unlucky_core::diagnose::engine::{Diagnoser, DiagnosisInput};
|
use unlucky_core::diagnose::engine::{Diagnoser, DiagnosisInput};
|
||||||
use unlucky_core::diagnose::loader::RuleLoader;
|
use unlucky_core::diagnose::loader::RuleLoader;
|
||||||
use unlucky_core::launch::session_index::SessionIndexEntry;
|
use unlucky_core::launch::session_index::{self, SessionIndexEntry};
|
||||||
use unlucky_core::platform::layout::GAME_LOG_DIR;
|
|
||||||
|
|
||||||
use crate::dto::{
|
use crate::dto::{
|
||||||
Diagnosis, DiagnosisReport, LogLevel, LogLine, LogOutcome, LogSession, SessionId,
|
Diagnosis, DiagnosisReport, LogLevel, LogLine, LogOutcome, LogSession, SessionId,
|
||||||
@@ -80,16 +79,20 @@ pub fn display_time(started_at: &str) -> String {
|
|||||||
format!("{date} {time}")
|
format!("{date} {time}")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 事件流文件路径(`<logs>/game/<log_path>`;新根没有时回落到旧家)。
|
/// 事件流文件路径(`<logs>/<log_path>`;新根没有时回落到旧家)。
|
||||||
|
///
|
||||||
|
/// `log_path` 由写入侧按**相对会话根**的形状产出(`game/<id>.jsonl`,见
|
||||||
|
/// [`session_index::log_path_for`]),解析只走 [`session_index::event_file`] 一处——
|
||||||
|
/// CLI 与 GUI 两个读取点共用它,谁也不许再自己拼一层 `game/`(M211/D2)。
|
||||||
///
|
///
|
||||||
/// 两处都找不到就返回**新根**的路径:调用方据此说出「该去哪找」,而不是一个旧目录路径。
|
/// 两处都找不到就返回**新根**的路径:调用方据此说出「该去哪找」,而不是一个旧目录路径。
|
||||||
pub fn event_path(env: &HostEnv, entry: &SessionIndexEntry) -> PathBuf {
|
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() {
|
if primary.is_file() {
|
||||||
return primary;
|
return primary;
|
||||||
}
|
}
|
||||||
if let Some(legacy) = legacy_root(env) {
|
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() {
|
if fallback.is_file() {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,6 +95,10 @@ fn temp_root(tag: &str) -> PathBuf {
|
|||||||
root
|
root
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 索引条目夹具。`log_path` **必须是生产写入的形状**(相对会话根:`game/<id>.jsonl`)。
|
||||||
|
///
|
||||||
|
/// M211/D2:这里以前传不带 `game/` 的 `<id>.jsonl`,于是「读取侧多 join 一层 `game/`」的 bug
|
||||||
|
/// 在夹具里永远看不见——夹具和生产各自自洽,两边都是绿的。改夹具比改路径更重要。
|
||||||
fn sample_entry(id: &str, file: &str, started_at: &str) -> SessionIndexEntry {
|
fn sample_entry(id: &str, file: &str, started_at: &str) -> SessionIndexEntry {
|
||||||
SessionIndexEntry {
|
SessionIndexEntry {
|
||||||
v: 1,
|
v: 1,
|
||||||
@@ -129,7 +133,7 @@ fn legacy_sessions_are_still_listed_and_openable() {
|
|||||||
std::fs::create_dir_all(&legacy_game).expect("建旧家目录");
|
std::fs::create_dir_all(&legacy_game).expect("建旧家目录");
|
||||||
let entry = sample_entry(
|
let entry = sample_entry(
|
||||||
"old-session",
|
"old-session",
|
||||||
"old-session.jsonl",
|
"game/old-session.jsonl",
|
||||||
"2026-11-01T10:00:00.000Z",
|
"2026-11-01T10:00:00.000Z",
|
||||||
);
|
);
|
||||||
session_index::append(
|
session_index::append(
|
||||||
@@ -174,7 +178,7 @@ fn new_sessions_are_written_under_logs_game() {
|
|||||||
let index = session_index::index_path(&env.sessions_root());
|
let index = session_index::index_path(&env.sessions_root());
|
||||||
assert_eq!(index, root.join(".ULL/logs/game/index.jsonl"));
|
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("写新索引");
|
session_index::append(&index, &entry).expect("写新索引");
|
||||||
let file = env.sessions_root().join("game/fresh.jsonl");
|
let file = env.sessions_root().join("game/fresh.jsonl");
|
||||||
std::fs::write(
|
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 env = HostEnv::at_root(&root);
|
||||||
let legacy_game = env.legacy_sessions_root().join("game");
|
let legacy_game = env.legacy_sessions_root().join("game");
|
||||||
std::fs::create_dir_all(&legacy_game).expect("建旧家目录");
|
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::append(
|
||||||
&session_index::index_path(&env.legacy_sessions_root()),
|
&session_index::index_path(&env.legacy_sessions_root()),
|
||||||
&old,
|
&old,
|
||||||
@@ -214,7 +218,7 @@ fn the_new_root_wins_when_both_indexes_know_the_same_id() {
|
|||||||
)
|
)
|
||||||
.expect("写旧事件流");
|
.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)
|
session_index::append(&session_index::index_path(&env.sessions_root()), &fresh)
|
||||||
.expect("写新索引");
|
.expect("写新索引");
|
||||||
let new_file = env.sessions_root().join("game/same.jsonl");
|
let new_file = env.sessions_root().join("game/same.jsonl");
|
||||||
|
|||||||
Reference in new issue
Block a user