Skip to main content

capsule_runtime/
diagnostic.rs

1//! The runtime's one way to write a line to standard output or standard error.
2//!
3//! `println!` and friends panic when the write fails, and `panic = "abort"` in the release
4//! profile turns that panic into `SIGABRT` at the print site — no unwind, no destructors, no
5//! `session_end`. A reader that takes the readiness line `mur run --json` writes and then closes
6//! the pipe is the ordinary success case, and every later write to that stream fails. Nothing
7//! here panics on a write error.
8//!
9//! A line the stream refused is not discarded where it can be kept: it goes to
10//! `<records dir>/logs/bootstrap.log` through [`crate::agent::append_bootstrap_log`], the same
11//! file the rest of the runtime's bootstrap diagnostics land in. Before a session's records
12//! directory exists there is nowhere else to put it and the line is dropped — the warnings that
13//! fire during staging say so in their own doc comments.
14//!
15//! Nothing here touches the broken-pipe signal. Rust's default is to ignore it, and that default
16//! is what turns a write to a closed pipe into an `EPIPE` this module can handle rather than an
17//! immediate kill. Restoring the system default disposition would trade the abort for death by
18//! signal, which is the same loss.
19//!
20//! It is also the `mur` CLI's one way to write report output — what a short-lived command such
21//! as `mur trace show` or `mur ps` prints for its reader — through [`report_to_stdout`],
22//! [`report_to_stderr`] and the `report_println!` family. A report line has no session to fall
23//! back to, so a stream that refuses one is latched closed: every later report write to it is
24//! dropped without a syscall, and the command finishes its work and exits with its own status.
25//! A closed reader is the end of the output, not a failure. A stdout write that fails for any
26//! other reason, such as `ENOSPC` on a redirect to a full disk, is recorded once and read back
27//! with [`stdout_failure`], so the command can be reported as failed after it has finished.
28
29use std::io::{ErrorKind, Write};
30use std::path::PathBuf;
31use std::sync::atomic::{AtomicBool, Ordering};
32use std::sync::Mutex;
33
34use crate::agent::append_bootstrap_log;
35use crate::workdir_fs::WorkdirRoot;
36
37/// What became of one diagnostic line.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum Emitted {
40    /// The stream took it.
41    Written,
42    /// The stream refused it and it was appended to `logs/bootstrap.log` instead.
43    Logged,
44    /// The stream refused it and no records directory was armed, so it went nowhere.
45    Dropped,
46}
47
48/// Where a refused line goes. `None` until [`set_diagnostic_workdir`] names a records directory.
49///
50/// Process-wide rather than threaded through every caller: a diagnostic can be raised from a
51/// spawned tokio task or a detached drain thread that holds no handle on the session.
52static FALLBACK_WORKDIR: Mutex<Option<WorkdirRoot>> = Mutex::new(None);
53
54/// Arm the fallback with this session's records directory — the one that holds `logs/`, outside
55/// the workdir a capsule sees as `"."`.
56///
57/// Called from `runtime::stage_session` as soon as it creates the records directory, and again
58/// from `runtime::launch` before the session announces itself. Setting it again replaces the
59/// previous value: a process that runs several sessions in sequence sends each session's
60/// refused lines to the session that is currently launching.
61pub fn set_diagnostic_workdir(records_root: &WorkdirRoot) {
62    let mut slot = FALLBACK_WORKDIR
63        .lock()
64        .unwrap_or_else(|poisoned| poisoned.into_inner());
65    *slot = Some(records_root.clone());
66}
67
68/// The armed fallback directory's host path, or `None` when no records directory exists yet.
69pub fn diagnostic_workdir() -> Option<PathBuf> {
70    fallback_root().map(|root| root.path().to_path_buf())
71}
72
73fn fallback_root() -> Option<WorkdirRoot> {
74    FALLBACK_WORKDIR
75        .lock()
76        .unwrap_or_else(|poisoned| poisoned.into_inner())
77        .clone()
78}
79
80/// Write `line` and a newline to standard output.
81pub fn to_stdout(line: &str) -> Emitted {
82    let fallback = fallback_root();
83    emit_to(&mut std::io::stdout().lock(), line, fallback.as_ref())
84}
85
86/// [`to_stdout`] for a line that carries a credential, such as a door token. A line the stream
87/// refuses is [`Emitted::Dropped`], never appended to `logs/bootstrap.log`: a credential written
88/// into the session directory would outlive the one reader it was printed for.
89pub fn credential_to_stdout(line: &str) -> Emitted {
90    emit_to(&mut std::io::stdout().lock(), line, None)
91}
92
93/// Write `line` and a newline to standard error.
94pub fn to_stderr(line: &str) -> Emitted {
95    let fallback = fallback_root();
96    emit_to(&mut std::io::stderr().lock(), line, fallback.as_ref())
97}
98
99/// Write `chunk` to standard output exactly as given, adding no newline.
100///
101/// For relaying bytes that already carry their own line endings — a delegated child capsule's
102/// stdout, a rendered report.
103pub fn raw_to_stdout(chunk: &str) -> Emitted {
104    let fallback = fallback_root();
105    emit_raw_to(&mut std::io::stdout().lock(), chunk, fallback.as_ref())
106}
107
108/// [`to_stdout`]'s core, over any writer, so a test can hand it a stream that refuses writes.
109pub(crate) fn emit_to<W: Write>(
110    out: &mut W,
111    line: &str,
112    fallback: Option<&WorkdirRoot>,
113) -> Emitted {
114    let written = out
115        .write_all(line.as_bytes())
116        .and_then(|()| out.write_all(b"\n"))
117        .and_then(|()| out.flush());
118    match written {
119        Ok(()) => Emitted::Written,
120        Err(_) => fall_back(line, fallback),
121    }
122}
123
124/// [`raw_to_stdout`]'s core. `chunk` is written verbatim; the fallback line is it without its
125/// trailing newlines, since `append_bootstrap_log` ends every record with one of its own.
126pub(crate) fn emit_raw_to<W: Write>(
127    out: &mut W,
128    chunk: &str,
129    fallback: Option<&WorkdirRoot>,
130) -> Emitted {
131    let written = out.write_all(chunk.as_bytes()).and_then(|()| out.flush());
132    match written {
133        Ok(()) => Emitted::Written,
134        Err(_) => fall_back(chunk.trim_end_matches('\n'), fallback),
135    }
136}
137
138fn fall_back(line: &str, fallback: Option<&WorkdirRoot>) -> Emitted {
139    match fallback {
140        Some(workdir) => {
141            append_bootstrap_log(workdir, line);
142            Emitted::Logged
143        }
144        None => Emitted::Dropped,
145    }
146}
147
148/// Write a formatted line to standard output, falling back to `logs/bootstrap.log`.
149///
150/// Takes `format!` arguments and appends the newline, and evaluates to `()`, so it is a drop-in
151/// for `println!` anywhere one appears — statement or match arm. A caller that wants to know what
152/// became of the line calls [`to_stdout`] directly.
153#[macro_export]
154macro_rules! runtime_out {
155    ($($arg:tt)*) => {{
156        $crate::diagnostic::to_stdout(&::std::format!($($arg)*));
157    }};
158}
159
160/// [`runtime_out!`] for standard error, a drop-in for `eprintln!`. See [`to_stderr`].
161#[macro_export]
162macro_rules! runtime_err {
163    ($($arg:tt)*) => {{
164        $crate::diagnostic::to_stderr(&::std::format!($($arg)*));
165    }};
166}
167
168/// Set once a report write to standard output has failed; every later report write to it is
169/// dropped unattempted.
170static STDOUT_CLOSED: AtomicBool = AtomicBool::new(false);
171
172/// [`STDOUT_CLOSED`] for standard error.
173static STDERR_CLOSED: AtomicBool = AtomicBool::new(false);
174
175/// The first standard-output report error that was not `BrokenPipe`, as its display text.
176static STDOUT_FAILURE: Mutex<Option<String>> = Mutex::new(None);
177
178/// Write `bytes` verbatim to standard output as report output, and flush.
179///
180/// Never panics and never falls back to `logs/bootstrap.log`. A refused write latches standard
181/// output closed and returns [`Emitted::Dropped`]; so does every call after it, without touching
182/// the stream. A refusal other than `BrokenPipe` is also kept for [`stdout_failure`].
183pub fn report_to_stdout(bytes: &[u8]) -> Emitted {
184    if STDOUT_CLOSED.load(Ordering::Acquire) {
185        return Emitted::Dropped;
186    }
187    report_to(
188        &mut std::io::stdout().lock(),
189        bytes,
190        &STDOUT_CLOSED,
191        Some(&STDOUT_FAILURE),
192    )
193}
194
195/// [`report_to_stdout`] for standard error. A refusal of any kind only latches the stream:
196/// there is nowhere left to report it.
197pub fn report_to_stderr(bytes: &[u8]) -> Emitted {
198    if STDERR_CLOSED.load(Ordering::Acquire) {
199        return Emitted::Dropped;
200    }
201    report_to(&mut std::io::stderr().lock(), bytes, &STDERR_CLOSED, None)
202}
203
204/// Whether a report write to standard output has failed. A command rendering a long report
205/// may stop early once this is true; nothing it writes afterwards reaches the stream.
206pub fn stdout_closed() -> bool {
207    STDOUT_CLOSED.load(Ordering::Acquire)
208}
209
210/// The first report write error on standard output that was not `BrokenPipe`, or `None` when
211/// every report write succeeded or the reader merely went away.
212pub fn stdout_failure() -> Option<String> {
213    STDOUT_FAILURE
214        .lock()
215        .unwrap_or_else(|poisoned| poisoned.into_inner())
216        .clone()
217}
218
219/// [`report_to_stdout`]'s core, over any writer and any latch, so a test can hand it a refusing
220/// stream without closing the process's own.
221///
222/// `failure`, when given, receives the text of the first error that is not `BrokenPipe`.
223pub(crate) fn report_to<W: Write>(
224    out: &mut W,
225    bytes: &[u8],
226    closed: &AtomicBool,
227    failure: Option<&Mutex<Option<String>>>,
228) -> Emitted {
229    if closed.load(Ordering::Acquire) {
230        return Emitted::Dropped;
231    }
232    match out.write_all(bytes).and_then(|()| out.flush()) {
233        Ok(()) => Emitted::Written,
234        Err(error) => {
235            closed.store(true, Ordering::Release);
236            if error.kind() != ErrorKind::BrokenPipe {
237                if let Some(slot) = failure {
238                    slot.lock()
239                        .unwrap_or_else(|poisoned| poisoned.into_inner())
240                        .get_or_insert_with(|| error.to_string());
241                }
242            }
243            Emitted::Dropped
244        }
245    }
246}
247
248/// The text `report_println!` and `report_eprintln!` write: the `format!` output and one
249/// newline, or a lone newline for the empty form.
250#[doc(hidden)]
251#[macro_export]
252macro_rules! __report_line {
253    () => {
254        ::std::string::String::from("\n")
255    };
256    ($($arg:tt)*) => {{
257        let mut line = ::std::format!($($arg)*);
258        line.push('\n');
259        line
260    }};
261}
262
263/// `println!` for report output, through [`report_to_stdout`]: same arguments, same bytes on an
264/// open stream, and a closed stream drops the line instead of panicking. Evaluates to `()`.
265#[macro_export]
266macro_rules! report_println {
267    ($($arg:tt)*) => {{
268        $crate::diagnostic::report_to_stdout($crate::__report_line!($($arg)*).as_bytes());
269    }};
270}
271
272/// `print!` for report output, through [`report_to_stdout`]. Flushes on every call.
273#[macro_export]
274macro_rules! report_print {
275    ($($arg:tt)*) => {{
276        $crate::diagnostic::report_to_stdout(::std::format!($($arg)*).as_bytes());
277    }};
278}
279
280/// `eprintln!` for report output, through [`report_to_stderr`].
281#[macro_export]
282macro_rules! report_eprintln {
283    ($($arg:tt)*) => {{
284        $crate::diagnostic::report_to_stderr($crate::__report_line!($($arg)*).as_bytes());
285    }};
286}
287
288/// `eprint!` for report output, through [`report_to_stderr`].
289#[macro_export]
290macro_rules! report_eprint {
291    ($($arg:tt)*) => {{
292        $crate::diagnostic::report_to_stderr(::std::format!($($arg)*).as_bytes());
293    }};
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use std::io::{Error, ErrorKind};
300
301    /// A stream that refuses every write and flush with `kind`, counting each call.
302    /// `ErrorKind::BrokenPipe` is a stream whose reader has gone away.
303    struct Refusing {
304        kind: ErrorKind,
305        calls: usize,
306    }
307
308    impl Refusing {
309        fn new(kind: ErrorKind) -> Self {
310            Self { kind, calls: 0 }
311        }
312    }
313
314    impl Write for Refusing {
315        fn write(&mut self, _buf: &[u8]) -> std::io::Result<usize> {
316            self.calls += 1;
317            Err(Error::new(self.kind, "refused"))
318        }
319
320        fn flush(&mut self) -> std::io::Result<()> {
321            self.calls += 1;
322            Err(Error::new(self.kind, "refused"))
323        }
324    }
325
326    #[test]
327    fn refused_line_lands_in_the_bootstrap_log() {
328        let workdir = tempfile::tempdir().expect("temp workdir");
329        let outcome = emit_to(
330            &mut Refusing::new(ErrorKind::BrokenPipe),
331            "{\"url\":\"http://127.0.0.1:1/\"}",
332            Some(&WorkdirRoot::open(workdir.path()).unwrap()),
333        );
334        assert_eq!(outcome, Emitted::Logged);
335        let log = workdir.path().join("logs").join("bootstrap.log");
336        let contents = std::fs::read_to_string(&log).expect("bootstrap.log was written");
337        assert_eq!(contents, "{\"url\":\"http://127.0.0.1:1/\"}\n");
338    }
339
340    #[test]
341    fn refused_line_with_no_workdir_is_dropped() {
342        let outcome = emit_to(
343            &mut Refusing::new(ErrorKind::BrokenPipe),
344            "a warning raised during staging",
345            None,
346        );
347        assert_eq!(outcome, Emitted::Dropped);
348    }
349
350    #[test]
351    fn accepted_line_gets_exactly_one_newline() {
352        let mut sink: Vec<u8> = Vec::new();
353        let outcome = emit_to(&mut sink, "session: ses_01", None);
354        assert_eq!(outcome, Emitted::Written);
355        assert_eq!(String::from_utf8(sink).unwrap(), "session: ses_01\n");
356    }
357
358    #[test]
359    fn raw_chunk_is_written_verbatim() {
360        let mut sink: Vec<u8> = Vec::new();
361        let outcome = emit_raw_to(&mut sink, "child line\n", None);
362        assert_eq!(outcome, Emitted::Written);
363        assert_eq!(String::from_utf8(sink).unwrap(), "child line\n");
364    }
365
366    #[test]
367    fn refused_raw_chunk_is_logged_without_a_doubled_newline() {
368        let workdir = tempfile::tempdir().expect("temp workdir");
369        let outcome = emit_raw_to(
370            &mut Refusing::new(ErrorKind::BrokenPipe),
371            "child line\n",
372            Some(&WorkdirRoot::open(workdir.path()).unwrap()),
373        );
374        assert_eq!(outcome, Emitted::Logged);
375        let log = workdir.path().join("logs").join("bootstrap.log");
376        let contents = std::fs::read_to_string(&log).expect("bootstrap.log was written");
377        assert_eq!(contents, "child line\n");
378    }
379
380    #[test]
381    fn report_over_a_broken_pipe_latches_and_stops_writing() {
382        let closed = AtomicBool::new(false);
383        let failure = Mutex::new(None);
384        let mut out = Refusing::new(ErrorKind::BrokenPipe);
385        let first = report_to(&mut out, b"row one\n", &closed, Some(&failure));
386        assert_eq!(first, Emitted::Dropped);
387        assert!(closed.load(Ordering::Acquire));
388        assert_eq!(*failure.lock().unwrap(), None);
389        let calls_after_first = out.calls;
390        assert!(calls_after_first > 0);
391
392        let second = report_to(&mut out, b"row two\n", &closed, Some(&failure));
393        assert_eq!(second, Emitted::Dropped);
394        assert_eq!(
395            out.calls, calls_after_first,
396            "a latched stream is not written"
397        );
398    }
399
400    #[test]
401    fn report_over_a_full_disk_latches_and_records_the_error() {
402        let closed = AtomicBool::new(false);
403        let failure = Mutex::new(None);
404        let mut out = Refusing::new(ErrorKind::StorageFull);
405        let outcome = report_to(&mut out, b"row\n", &closed, Some(&failure));
406        assert_eq!(outcome, Emitted::Dropped);
407        assert!(closed.load(Ordering::Acquire));
408        assert_eq!(failure.lock().unwrap().as_deref(), Some("refused"));
409    }
410
411    #[test]
412    fn report_over_an_open_stream_writes_the_formatted_bytes() {
413        let cases: [(String, &str); 4] = [
414            (crate::__report_line!("{} rows", 3), "3 rows\n"),
415            (crate::__report_line!(), "\n"),
416            (::std::format!("value for {}: ", "KEY"), "value for KEY: "),
417            (crate::__report_line!("{:>4}|", 7), "   7|\n"),
418        ];
419        for (text, expected) in cases {
420            let closed = AtomicBool::new(false);
421            let mut sink: Vec<u8> = Vec::new();
422            let outcome = report_to(&mut sink, text.as_bytes(), &closed, None);
423            assert_eq!(outcome, Emitted::Written);
424            assert_eq!(String::from_utf8(sink).unwrap(), expected);
425            assert!(!closed.load(Ordering::Acquire));
426        }
427    }
428}