recursive/logging.rs
1//! Tracing writer that suppresses output while the TUI is active.
2//!
3//! ## Why this exists
4//!
5//! `main.rs` initialises its `tracing-subscriber` *before* the TUI
6//! takes over the terminal. After the TUI enters its alternate
7//! screen, every `tracing::info!` / `tracing::warn!` is still being
8//! written to the stderr file descriptor — which is the same
9//! descriptor the alternate screen is rendered onto, so the log
10//! lines land *inside* the TUI surface (typically right next to
11//! the input box, since that's where the cursor was last parked by
12//! `terminal.draw`). The user sees stray log lines like
13//! `2026-06-03T… INFO agent.turn: finished steps=2 …` rendered
14//! inside their input box.
15//!
16//! ## How it works
17//!
18//! The `TUI_QUIET` atomic flag flips to `true` while the TUI is
19//! running. The `StderrOrNull` writer short-circuits to a no-op
20//! while the flag is set, and falls back to `std::io::stderr`
21//! otherwise. The TUI's `run()` function takes an RAII guard via
22//! [`suppress_tracing_for_tui`] so the flag is restored on every
23//! exit path — including panics — letting the user still see the
24//! panic message on stderr after `LeaveAlternateScreen`.
25//!
26//! ## What about user-visible logs during a TUI session?
27//!
28//! Things the user actually wants to see during a session (hook
29//! progress, the spinner verb, etc.) flow through the TUI's own
30//! event sink, not the global subscriber. If you need a brand-new
31//! `tracing::info!` line to be visible during a TUI session, push
32//! it onto the sink as a `UiEvent` instead.
33
34use std::io;
35use std::sync::atomic::{AtomicBool, Ordering};
36
37#[cfg(feature = "cli")]
38use tracing_subscriber::fmt::MakeWriter;
39
40static TUI_QUIET: AtomicBool = AtomicBool::new(false);
41
42/// Mark the global tracing writer as suppressed (TUI active) or
43/// normal. Called by `tui::run()` via the RAII guard below; exposed
44/// publicly in case other long-running surfaces (e.g. an HTTP
45/// server's interactive console) want the same behaviour.
46pub fn set_tui_quiet(quiet: bool) {
47 TUI_QUIET.store(quiet, Ordering::Relaxed);
48}
49
50/// Returns the current TUI-quiet state. Exposed for tests.
51pub fn is_tui_quiet() -> bool {
52 TUI_QUIET.load(Ordering::Relaxed)
53}
54
55/// RAII guard that flips [`set_tui_quiet(false)`] on drop, ensuring
56/// the global tracing writer is restored even if the TUI panics
57/// partway through.
58pub struct TuiQuietGuard;
59
60impl Drop for TuiQuietGuard {
61 fn drop(&mut self) {
62 set_tui_quiet(false);
63 }
64}
65
66/// Acquire a guard that holds the tracing writer in the suppressed
67/// state. When the guard is dropped the writer is restored. Use
68/// this at the very top of `tui::run()` (before `enable_raw_mode`).
69pub fn suppress_tracing_for_tui() -> TuiQuietGuard {
70 set_tui_quiet(true);
71 TuiQuietGuard
72}
73
74/// `io::Write` adapter that drops every byte while the TUI is
75/// active and otherwise mirrors `std::io::stderr`.
76#[derive(Debug)]
77pub struct StderrOrNull;
78
79impl io::Write for StderrOrNull {
80 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
81 if TUI_QUIET.load(Ordering::Relaxed) {
82 // Pretend the write succeeded so the caller's formatting
83 // does not error out. We do not count the suppressed
84 // bytes because tracing does not care.
85 return Ok(buf.len());
86 }
87 let mut handle = std::io::stderr().lock();
88 handle.write(buf)
89 }
90
91 fn flush(&mut self) -> io::Result<()> {
92 if TUI_QUIET.load(Ordering::Relaxed) {
93 return Ok(());
94 }
95 std::io::stderr().flush()
96 }
97}
98
99/// `MakeWriter` factory that hands out `StderrOrNull` writers. The
100/// `tracing_subscriber::fmt` layer accepts this in `with_writer`.
101#[derive(Clone, Debug)]
102pub struct StderrOrNullMaker;
103
104#[cfg(feature = "cli")]
105impl<'a> MakeWriter<'a> for StderrOrNullMaker {
106 type Writer = StderrOrNull;
107 fn make_writer(&'a self) -> Self::Writer {
108 StderrOrNull
109 }
110}
111
112#[cfg(test)]
113mod tests {
114 use super::*;
115 use std::io::Write;
116 use std::sync::{Mutex, MutexGuard};
117
118 // `TUI_QUIET` is a process-global `AtomicBool`. Every test in this module
119 // mutates it (`set_tui_quiet` / `suppress_tracing_for_tui`) and several
120 // assert its value. Run in parallel (cargo test's default) they race: one
121 // test's `set_tui_quiet(true)` flips the flag while another is in the
122 // middle of `assert!(!is_tui_quiet())`, producing intermittent failures
123 // (observed blocking the g331 land-preserve run on 2026-07-24). Acquire
124 // this lock at the top of every test that touches the global so the whole
125 // module runs serially. (Goal 345 hardening)
126 static QUIET_TEST_LOCK: Mutex<()> = Mutex::new(());
127 fn quiet_test_lock() -> MutexGuard<'static, ()> {
128 QUIET_TEST_LOCK.lock().expect("quiet test lock poisoned")
129 }
130
131 #[test]
132 fn stderr_or_null_drops_bytes_when_quiet() {
133 let _l = quiet_test_lock();
134 set_tui_quiet(true);
135 let mut w = StderrOrNull;
136 let n = w.write(b"hello").unwrap();
137 assert_eq!(n, 5);
138 w.flush().unwrap();
139 set_tui_quiet(false);
140 }
141
142 #[test]
143 fn guard_restores_quiet_state_on_drop() {
144 let _l = quiet_test_lock();
145 assert!(!is_tui_quiet());
146 {
147 let _g = suppress_tracing_for_tui();
148 assert!(is_tui_quiet());
149 }
150 assert!(!is_tui_quiet());
151 }
152
153 #[test]
154 fn set_tui_quiet_stores_given_value() {
155 let _l = quiet_test_lock();
156 // kills `store(!quiet, ...)` or `store(true, ...)` mutations in set_tui_quiet
157 set_tui_quiet(true);
158 assert!(is_tui_quiet(), "must be quiet after set_tui_quiet(true)");
159 set_tui_quiet(false);
160 assert!(
161 !is_tui_quiet(),
162 "must not be quiet after set_tui_quiet(false)"
163 );
164 }
165
166 #[test]
167 fn guard_restores_even_if_another_guard_already_dropped() {
168 let _l = quiet_test_lock();
169 // Two nested guards; the inner drop will flip the flag to
170 // false even though the outer is still alive. The guard is
171 // single-shot, not reference-counted. The pragmatic
172 // consequence is that *the outermost* guard's drop is the
173 // only one that matters, and nested calls should be
174 // avoided. We document the behaviour here so the test
175 // catches any future refactor that tries to make this
176 // re-entrant.
177 let _outer = suppress_tracing_for_tui();
178 assert!(is_tui_quiet());
179 {
180 let _inner = suppress_tracing_for_tui();
181 assert!(is_tui_quiet());
182 }
183 assert!(
184 !is_tui_quiet(),
185 "inner guard's drop already cleared the flag (documented)"
186 );
187 }
188
189 #[test]
190 fn stderr_or_null_write_not_quiet_returns_byte_count() {
191 let _l = quiet_test_lock();
192 // kills branch mutation: write path when TUI_QUIET is false must call
193 // the real stderr, not the short-circuit return.
194 set_tui_quiet(false);
195 let mut w = StderrOrNull;
196 // Write empty slice to avoid polluting test output; byte count must still match.
197 let n = w.write(b"").unwrap();
198 assert_eq!(n, 0, "writing 0 bytes while not quiet must return 0");
199 }
200
201 #[test]
202 fn stderr_or_null_flush_not_quiet_succeeds() {
203 let _l = quiet_test_lock();
204 // kills `if TUI_QUIET.load(...)` guard removal mutation in flush():
205 // when not quiet, flush must succeed (delegates to real stderr).
206 set_tui_quiet(false);
207 let mut w = StderrOrNull;
208 assert!(w.flush().is_ok(), "flush() when not quiet must succeed");
209 }
210
211 #[test]
212 fn suppress_tracing_sets_quiet_immediately() {
213 let _l = quiet_test_lock();
214 // kills `set_tui_quiet(true)` → `set_tui_quiet(false)` mutation
215 // in suppress_tracing_for_tui before the guard is dropped.
216 set_tui_quiet(false);
217 let _g = suppress_tracing_for_tui();
218 assert!(
219 is_tui_quiet(),
220 "suppress_tracing_for_tui must set quiet=true before returning"
221 );
222 drop(_g);
223 assert!(
224 !is_tui_quiet(),
225 "dropping the guard must restore quiet=false"
226 );
227 }
228}