From ecff75d514c97841bd41bc8715ff76fe1cb71af9 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Sat, 1 Aug 2026 09:38:04 +0200 Subject: [PATCH] fix(daemon): log fatal startup errors to daemon.log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A daemon that fails during startup reported the reason via eprintln! in main's error arm. Under launchd stderr is discarded, so the failure was invisible: daemon.log was created and left empty — exactly the case the log file was added for. Found by the manual acceptance run: the launchd agent loaded correctly (RunAtLoad fired, runs=9) but every spawn exited 1 because a previously started daemon held the pidfile, and nothing recorded why. Fatal errors on the daemon path now go through tracing::error!, reaching both the log file and stderr. Other subcommands keep eprintln!, since their stderr is the user's terminal. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EGntTHCW3sEPy1VBRopNNp --- crates/xy/src/main.rs | 15 ++++++++++++--- crates/xy/tests/daemon_log.rs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) create mode 100644 crates/xy/tests/daemon_log.rs diff --git a/crates/xy/src/main.rs b/crates/xy/src/main.rs index ade518d..ca94c02 100644 --- a/crates/xy/src/main.rs +++ b/crates/xy/src/main.rs @@ -92,7 +92,9 @@ async fn main() -> std::process::ExitCode { let filter = tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")); - if matches!(cli.cmd, Cmd::Daemon) { + let is_daemon = matches!(cli.cmd, Cmd::Daemon); + + if is_daemon { use tracing_subscriber::fmt::writer::MakeWriterExt; if let Err(err) = paths.ensure_dirs() { @@ -134,8 +136,15 @@ async fn main() -> std::process::ExitCode { match result { Ok(code) => std::process::ExitCode::from(code as u8), - Err(e) => { - eprintln!("xy: {e:#}"); + Err(err) => { + // Under launchd the daemon's stderr is discarded, so a fatal + // startup error is only diagnosable if it reaches the log file. + if is_daemon { + tracing::error!("{err:#}"); + } else { + eprintln!("xy: {err:#}"); + } + std::process::ExitCode::from(1) } } diff --git a/crates/xy/tests/daemon_log.rs b/crates/xy/tests/daemon_log.rs new file mode 100644 index 0000000..a51d212 --- /dev/null +++ b/crates/xy/tests/daemon_log.rs @@ -0,0 +1,30 @@ +mod common; +use common::*; + +#[tokio::test] +async fn fatal_startup_error_is_written_to_daemon_log() { + let xy = xy_bin(); + let mut h = Harness::new(); + + h.start_daemon(&xy).await; + + let log = h.state_dir.join("logs/daemon.log"); + + let before = std::fs::read_to_string(&log).unwrap_or_default(); + + assert!( + !before.contains("another xy daemon"), + "log already reports contention before the second daemon ran: {before}" + ); + + let (code, _out, _err) = h.run_cli(&xy, &["daemon"]).await; + + assert_eq!(code, 1, "second daemon should exit 1 on pidfile contention"); + + let after = std::fs::read_to_string(&log).expect("daemon.log should exist"); + + assert!( + after.contains("another xy daemon"), + "daemon.log must record why the daemon refused to start, got: {after:?}" + ); +}