crabidy/cbd-tui/src/stderr.rs

113 lines
4.5 KiB
Rust

//! Keeping foreign writes off the TUI's screen.
//!
//! `tracing` goes to a log file precisely because the terminal belongs to the
//! interface — but that only covers what *we* log. File descriptor 2 still
//! points at the terminal, and plenty in this process never goes through
//! `tracing`:
//!
//! - ALSA prints from C (`ALSA lib pcm.c:…: underrun occurred`), which the
//! bundled `cbd` sees because the audio stack shares its process.
//! - the default panic hook, and anything a dependency decides to
//! `eprintln!`.
//!
//! Those bytes land wherever the cursor is, scroll the terminal by a line and
//! leave the whole layout looking shifted — the queue appearing to bleed into
//! the now-playing pane. Because ratatui repaints only the cells that changed,
//! the damage persists until something forces a full redraw. Worse, the
//! message is then *lost*: it never reaches the log, so afterwards there is no
//! record of the underrun that caused it.
//!
//! So the fix is one `dup2`: point fd 2 at a file next to the log before the
//! alternate screen is entered. The diagnostics are kept, just not on screen.
use std::path::{Path, PathBuf};
/// The directory both binaries log into: `crabidy/` under the state dir,
/// falling back to the cache dir and then to the temp dir.
///
/// Shared so the log file and the captured stderr always land together.
pub fn log_dir() -> PathBuf {
dirs::state_dir()
.or_else(dirs::cache_dir)
.unwrap_or_else(std::env::temp_dir)
.join("crabidy")
}
/// Points file descriptor 2 at `path` (appending), so writes that bypass
/// `tracing` are recorded instead of scribbling on the interface.
///
/// Call once, before the terminal is put into raw mode. Errors are returned
/// rather than handled: a client that cannot open its log file should still
/// start — it just keeps the noisy stderr it has always had.
#[cfg(unix)]
pub fn capture_into(path: &Path) -> std::io::Result<()> {
use std::os::fd::AsRawFd;
let file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
// `dup2` makes fd 2 a second reference to this file, so dropping `file`
// (and its own descriptor) at the end of this function leaves stderr
// pointing at it.
if unsafe { libc::dup2(file.as_raw_fd(), libc::STDERR_FILENO) } == -1 {
return Err(std::io::Error::last_os_error());
}
Ok(())
}
/// Nothing to do off Unix: there is no `dup2`, and the crabidy clients are
/// terminal programs on Linux and macOS.
#[cfg(not(unix))]
pub fn capture_into(_path: &Path) -> std::io::Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// The redirect must actually move the process's stderr, and appending
/// must not truncate what an earlier run wrote.
///
/// The write goes to the descriptor directly rather than through
/// `eprintln!`: that is what a C library does (the case this exists for),
/// and the test harness intercepts the macro but not the descriptor.
#[cfg(unix)]
#[test]
fn captured_stderr_reaches_the_file_and_appends() {
use std::io::Write;
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("cbd.stderr.log");
std::fs::write(&path, "earlier run\n").expect("seed");
// Keep the real stderr so the rest of the suite still has one. Briefly
// process-wide, so a parallel test writing to fd 2 in this window would
// land in the file too — harmless here, and the alternative is a child
// process for one assertion.
let saved = unsafe { libc::dup(libc::STDERR_FILENO) };
assert!(saved >= 0, "could not save stderr");
capture_into(&path).expect("redirect");
let wrote = std::io::stderr().write_all(b"underrun occurred\n");
let flushed = std::io::stderr().flush();
let restored = unsafe { libc::dup2(saved, libc::STDERR_FILENO) };
assert!(restored >= 0, "could not restore stderr");
unsafe { libc::close(saved) };
wrote.expect("write to the redirected stderr");
flushed.expect("flush");
let captured = std::fs::read_to_string(&path).expect("read back");
assert!(captured.starts_with("earlier run\n"), "{captured:?}");
assert!(captured.contains("underrun occurred"), "{captured:?}");
}
#[test]
fn the_log_dir_is_under_crabidy() {
assert_eq!(
log_dir().file_name().and_then(|n| n.to_str()),
Some("crabidy")
);
}
}