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
20use std::io::Write;
21use std::path::{Path, PathBuf};
22use std::sync::Mutex;
23
24use crate::agent::append_bootstrap_log;
25
26/// What became of one diagnostic line.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum Emitted {
29    /// The stream took it.
30    Written,
31    /// The stream refused it and it was appended to `logs/bootstrap.log` instead.
32    Logged,
33    /// The stream refused it and no session workdir was armed, so it went nowhere.
34    Dropped,
35}
36
37/// Where a refused line goes. `None` until [`set_diagnostic_workdir`] names a session directory.
38///
39/// Process-wide rather than threaded through every caller: a diagnostic can be raised from a
40/// spawned tokio task or a detached drain thread that holds no handle on the session.
41static FALLBACK_WORKDIR: Mutex<Option<PathBuf>> = Mutex::new(None);
42
43/// Arm the fallback with this session's directory — the one that holds `logs/`, not the user
44/// project directory a capsule sees as `"."`.
45///
46/// Called from `runtime::stage_session` as soon as it creates the session directory, and again
47/// from `runtime::launch` before the session announces itself. Setting it again replaces the
48/// previous value: a process that runs several sessions in sequence sends each session's
49/// refused lines to the session that is currently launching.
50pub fn set_diagnostic_workdir(workdir: &Path) {
51    let mut slot = FALLBACK_WORKDIR
52        .lock()
53        .unwrap_or_else(|poisoned| poisoned.into_inner());
54    *slot = Some(workdir.to_path_buf());
55}
56
57/// The armed fallback directory, or `None` when no session workdir exists yet.
58pub fn diagnostic_workdir() -> Option<PathBuf> {
59    FALLBACK_WORKDIR
60        .lock()
61        .unwrap_or_else(|poisoned| poisoned.into_inner())
62        .clone()
63}
64
65/// Write `line` and a newline to standard output.
66pub fn to_stdout(line: &str) -> Emitted {
67    let fallback = diagnostic_workdir();
68    emit_to(&mut std::io::stdout().lock(), line, fallback.as_deref())
69}
70
71/// [`to_stdout`] for a line that carries a credential, such as a door token. A line the stream
72/// refuses is [`Emitted::Dropped`], never appended to `logs/bootstrap.log`: a credential written
73/// into the session directory would outlive the one reader it was printed for.
74pub fn credential_to_stdout(line: &str) -> Emitted {
75    emit_to(&mut std::io::stdout().lock(), line, None)
76}
77
78/// Write `line` and a newline to standard error.
79pub fn to_stderr(line: &str) -> Emitted {
80    let fallback = diagnostic_workdir();
81    emit_to(&mut std::io::stderr().lock(), line, fallback.as_deref())
82}
83
84/// Write `chunk` to standard output exactly as given, adding no newline.
85///
86/// For relaying bytes that already carry their own line endings — a delegated child capsule's
87/// stdout, a rendered report.
88pub fn raw_to_stdout(chunk: &str) -> Emitted {
89    let fallback = diagnostic_workdir();
90    emit_raw_to(&mut std::io::stdout().lock(), chunk, fallback.as_deref())
91}
92
93/// [`to_stdout`]'s core, over any writer, so a test can hand it a stream that refuses writes.
94pub(crate) fn emit_to<W: Write>(out: &mut W, line: &str, fallback: Option<&Path>) -> Emitted {
95    let written = out
96        .write_all(line.as_bytes())
97        .and_then(|()| out.write_all(b"\n"))
98        .and_then(|()| out.flush());
99    match written {
100        Ok(()) => Emitted::Written,
101        Err(_) => fall_back(line, fallback),
102    }
103}
104
105/// [`raw_to_stdout`]'s core. `chunk` is written verbatim; the fallback line is it without its
106/// trailing newlines, since `append_bootstrap_log` ends every record with one of its own.
107pub(crate) fn emit_raw_to<W: Write>(out: &mut W, chunk: &str, fallback: Option<&Path>) -> Emitted {
108    let written = out.write_all(chunk.as_bytes()).and_then(|()| out.flush());
109    match written {
110        Ok(()) => Emitted::Written,
111        Err(_) => fall_back(chunk.trim_end_matches('\n'), fallback),
112    }
113}
114
115fn fall_back(line: &str, fallback: Option<&Path>) -> Emitted {
116    match fallback {
117        Some(workdir) => {
118            append_bootstrap_log(workdir, line);
119            Emitted::Logged
120        }
121        None => Emitted::Dropped,
122    }
123}
124
125/// Write a formatted line to standard output, falling back to `logs/bootstrap.log`.
126///
127/// Takes `format!` arguments and appends the newline, and evaluates to `()`, so it is a drop-in
128/// for `println!` anywhere one appears — statement or match arm. A caller that wants to know what
129/// became of the line calls [`to_stdout`] directly.
130#[macro_export]
131macro_rules! runtime_out {
132    ($($arg:tt)*) => {{
133        $crate::diagnostic::to_stdout(&::std::format!($($arg)*));
134    }};
135}
136
137/// [`runtime_out!`] for standard error, a drop-in for `eprintln!`. See [`to_stderr`].
138#[macro_export]
139macro_rules! runtime_err {
140    ($($arg:tt)*) => {{
141        $crate::diagnostic::to_stderr(&::std::format!($($arg)*));
142    }};
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use std::io::{Error, ErrorKind};
149
150    /// A stream that has gone away: every write fails the way a closed pipe fails.
151    struct BrokenPipe;
152
153    impl Write for BrokenPipe {
154        fn write(&mut self, _buf: &[u8]) -> std::io::Result<usize> {
155            Err(Error::new(
156                ErrorKind::BrokenPipe,
157                "Broken pipe (os error 32)",
158            ))
159        }
160
161        fn flush(&mut self) -> std::io::Result<()> {
162            Err(Error::new(
163                ErrorKind::BrokenPipe,
164                "Broken pipe (os error 32)",
165            ))
166        }
167    }
168
169    #[test]
170    fn refused_line_lands_in_the_bootstrap_log() {
171        let workdir = tempfile::tempdir().expect("temp workdir");
172        let outcome = emit_to(
173            &mut BrokenPipe,
174            "{\"url\":\"http://127.0.0.1:1/\"}",
175            Some(workdir.path()),
176        );
177        assert_eq!(outcome, Emitted::Logged);
178        let log = workdir.path().join("logs").join("bootstrap.log");
179        let contents = std::fs::read_to_string(&log).expect("bootstrap.log was written");
180        assert_eq!(contents, "{\"url\":\"http://127.0.0.1:1/\"}\n");
181    }
182
183    #[test]
184    fn refused_line_with_no_workdir_is_dropped() {
185        let outcome = emit_to(&mut BrokenPipe, "a warning raised during staging", None);
186        assert_eq!(outcome, Emitted::Dropped);
187    }
188
189    #[test]
190    fn accepted_line_gets_exactly_one_newline() {
191        let mut sink: Vec<u8> = Vec::new();
192        let outcome = emit_to(&mut sink, "session: ses_01", None);
193        assert_eq!(outcome, Emitted::Written);
194        assert_eq!(String::from_utf8(sink).unwrap(), "session: ses_01\n");
195    }
196
197    #[test]
198    fn raw_chunk_is_written_verbatim() {
199        let mut sink: Vec<u8> = Vec::new();
200        let outcome = emit_raw_to(&mut sink, "child line\n", None);
201        assert_eq!(outcome, Emitted::Written);
202        assert_eq!(String::from_utf8(sink).unwrap(), "child line\n");
203    }
204
205    #[test]
206    fn refused_raw_chunk_is_logged_without_a_doubled_newline() {
207        let workdir = tempfile::tempdir().expect("temp workdir");
208        let outcome = emit_raw_to(&mut BrokenPipe, "child line\n", Some(workdir.path()));
209        assert_eq!(outcome, Emitted::Logged);
210        let log = workdir.path().join("logs").join("bootstrap.log");
211        let contents = std::fs::read_to_string(&log).expect("bootstrap.log was written");
212        assert_eq!(contents, "child line\n");
213    }
214}