crates/veilvoice-gui/src/crashlog.rs
what this file is for · veilvoice-gui · 447 lines · the same file on GitHub
The file as it is in the tree, in the colours you chose. A line number is a link, and so is every box in this file’s diagram: it opens here with the function it names marked.
// SPDX-License-Identifier: CC-BY-NC-SA-4.0
//! Make a failure that produces no output produce some.
//!
//! # The problem this exists for
//!
//! `veilvoice-gui` is built with `windows_subsystem = "windows"`, so it has no
//! console, and the workspace builds with `panic = "abort"`, so a panic does
//! not unwind into anything that could report it. Put together, **every way
//! this application can fail on Windows produces exactly nothing**: no message,
//! no dialog, no log. The window appears or it does not.
//!
//! That is not hypothetical. A release shipped and the report was "it flashes a
//! command prompt, loads in an unusable state, and crashes" -- which is all a
//! user *can* report, because the program tells them nothing. The console flash
//! turned out to be subprocesses (see `no_window` in [`crate::reduced_motion`]),
//! and the crash could not be diagnosed at all from what was observable.
//!
//! # What this does about it
//!
//! Two failures are caught and written to a file beside the preferences:
//!
//! * **A panic**, through a hook. The hook runs before `abort` even under
//! `panic = "abort"`, so there is a window in which to write.
//! * **A startup failure from `eframe`**, which is a returned `Err` rather than
//! a panic and is otherwise printed to a stderr nobody can see.
//!
//! The second is the one worth expecting. This application renders through
//! **glow**, which is OpenGL, and creating a GL context depends on the graphics
//! driver. In a virtual machine, over a remote desktop session, or on a laptop
//! whose hybrid graphics hand the process the wrong adapter, that call fails --
//! and the honest answer to "why did nothing happen?" needs to survive the
//! process exiting.
//!
//! # What it deliberately does not do
//!
//! **It does not report anything anywhere.** The file is written next to the
//! preferences, on the user's own disk, and stays there until they delete it or
//! the application clears it. A privacy tool that phones home about its own
//! crashes would be exactly the thing this project spends its documentation
//! refusing to be, and there is no network code in the dependency graph to do
//! it with even if that changed.
//!
//! **It records no user content.** A panic message, a source location, the
//! version and a timestamp. Not the file being processed, not a path the user
//! chose, not a passphrase -- nothing that is theirs.
//!
//! # In plain words
//!
//! Makes sure that if VeilVoice falls over, it leaves something behind saying so.
//!
//! A windowed program on Windows has nowhere to print to. Without this, a failure
//! at startup produces a window that never appears and no message anywhere, and
//! the only thing anybody can report is "it crashed", which is exactly the report
//! that arrived once.
//!
//! So the reason is written to a file, and the file is shown to you next time the
//! application opens. It stays on your machine and is never sent anywhere.
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
/// The file a failure is written to, beside the preferences.
pub fn default_path() -> Option<PathBuf> {
crate::prefs::default_path().and_then(|p| p.parent().map(|d| d.join("last-crash.txt")))
}
/// Seconds since the Unix epoch, or 0 if the clock is unreadable.
///
/// No date formatting and no dependency for it. A support conversation needs to
/// know *which* run failed and roughly when, and an epoch second answers that;
/// pulling in a calendar library to render it would be a poor trade in a
/// project whose argument is that its dependency graph can be read.
fn stamp() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
/// Write one failure report. Never panics, whatever happens.
///
/// Called from a panic hook, so it has to be the most defensive code in the
/// crate: a panic in here during a panic is an abort with even less to show for
/// it than before. Every error is swallowed deliberately -- there is nowhere
/// left to report a failure to report a failure.
pub fn write(path: &Path, kind: &str, detail: &str) {
let mut text = String::new();
text.push_str("VeilVoice ");
text.push_str(crate::VERSION);
text.push_str(" ended unexpectedly.\n\n");
text.push_str("what: ");
text.push_str(kind);
text.push('\n');
text.push_str("when: ");
text.push_str(&stamp().to_string());
text.push_str(" (seconds since 1970-01-01 UTC)\n");
text.push_str("os: ");
text.push_str(std::env::consts::OS);
text.push(' ');
text.push_str(std::env::consts::ARCH);
text.push_str("\n\ndetail:\n");
// Bounded: a panic message can carry a formatted value of any size, and
// this file is read by a person.
let detail = if detail.len() > 4000 {
&detail[..4000]
} else {
detail
};
text.push_str(detail);
text.push_str(
"\n\n\
This file was written on your machine and sent nowhere. VeilVoice has\n\
no network code at all. Delete it whenever you like.\n\n",
);
text.push_str(&advice(detail));
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(mut file) = std::fs::File::create(path) {
let _ = file.write_all(text.as_bytes());
let _ = file.flush();
}
}
/// The paragraph that tries to be useful about *this* failure.
///
/// # Why this is not one paragraph
///
/// It was. Every report ended with the same note about OpenGL contexts, which
/// is the right guess for a window that never appeared and the wrong one for
/// most of the ways this program can actually fail. A report that confidently
/// names a cause it did not check is worse than one that names none: it sends
/// the reader to look at their graphics driver while the real message, two
/// lines above, says a keyboard library is missing.
///
/// That happened here rather than in theory. Upgrading the window toolkit
/// added a runtime dependency on `libxkbcommon-x11`, which is opened by name
/// at startup and therefore invisible to every packaging tool that works out
/// dependencies from what a binary links against. On a machine without it the
/// application built, installed and packaged perfectly and then aborted before
/// drawing anything, and the report blamed the GPU.
///
/// So the note is chosen from the panic message. The library case names the
/// library and the package that carries it on each family of Linux; anything
/// unrecognised keeps the graphics note, which remains the best guess when
/// there is nothing else to go on.
fn advice(detail: &str) -> String {
if let Some(library) = missing_library(detail) {
return format!(
"This looks like a missing system library rather than a fault in\n\
VeilVoice: {library} could not be opened. It is a shared library the\n\
window toolkit loads by name at startup, so a package manager\n\
cannot tell it is needed and will not have pulled it in.\n\n\
On Debian and Ubuntu: sudo apt install libxkbcommon-x11-0\n\
On Fedora and RHEL: sudo dnf install libxkbcommon-x11\n\
On Arch: sudo pacman -S libxkbcommon-x11\n\
On Alpine: sudo apk add libxkbcommon-x11\n\n\
The command-line tool `veilvoice` does the same work and needs\n\
none of this.\n"
);
}
"If the window never appeared, the most likely cause is that this\n\
computer could not give the application an OpenGL context. This is\n\
common in a virtual machine, over a remote desktop session, or with\n\
hybrid graphics. The command-line tool `veilvoice` does the same work\n\
and needs no graphics at all.\n"
.to_string()
}
/// The name of the shared library a panic message says could not be loaded.
///
/// Matched on the shape of the message rather than on one library's name, so
/// the next toolkit upgrade that adds one is covered without an edit here.
/// `xkbcommon-dl` writes "Library libfoo.so could not be loaded."; the same
/// shape covers the other dynamic loaders in this dependency graph.
fn missing_library(detail: &str) -> Option<&str> {
let lowered = detail.to_ascii_lowercase();
if !lowered.contains("could not be loaded") && !lowered.contains("cannot open shared object") {
return None;
}
detail
.split_whitespace()
.find(|word| word.contains(".so") || word.contains(".dll") || word.contains(".dylib"))
.map(|word| word.trim_matches(|c: char| !c.is_ascii_graphic() || c == ',' || c == ':'))
}
/// Install the panic hook. Call once, as early in `main` as possible.
///
/// Chained rather than replacing: the default hook prints to stderr, which is
/// useful when there *is* a console (a debug build, or the binary run from a
/// terminal), and this adds the file for when there is not.
pub fn install() {
let Some(path) = default_path() else {
return; // No config directory: nowhere to write, and not worth failing over.
};
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let detail = format!(
"{}\n\nlocation: {}",
info.payload()
.downcast_ref::<&str>()
.map(|s| s.to_string())
.or_else(|| info.payload().downcast_ref::<String>().cloned())
.unwrap_or_else(|| "(no message)".to_string()),
info.location()
.map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()))
.unwrap_or_else(|| "(unknown)".to_string()),
);
write(&path, "a panic", &detail);
previous(info);
}));
}
/// Record a startup failure that `eframe` returned rather than panicked.
pub fn record_startup_failure(detail: &str) {
if let Some(path) = default_path() {
write(&path, "the window could not be created", detail);
}
}
/// Read a previous report, if one is there, so the interface can mention it.
pub fn previous() -> Option<(PathBuf, String)> {
let path = default_path()?;
let text = std::fs::read_to_string(&path).ok()?;
Some((path, text))
}
/// Forget a previous report.
pub fn clear() {
if let Some(path) = default_path() {
let _ = std::fs::remove_file(path);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// No panic VeilVoice writes itself puts a path in its message.
///
/// The crash panel invites somebody to paste this report into a public
/// issue tracker, and says VeilVoice puts no file names in it. The panic
/// message is the one part not written in advance, so that claim has to be
/// checked against the source rather than asserted.
///
/// A dependency's panic could still carry a path, which this cannot see
/// and does not pretend to. That is exactly why the panel names the error
/// message as the variable part and puts the whole text on screen instead
/// of promising on a library's behalf.
#[test]
fn no_panic_in_this_program_formats_a_path_into_its_message() {
use std::path::Path;
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("crates/")
.to_path_buf();
let mut offenders = Vec::new();
let mut stack = vec![root.clone()];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
// Only code that ends up in a shipped binary.
//
// `tests/`, `benches/` and `examples/` are excluded, and
// not as a convenience: a panic in an integration test is
// read by whoever ran it, never written to a crash report,
// and never pasted into an issue tracker. The first run of
// this test flagged one in `veilvoice-verify/tests`, which
// is a true negative rather than a finding. `target` and
// dotted directories are not this project's source at all.
let name = path.file_name().unwrap_or_default().to_string_lossy();
let shipped =
!matches!(name.as_ref(), "target" | "tests" | "benches" | "examples")
&& !name.starts_with('.');
if shipped {
stack.push(path);
}
continue;
}
if path.extension().and_then(|e| e.to_str()) != Some("rs") {
continue;
}
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
// Tests may say whatever they like: their panics are read by
// whoever ran them, not pasted into an issue tracker.
let body = text.split("\n#[cfg(test)]").next().unwrap_or(&text);
for (n, line) in body.lines().enumerate() {
let trimmed = line.trim_start();
if trimmed.starts_with("//") {
continue;
}
let panics = trimmed.contains(".expect(")
|| trimmed.contains("panic!(")
|| trimmed.contains("assert!(")
|| trimmed.contains("assert_eq!(");
if panics && (trimmed.contains("display()") || trimmed.contains(".path(")) {
offenders.push(format!("{}:{}: {}", path.display(), n + 1, trimmed));
}
}
}
}
assert!(
offenders.is_empty(),
"these panics would put a path into a crash report a person is \
invited to paste in public:\n{}",
offenders.join("\n")
);
}
fn scratch(tag: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("veilvoice-crashlog-{}-{}", std::process::id(), tag));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("scratch");
dir.join("last-crash.txt")
}
#[test]
fn a_report_names_the_version_and_the_detail() {
let path = scratch("basic");
write(&path, "a panic", "something went wrong at the seams");
let text = std::fs::read_to_string(&path).expect("written");
assert!(text.contains(crate::VERSION), "no version: {text}");
assert!(text.contains("a panic"));
assert!(text.contains("something went wrong at the seams"));
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}
#[test]
fn a_report_says_it_was_sent_nowhere() {
// The sentence matters as much as the file. Somebody whose privacy tool
// just crashed is entitled to know instantly that nothing left the
// machine, and a test keeps that sentence from being edited away.
let path = scratch("privacy");
write(&path, "a panic", "x");
let text = std::fs::read_to_string(&path).expect("written");
assert!(
text.contains("sent nowhere") && text.contains("no network code"),
"the report must state that nothing was transmitted: {text}"
);
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}
#[test]
fn a_report_points_at_the_command_line_tool() {
// The most likely cause of an invisible window is a machine that cannot
// give the process an OpenGL context, and the useful thing to tell that
// user is that there is a front-end which needs no graphics at all.
let path = scratch("advice");
write(&path, "the window could not be created", "no GL context");
let text = std::fs::read_to_string(&path).expect("written");
assert!(text.contains("OpenGL"), "no cause named: {text}");
assert!(
text.contains("`veilvoice`"),
"no alternative offered: {text}"
);
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}
#[test]
fn a_missing_library_is_named_instead_of_the_graphics_card() {
// The failure this was written for: the window toolkit opens
// libxkbcommon-x11 by name at startup, so nothing that derives
// dependencies from linkage knows it is needed. The old report sent
// the reader to look at their GPU.
let path = scratch("library");
write(
&path,
"a panic",
"Library libxkbcommon-x11.so could not be loaded.",
);
let text = std::fs::read_to_string(&path).expect("written");
assert!(
text.contains("libxkbcommon-x11.so"),
"the library was not named: {text}"
);
assert!(
text.contains("apt install") && text.contains("dnf install"),
"no way to fix it was offered: {text}"
);
assert!(
!text.contains("OpenGL"),
"still blaming the graphics stack for a missing library: {text}"
);
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}
#[test]
fn the_loader_message_shape_is_matched_not_one_library_name() {
// The next toolkit upgrade that adds a loaded-by-name library should
// be covered without an edit here.
assert_eq!(
missing_library("Library libsomething-else.so could not be loaded."),
Some("libsomething-else.so")
);
assert_eq!(
missing_library("libfoo.so.1: cannot open shared object file"),
Some("libfoo.so.1")
);
assert_eq!(missing_library("index out of bounds"), None);
assert_eq!(missing_library("the window could not be created"), None);
}
#[test]
fn an_enormous_panic_message_is_bounded() {
// A panic message can carry a formatted value of any size; this file is
// read by a person, and by a text editor that has to open it.
let path = scratch("huge");
write(&path, "a panic", &"x".repeat(500_000));
let text = std::fs::read_to_string(&path).expect("written");
assert!(
text.len() < 10_000,
"the report grew to {} bytes",
text.len()
);
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}
#[test]
fn writing_into_a_directory_that_does_not_exist_creates_it() {
let base =
std::env::temp_dir().join(format!("veilvoice-crashlog-{}-deep", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let path = base.join("nested").join("last-crash.txt");
write(&path, "a panic", "x");
assert!(path.exists(), "the report was not written");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn an_unwritable_destination_is_survived_rather_than_panicked_on() {
// This runs inside a panic hook. A panic here is an abort with even
// less to show for it, so every failure has to be swallowed.
let path = Path::new("/this/path/cannot/exist/anywhere/last-crash.txt");
write(path, "a panic", "x"); // must simply return
}
}