fix(log): never rotate a log file another live process is writing; qbzd logs to qbzd.log

open_log_file_at renamed the live file to .prev on every open, so a second
qbz launch (the #749 two-line qbz.log) or a qbzd in the same directory took
the running process's log away. Writers now hold a shared advisory lock on
<name>.lock; an opener that cannot take it exclusively appends instead of
rotating. The daemon gets its own qbzd.log.

cc
This commit is contained in:
vicrodh committed 2026-09-13 03:36:50 -06:00
1 parent e9b02cdedb
commit d58fdf45b0
4 files changed
+163 -62

No files matched your search

+140 -56
View File
@@ -1,43 +1,52 @@
//! One-shot logger installation + the on-disk file sink (open / rotate). //! One-shot logger installation + the on-disk file sink (open / rotate).
use std::fs::File; use std::fs::{File, OpenOptions, TryLockError};
use std::io::BufWriter; use std::io::BufWriter;
use std::path::PathBuf; use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use crate::tee::TeeLogger; use crate::tee::TeeLogger;
static INSTALLED: AtomicBool = AtomicBool::new(false); static INSTALLED: AtomicBool = AtomicBool::new(false);
/// Install the [`TeeLogger`] as the global `log` logger. /// The desktop app's log file name.
pub const DEFAULT_LOG_FILE: &str = "qbz.log";
/// The on-disk sink plus the advisory lock that marks this process as a live
/// writer of that file (see [`open_log_file_at`]). Dropping it releases both.
pub(crate) struct FileSink {
pub(crate) writer: BufWriter<File>,
pub(crate) lock: Option<File>,
}
/// Install the [`TeeLogger`] as the global `log` logger, writing to
/// [`DEFAULT_LOG_FILE`].
/// ///
/// Builds the inner `env_logger` logger from `RUST_LOG` (falling back to `default_level`), /// Builds the inner `env_logger` logger from `RUST_LOG` (falling back to `default_level`),
/// opens/rotates the on-disk file, then sets the boxed logger + max level. Idempotent: /// opens/rotates the on-disk file, then sets the boxed logger + max level. Idempotent:
/// a second call is a guarded no-op (it neither rotates the file again nor panics). /// a second call is a guarded no-op (it neither rotates the file again nor panics).
pub fn install(default_level: &str) { pub fn install(default_level: &str) {
install_with_file_sink(default_level, true); install_with_file_sink(default_level, Some(DEFAULT_LOG_FILE));
}
/// Same as [`install`], but to `file_name` inside the same logs directory.
/// `qbzd` uses `qbzd.log` so a daemon never shares (or rotates) the desktop
/// app's file.
pub fn install_named(default_level: &str, file_name: &str) {
install_with_file_sink(default_level, Some(file_name));
} }
/// Same as [`install`], but with the on-disk file sink DISABLED (stderr + ring only). /// Same as [`install`], but with the on-disk file sink DISABLED (stderr + ring only).
/// ///
/// For an internal, disposable child process that re-enters the same `main`. /// For an internal, disposable child process that re-enters the same `main`
/// [`open_log_file`] rotates `qbz.log` to `qbz.log.prev` and creates a fresh /// (presentation / GPU preflight, #749). A child's output is already captured
/// file on every call, so a child running it MID-RUN renames the live log out /// by the parent, so it loses nothing by staying off the file — and it never
/// from under the parent — which keeps writing to the renamed inode — and /// has to touch the parent's file at all.
/// leaves `qbz.log` holding nothing but the child's own first lines.
///
/// Field-reported in #749: on Linux every launch spawns the presentation /
/// GPU preflight child, so `~/.local/share/qbz/logs/qbz.log` ended at two
/// lines on EVERY run while the app itself was healthy and its whole log went
/// to `qbz.log.prev`. The user's log file, the first thing a bug report
/// attaches, was structurally useless. A child's output is already captured
/// by the parent (proof line on stdout, `preflight_output_tail` on failure),
/// so it loses nothing by staying off the file.
pub fn install_without_file_sink(default_level: &str) { pub fn install_without_file_sink(default_level: &str) {
install_with_file_sink(default_level, false); install_with_file_sink(default_level, None);
} }
fn install_with_file_sink(default_level: &str, file_sink: bool) { fn install_with_file_sink(default_level: &str, file_name: Option<&str>) {
// True one-shot guard: avoid re-rotating the log file or fighting an already-set logger. // True one-shot guard: avoid re-rotating the log file or fighting an already-set logger.
if INSTALLED.swap(true, Ordering::SeqCst) { if INSTALLED.swap(true, Ordering::SeqCst) {
return; return;
@@ -57,10 +66,10 @@ fn install_with_file_sink(default_level: &str, file_sink: bool) {
) )
.build(); .build();
let level = inner.filter(); let level = inner.filter();
let file = if file_sink { open_log_file() } else { None }; let sink = file_name.and_then(open_log_file);
// Ignore the Err if a logger was somehow already set elsewhere. // Ignore the Err if a logger was somehow already set elsewhere.
if log::set_boxed_logger(Box::new(TeeLogger::new(inner, file))).is_ok() { if log::set_boxed_logger(Box::new(TeeLogger::new(inner, sink))).is_ok() {
log::set_max_level(level); log::set_max_level(level);
} }
} }
@@ -70,35 +79,74 @@ pub fn set_level(level: log::LevelFilter) {
log::set_max_level(level); log::set_max_level(level);
} }
/// Path to the current-run log file (`~/.local/share/qbz/logs/qbz.log`), if a data dir exists. /// `~/.local/share/qbz/logs`, if a data dir exists.
fn logs_dir() -> Option<PathBuf> {
Some(dirs::data_dir()?.join("qbz").join("logs"))
}
/// Path to the desktop app's current-run log file (`~/.local/share/qbz/logs/qbz.log`).
pub fn log_file_path() -> Option<PathBuf> { pub fn log_file_path() -> Option<PathBuf> {
Some(dirs::data_dir()?.join("qbz").join("logs").join("qbz.log")) log_file_path_named(DEFAULT_LOG_FILE)
} }
/// Open the log file for this run, rotating any previous one to `qbz.log.prev`. /// Path to `file_name` inside the logs directory (`qbzd.log` for the daemon).
/// Returns `None` (file sink disabled, gracefully) on any filesystem error. pub fn log_file_path_named(file_name: &str) -> Option<PathBuf> {
fn open_log_file() -> Option<BufWriter<File>> { Some(logs_dir()?.join(file_name))
open_log_file_at(&log_file_path()?)
} }
/// [`open_log_file`] against an explicit path, so the rotation contract is fn open_log_file(file_name: &str) -> Option<FileSink> {
/// testable without a real data dir. open_log_file_at(&log_file_path_named(file_name)?)
}
/// Open the run's log file at `path`.
/// ///
/// NOTE the hazard this encodes: every call renames the CURRENT file away and /// Rotation (`<name>` → `<name>.prev`, then a fresh file) happens ONLY when no
/// starts an empty one. That is right once per process run and wrong for /// live process is writing the file. Liveness is an advisory lock on the
/// anything else — see [`install_without_file_sink`]. /// sidecar `<name>.lock`: every writer holds it shared for its lifetime; an
fn open_log_file_at(path: &std::path::Path) -> Option<BufWriter<File>> { /// opener probes it with a non-blocking exclusive lock and, when that is
/// refused, APPENDS to the live file instead of renaming it away. That is
/// what a second `qbz` launch, a `qbzd` sharing the directory, or any future
/// caller of [`install`] used to do to a running process (#749, and the
/// 2026-09 static review). The lock lives on the sidecar, never on the log
/// itself, so it can never interfere with writes (a shared `LockFileEx`
/// would, on Windows). Returns `None` (file sink disabled, gracefully) on any
/// filesystem error.
fn open_log_file_at(path: &Path) -> Option<FileSink> {
let dir = path.parent()?; let dir = path.parent()?;
std::fs::create_dir_all(dir).ok()?; std::fs::create_dir_all(dir).ok()?;
let name = path.file_name()?.to_string_lossy().into_owned();
if path.exists() { let lock = OpenOptions::new()
let prev = dir.join("qbz.log.prev"); .create(true)
// Best-effort rotation; a failure here must not disable logging. .read(true)
let _ = std::fs::rename(path, &prev); .write(true)
.open(dir.join(format!("{name}.lock")))
.ok();
let live_writer = match lock.as_ref().map(File::try_lock) {
Some(Err(TryLockError::WouldBlock)) => true,
// Granted, unsupported (exotic filesystem) or no sidecar: nobody we
// can see is live — keep the historical rotate behaviour.
Some(Ok(())) | Some(Err(TryLockError::Error(_))) | None => false,
};
if let Some(lock) = lock.as_ref() {
// Join the live writers (downgrade the probe if we held it).
let _ = lock.unlock();
let _ = lock.lock_shared();
} }
let file = File::create(path).ok()?; let file = if live_writer && path.exists() {
Some(BufWriter::new(file)) OpenOptions::new().append(true).open(path).ok()?
} else {
if path.exists() {
// Best-effort rotation; a failure here must not disable logging.
let _ = std::fs::rename(path, dir.join(format!("{name}.prev")));
}
File::create(path).ok()?
};
Some(FileSink {
writer: BufWriter::new(file),
lock,
})
} }
#[cfg(test)] #[cfg(test)]
@@ -106,32 +154,68 @@ mod tests {
use super::*; use super::*;
use std::io::Write; use std::io::Write;
/// The rotation contract, and the reason a child process must never run it: fn fresh_dir(tag: &str) -> PathBuf {
/// a second open renames the live file to `qbz.log.prev` and leaves an let dir = std::env::temp_dir().join(format!("qbz-log-{tag}-{}", std::process::id()));
/// empty `qbz.log` behind. This is #749 in three lines.
#[test]
fn a_second_open_rotates_the_live_file_away() {
let dir = std::env::temp_dir().join(format!("qbz-log-rotate-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
dir
}
/// #749 was a child process renaming the live log away; a second `qbz`
/// launch or a `qbzd` in the same directory did the same. A live writer
/// holds the sidecar lock, so a second opener APPENDS.
#[test]
fn a_second_open_while_the_first_writer_is_live_appends() {
let dir = fresh_dir("live");
let path = dir.join("qbz.log"); let path = dir.join("qbz.log");
let mut first = open_log_file_at(&path).expect("first open"); let mut first = open_log_file_at(&path).expect("first open");
writeln!(first, "parent line").unwrap(); writeln!(first.writer, "parent line").unwrap();
first.flush().unwrap(); first.writer.flush().unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "parent line\n");
// A second process (the preflight child) installing its own logger.
let mut second = open_log_file_at(&path).expect("second open"); let mut second = open_log_file_at(&path).expect("second open");
writeln!(second, "child line").unwrap(); writeln!(second.writer, "second-instance line").unwrap();
second.flush().unwrap(); second.writer.flush().unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "child line\n");
assert_eq!( assert_eq!(
std::fs::read_to_string(dir.join("qbz.log.prev")).unwrap(), std::fs::read_to_string(&path).unwrap(),
"parent line\n", "parent line\nsecond-instance line\n"
"the parent's log survives only under .prev"
); );
assert!(!dir.join("qbz.log.prev").exists(), "nothing was rotated");
drop(second);
drop(first);
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
} }
/// With no live writer the historical contract holds: rotate to
/// `<name>.prev` and start fresh. The prev name follows the file name.
#[test]
fn open_after_the_previous_writer_closed_rotates_to_prev() {
let dir = fresh_dir("rotate");
let path = dir.join("qbzd.log");
{
let mut first = open_log_file_at(&path).expect("first open");
writeln!(first.writer, "run one").unwrap();
first.writer.flush().unwrap();
}
let mut second = open_log_file_at(&path).expect("second open");
writeln!(second.writer, "run two").unwrap();
second.writer.flush().unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "run two\n");
assert_eq!(
std::fs::read_to_string(dir.join("qbzd.log.prev")).unwrap(),
"run one\n"
);
drop(second);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn named_log_paths_share_the_logs_directory() {
let Some(default) = log_file_path() else { return };
let daemon = log_file_path_named("qbzd.log").unwrap();
assert_eq!(default.parent(), daemon.parent());
assert_eq!(default.file_name().unwrap(), DEFAULT_LOG_FILE);
assert_eq!(daemon.file_name().unwrap(), "qbzd.log");
}
} }
+3 -2
View File
@@ -4,7 +4,8 @@
//! `env_logger`'s built `Logger` and fans visible records out to three sinks: //! `env_logger`'s built `Logger` and fans visible records out to three sinks:
//! 1. **stderr** (redacted text, same line format as the file sink), //! 1. **stderr** (redacted text, same line format as the file sink),
//! 2. a bounded **in-memory ring** ([`ring`], cap [`ring::RING_CAP`]), and //! 2. a bounded **in-memory ring** ([`ring`], cap [`ring::RING_CAP`]), and
//! 3. an **on-disk file** (`~/.local/share/qbz/logs/qbz.log`, prev-rotated at startup). //! 3. an **on-disk file** (`~/.local/share/qbz/logs/qbz.log`; `qbzd.log` for the daemon),
//! prev-rotated at startup unless another live process is writing it.
//! //!
//! Secret **redaction** ([`redact`]) is applied once at the single write choke point, //! Secret **redaction** ([`redact`]) is applied once at the single write choke point,
//! so every downstream consumer (stderr, ring, file, clipboard, paste upload) gets clean text. //! so every downstream consumer (stderr, ring, file, clipboard, paste upload) gets clean text.
@@ -26,6 +27,6 @@ pub mod tee;
pub use bundle::{format_diagnostics_bundle, DiagFields}; pub use bundle::{format_diagnostics_bundle, DiagFields};
pub use fatal::install as install_fatal_signal_reporter; pub use fatal::install as install_fatal_signal_reporter;
pub use install::{install, install_without_file_sink, set_level}; pub use install::{install, install_named, install_without_file_sink, set_level};
pub use line::LogLine; pub use line::LogLine;
pub use redact::{redact, register_secret}; pub use redact::{redact, register_secret};
+17 -2
View File
@@ -7,6 +7,7 @@ use std::time::Instant;
use log::{Log, Metadata, Record}; use log::{Log, Metadata, Record};
use crate::install::FileSink;
use crate::line::LogLine; use crate::line::LogLine;
use crate::repeat::ConsecutiveRecords; use crate::repeat::ConsecutiveRecords;
use crate::{redact, ring}; use crate::{redact, ring};
@@ -25,15 +26,23 @@ pub struct TeeLogger {
struct Output { struct Output {
file: Option<BufWriter<File>>, file: Option<BufWriter<File>>,
/// Held for the process lifetime: marks this process as a live writer of
/// the log file (see `install::open_log_file_at`).
_lock: Option<File>,
consecutive: ConsecutiveRecords, consecutive: ConsecutiveRecords,
} }
impl TeeLogger { impl TeeLogger {
pub(crate) fn new(inner: env_logger::Logger, file: Option<BufWriter<File>>) -> Self { pub(crate) fn new(inner: env_logger::Logger, sink: Option<FileSink>) -> Self {
let (file, lock) = match sink {
Some(sink) => (Some(sink.writer), sink.lock),
None => (None, None),
};
Self { Self {
inner, inner,
output: Mutex::new(Output { output: Mutex::new(Output {
file, file,
_lock: lock,
consecutive: ConsecutiveRecords::default(), consecutive: ConsecutiveRecords::default(),
}), }),
} }
@@ -140,7 +149,13 @@ mod tests {
let inner = env_logger::Builder::new() let inner = env_logger::Builder::new()
.filter_level(log::LevelFilter::Info) .filter_level(log::LevelFilter::Info)
.build(); .build();
let logger = TeeLogger::new(inner, Some(BufWriter::new(file))); let logger = TeeLogger::new(
inner,
Some(FileSink {
writer: BufWriter::new(file),
lock: None,
}),
);
logger.log( logger.log(
&Record::builder() &Record::builder()
.level(Level::Info) .level(Level::Info)
+3 -2
View File
@@ -40,8 +40,9 @@ pub struct BootedRuntime {
/// gracefully. Returns the process exit code (0 = clean shutdown). `warns` are /// gracefully. Returns the process exit code (0 = clean shutdown). `warns` are
/// the unknown-key warnings surfaced by [`QbzdConfig::load`] in `main`. /// the unknown-key warnings surfaced by [`QbzdConfig::load`] in `main`.
pub async fn run(roots: ProfileRoots, cfg: QbzdConfig, warns: Vec<String>, orbit: bool) -> Result<i32, String> { pub async fn run(roots: ProfileRoots, cfg: QbzdConfig, warns: Vec<String>, orbit: bool) -> Result<i32, String> {
// 1. argv parse happened in main(). 2. logging: // 1. argv parse happened in main(). 2. logging — to its OWN file, never
qbz_log::install(&cfg.log.level); // the desktop app's (a shared qbz.log was rotated from under the GUI):
qbz_log::install_named(&cfg.log.level, "qbzd.log");
// A headless daemon that dies of a signal leaves even less behind than // A headless daemon that dies of a signal leaves even less behind than
// the GUI does: no window, no user watching. Same reporter, same reason // the GUI does: no window, no user watching. Same reporter, same reason
// (qbz-log/src/fatal.rs). // (qbz-log/src/fatal.rs).