fix(launch): M298 收尾失败不再静默、Toast 接上渲染点
三条提示通道各漏一半,用户才会「启动失败但找不到崩溃日志」: 1) 收尾失败静默不弹:finish() 报错那条出口把 LaunchFinished.session 发成真实 会话,而它登记的现场走 before_start 写死空串 ⇒ dialog.rs 的并发守卫 (!finished.session.is_empty() && report.session != finished.session)成立后 return。改成**会话归属单一来源**:before_start 新增可选 SessionAttribution, presenter 的 publish_before_start 把它写进报告后**返回同一个值**给 DTO; 两处不可能再各写一遍。守卫原样保留(A 场的现场不许解释 B 场,有用例钉住)。 2) Toast 只 push 不渲染:新增 app/view/toast.rs 覆盖层(MD3E 胶囊 = ShapeScale::Full,底部居中,InverseSurface 底),接在页面树之上、模态之下; 过期仍走 clock.rs 的 expire;进/出过渡复用模态那一对腿(motion::modal_spec / modal_scale / scaled),时间轴在 update 侧由 state/toast.rs 采样,关掉动效时 恒为终值(出图可复现)。两个拷贝路径补上 view_dirty(推了不置脏要等下一次事件)。 3) 三个贴线文件先拆再改(纯搬移 + pub use 保路径,机器核对:搬出的 14 个函数体 逐行相同):crash_report.rs 394→318(+context.rs / slot.rs)、 host/launch.rs 399→233(+build.rs)、app/dialog.rs 390→178(+model.rs)。 证据:DevDocs/artifacts/m298/toast-capsule.png(--screenshot + ULL_TOAST_FIXTURE, 图上可见「游戏崩溃 · 12.0 秒」胶囊)。
This commit is contained in:
1 parent
fc8f5c6794
commit
97768b7e3f
19 files changed
+1699
-553
No files matched your search
@@ -18,11 +18,17 @@
|
||||
//! 进程级槽位只存**已脱敏**的文本([`CrashReport::collect`] 的出口就是脱敏边界)。
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use super::crash_scan::{self, CrashArtifact, CrashSource};
|
||||
use super::crash_scan::{self, CrashSource};
|
||||
use super::launch_session::SessionHandle;
|
||||
use super::redact::Redactor;
|
||||
|
||||
mod context;
|
||||
mod slot;
|
||||
|
||||
pub use context::ReportContext;
|
||||
pub use slot::{clear, failing_launch, publish};
|
||||
|
||||
use context::{fact, scrub_tail};
|
||||
|
||||
/// 弹窗里贴「捕获输出」的行数上限(够看清 JVM 报错,又不把弹窗撑破)。
|
||||
pub const TAIL_LINES: usize = 30;
|
||||
@@ -83,6 +89,19 @@ pub struct CrashReport {
|
||||
pub stderr_tail: Vec<String>,
|
||||
}
|
||||
|
||||
/// 「进程确实起来过」的那一场会话归因([`CrashReport::before_start`] 的可选入参)。
|
||||
///
|
||||
/// 只带两样东西:**会话 id**(必须与收尾事件里的 `session` 是同一个值)与事件流落点。
|
||||
/// 收尾失败时进程真的起来过,所以这份现场**有主人**;写成空串等于说「这场失败不属于任何人」,
|
||||
/// 界面侧只能把它丢掉(M298)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SessionAttribution<'a> {
|
||||
/// 会话 id。
|
||||
pub id: &'a str,
|
||||
/// 该会话的事件流 JSONL(`None` = 拿不到落点,仍然记得住归属)。
|
||||
pub log: Option<&'a std::path::Path>,
|
||||
}
|
||||
|
||||
/// 证据形态(界面按它选 i18n key,**不许**再自己判断)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Evidence {
|
||||
@@ -208,19 +227,39 @@ impl CrashReport {
|
||||
}
|
||||
}
|
||||
|
||||
/// 收集一次**进程没起来**的现场(装配 / 起进程阶段失败)。
|
||||
/// 收集一次**没有 `SessionHandle`** 的现场。
|
||||
///
|
||||
/// 两个用法,判据是同一个参数(`session`):
|
||||
///
|
||||
/// | 出口 | `session` | 为什么 |
|
||||
/// |---|---|---|
|
||||
/// | 装配失败 / spawn 失败 | `None` | 进程根本没起来,这一场**确实**没有会话 |
|
||||
/// | 收尾失败(`finish()` 报错) | `Some(归因)` | 进程起来过,这次失败属于那一场 |
|
||||
///
|
||||
/// 第二种是 M298 修的缺口:此前两个出口都写空串,而界面侧的并发守卫
|
||||
/// (`CrashReport::session != LaunchFinished::session`)会因此把「自己这一场」判成
|
||||
/// 「别人的报告」而**静默不弹**。归因必须由调用方给(只有它知道是哪一场),本函数只负责
|
||||
/// 把它原样记进报告——两边同源,守卫才可能判对。
|
||||
///
|
||||
/// 这种失败最常见的形态就是「找不到 crash-report」——游戏根本没被执行到,所以
|
||||
/// [`Evidence`] 落到 [`Evidence::Nothing`],界面必须说「游戏还没来得及写日志」。
|
||||
#[must_use]
|
||||
pub fn before_start(ctx: &ReportContext, stage: &str, detail: &str, exit_type: &str) -> Self {
|
||||
pub fn before_start(
|
||||
ctx: &ReportContext,
|
||||
stage: &str,
|
||||
detail: &str,
|
||||
exit_type: &str,
|
||||
session: Option<SessionAttribution<'_>>,
|
||||
) -> Self {
|
||||
let redactor = ctx.redactor();
|
||||
let session_id = session.map_or_else(String::new, |it| it.id.to_string());
|
||||
let session_log = session.and_then(|it| it.log.map(PathBuf::from));
|
||||
Self {
|
||||
instance: ctx.instance.clone(),
|
||||
name: ctx.name.clone(),
|
||||
version: ctx.version.clone(),
|
||||
session: String::new(),
|
||||
session_log: None,
|
||||
session: session_id,
|
||||
session_log,
|
||||
version_json: ctx.version_json.clone(),
|
||||
launcher_logs: Some(ctx.launcher_logs.clone()),
|
||||
run_dir: Some(ctx.run_dir.clone()),
|
||||
@@ -275,120 +314,5 @@ impl CrashReport {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`CrashReport::collect`] / [`CrashReport::before_start`] 的输入(全是启动侧已有的东西)。
|
||||
///
|
||||
/// 字段是**拥有的**:构造一次、在四个失败出口上反复用(借用版会让每个出口都要重算一遍
|
||||
/// 主目录与脱敏器,而这条路径低频、克隆代价可以忽略)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReportContext {
|
||||
/// 实例 id。
|
||||
pub instance: String,
|
||||
/// 实例名。
|
||||
pub name: String,
|
||||
/// 版本 id。
|
||||
pub version: String,
|
||||
/// 实例运行目录。
|
||||
pub run_dir: PathBuf,
|
||||
/// 启动器日志目录(`.ULL/logs`)。
|
||||
pub launcher_logs: PathBuf,
|
||||
/// 本次会话的开始时刻(现场文件的时间闸)。
|
||||
pub started_at: std::time::SystemTime,
|
||||
/// 该实例绑定的版本 JSON。
|
||||
pub version_json: Option<PathBuf>,
|
||||
/// Java 一行说明。
|
||||
pub java: String,
|
||||
/// 系统一行说明。
|
||||
pub system: String,
|
||||
/// 要额外抹掉的串(会话 id、access token 等)。
|
||||
pub secrets: Vec<String>,
|
||||
}
|
||||
|
||||
impl ReportContext {
|
||||
/// 建一份(`java` 可以后补:装配阶段还没选 Java)。
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
instance: impl Into<String>,
|
||||
name: impl Into<String>,
|
||||
version: impl Into<String>,
|
||||
run_dir: impl Into<PathBuf>,
|
||||
launcher_logs: impl Into<PathBuf>,
|
||||
started_at: std::time::SystemTime,
|
||||
system: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
instance: instance.into(),
|
||||
name: name.into(),
|
||||
version: version.into(),
|
||||
run_dir: run_dir.into(),
|
||||
launcher_logs: launcher_logs.into(),
|
||||
started_at,
|
||||
version_json: None,
|
||||
java: String::new(),
|
||||
system: system.into(),
|
||||
secrets: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn redactor(&self) -> Redactor {
|
||||
let mut redactor = Redactor::new();
|
||||
for key in ["USERPROFILE", "HOME"] {
|
||||
if let Ok(home) = std::env::var(key) {
|
||||
if !home.trim().is_empty() {
|
||||
redactor = redactor.with_home_str(home);
|
||||
}
|
||||
}
|
||||
}
|
||||
for secret in &self.secrets {
|
||||
redactor = redactor.with_secret(secret.clone());
|
||||
}
|
||||
redactor
|
||||
}
|
||||
}
|
||||
|
||||
fn scrub_tail(lines: &[String], redactor: &Redactor) -> Vec<String> {
|
||||
lines
|
||||
.iter()
|
||||
.map(|line| redactor.scrub(line).into_owned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn fact(artifact: &CrashArtifact, redactor: &Redactor) -> ArtifactFact {
|
||||
let excerpt = artifact.excerpt.as_ref().map(|text| {
|
||||
let scrubbed = redactor.scrub(text);
|
||||
let trimmed: String = scrubbed.chars().take(MAX_EXCERPT_CHARS).collect();
|
||||
trimmed
|
||||
});
|
||||
ArtifactFact {
|
||||
source: artifact.source.as_str().to_string(),
|
||||
path: artifact.path.clone(),
|
||||
excerpt,
|
||||
}
|
||||
}
|
||||
|
||||
fn slot() -> &'static Mutex<Option<CrashReport>> {
|
||||
static SLOT: OnceLock<Mutex<Option<CrashReport>>> = OnceLock::new();
|
||||
SLOT.get_or_init(|| Mutex::new(None))
|
||||
}
|
||||
|
||||
/// 登记一次失败启动的现场(启动线程收尾时调用)。
|
||||
pub fn publish(report: CrashReport) {
|
||||
if let Ok(mut guard) = slot().lock() {
|
||||
*guard = Some(report);
|
||||
}
|
||||
}
|
||||
|
||||
/// 读最近一次失败启动的现场。
|
||||
#[must_use]
|
||||
pub fn failing_launch() -> Option<CrashReport> {
|
||||
slot().lock().ok().and_then(|guard| guard.clone())
|
||||
}
|
||||
|
||||
/// 清空槽位(测试用)。
|
||||
pub fn clear() {
|
||||
if let Ok(mut guard) = slot().lock() {
|
||||
*guard = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,108 @@
|
||||
//! 现场的**输入**与脱敏(M298 从 `crash_report.rs` 拆出,纯搬移)。
|
||||
//!
|
||||
//! 这个文件只回答两件事:①收集一次现场要知道哪几样事实([`ReportContext`]);
|
||||
//! ②它们在被写进 [`CrashReport`](super::CrashReport) 之前必须先过哪一道脱敏闸
|
||||
//! ([`fact`] / [`scrub_tail`])。
|
||||
//!
|
||||
//! 脱敏器**不缓存**:每次 [`ReportContext::redactor`] 都重新读一遍主目录环境变量。这条路径
|
||||
//! 只在启动收尾时走一次,缓存省不下什么,却会引入「进程启动后 HOME 变了」的第二份真相。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::super::crash_scan::CrashArtifact;
|
||||
use super::super::redact::Redactor;
|
||||
use super::{ArtifactFact, MAX_EXCERPT_CHARS};
|
||||
|
||||
/// [`CrashReport::collect`](super::CrashReport::collect) /
|
||||
/// [`CrashReport::before_start`](super::CrashReport::before_start) 的输入(全是启动侧已有的东西)。
|
||||
///
|
||||
/// 字段是**拥有的**:构造一次、在四个失败出口上反复用(借用版会让每个出口都要重算一遍
|
||||
/// 主目录与脱敏器,而这条路径低频、克隆代价可以忽略)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReportContext {
|
||||
/// 实例 id。
|
||||
pub instance: String,
|
||||
/// 实例名。
|
||||
pub name: String,
|
||||
/// 版本 id。
|
||||
pub version: String,
|
||||
/// 实例运行目录。
|
||||
pub run_dir: PathBuf,
|
||||
/// 启动器日志目录(`.ULL/logs`)。
|
||||
pub launcher_logs: PathBuf,
|
||||
/// 本次会话的开始时刻(现场文件的时间闸)。
|
||||
pub started_at: std::time::SystemTime,
|
||||
/// 该实例绑定的版本 JSON。
|
||||
pub version_json: Option<PathBuf>,
|
||||
/// Java 一行说明。
|
||||
pub java: String,
|
||||
/// 系统一行说明。
|
||||
pub system: String,
|
||||
/// 要额外抹掉的串(会话 id、access token 等)。
|
||||
pub secrets: Vec<String>,
|
||||
}
|
||||
|
||||
impl ReportContext {
|
||||
/// 建一份(`java` 可以后补:装配阶段还没选 Java)。
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
instance: impl Into<String>,
|
||||
name: impl Into<String>,
|
||||
version: impl Into<String>,
|
||||
run_dir: impl Into<PathBuf>,
|
||||
launcher_logs: impl Into<PathBuf>,
|
||||
started_at: std::time::SystemTime,
|
||||
system: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
instance: instance.into(),
|
||||
name: name.into(),
|
||||
version: version.into(),
|
||||
run_dir: run_dir.into(),
|
||||
launcher_logs: launcher_logs.into(),
|
||||
started_at,
|
||||
version_json: None,
|
||||
java: String::new(),
|
||||
system: system.into(),
|
||||
secrets: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 本次现场用的脱敏器(主目录 + `secrets` 两把刀)。
|
||||
pub(super) fn redactor(&self) -> Redactor {
|
||||
let mut redactor = Redactor::new();
|
||||
for key in ["USERPROFILE", "HOME"] {
|
||||
if let Ok(home) = std::env::var(key) {
|
||||
if !home.trim().is_empty() {
|
||||
redactor = redactor.with_home_str(home);
|
||||
}
|
||||
}
|
||||
}
|
||||
for secret in &self.secrets {
|
||||
redactor = redactor.with_secret(secret.clone());
|
||||
}
|
||||
redactor
|
||||
}
|
||||
}
|
||||
|
||||
/// 捕获输出尾巴的脱敏(逐行过同一把刀)。
|
||||
pub(super) fn scrub_tail(lines: &[String], redactor: &Redactor) -> Vec<String> {
|
||||
lines
|
||||
.iter()
|
||||
.map(|line| redactor.scrub(line).into_owned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 现场文件 → 报告里的一条事实(摘要抹平 + 截断到 [`MAX_EXCERPT_CHARS`])。
|
||||
pub(super) fn fact(artifact: &CrashArtifact, redactor: &Redactor) -> ArtifactFact {
|
||||
let excerpt = artifact.excerpt.as_ref().map(|text| {
|
||||
let scrubbed = redactor.scrub(text);
|
||||
let trimmed: String = scrubbed.chars().take(MAX_EXCERPT_CHARS).collect();
|
||||
trimmed
|
||||
});
|
||||
ArtifactFact {
|
||||
source: artifact.source.as_str().to_string(),
|
||||
path: artifact.path.clone(),
|
||||
excerpt,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//! 进程级**单槽**:最近一次失败启动的现场(M298 从 `crash_report.rs` 拆出,纯搬移)。
|
||||
//!
|
||||
//! ## 为什么是一格而不是一条队列
|
||||
//!
|
||||
//! 失败弹窗的消费方是「收尾事件到达的那一拍」——一个 [`LaunchFinished`] 只解释**一场**失败。
|
||||
//! 多存几份没有读者,反而会让「这份现场是哪一场的」变成要靠时间猜的问题。
|
||||
//!
|
||||
//! 归属的正确性**不靠这个槽**:报告自己带 `session`,界面侧的并发守卫拿它与收尾事件比
|
||||
//! (见 `crates/app-iced/src/app/dialog.rs`;M298 把 session 归属做成单一来源)。
|
||||
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use super::CrashReport;
|
||||
|
||||
fn slot() -> &'static Mutex<Option<CrashReport>> {
|
||||
static SLOT: OnceLock<Mutex<Option<CrashReport>>> = OnceLock::new();
|
||||
SLOT.get_or_init(|| Mutex::new(None))
|
||||
}
|
||||
|
||||
/// 登记一次失败启动的现场(启动线程收尾时调用)。
|
||||
pub fn publish(report: CrashReport) {
|
||||
if let Ok(mut guard) = slot().lock() {
|
||||
*guard = Some(report);
|
||||
}
|
||||
}
|
||||
|
||||
/// 读最近一次失败启动的现场。
|
||||
#[must_use]
|
||||
pub fn failing_launch() -> Option<CrashReport> {
|
||||
slot().lock().ok().and_then(|guard| guard.clone())
|
||||
}
|
||||
|
||||
/// 清空槽位(测试用)。
|
||||
pub fn clear() {
|
||||
if let Ok(mut guard) = slot().lock() {
|
||||
*guard = None;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::{clear, failing_launch, publish, ArtifactFact, CrashReport, Evidence, ReportContext};
|
||||
use super::{
|
||||
clear, failing_launch, publish, ArtifactFact, CrashReport, Evidence, ReportContext,
|
||||
SessionAttribution,
|
||||
};
|
||||
|
||||
fn report(artifacts: Vec<ArtifactFact>, tail: Vec<String>) -> CrashReport {
|
||||
CrashReport {
|
||||
@@ -104,6 +107,7 @@ fn before_start_marks_the_launch_as_never_started_and_scrubs_the_detail() {
|
||||
"spawn",
|
||||
&format!("{home}/java/bin/javaw.exe 不存在"),
|
||||
"spawn",
|
||||
None,
|
||||
);
|
||||
assert!(!report.started(), "进程没起来");
|
||||
assert_eq!(report.evidence(), Evidence::Nothing);
|
||||
@@ -123,3 +127,43 @@ fn slot_keeps_the_last_failure_only() {
|
||||
clear();
|
||||
assert!(failing_launch().is_none());
|
||||
}
|
||||
|
||||
/// M298:收尾失败出口的现场**必须记得自己是哪一场**。
|
||||
///
|
||||
/// **变异证明**:把 `before_start` 里的 `session` 参数改回写死 `String::new()`(本 mission 修之前
|
||||
/// 的写法),本用例第一段立刻红。产品上那条变异的表现是「真收尾失败静默不弹框」——
|
||||
/// 界面侧的并发守卫会拿空串去和收尾事件里的真实会话比,判成「别人的报告」后 `return`。
|
||||
///
|
||||
/// 第二段是方向相反的守卫:装配失败 / spawn 失败**确实**没有会话,那里不许为了"凑一个非空"
|
||||
/// 而编一个 id 出来(那会让那些出口的现场指向不存在的会话)。
|
||||
#[test]
|
||||
fn a_finish_failure_report_keeps_its_session() {
|
||||
let ctx = ReportContext::new(
|
||||
"survival",
|
||||
"生存",
|
||||
"1.20.4",
|
||||
std::path::Path::new("/instances/survival"),
|
||||
std::path::Path::new("/.ULL/logs"),
|
||||
std::time::SystemTime::now(),
|
||||
"os=windows",
|
||||
);
|
||||
let log = PathBuf::from("/sessions/game/9f2c.jsonl");
|
||||
let session = SessionAttribution {
|
||||
id: "9f2c",
|
||||
log: Some(log.as_path()),
|
||||
};
|
||||
let finished =
|
||||
CrashReport::before_start(&ctx, "spawn", "会话写入器不可用", "spawn", Some(session));
|
||||
assert_eq!(finished.session, "9f2c", "现场必须带着自己那一场会话");
|
||||
assert_eq!(finished.session_log.as_deref(), Some(log.as_path()));
|
||||
// 会话在,就不该在清单里说「进程没起来」。
|
||||
assert!(!finished
|
||||
.missing_lines()
|
||||
.iter()
|
||||
.any(|line| line.contains("会话 JSONL")));
|
||||
|
||||
let never_started =
|
||||
CrashReport::before_start(&ctx, "version_manifest", "缺清单", "version_manifest", None);
|
||||
assert!(never_started.session.is_empty(), "装配失败确实没有会话");
|
||||
assert!(never_started.session_log.is_none());
|
||||
}
|
||||
Reference in new issue
Block a user