From edd6402fd54431f50bf6f28f1d3aa4e217aa9646 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 11 Nov 2026 09:19:42 +0800 Subject: [PATCH] =?UTF-8?q?fix(core):=20=E8=A7=84=E5=88=99=E7=BB=93?= =?UTF-8?q?=E8=AE=BA=E6=94=B9=E5=9C=A8=E8=84=B1=E6=95=8F=E5=89=8D=E7=AE=97?= =?UTF-8?q?=EF=BC=8C=E4=BC=9A=E8=AF=9D=E8=B7=AF=E4=B8=8A=20fabric.loader?= =?UTF-8?q?=5F0=5F12=20=E8=83=BD=E5=91=BD=E4=B8=AD=EF=BC=88M241=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 会话 JSONL 是写前脱敏的,而脱敏的「≥64 无点号不透明串」是有损的: fabric.loader_0_12 唯一的证据行(Sponge mixin 类路径 66 字符)被抹成 ,规则在会话路上永不命中。 修法不动脱敏策略、也不往盘上多写一份原文: 1. SessionWriter 在内存里留一个 2000 行的原始尾窗(不落盘); 2. 收尾时把这些**原始**事实交给新的 diagnose::session::conclude 跑规则, 产出契约形状的 diagnosis payload —— 它落盘前仍过 Redactor::scrub_json; 3. 日志页优先读这条落盘结论,老会话才回落到「在脱敏文本上重跑规则」。 为什么不是「原文与脱敏文本并存」:会话 JSONL 里存原文会踩 core/tests/launch/pipeline_cases.rs 早已钉死的「事件流不得含凭据明文」。 顺带把 EventKind 纯搬移到 launch/session/event_kind.rs 守 400 行上限。 --- crates/core/src/diagnose/mod.rs | 8 +- crates/core/src/diagnose/session.rs | 85 +++++++++++ crates/core/src/diagnose/session_tests.rs | 90 ++++++++++++ crates/core/src/launch/session.rs | 102 ++++++------- crates/core/src/launch/session/event_kind.rs | 58 ++++++++ crates/core/src/launch/session/raw_window.rs | 59 ++++++++ .../src/launch/session/raw_window_tests.rs | 46 ++++++ crates/presenter/src/host/logs.rs | 32 ++++- crates/presenter/src/host/logs/diagnosis.rs | 82 +++++++++++ crates/presenter/src/host/logs/m241_tests.rs | 136 ++++++++++++++++++ 10 files changed, 633 insertions(+), 65 deletions(-) create mode 100644 crates/core/src/diagnose/session.rs create mode 100644 crates/core/src/diagnose/session_tests.rs create mode 100644 crates/core/src/launch/session/event_kind.rs create mode 100644 crates/core/src/launch/session/raw_window.rs create mode 100644 crates/core/src/launch/session/raw_window_tests.rs create mode 100644 crates/presenter/src/host/logs/diagnosis.rs create mode 100644 crates/presenter/src/host/logs/m241_tests.rs diff --git a/crates/core/src/diagnose/mod.rs b/crates/core/src/diagnose/mod.rs index d026f43..bba9a17 100644 --- a/crates/core/src/diagnose/mod.rs +++ b/crates/core/src/diagnose/mod.rs @@ -7,11 +7,13 @@ //! //! 规划中的公共面(M15):信号采集、规则匹配、诊断报告、规则热加载。 -// 只有这五个顶层模块;子模块(collectors 下的 signals/sources/environment、engine 下的 -// finding/synthetic、hs_err 下的 hs_err_report、rules 下的 fix_action/fix_params)由各自的 -// 父文件用 #[path] 声明。这里**不要**重复声明,否则同一文件会被编译成两份不同类型。 +// 只有这六个顶层模块;子模块(collectors 下的 signals/sources/environment、engine 下的 +// finding/synthetic、hs_err 下的 hs_err_report、rules 下的 fix_action/fix_params、session 下的 +// session_tests)由各自的父文件用 #[path] 声明。这里**不要**重复声明,否则同一文件会被编译成 +// 两份不同类型。 pub mod collectors; pub mod engine; pub mod hs_err; pub mod loader; pub mod rules; +pub mod session; diff --git a/crates/core/src/diagnose/session.rs b/crates/core/src/diagnose/session.rs new file mode 100644 index 0000000..0927c09 --- /dev/null +++ b/crates/core/src/diagnose/session.rs @@ -0,0 +1,85 @@ +//! 会话收尾的规则结论:**在原始行还热着的时候**算一次(M241)。 +//! +//! ## 为什么不能等读回 JSONL 再算 +//! +//! 会话事件流是「写前先脱敏」的([`crate::launch::redact`],`落盘即脱敏`),而脱敏的两条 +//! 形状规则是**有损**的:`≥64` 无点号不透明串、带点 token。于是「读回 JSONL → 喂规则引擎」 +//! 这条路看到的不是游戏说的话,而是**被抹过的**游戏说的话。M239 实测:`fabric.loader_0_12` +//! (critical / priority 80)的唯一证据行 +//! `java.lang.NoClassDefFoundError: org/spongepowered/…/FabricMixinTransformerProxy` +//! 里的类路径 66 字符、无点号,正好撞上 `≥64` 那条规则 → 变成 `` → +//! 规则在会话路上**永不命中**。 +//! +//! ## 分工(`DevDocs/SubAgentsTalk/launch-接口约定.md` §3) +//! +//! 「launch 报事实、M15 下结论」没有变:launch 把**原始事实**(退出码、存活时长、原始 +//! stdout/stderr 尾窗)交过来,结论在 `diagnose` 里算,产出的就是 `diagnosis` 事件的 +//! payload([`DiagnosisReport::to_record`])。落盘仍然只留**已脱敏**的结论:写会话的 +//! `SessionWriter::diagnosis` 会再走一遍 `Redactor::scrub_json`。 +//! +//! 换句话说:**原始文本只活在内存里**,盘上永远只有脱敏文本——不是「再存一份原文」。 + +use serde_json::Value; + +use crate::diagnose::collectors::{ExitSignal, SignalOrigin}; +use crate::diagnose::engine::{Diagnoser, DiagnosisInput}; +use crate::diagnose::loader::RuleLoader; + +/// 会话收尾时交给规则引擎的**原始**事实(未脱敏,只在本进程内存里)。 +#[derive(Debug, Clone, Copy)] +pub struct RawFacts<'a> { + /// 退出码(`None` = 没有退出判定)。 + pub exit_code: Option, + /// 进程存活毫秒数。 + pub duration_ms: u64, + /// 全量错误行数(不受尾窗影响)。 + pub error_lines: u32, + /// 原始 stdout 尾窗(按时间顺序)。 + pub stdout: &'a [String], + /// 原始 stderr 尾窗(按时间顺序)。 + pub stderr: &'a [String], +} + +impl RawFacts<'_> { + /// 有没有任何可匹配的文本。 + fn has_text(&self) -> bool { + !self.stdout.is_empty() || !self.stderr.is_empty() + } +} + +/// 跑一遍规则引擎,返回 `diagnosis` 事件的 payload;没有结论时返回 `None`。 +/// +/// 返回 `None` 的两种情况都**不写事件**:没有任何文本可匹配(无从判断),或规则引擎没认出 +/// 已知问题(此时读回 JSONL 的那条兜底路径看到的是同一份证据,重跑也是空手)。 +#[must_use] +pub fn conclude(facts: &RawFacts<'_>) -> Option { + if !facts.has_text() { + return None; + } + let stderr_text = facts.stderr.join("\n"); + let exit = ExitSignal::classify( + facts.exit_code, + Some(facts.duration_ms), + facts.error_lines, + &stderr_text, + ); + let mut input = DiagnosisInput::new().with_exit(exit); + if !facts.stdout.is_empty() { + input.push_text(SignalOrigin::Stdout, facts.stdout.join("\n")); + } + if !facts.stderr.is_empty() { + input.push_text(SignalOrigin::Stderr, facts.stderr.join("\n")); + } + let report = Diagnoser::new(rules()).analyze(&input); + report.primary()?; + serde_json::to_value(report.to_record()).ok() +} + +/// 内嵌规则集编译一次的那份规则(每次重新取;`RuleLoader` 自带缓存语义)。 +fn rules() -> Vec { + RuleLoader::embedded().rule_set().rules().to_vec() +} + +#[cfg(test)] +#[path = "session_tests.rs"] +mod tests; diff --git a/crates/core/src/diagnose/session_tests.rs b/crates/core/src/diagnose/session_tests.rs new file mode 100644 index 0000000..31eb3ff --- /dev/null +++ b/crates/core/src/diagnose/session_tests.rs @@ -0,0 +1,90 @@ +//! `diagnose::session` 的单测(拆文件守 400 行上限)。 + +use super::*; +use crate::launch::redact::Redactor; + +/// `fabric.loader_0_12`(critical / priority 80)在 `rules.d/fabric.yaml` 里的唯一证据行。 +const EVIDENCE: &str = "java.lang.NoClassDefFoundError: \ + org/spongepowered/asm/mixin/transformer/FabricMixinTransformerProxy"; + +fn facts<'a>(stdout: &'a [String], stderr: &'a [String]) -> RawFacts<'a> { + RawFacts { + exit_code: Some(1), + duration_ms: 4_200, + error_lines: 2, + stdout, + stderr, + } +} + +fn record_id(record: &Value) -> Option<&str> { + record + .get("candidates") + .and_then(Value::as_array) + .and_then(|items| items.first()) + .and_then(|item| item.get("id")) + .and_then(Value::as_str) +} + +/// **判据 ①**:原始证据行能命中 `fabric.loader_0_12`;同一行被脱敏后命不中。 +/// +/// 后半句不是「补充说明」,它是这个模块存在的理由——会话 JSONL 里只有脱敏文本。 +#[test] +fn raw_evidence_hits_the_fabric_rule_while_scrubbed_evidence_misses_it() { + let raw = [EVIDENCE.to_string()]; + let empty: [String; 0] = []; + + let record = conclude(&facts(&empty, &raw)).expect("原始证据应当有结论"); + assert_eq!(record_id(&record), Some("fabric.loader_0_12"), "{record}"); + assert_eq!(record["source"], serde_json::json!("rules")); + assert_eq!(record["needs_more_info"], serde_json::json!(false)); + + let scrubbed = [Redactor::new().scrub(EVIDENCE).into_owned()]; + assert!( + scrubbed[0].contains(""), + "夹具前提:这条证据行确实会被抹:{}", + scrubbed[0] + ); + let after = conclude(&facts(&empty, &scrubbed)); + assert_ne!( + after.as_ref().and_then(record_id), + Some("fabric.loader_0_12"), + "脱敏后的文本不该再命中——这就是 M239 报的那个 bug" + ); +} + +/// 没有文本就没有结论(不写空事件)。 +#[test] +fn no_text_means_no_conclusion() { + let empty: [String; 0] = []; + assert!(conclude(&facts(&empty, &empty)).is_none()); +} + +/// 认不出来的日志也不写事件(让读回 JSONL 的兜底路径去跑,避免两条路给出不同结论)。 +#[test] +fn unrecognized_output_means_no_conclusion() { + let stdout = ["[12:00:00] [main/INFO]: Setting user".to_string()]; + let empty: [String; 0] = []; + assert!(conclude(&facts(&stdout, &empty)).is_none()); +} + +/// 结论里带的是**已渲染**的文案与物化动作,展示侧不必再自己拼。 +#[test] +fn the_record_carries_rendered_text_and_actions() { + let raw = ["Fabric loader failed".to_string(), EVIDENCE.to_string()]; + let empty: [String; 0] = []; + let record = conclude(&facts(&empty, &raw)).expect("应有结论"); + let candidate = &record["candidates"][0]; + assert!(!candidate["message"].as_str().unwrap_or_default().is_empty()); + assert_eq!( + candidate["fix_actions"][0]["kind"], + serde_json::json!("reinstall") + ); + assert!( + candidate["evidence"][0] + .as_str() + .unwrap_or_default() + .contains("NoClassDefFoundError"), + "{candidate}" + ); +} diff --git a/crates/core/src/launch/session.rs b/crates/core/src/launch/session.rs index dfdec89..5084117 100644 --- a/crates/core/src/launch/session.rs +++ b/crates/core/src/launch/session.rs @@ -3,9 +3,13 @@ //! schema 见 `DevDocs/SubAgentsTalk/launch-接口约定.md` §2/§3(v1)——M15 诊断采集器与 //! M16 `read_game_log`/`list_game_sessions` 消费同一份文件,是**单一真相源**。 //! -//! 两条纪律写死在实现里: +//! 三条纪律写死在实现里: //! 1. 脱敏在写入前做([`Redactor`]),`cmd_built.argv` 也不例外(命令行必含 accessToken); -//! 2. 「launch 报事实、M15 下结论」:`diagnosis` 事件由 M15 产出、由本模块落盘。 +//! 2. 「launch 报事实、M15 下结论」:`diagnosis` 事件由 M15 产出、由本模块落盘; +//! 3. **规则结论在脱敏前算**(M241):盘上只有脱敏文本,而脱敏的有损规则会把证据行也抹掉 +//! (`fabric.loader_0_12` 的类路径就被 `≥64` 那条吃掉)。所以原始行在内存里留一个有界尾窗 +//! (`session/raw_window.rs`),收尾时交给 [`crate::diagnose::session::conclude`] 算一次, +//! 落盘的仍然只有**结论**——它自己还要再走一遍 `scrub_json`。 use std::fs::{self, File, OpenOptions}; use std::io::{BufWriter, Write}; @@ -14,6 +18,7 @@ use std::path::{Path, PathBuf}; use serde::Serialize; use serde_json::Value; +use crate::diagnose::session::{self as diagnosis, RawFacts}; use crate::launch::args_table::AssembledCommand; use crate::launch::error::{AssemblyError, LaunchError, Result}; use crate::launch::exit_analysis::ExitAnalysis; @@ -28,60 +33,12 @@ use crate::launch::window_detect::Detection; /// 事件 schema 版本。 pub const SCHEMA_VERSION: u8 = 1; -/// 事件类型。 -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum EventKind { - /// 会话开始。 - SessionStart, - /// Java 选择结果。 - JavaSelected, - /// 命令行组装完成。 - CmdBuilt, - /// natives 解压结果。 - NativesExtracted, - /// 启动前失败(没有进程)。 - AssemblyFailed, - /// 进程已启动。 - ProcStart, - /// stdout 行。 - Stdout, - /// stderr 行。 - Stderr, - /// 检测到游戏窗口。 - WindowDetected, - /// 进程退出。 - Exit, - /// 发现崩溃报告 / hs_err。 - CrashFound, - /// 诊断结论(M15 产出)。 - Diagnosis, - /// 被主动结束。 - Killed, - /// 会话结束。 - SessionEnd, -} +#[path = "session/event_kind.rs"] +mod event_kind; +#[path = "session/raw_window.rs"] +mod raw_window; -impl EventKind { - /// JSONL 里的 `kind` 字符串。 - pub fn as_str(self) -> &'static str { - match self { - Self::SessionStart => "session_start", - Self::JavaSelected => "java_selected", - Self::CmdBuilt => "cmd_built", - Self::NativesExtracted => "natives_extracted", - Self::AssemblyFailed => "assembly_failed", - Self::ProcStart => "proc_start", - Self::Stdout => "stdout", - Self::Stderr => "stderr", - Self::WindowDetected => "window_detected", - Self::Exit => "exit", - Self::CrashFound => "crash_found", - Self::Diagnosis => "diagnosis", - Self::Killed => "killed", - Self::SessionEnd => "session_end", - } - } -} +pub use event_kind::EventKind; /// 会话结束原因。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -144,6 +101,10 @@ pub struct SessionWriter { exit_type: Option, silent_failure: bool, diagnosis_top: Option, + /// 是否值得跑诊断(与 `ExitAnalysis::needs_diagnosis` 同值)。 + needs_diagnosis: bool, + /// 原始行尾窗(诊断用;**不落盘**,见模块文档第 3 条)。 + raw: raw_window::RawWindow, finished: bool, } @@ -196,6 +157,8 @@ impl SessionWriter { exit_type: None, silent_failure: false, diagnosis_top: None, + needs_diagnosis: false, + raw: raw_window::RawWindow::default(), finished: false, }) } @@ -281,11 +244,14 @@ impl SessionWriter { } /// `stdout` / `stderr` 行事件。 + /// + /// 原始文本先进内存尾窗(诊断用),落盘的是脱敏后的那份([`Redactor`])。 pub fn line(&mut self, line: &LogLine) -> Result<()> { let kind = match line.stream { Stream::Stdout => EventKind::Stdout, Stream::Stderr => EventKind::Stderr, }; + self.raw.push(line); let text = self.redactor.scrub(&line.text).into_owned(); let payload = payload::log_line(&text, line.level()); self.record(kind, payload) @@ -314,6 +280,7 @@ impl SessionWriter { self.exit_code = Some(analysis.code); self.exit_type = Some(analysis.exit_type.as_str().to_string()); self.silent_failure = analysis.silent_failure.is_some(); + self.needs_diagnosis = analysis.needs_diagnosis; let payload = payload::exit(analysis); self.record(EventKind::Exit, payload) } @@ -354,9 +321,34 @@ impl SessionWriter { self.record(EventKind::Diagnosis, record) } + /// 用**原始行**补一次规则结论(写失败不打断收尾:launch 另有 `event_write_errors` 收集)。 + /// + /// 门槛与 launch 的挂钩一致(`ExitAnalysis::needs_diagnosis`):干净退出不跑引擎,也不写空事件。 + fn conclude_from_raw(&mut self, duration_ms: u64) { + if !self.needs_diagnosis || self.diagnosis_top.is_some() { + return; + } + let (stdout, stderr) = self.raw.facts(); + let facts = RawFacts { + exit_code: self.exit_code, + duration_ms, + error_lines: self.raw.error_lines(), + stdout: &stdout, + stderr: &stderr, + }; + if let Some(record) = diagnosis::conclude(&facts) { + let _ = self.diagnosis(record); + } + } + /// `session_end` + 追加索引,返回索引条目。 + /// + /// 收尾前先补一次**原始行**上的规则结论(模块文档第 3 条):挂上真实 `DiagnosisHook` 时 + /// 它已经写过结论(`diagnosis_top` 非空),这里不重复;没挂(今天的 `NoopDiagnosis`)就由 + /// 这里补位,免得读回 JSONL 时只剩脱敏文本可看。 pub fn finish(&mut self, reason: EndReason) -> Result { let duration_ms = self.clock.elapsed_ms(); + self.conclude_from_raw(duration_ms); let payload = payload::session_end(reason.as_str(), duration_ms); self.record(EventKind::SessionEnd, payload)?; self.finished = true; diff --git a/crates/core/src/launch/session/event_kind.rs b/crates/core/src/launch/session/event_kind.rs new file mode 100644 index 0000000..21df693 --- /dev/null +++ b/crates/core/src/launch/session/event_kind.rs @@ -0,0 +1,58 @@ +//! 会话事件类型(从 `session.rs` 纯搬移,守单文件 400 行上限)。 +//! +//! 对外路径不变:`unlucky_core::launch::session::EventKind`。 + +/// 事件类型。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EventKind { + /// 会话开始。 + SessionStart, + /// Java 选择结果。 + JavaSelected, + /// 命令行组装完成。 + CmdBuilt, + /// natives 解压结果。 + NativesExtracted, + /// 启动前失败(没有进程)。 + AssemblyFailed, + /// 进程已启动。 + ProcStart, + /// stdout 行。 + Stdout, + /// stderr 行。 + Stderr, + /// 检测到游戏窗口。 + WindowDetected, + /// 进程退出。 + Exit, + /// 发现崩溃报告 / hs_err。 + CrashFound, + /// 诊断结论(M15 产出)。 + Diagnosis, + /// 被主动结束。 + Killed, + /// 会话结束。 + SessionEnd, +} + +impl EventKind { + /// JSONL 里的 `kind` 字符串。 + pub fn as_str(self) -> &'static str { + match self { + Self::SessionStart => "session_start", + Self::JavaSelected => "java_selected", + Self::CmdBuilt => "cmd_built", + Self::NativesExtracted => "natives_extracted", + Self::AssemblyFailed => "assembly_failed", + Self::ProcStart => "proc_start", + Self::Stdout => "stdout", + Self::Stderr => "stderr", + Self::WindowDetected => "window_detected", + Self::Exit => "exit", + Self::CrashFound => "crash_found", + Self::Diagnosis => "diagnosis", + Self::Killed => "killed", + Self::SessionEnd => "session_end", + } + } +} diff --git a/crates/core/src/launch/session/raw_window.rs b/crates/core/src/launch/session/raw_window.rs new file mode 100644 index 0000000..cb60a2e --- /dev/null +++ b/crates/core/src/launch/session/raw_window.rs @@ -0,0 +1,59 @@ +//! 原始行尾窗(M241):诊断要在**脱敏前**的文本上跑,落盘仍然只有脱敏文本。 +//! +//! 会话事件流是写前脱敏的,脱敏的两条形状规则有损(见 [`crate::diagnose::session`] 的模块 +//! 文档)。所以边界是:原始行**只在本进程内存里活到会话收尾**,收尾时算一次规则结论 +//! (`diagnose::session::conclude`),写进 JSONL 的是结论本体,仍然过 `Redactor::scrub_json`。 +//! +//! 容量按展示侧 `DEFAULT_TAIL` 同量级取:规则引擎看的是最近的日志,不是开局那几行; +//! 错误行数另外全量统计(`exit0 静默失败` 那类判据要的是总数,不是尾窗里的数)。 + +use std::collections::VecDeque; + +use crate::launch::pump::LogLine; + +/// 保留多少行原始文本(尾窗)。 +pub const CAPACITY: usize = 2000; + +/// 原始行尾窗 + 全量错误行计数。 +#[derive(Debug, Default)] +pub struct RawWindow { + lines: VecDeque<(bool, String)>, + errors: u32, +} + +impl RawWindow { + /// 记一行**原始**(未脱敏)文本。 + pub fn push(&mut self, line: &LogLine) { + if line.level().is_error() { + self.errors = self.errors.saturating_add(1); + } + if self.lines.len() >= CAPACITY { + self.lines.pop_front(); + } + self.lines + .push_back((line.stream.is_stderr(), line.text.clone())); + } + + /// 全量错误行数。 + pub fn error_lines(&self) -> u32 { + self.errors + } + + /// 尾窗按流拆开:`(stdout, stderr)`,各自按时间顺序。 + pub fn facts(&self) -> (Vec, Vec) { + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + for (is_stderr, text) in &self.lines { + if *is_stderr { + stderr.push(text.clone()); + } else { + stdout.push(text.clone()); + } + } + (stdout, stderr) + } +} + +#[cfg(test)] +#[path = "raw_window_tests.rs"] +mod tests; diff --git a/crates/core/src/launch/session/raw_window_tests.rs b/crates/core/src/launch/session/raw_window_tests.rs new file mode 100644 index 0000000..206ce11 --- /dev/null +++ b/crates/core/src/launch/session/raw_window_tests.rs @@ -0,0 +1,46 @@ +//! `raw_window` 的单测。 + +use super::*; +use crate::launch::pump::Stream; + +fn line(stream: Stream, text: &str) -> LogLine { + LogLine::new(stream, text) +} + +#[test] +fn keeps_the_tail_and_counts_every_error_line() { + let mut window = RawWindow::default(); + window.push(&line( + Stream::Stderr, + "java.lang.IllegalStateException: boom", + )); + window.push(&line(Stream::Stdout, "[main/INFO]: Setting user")); + assert_eq!(window.error_lines(), 1); + let (stdout, stderr) = window.facts(); + assert_eq!(stdout, vec!["[main/INFO]: Setting user".to_string()]); + assert_eq!( + stderr, + vec!["java.lang.IllegalStateException: boom".to_string()] + ); +} + +/// 尾窗有界:`CAPACITY` 之前的行被丢掉,但**错误行数照旧全量**。 +#[test] +fn the_window_is_bounded_while_error_count_is_not() { + let mut window = RawWindow::default(); + for index in 0..(CAPACITY + 10) { + window.push(&line(Stream::Stdout, &format!("line {index}"))); + } + let (stdout, _) = window.facts(); + assert_eq!(stdout.len(), CAPACITY); + assert_eq!(stdout.first().map(String::as_str), Some("line 10")); + assert_eq!(window.error_lines(), 0); + + for _ in 0..3 { + window.push(&line( + Stream::Stderr, + "java.lang.OutOfMemoryError: Java heap space", + )); + } + assert_eq!(window.error_lines(), 3); +} diff --git a/crates/presenter/src/host/logs.rs b/crates/presenter/src/host/logs.rs index 76a3c72..b126b1a 100644 --- a/crates/presenter/src/host/logs.rs +++ b/crates/presenter/src/host/logs.rs @@ -28,6 +28,8 @@ use crate::dto::{ use super::env::HostEnv; +#[path = "logs/diagnosis.rs"] +mod diagnosis; #[path = "logs/location.rs"] mod location; @@ -141,30 +143,43 @@ pub fn line_of(event: &Value) -> Option { }) } -/// 整个事件流 → 日志行(解析失败的行跳过,容忍被 kill 时截断的尾行)。 -pub fn read_lines(path: &Path) -> Vec { +/// 整个事件流的原始 JSON 行(解析失败的行跳过,容忍被 kill 时截断的尾行)。 +/// +/// 一次读完所有事件:日志行与诊断结论都从这同一份读里取,不重复读盘。 +pub fn read_events(path: &Path) -> Vec { let Ok(text) = std::fs::read_to_string(path) else { return Vec::new(); }; text.lines() .filter(|line| !line.trim().is_empty()) .filter_map(|line| serde_json::from_str::(line).ok()) - .filter_map(|event| line_of(&event)) .collect() } +/// 整个事件流 → 日志行(**展示面**:`payload.line`,即写盘前已脱敏的文本)。 +pub fn read_lines(path: &Path) -> Vec { + read_events(path).iter().filter_map(line_of).collect() +} + /// 打开一个会话:日志行 + 规则引擎结论。 +/// +/// 结论优先取会话里**落盘的那份**(写会话时用原始行算过,见 [`diagnosis`] 模块文档); +/// 老会话没有它,才回落到「在脱敏文本上重跑规则」。 pub fn open(env: &HostEnv, key: &str) -> (Vec, Option) { let Some(entry) = find(env, key) else { return (Vec::new(), None); }; - let path = event_path(env, &entry); - let lines = read_lines(&path); - let diagnosis = diagnose_lines(&entry, &lines); + let events = read_events(&event_path(env, &entry)); + let lines: Vec = events.iter().filter_map(line_of).collect(); + let diagnosis = diagnosis::stored(&entry, &events).or_else(|| diagnose_lines(&entry, &lines)); (lines, diagnosis) } -/// 规则引擎跑一遍(**规则先跑,模型补位**:这里只出规则结论)。 +/// 在**脱敏文本**上重跑规则引擎(老会话的兜底路径)。 +/// +/// ⚠️ 这条路的证据是「写盘前脱敏过的」行:`≥64` 无点号长串(Sponge 类路径、长 hash)会被 +/// 抹成 ``,依赖那些字面量的规则(`fabric.loader_0_12`)在这里**不可能命中**。 +/// 新会话通常不必走这条路——写会话时用原始行算过结论,见 [`diagnosis::stored`]。 pub fn diagnose_lines(entry: &SessionIndexEntry, lines: &[LogLine]) -> Option { let stderr_text: String = lines .iter() @@ -355,6 +370,9 @@ pub fn session_finished(env: &HostEnv, session: &str) -> bool { }) } +#[cfg(test)] +#[path = "logs/m241_tests.rs"] +mod m241_tests; #[cfg(test)] #[path = "logs_tests.rs"] mod tests; diff --git a/crates/presenter/src/host/logs/diagnosis.rs b/crates/presenter/src/host/logs/diagnosis.rs new file mode 100644 index 0000000..d5a11a3 --- /dev/null +++ b/crates/presenter/src/host/logs/diagnosis.rs @@ -0,0 +1,82 @@ +//! 会话里落盘的诊断结论(M241):读回时优先用它,不要拿脱敏文本重跑规则。 +//! +//! ## 为什么不能重跑 +//! +//! 会话 JSONL 是**写前脱敏**的,脱敏的两条形状规则有损:`≥64` 无点号不透明串会把证据行 +//! 一起抹掉(`fabric.loader_0_12` 的 `org/spongepowered/…/FabricMixinTransformerProxy` +//! 66 字符 → ``)。在脱敏文本上重跑规则必然空手——M239 实测那条 critical +//! 规则在会话路上「永不命中」。 +//! +//! 修法是**换证据**而不是换判据:写会话时原始行还在内存里,那时已经算过一份结论并落进 +//! `diagnosis` 事件(`launch::session::SessionWriter::finish` → `diagnose::session::conclude`), +//! 那份结论在盘上仍然是脱敏过的(`Redactor::scrub_json`)。展示侧只读它。 +//! +//! 老会话(本次改动之前写的 JSONL)没有 `diagnosis` 事件 → 调用方回落到 +//! [`super::diagnose_lines`](在脱敏文本上重跑),行为与今天一致,不会凭空少一张卡片。 + +use serde_json::Value; +use unlucky_core::launch::session_index::SessionIndexEntry; + +use crate::dto::{Diagnosis, DiagnosisReport}; + +use super::confidence_percent; + +/// 事件流里最后一条 `diagnosis` 事件 → 日志页的诊断卡片 DTO。 +/// +/// 正常只会有一条(挂钩写的,或收尾补的,二选一——见 `SessionWriter::conclude_from_raw` 的门槛)。 +/// 真出现多条时取**最后一条**:收尾补的那份证据最新鲜。 +pub(super) fn stored(entry: &SessionIndexEntry, events: &[Value]) -> Option { + let payload = events + .iter() + .rev() + .find(|event| event.get("kind").and_then(Value::as_str) == Some("diagnosis"))? + .get("payload")?; + let candidate = payload + .get("candidates") + .and_then(Value::as_array) + .and_then(|items| items.first()) + .or_else(|| { + payload + .get("secondary") + .and_then(Value::as_array) + .and_then(|items| items.first()) + })?; + let rule = candidate.get("id").and_then(Value::as_str)?; + let title = candidate.get("message").and_then(Value::as_str)?; + if title.is_empty() { + return None; + } + let confidence = candidate + .get("confidence") + .and_then(Value::as_f64) + .unwrap_or_default(); + let hint = candidate + .get("detail") + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| { + payload + .get("exit") + .and_then(|exit| exit.get("exit_type")) + .and_then(Value::as_str) + .map(str::to_string) + }) + .unwrap_or_else(|| "未知退出".to_string()); + let fix = candidate + .get("fix_actions") + .and_then(Value::as_array) + .and_then(|items| items.first()) + .and_then(|action| action.get("tool")) + .and_then(Value::as_str) + .map(str::to_string); + Some(DiagnosisReport { + session: entry.session_id.clone(), + diagnosis: Diagnosis { + rule: rule.to_string(), + title: title.to_string(), + confidence: confidence_percent(confidence as f32), + hint, + fix, + }, + }) +} diff --git a/crates/presenter/src/host/logs/m241_tests.rs b/crates/presenter/src/host/logs/m241_tests.rs new file mode 100644 index 0000000..ea46d10 --- /dev/null +++ b/crates/presenter/src/host/logs/m241_tests.rs @@ -0,0 +1,136 @@ +//! M241:诊断吃**原始**文本、展示/导出只吃脱敏文本(拆文件守 400 行上限)。 + +use super::*; +use unlucky_core::launch::exit_analysis::{self, ExitSignals}; +use unlucky_core::launch::pump::{LogLine as CoreLine, Stream}; +use unlucky_core::launch::redact::Redactor; +use unlucky_core::launch::session::{EndReason, SessionMeta, SessionWriter}; + +/// 一个临时数据根(`/.ULL` + `/.minecraft`)。 +fn temp_root(tag: &str) -> PathBuf { + let root = std::env::temp_dir().join(format!("ull-m241-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + root +} + +/// `fabric.loader_0_12`(critical / priority 80)在 `rules.d/fabric.yaml:114` 的唯一证据行。 +/// +/// 类路径 66 字符、无点号 → 撞上 `redact.rs` 的「≥64 不透明串」规则 → 脱敏后被抹成 +/// ``。这正是 M239 报的那个「会话路上永不命中」。 +const EVIDENCE: &str = "java.lang.NoClassDefFoundError: \ + org/spongepowered/asm/mixin/transformer/FabricMixinTransformerProxy"; +/// 真凭据(写盘前必须被抹掉的形状)。 +const TOKEN: &str = "SECRET-TOKEN-123456"; + +fn meta() -> SessionMeta { + SessionMeta::new("inst", "1.20.1", "1.20.1") +} + +/// 判据(M241):① 规则引擎对含长类名的行**能命中**;② 用户可见/导出的文本**仍是被脱敏的**。 +/// +/// 端到端走真链路:`SessionWriter` 写盘(写前脱敏)→ 真 JSONL → [`open`] 读回。 +/// +/// 变异证明: +/// - 把 `SessionWriter::finish` 里补结论的输入换成脱敏后的行 ⇒ ① 红(`fabric.loader_0_12` +/// 命不中,`diagnosis_top` 不是它); +/// - 把 `SessionWriter::line` 里的 `scrub` 去掉(导出面改成原始文本)⇒ ② 红(类路径与凭据 +/// 原样出现在展示行与盘上)。 +#[test] +fn raw_evidence_hits_the_rule_while_the_export_surface_stays_redacted() { + let root = temp_root("diagraw"); + let env = HostEnv::at_root(&root); + let redactor = Redactor::new().with_secret(TOKEN); + let mut writer = + SessionWriter::create_with_id(&env.sessions_root(), "diagraw-1", meta(), redactor) + .expect("建会话"); + writer.session_start().expect("session_start"); + let lines = vec![ + CoreLine::new(Stream::Stderr, "Failed to start Fabric loader"), + CoreLine::new(Stream::Stderr, EVIDENCE), + CoreLine::new(Stream::Stdout, format!("auth: token={TOKEN}")), + ]; + for line in &lines { + writer.line(line).expect("写行"); + } + let analysis = exit_analysis::analyze(&ExitSignals::new(1, 40_000, &lines)); + writer.exit(&analysis).expect("exit"); + let entry = writer.finish(EndReason::Exit).expect("finish"); + + // ① 规则对含长类名的行能命中(结论在原始行上算过,落进索引)。 + assert_eq!( + entry.diagnosis_top.as_deref(), + Some("fabric.loader_0_12"), + "会话路上必须命中 fabric.loader_0_12" + ); + let (opened, diagnosis) = open(&env, "diagraw-1"); + assert_eq!( + diagnosis.map(|report| report.diagnosis.rule), + Some("fabric.loader_0_12".to_string()) + ); + + // ② 展示面(日志行 DTO)仍是被脱敏的。 + assert!( + opened + .iter() + .any(|line| line.text.contains("")), + "长类名必须被抹成占位符:{opened:?}" + ); + assert!( + !opened + .iter() + .any(|line| line.text.contains("FabricMixinTransformerProxy")), + "展示行不许出现原始类路径:{opened:?}" + ); + assert!( + !opened.iter().any(|line| line.text.contains(TOKEN)), + "展示行不许出现凭据:{opened:?}" + ); + + // ②' 盘上那份(= 导出包收进去的同一份文件)也没有原文——修 bug 不等于把密钥写进盘。 + let disk = std::fs::read_to_string(event_path(&env, &entry)).expect("读会话文件"); + assert!(!disk.contains("FabricMixinTransformerProxy"), "{disk}"); + assert!(!disk.contains(TOKEN), "{disk}"); + std::fs::remove_dir_all(&root).ok(); +} + +/// 老会话(本次改动之前写的 JSONL,没有 `diagnosis` 事件)走兜底:在脱敏文本上重跑规则。 +/// +/// 判据:短字面量的规则(`memory.out_of_memory`)在脱敏文本上照样命中 ⇒ 兜底没坏。 +#[test] +fn sessions_without_a_stored_conclusion_still_get_a_card() { + let root = temp_root("diagraw-legacy"); + let env = HostEnv::at_root(&root); + let entry = SessionIndexEntry { + session_id: "legacy".to_string(), + instance_id: "inst".to_string(), + started_at: "2026-11-01T10:00:00.000Z".to_string(), + ended_at: "2026-11-01T10:01:00.000Z".to_string(), + exit_code: Some(1), + exit_type: Some("application_error".to_string()), + log_path: "game/legacy.jsonl".to_string(), + ..SessionIndexEntry::default() + }; + session_index::append(&session_index::index_path(&env.sessions_root()), &entry) + .expect("写索引"); + let file = env.sessions_root().join("game/legacy.jsonl"); + std::fs::create_dir_all(file.parent().expect("父目录")).expect("建目录"); + std::fs::write( + &file, + concat!( + "{\"kind\":\"stderr\",\"payload\":{\"line\":\"java.lang.OutOfMemoryError: Java heap space\",\"level\":\"error\"}}\n", + "{\"kind\":\"session_end\",\"payload\":{\"reason\":\"exit\"}}\n" + ), + ) + .expect("写会话"); + + let (lines, diagnosis) = open(&env, "legacy"); + assert!(lines + .iter() + .any(|line| line.text.contains("OutOfMemoryError"))); + assert_eq!( + diagnosis.map(|report| report.diagnosis.rule), + Some("memory.out_of_memory".to_string()), + "没有落盘结论时应回落到规则重跑" + ); + std::fs::remove_dir_all(&root).ok(); +}