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