From 21ddd01bc9d090e9848cbd31af3133a3aab04433 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 23 Aug 2026 02:23:23 +0800 Subject: [PATCH] fix(updater): record a panic where someone can find it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `crash::install` puts a panic hook in front of `crash.log`. The GUI installs it and the server installs it. The updater did not — and of the three it is the one that needs it most. It runs *detached*, after the GUI it is replacing has exited, so its stderr is attached to nothing anybody will read. And it is doing the one job in this product that can leave an install broken. A panic mid-swap was therefore silence: the app does not come back, `tty7-updater.log` stops mid-sentence, and there is nothing anywhere that says why. All three of its `cfg`'d mains install it now, so the role travels with whichever platform failed. `crash.log` is the file the other two roles already write, in the config directory, so the three land in one place in the order they failed. The CLI stays out deliberately: it is a short-lived foreground process whose panic prints to a terminal someone is already looking at, and a second copy in `crash.log` buys nothing. The guard reads the three entry points rather than a list kept here, and was checked against the state that shipped — it names the updater. The mechanism itself was already covered: `a_panic_lands_in_the_crash_log` proves the hook writes the record. What nothing held was whether each binary calls it, which is exactly what was missing. --- crates/tty7-core/src/core/crash.rs | 31 ++++++++++++++++++++++++++++++ src/bin/tty7-updater.rs | 17 ++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/crates/tty7-core/src/core/crash.rs b/crates/tty7-core/src/core/crash.rs index a1460c6e..2f05155d 100644 --- a/crates/tty7-core/src/core/crash.rs +++ b/crates/tty7-core/src/core/crash.rs @@ -121,6 +121,37 @@ mod tests { assert!(body.contains("test v"), "role + version: {body}"); } + /// Every binary whose stderr nobody is watching installs the hook. + /// + /// A panic is only as useful as the place it lands. The GUI and the server + /// both outlive the terminal that started them, and the updater is worse + /// than either: it runs *detached*, after the GUI it is replacing has + /// exited, doing the one job in this product that can leave an install + /// broken. It shipped without the hook — a panic mid-swap was silence, with + /// `tty7-updater.log` stopping mid-sentence and nothing to say why. + /// + /// The CLI is deliberately absent. It is a short-lived foreground process + /// whose panic prints to a terminal someone is already looking at, and a + /// second copy in `crash.log` buys nothing. + #[test] + fn every_detached_binary_records_its_panics() { + for (role, source) in [ + ("app", include_str!("../../../../src/main.rs")), + ( + "updater", + include_str!("../../../../src/bin/tty7-updater.rs"), + ), + ("server", include_str!("../../../tty7-server/src/main.rs")), + ] { + assert!( + source.contains("crash::install"), + "the {role} binary never installs the panic hook, so its crashes \ + go wherever its stderr goes — which for a detached process is \ + nowhere" + ); + } + } + #[test] fn civil_from_days_matches_known_dates() { assert_eq!(civil_from_days(0), (1970, 1, 1)); diff --git a/src/bin/tty7-updater.rs b/src/bin/tty7-updater.rs index 3b91643e..9a6da2e7 100644 --- a/src/bin/tty7-updater.rs +++ b/src/bin/tty7-updater.rs @@ -4162,8 +4162,23 @@ mod windows { } } +/// Record a panic where someone can find it. +/// +/// This binary runs *detached*, after the GUI it is replacing has exited, so +/// its stderr is attached to nothing a user will ever read — and it is doing +/// the one job in the product that can leave an install broken. Without this a +/// panic mid-swap is simply silence: the app does not come back, `tty7-updater.log` +/// stops mid-sentence, and there is nothing to say why. +/// +/// `crash.log` is the same file the app and the server write to, in the config +/// directory, so the three roles land in one place in the order they failed. +fn install_crash_log() { + tty7_core::core::crash::install("updater"); +} + #[cfg(target_os = "macos")] fn main() { + install_crash_log(); if let Err(error) = macos::run() { eprintln!("tty7-updater: {error}"); std::process::exit(1); @@ -4172,6 +4187,7 @@ fn main() { #[cfg(target_os = "windows")] fn main() { + install_crash_log(); if let Err(error) = windows::run() { eprintln!("tty7-updater: {error}"); std::process::exit(1); @@ -4180,6 +4196,7 @@ fn main() { #[cfg(target_os = "linux")] fn main() { + install_crash_log(); if let Err(error) = linux::run() { eprintln!("tty7-updater: {error}"); std::process::exit(1);