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/// Set by [`send_stdout_to_stderr`]: every line this module would write to standard output goes to
81/// standard error instead, so a command printing one machine-readable document keeps standard
82/// output for it alone.
83static STDOUT_TO_STDERR: AtomicBool = AtomicBool::new(false);
84
85/// From now on, for the rest of the process, send every line this module writes to standard output
86/// — [`to_stdout`], [`credential_to_stdout`], [`raw_to_stdout`], [`report_to_stdout`] and the
87/// macros over them — to standard error instead.
88///
89/// For a command whose `--json` form prints one document on standard output while its text report
90/// still runs: the document goes out through [`document_to_stdout`], which ignores this switch.
91pub fn send_stdout_to_stderr() {
92    STDOUT_TO_STDERR.store(true, Ordering::Release);
93}
94
95fn stdout_goes_to_stderr() -> bool {
96    STDOUT_TO_STDERR.load(Ordering::Acquire)
97}
98
99/// Write `document` verbatim to the real standard output, whatever [`send_stdout_to_stderr`] set,
100/// with the same refusal handling as [`report_to_stdout`].
101pub fn document_to_stdout(document: &str) -> Emitted {
102    if STDOUT_CLOSED.load(Ordering::Acquire) {
103        return Emitted::Dropped;
104    }
105    report_to(
106        &mut std::io::stdout().lock(),
107        document.as_bytes(),
108        &STDOUT_CLOSED,
109        Some(&STDOUT_FAILURE),
110    )
111}
112
113/// Write `line` and a newline to standard output.
114pub fn to_stdout(line: &str) -> Emitted {
115    if stdout_goes_to_stderr() {
116        return to_stderr(line);
117    }
118    let fallback = fallback_root();
119    emit_to(&mut std::io::stdout().lock(), line, fallback.as_ref())
120}
121
122/// [`to_stdout`] for a line that carries a credential, such as a door token. A line the stream
123/// refuses is [`Emitted::Dropped`], never appended to `logs/bootstrap.log`: a credential written
124/// into the session directory would outlive the one reader it was printed for.
125pub fn credential_to_stdout(line: &str) -> Emitted {
126    if stdout_goes_to_stderr() {
127        return emit_to(&mut std::io::stderr().lock(), line, None);
128    }
129    emit_to(&mut std::io::stdout().lock(), line, None)
130}
131
132/// Write `line` and a newline to standard error.
133pub fn to_stderr(line: &str) -> Emitted {
134    let fallback = fallback_root();
135    emit_to(&mut std::io::stderr().lock(), line, fallback.as_ref())
136}
137
138/// Write `chunk` to standard output exactly as given, adding no newline.
139///
140/// For relaying bytes that already carry their own line endings — a delegated child capsule's
141/// stdout, a rendered report.
142pub fn raw_to_stdout(chunk: &str) -> Emitted {
143    let fallback = fallback_root();
144    if stdout_goes_to_stderr() {
145        return emit_raw_to(&mut std::io::stderr().lock(), chunk, fallback.as_ref());
146    }
147    emit_raw_to(&mut std::io::stdout().lock(), chunk, fallback.as_ref())
148}
149
150/// [`to_stdout`]'s core, over any writer, so a test can hand it a stream that refuses writes.
151pub(crate) fn emit_to<W: Write>(
152    out: &mut W,
153    line: &str,
154    fallback: Option<&WorkdirRoot>,
155) -> Emitted {
156    let written = out
157        .write_all(line.as_bytes())
158        .and_then(|()| out.write_all(b"\n"))
159        .and_then(|()| out.flush());
160    match written {
161        Ok(()) => Emitted::Written,
162        Err(_) => fall_back(line, fallback),
163    }
164}
165
166/// [`raw_to_stdout`]'s core. `chunk` is written verbatim; the fallback line is it without its
167/// trailing newlines, since `append_bootstrap_log` ends every record with one of its own.
168pub(crate) fn emit_raw_to<W: Write>(
169    out: &mut W,
170    chunk: &str,
171    fallback: Option<&WorkdirRoot>,
172) -> Emitted {
173    let written = out.write_all(chunk.as_bytes()).and_then(|()| out.flush());
174    match written {
175        Ok(()) => Emitted::Written,
176        Err(_) => fall_back(chunk.trim_end_matches('\n'), fallback),
177    }
178}
179
180fn fall_back(line: &str, fallback: Option<&WorkdirRoot>) -> Emitted {
181    match fallback {
182        Some(workdir) => {
183            append_bootstrap_log(workdir, line);
184            Emitted::Logged
185        }
186        None => Emitted::Dropped,
187    }
188}
189
190/// Write a formatted line to standard output, falling back to `logs/bootstrap.log`.
191///
192/// Takes `format!` arguments and appends the newline, and evaluates to `()`, so it is a drop-in
193/// for `println!` anywhere one appears — statement or match arm. A caller that wants to know what
194/// became of the line calls [`to_stdout`] directly.
195#[macro_export]
196macro_rules! runtime_out {
197    ($($arg:tt)*) => {{
198        $crate::diagnostic::to_stdout(&::std::format!($($arg)*));
199    }};
200}
201
202/// [`runtime_out!`] for standard error, a drop-in for `eprintln!`. See [`to_stderr`].
203#[macro_export]
204macro_rules! runtime_err {
205    ($($arg:tt)*) => {{
206        $crate::diagnostic::to_stderr(&::std::format!($($arg)*));
207    }};
208}
209
210/// Set once a report write to standard output has failed; every later report write to it is
211/// dropped unattempted.
212static STDOUT_CLOSED: AtomicBool = AtomicBool::new(false);
213
214/// [`STDOUT_CLOSED`] for standard error.
215static STDERR_CLOSED: AtomicBool = AtomicBool::new(false);
216
217/// The first standard-output report error that was not `BrokenPipe`, as its display text.
218static STDOUT_FAILURE: Mutex<Option<String>> = Mutex::new(None);
219
220/// Write `bytes` verbatim to standard output as report output, and flush.
221///
222/// Never panics and never falls back to `logs/bootstrap.log`. A refused write latches standard
223/// output closed and returns [`Emitted::Dropped`]; so does every call after it, without touching
224/// the stream. A refusal other than `BrokenPipe` is also kept for [`stdout_failure`].
225pub fn report_to_stdout(bytes: &[u8]) -> Emitted {
226    if stdout_goes_to_stderr() {
227        return report_to_stderr(bytes);
228    }
229    if STDOUT_CLOSED.load(Ordering::Acquire) {
230        return Emitted::Dropped;
231    }
232    report_to(
233        &mut std::io::stdout().lock(),
234        bytes,
235        &STDOUT_CLOSED,
236        Some(&STDOUT_FAILURE),
237    )
238}
239
240/// [`report_to_stdout`] for standard error. A refusal of any kind only latches the stream:
241/// there is nowhere left to report it.
242pub fn report_to_stderr(bytes: &[u8]) -> Emitted {
243    if STDERR_CLOSED.load(Ordering::Acquire) {
244        return Emitted::Dropped;
245    }
246    report_to(&mut std::io::stderr().lock(), bytes, &STDERR_CLOSED, None)
247}
248
249/// Whether a report write to standard output has failed. A command rendering a long report
250/// may stop early once this is true; nothing it writes afterwards reaches the stream.
251pub fn stdout_closed() -> bool {
252    STDOUT_CLOSED.load(Ordering::Acquire)
253}
254
255/// The first report write error on standard output that was not `BrokenPipe`, or `None` when
256/// every report write succeeded or the reader merely went away.
257pub fn stdout_failure() -> Option<String> {
258    STDOUT_FAILURE
259        .lock()
260        .unwrap_or_else(|poisoned| poisoned.into_inner())
261        .clone()
262}
263
264/// [`report_to_stdout`]'s core, over any writer and any latch, so a test can hand it a refusing
265/// stream without closing the process's own.
266///
267/// `failure`, when given, receives the text of the first error that is not `BrokenPipe`.
268pub(crate) fn report_to<W: Write>(
269    out: &mut W,
270    bytes: &[u8],
271    closed: &AtomicBool,
272    failure: Option<&Mutex<Option<String>>>,
273) -> Emitted {
274    if closed.load(Ordering::Acquire) {
275        return Emitted::Dropped;
276    }
277    match out.write_all(bytes).and_then(|()| out.flush()) {
278        Ok(()) => Emitted::Written,
279        Err(error) => {
280            closed.store(true, Ordering::Release);
281            if error.kind() != ErrorKind::BrokenPipe {
282                if let Some(slot) = failure {
283                    slot.lock()
284                        .unwrap_or_else(|poisoned| poisoned.into_inner())
285                        .get_or_insert_with(|| error.to_string());
286                }
287            }
288            Emitted::Dropped
289        }
290    }
291}
292
293/// The text `report_println!` and `report_eprintln!` write: the `format!` output and one
294/// newline, or a lone newline for the empty form.
295#[doc(hidden)]
296#[macro_export]
297macro_rules! __report_line {
298    () => {
299        ::std::string::String::from("\n")
300    };
301    ($($arg:tt)*) => {{
302        let mut line = ::std::format!($($arg)*);
303        line.push('\n');
304        line
305    }};
306}
307
308/// `println!` for report output, through [`report_to_stdout`]: same arguments, same bytes on an
309/// open stream, and a closed stream drops the line instead of panicking. Evaluates to `()`.
310#[macro_export]
311macro_rules! report_println {
312    ($($arg:tt)*) => {{
313        $crate::diagnostic::report_to_stdout($crate::__report_line!($($arg)*).as_bytes());
314    }};
315}
316
317/// `print!` for report output, through [`report_to_stdout`]. Flushes on every call.
318#[macro_export]
319macro_rules! report_print {
320    ($($arg:tt)*) => {{
321        $crate::diagnostic::report_to_stdout(::std::format!($($arg)*).as_bytes());
322    }};
323}
324
325/// `eprintln!` for report output, through [`report_to_stderr`].
326#[macro_export]
327macro_rules! report_eprintln {
328    ($($arg:tt)*) => {{
329        $crate::diagnostic::report_to_stderr($crate::__report_line!($($arg)*).as_bytes());
330    }};
331}
332
333/// `eprint!` for report output, through [`report_to_stderr`].
334#[macro_export]
335macro_rules! report_eprint {
336    ($($arg:tt)*) => {{
337        $crate::diagnostic::report_to_stderr(::std::format!($($arg)*).as_bytes());
338    }};
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use std::io::{Error, ErrorKind};
345
346    /// A stream that refuses every write and flush with `kind`, counting each call.
347    /// `ErrorKind::BrokenPipe` is a stream whose reader has gone away.
348    struct Refusing {
349        kind: ErrorKind,
350        calls: usize,
351    }
352
353    impl Refusing {
354        fn new(kind: ErrorKind) -> Self {
355            Self { kind, calls: 0 }
356        }
357    }
358
359    impl Write for Refusing {
360        fn write(&mut self, _buf: &[u8]) -> std::io::Result<usize> {
361            self.calls += 1;
362            Err(Error::new(self.kind, "refused"))
363        }
364
365        fn flush(&mut self) -> std::io::Result<()> {
366            self.calls += 1;
367            Err(Error::new(self.kind, "refused"))
368        }
369    }
370
371    #[test]
372    fn refused_line_lands_in_the_bootstrap_log() {
373        let workdir = tempfile::tempdir().expect("temp workdir");
374        let outcome = emit_to(
375            &mut Refusing::new(ErrorKind::BrokenPipe),
376            "{\"url\":\"http://127.0.0.1:1/\"}",
377            Some(&WorkdirRoot::open(workdir.path()).unwrap()),
378        );
379        assert_eq!(outcome, Emitted::Logged);
380        let log = workdir.path().join("logs").join("bootstrap.log");
381        let contents = std::fs::read_to_string(&log).expect("bootstrap.log was written");
382        assert_eq!(contents, "{\"url\":\"http://127.0.0.1:1/\"}\n");
383    }
384
385    #[test]
386    fn refused_line_with_no_workdir_is_dropped() {
387        let outcome = emit_to(
388            &mut Refusing::new(ErrorKind::BrokenPipe),
389            "a warning raised during staging",
390            None,
391        );
392        assert_eq!(outcome, Emitted::Dropped);
393    }
394
395    #[test]
396    fn accepted_line_gets_exactly_one_newline() {
397        let mut sink: Vec<u8> = Vec::new();
398        let outcome = emit_to(&mut sink, "session: ses_01", None);
399        assert_eq!(outcome, Emitted::Written);
400        assert_eq!(String::from_utf8(sink).unwrap(), "session: ses_01\n");
401    }
402
403    #[test]
404    fn raw_chunk_is_written_verbatim() {
405        let mut sink: Vec<u8> = Vec::new();
406        let outcome = emit_raw_to(&mut sink, "child line\n", None);
407        assert_eq!(outcome, Emitted::Written);
408        assert_eq!(String::from_utf8(sink).unwrap(), "child line\n");
409    }
410
411    #[test]
412    fn refused_raw_chunk_is_logged_without_a_doubled_newline() {
413        let workdir = tempfile::tempdir().expect("temp workdir");
414        let outcome = emit_raw_to(
415            &mut Refusing::new(ErrorKind::BrokenPipe),
416            "child line\n",
417            Some(&WorkdirRoot::open(workdir.path()).unwrap()),
418        );
419        assert_eq!(outcome, Emitted::Logged);
420        let log = workdir.path().join("logs").join("bootstrap.log");
421        let contents = std::fs::read_to_string(&log).expect("bootstrap.log was written");
422        assert_eq!(contents, "child line\n");
423    }
424
425    #[test]
426    fn report_over_a_broken_pipe_latches_and_stops_writing() {
427        let closed = AtomicBool::new(false);
428        let failure = Mutex::new(None);
429        let mut out = Refusing::new(ErrorKind::BrokenPipe);
430        let first = report_to(&mut out, b"row one\n", &closed, Some(&failure));
431        assert_eq!(first, Emitted::Dropped);
432        assert!(closed.load(Ordering::Acquire));
433        assert_eq!(*failure.lock().unwrap(), None);
434        let calls_after_first = out.calls;
435        assert!(calls_after_first > 0);
436
437        let second = report_to(&mut out, b"row two\n", &closed, Some(&failure));
438        assert_eq!(second, Emitted::Dropped);
439        assert_eq!(
440            out.calls, calls_after_first,
441            "a latched stream is not written"
442        );
443    }
444
445    #[test]
446    fn report_over_a_full_disk_latches_and_records_the_error() {
447        let closed = AtomicBool::new(false);
448        let failure = Mutex::new(None);
449        let mut out = Refusing::new(ErrorKind::StorageFull);
450        let outcome = report_to(&mut out, b"row\n", &closed, Some(&failure));
451        assert_eq!(outcome, Emitted::Dropped);
452        assert!(closed.load(Ordering::Acquire));
453        assert_eq!(failure.lock().unwrap().as_deref(), Some("refused"));
454    }
455
456    #[test]
457    fn report_over_an_open_stream_writes_the_formatted_bytes() {
458        let cases: [(String, &str); 4] = [
459            (crate::__report_line!("{} rows", 3), "3 rows\n"),
460            (crate::__report_line!(), "\n"),
461            (::std::format!("value for {}: ", "KEY"), "value for KEY: "),
462            (crate::__report_line!("{:>4}|", 7), "   7|\n"),
463        ];
464        for (text, expected) in cases {
465            let closed = AtomicBool::new(false);
466            let mut sink: Vec<u8> = Vec::new();
467            let outcome = report_to(&mut sink, text.as_bytes(), &closed, None);
468            assert_eq!(outcome, Emitted::Written);
469            assert_eq!(String::from_utf8(sink).unwrap(), expected);
470            assert!(!closed.load(Ordering::Acquire));
471        }
472    }
473}