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:
AskaEth committed 2026-11-13 13:18:11 +08:00
1 parent fc8f5c6794
commit 97768b7e3f
19 files changed
+1699 -553

No files matched your search

+46 -122
View File
@@ -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;
}
}
+45 -1
View File
@@ -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());
}