feat(m188): launcher.log 早落盘 + 会话日志搬到 logs/game(旧家只读兼容)
用户投诉原话:「我们的启动日志放在哪?…他找半天没找到崩溃日志」「只有一个安装日志」。 启动器运行日志(.ULL/logs/launcher.log) - presenter 构造 HostEnv 时就登记日志目录:启动早期缓冲的 200 行([probe]/[font]/[theme]) 当场刷盘,不再是「只有开过一局游戏才有日志」。 - app/** 里所有 eprintln! 诊断改走 app::diag::diag!(stderr + 文件双写,行首 [tag] 即 scope), 含 boot/boot::fallback/state/update 各线。 - boot 那一拍补一行后端事实(request/effective/fallback)。 - 滚动上限(1 MiB → launcher.1.log,只留一份)写进 README 与 docs/logs-location.md。 会话日志搬家:data/agent/sessions → logs - LauncherLayout: sessions_root()/game_logs() 新增,agent_sessions() 降级为只读兼容。 - host/logs 与 cli/commands/sessions 两个根合并(同 id 新根优先),event_path 回落旧家。 - 不搬不删;日志目录写一份 session-log-location.json 记录(moved=0, policy=read-only-compat)。 入口与文案 - 设置页「通用」新增「日志文件夹」行:路径 + 打开目录 + 复制路径(i18n 中英)。 - 日志页页头的「打开目录」不再是占位:走 M187 的 open_dir,失败文本带路径并送进剪贴板。 - README.md / README.zh-CN.md 新增「日志在哪」章节(含 crash-reports 只在游戏走到那一步才有), docs/logs-location.md 给出上限、双写判据、迁移策略与没验的部分。 守门(每条都有变异证明,见报告) - 旧家会话仍列得出/打不开 ⇒ 只读新根变红;新会话写 logs/game ⇒ 只读旧根、写回旧家都变红。 - launcher.log 滚动 ⇒ 干掉 rename 变红。 - 打开日志文件夹指向真实目录 ⇒ 换成 cache/ 或去掉 create_dir_all 都变红。 - 日志根随三模式变化 ⇒ 把 HostEnv::logs_dir 写死成便携落点变红。
This commit is contained in:
1 parent
efc663a3cb
commit
5fd5cd47dc
47 files changed
+1371
-119
No files matched your search
@@ -0,0 +1,164 @@
|
||||
//! 启动期诊断行的**双写**:stderr + `<.ULL>/logs/launcher.log`(M188)。
|
||||
//!
|
||||
//! ## 为什么要有这一层
|
||||
//!
|
||||
//! 双击启动的 GUI 进程**没有 stderr 去向**(Windows 上根本没有控制台)。此前 `[font]` /
|
||||
//! `[probe]` / `[theme]` / `[appearance]` / `[gpu]` 这些诊断只走 `eprintln!`,于是「启动器视角
|
||||
//! 到底发生了什么」在事故之后一个字都不剩——用户投诉的「只有一个安装日志」有一半是它。
|
||||
//!
|
||||
//! 现在这些行同时进 `launch::launcher_log`:
|
||||
//!
|
||||
//! ```text
|
||||
//! diag!("[font] 求值耗时 {:.0} ms", ms) → stderr: [font] 求值耗时 12 ms
|
||||
//! → launcher.log: 2026-… [font] 求值耗时 12 ms
|
||||
//! ```
|
||||
//!
|
||||
//! scope 就是行首那个 `[tag]`,所以日志行与老用法**长得一样**,`grep '\[gpu\]'` 仍然好使。
|
||||
//!
|
||||
//! ## 哪些行进日志、哪些只进 stderr
|
||||
//!
|
||||
//! 判据只有一条:**事故之后还要不要看它**。
|
||||
//!
|
||||
//! | 进日志 | 只进 stderr |
|
||||
//! |---|---|
|
||||
//! | 启动编排(探测结论 / 字体重建 / 主题来源 / 后端最终值 / 降级重开) | 验收与出图通道的逐帧度量(`[window]` / `[perf]` / `[caps]`——那些行由 `renderer::acceptance` 打,不在本 mission scope) |
|
||||
//! | 用户动作的结果(打开目录、写设置失败、更新忽略/应用、语言没落盘) | 组件几何探针(`[feedback-probe]` / `[gallery]` 的断言输出,`components/**`) |
|
||||
//! | 一次性且量很小的诊断(`[appearance]` / `[corner]` 的切换、`[theme]`、`[shot]` 的通道提示) | 每条日志行的正文里再套一层括号(不是标签,见 [`split_scope`]) |
|
||||
//!
|
||||
//! 反过来**不许**把高频行接进来:`launcher.log` 有 1 MiB 上限(滚成 `launcher.1.log`,只留一份),
|
||||
//! 逐帧写会把「事故现场」冲掉。
|
||||
|
||||
/// 行首没有 `[tag]` 时的默认 scope。
|
||||
pub const APP_SCOPE: &str = "app";
|
||||
|
||||
/// 记一行诊断:stderr 照旧,另外进启动器日志(best-effort,写不进去也不影响启动)。
|
||||
pub(crate) fn emit(line: &str) {
|
||||
eprintln!("{line}");
|
||||
let (scope, message) = split_scope(line);
|
||||
unlucky_core::launch::launcher_log::record(scope, message);
|
||||
}
|
||||
|
||||
/// `[font] 求值耗时 12 ms` → (`font`, `求值耗时 12 ms`)。
|
||||
///
|
||||
/// scope 只认「短、无空白、非空」的方括号前缀:`[a b]` 这种是正文而不是标签,别把整行拆坏。
|
||||
pub(crate) fn split_scope(line: &str) -> (&str, &str) {
|
||||
let Some(rest) = line.strip_prefix('[') else {
|
||||
return (APP_SCOPE, line);
|
||||
};
|
||||
let Some((scope, tail)) = rest.split_once(']') else {
|
||||
return (APP_SCOPE, line);
|
||||
};
|
||||
if scope.is_empty() || scope.len() > 24 || scope.contains(char::is_whitespace) {
|
||||
return (APP_SCOPE, line);
|
||||
}
|
||||
(scope, tail.trim_start())
|
||||
}
|
||||
|
||||
/// 双写一行诊断(`diag!("[scope] 正文")`,参数同 `eprintln!`)。
|
||||
macro_rules! diag {
|
||||
($($arg:tt)*) => {{
|
||||
crate::app::diag::emit(&format!($($arg)*));
|
||||
}};
|
||||
}
|
||||
|
||||
pub(crate) use diag;
|
||||
|
||||
/// 串行化「会改 `launcher_log` 全局目录」的用例。
|
||||
///
|
||||
/// `core::launch::launcher_log` 是**进程级单例**(目录 + 缓冲),而 app-iced 的测试二进制里
|
||||
/// 有两条用例会 `set_dir` 到自己的临时目录(本文件的滚动闸、`app/dialog/tests.rs` 的启动事实闸)。
|
||||
/// 它们必须串行,否则互相把对方的落点改走——那是两条闸互相打架,不是产品问题。
|
||||
#[cfg(test)]
|
||||
pub(crate) static LOG_STATE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::split_scope;
|
||||
|
||||
#[test]
|
||||
fn the_leading_bracket_becomes_the_scope() {
|
||||
assert_eq!(
|
||||
split_scope("[font] 求值耗时 12 ms"),
|
||||
("font", "求值耗时 12 ms")
|
||||
);
|
||||
assert_eq!(split_scope("[gpu]"), ("gpu", ""));
|
||||
// 正文里的方括号不是标签
|
||||
assert_eq!(
|
||||
split_scope("[page] 占位动作 [x] pressed"),
|
||||
("page", "占位动作 [x] pressed")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_without_a_tag_fall_back_to_the_default_scope() {
|
||||
assert_eq!(split_scope("没有标签的一行"), ("app", "没有标签的一行"));
|
||||
assert_eq!(split_scope("[a b] 空白不是标签"), ("app", "[a b] 空白不是标签"));
|
||||
}
|
||||
|
||||
/// 守门②:**启动器日志的滚动真的会发生**,写超上限之后文件仍然有界。
|
||||
///
|
||||
/// ## 为什么要「重试」
|
||||
///
|
||||
/// `launcher_log` 是**进程级单例**(目录 + 缓冲),而同进程里还有一条用例
|
||||
/// (`app/dialog/tests.rs` 的 `boot_facts_are_recorded_into_the_launcher_log`)会
|
||||
/// `set_dir` 到它自己的临时目录。两边撞上时我这几行的落点就换了人,所以每一轮都要
|
||||
/// **从头跑一遍**(登记 → 造超限 → 追加 → 看有没有滚),并且用「我这份超限文件有没有被
|
||||
/// rename」判断这一轮是不是真的跑在我目录上。重试不是放宽断言:每轮该发生的都得真发生。
|
||||
///
|
||||
/// 变异证明:删掉 `core::launch::launcher_log::append` 里那段「超限就 rename 成 launcher.1.log」,
|
||||
/// 每一轮都不会出现 `launcher.1.log`,本条在重试用尽后红。
|
||||
#[test]
|
||||
fn the_launcher_log_rolls_over_and_stays_bounded() {
|
||||
use unlucky_core::launch::launcher_log;
|
||||
|
||||
let _guard = super::LOG_STATE_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner());
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("ull-launcher-log-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("建夹具目录");
|
||||
let path = dir.join(launcher_log::FILE_NAME);
|
||||
let rolled = dir.join("launcher.1.log");
|
||||
let oversize = usize::try_from(launcher_log::MAX_BYTES).expect("上限能塞进 usize") + 16;
|
||||
|
||||
for _ in 0..50 {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(&rolled);
|
||||
launcher_log::set_dir(&dir);
|
||||
if launcher_log::path() != Some(path.clone()) {
|
||||
continue; // 这一轮被别人的 set_dir 抢先了
|
||||
}
|
||||
launcher_log::record("boot", "第一行");
|
||||
std::fs::write(&path, vec![b'x'; oversize]).expect("造一份超限日志");
|
||||
if launcher_log::path() != Some(path.clone()) {
|
||||
continue;
|
||||
}
|
||||
launcher_log::record("boot", "滚过之后这一行必须在新文件里");
|
||||
if launcher_log::path() != Some(path.clone()) {
|
||||
continue; // 追加落到了别人的目录,这一轮不算
|
||||
}
|
||||
|
||||
// 到这儿为止,追加**确实**落在本目录:超限就必须滚。
|
||||
assert!(rolled.is_file(), "超限之后旧档必须滚成 launcher.1.log");
|
||||
let size = std::fs::metadata(&path).expect("新文件").len();
|
||||
assert!(
|
||||
size < launcher_log::MAX_BYTES,
|
||||
"滚完之后新文件必须远小于上限:{size}"
|
||||
);
|
||||
let text = std::fs::read_to_string(&path).expect("读新文件");
|
||||
assert!(text.contains("滚过之后这一行"), "{text}");
|
||||
|
||||
// 只留一份历史:再滚一次仍然是 `launcher.1.log`,不会长出 launcher.2.log。
|
||||
std::fs::write(&path, vec![b'y'; oversize]).expect("再造一份超限日志");
|
||||
launcher_log::record("boot", "第二次滚动");
|
||||
assert!(rolled.is_file());
|
||||
assert!(!dir.join("launcher.2.log").exists(), "历史只留一份");
|
||||
assert!(std::fs::metadata(&path).expect("新文件").len() < launcher_log::MAX_BYTES);
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
return;
|
||||
}
|
||||
panic!("50 轮都撞上别的用例在改全局日志目录,这一条没能真正跑起来");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user