//! 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") ); } }