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/// Write `line` and a newline to standard error.
72pub fn to_stderr(line: &str) -> Emitted {
73    let fallback = diagnostic_workdir();
74    emit_to(&mut std::io::stderr().lock(), line, fallback.as_deref())
75}
76
77/// Write `chunk` to standard output exactly as given, adding no newline.
78///
79/// For relaying bytes that already carry their own line endings — a delegated child capsule's
80/// stdout, a rendered report.
81pub fn raw_to_stdout(chunk: &str) -> Emitted {
82    let fallback = diagnostic_workdir();
83    emit_raw_to(&mut std::io::stdout().lock(), chunk, fallback.as_deref())
84}
85
86/// [`to_stdout`]'s core, over any writer, so a test can hand it a stream that refuses writes.
87pub(crate) fn emit_to<W: Write>(out: &mut W, line: &str, fallback: Option<&Path>) -> Emitted {
88    let written = out
89        .write_all(line.as_bytes())
90        .and_then(|()| out.write_all(b"\n"))
91        .and_then(|()| out.flush());
92    match written {
93        Ok(()) => Emitted::Written,
94        Err(_) => fall_back(line, fallback),
95    }
96}
97
98/// [`raw_to_stdout`]'s core. `chunk` is written verbatim; the fallback line is it without its
99/// trailing newlines, since `append_bootstrap_log` ends every record with one of its own.
100pub(crate) fn emit_raw_to<W: Write>(out: &mut W, chunk: &str, fallback: Option<&Path>) -> Emitted {
101    let written = out.write_all(chunk.as_bytes()).and_then(|()| out.flush());
102    match written {
103        Ok(()) => Emitted::Written,
104        Err(_) => fall_back(chunk.trim_end_matches('\n'), fallback),
105    }
106}
107
108fn fall_back(line: &str, fallback: Option<&Path>) -> Emitted {
109    match fallback {
110        Some(workdir) => {
111            append_bootstrap_log(workdir, line);
112            Emitted::Logged
113        }
114        None => Emitted::Dropped,
115    }
116}
117
118/// Write a formatted line to standard output, falling back to `logs/bootstrap.log`.
119///
120/// Takes `format!` arguments and appends the newline, and evaluates to `()`, so it is a drop-in
121/// for `println!` anywhere one appears — statement or match arm. A caller that wants to know what
122/// became of the line calls [`to_stdout`] directly.
123#[macro_export]
124macro_rules! runtime_out {
125    ($($arg:tt)*) => {{
126        $crate::diagnostic::to_stdout(&::std::format!($($arg)*));
127    }};
128}
129
130/// [`runtime_out!`] for standard error, a drop-in for `eprintln!`. See [`to_stderr`].
131#[macro_export]
132macro_rules! runtime_err {
133    ($($arg:tt)*) => {{
134        $crate::diagnostic::to_stderr(&::std::format!($($arg)*));
135    }};
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use std::io::{Error, ErrorKind};
142
143    /// A stream that has gone away: every write fails the way a closed pipe fails.
144    struct BrokenPipe;
145
146    impl Write for BrokenPipe {
147        fn write(&mut self, _buf: &[u8]) -> std::io::Result<usize> {
148            Err(Error::new(
149                ErrorKind::BrokenPipe,
150                "Broken pipe (os error 32)",
151            ))
152        }
153
154        fn flush(&mut self) -> std::io::Result<()> {
155            Err(Error::new(
156                ErrorKind::BrokenPipe,
157                "Broken pipe (os error 32)",
158            ))
159        }
160    }
161
162    #[test]
163    fn refused_line_lands_in_the_bootstrap_log() {
164        let workdir = tempfile::tempdir().expect("temp workdir");
165        let outcome = emit_to(
166            &mut BrokenPipe,
167            "{\"url\":\"http://127.0.0.1:1/\"}",
168            Some(workdir.path()),
169        );
170        assert_eq!(outcome, Emitted::Logged);
171        let log = workdir.path().join("logs").join("bootstrap.log");
172        let contents = std::fs::read_to_string(&log).expect("bootstrap.log was written");
173        assert_eq!(contents, "{\"url\":\"http://127.0.0.1:1/\"}\n");
174    }
175
176    #[test]
177    fn refused_line_with_no_workdir_is_dropped() {
178        let outcome = emit_to(&mut BrokenPipe, "a warning raised during staging", None);
179        assert_eq!(outcome, Emitted::Dropped);
180    }
181
182    #[test]
183    fn accepted_line_gets_exactly_one_newline() {
184        let mut sink: Vec<u8> = Vec::new();
185        let outcome = emit_to(&mut sink, "session: ses_01", None);
186        assert_eq!(outcome, Emitted::Written);
187        assert_eq!(String::from_utf8(sink).unwrap(), "session: ses_01\n");
188    }
189
190    #[test]
191    fn raw_chunk_is_written_verbatim() {
192        let mut sink: Vec<u8> = Vec::new();
193        let outcome = emit_raw_to(&mut sink, "child line\n", None);
194        assert_eq!(outcome, Emitted::Written);
195        assert_eq!(String::from_utf8(sink).unwrap(), "child line\n");
196    }
197
198    #[test]
199    fn refused_raw_chunk_is_logged_without_a_doubled_newline() {
200        let workdir = tempfile::tempdir().expect("temp workdir");
201        let outcome = emit_raw_to(&mut BrokenPipe, "child line\n", Some(workdir.path()));
202        assert_eq!(outcome, Emitted::Logged);
203        let log = workdir.path().join("logs").join("bootstrap.log");
204        let contents = std::fs::read_to_string(&log).expect("bootstrap.log was written");
205        assert_eq!(contents, "child line\n");
206    }
207}