1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum Emitted {
39 Written,
41 Logged,
43 Dropped,
45}
46
47static FALLBACK_WORKDIR: Mutex<Option<PathBuf>> = Mutex::new(None);
52
53pub 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
67pub fn diagnostic_workdir() -> Option<PathBuf> {
69 FALLBACK_WORKDIR
70 .lock()
71 .unwrap_or_else(|poisoned| poisoned.into_inner())
72 .clone()
73}
74
75pub 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
81pub fn credential_to_stdout(line: &str) -> Emitted {
85 emit_to(&mut std::io::stdout().lock(), line, None)
86}
87
88pub 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
94pub 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
103pub(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
115pub(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#[macro_export]
141macro_rules! runtime_out {
142 ($($arg:tt)*) => {{
143 $crate::diagnostic::to_stdout(&::std::format!($($arg)*));
144 }};
145}
146
147#[macro_export]
149macro_rules! runtime_err {
150 ($($arg:tt)*) => {{
151 $crate::diagnostic::to_stderr(&::std::format!($($arg)*));
152 }};
153}
154
155static STDOUT_CLOSED: AtomicBool = AtomicBool::new(false);
158
159static STDERR_CLOSED: AtomicBool = AtomicBool::new(false);
161
162static STDOUT_FAILURE: Mutex<Option<String>> = Mutex::new(None);
164
165pub 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
182pub 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
191pub fn stdout_closed() -> bool {
194 STDOUT_CLOSED.load(Ordering::Acquire)
195}
196
197pub fn stdout_failure() -> Option<String> {
200 STDOUT_FAILURE
201 .lock()
202 .unwrap_or_else(|poisoned| poisoned.into_inner())
203 .clone()
204}
205
206pub(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#[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#[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#[macro_export]
261macro_rules! report_print {
262 ($($arg:tt)*) => {{
263 $crate::diagnostic::report_to_stdout(::std::format!($($arg)*).as_bytes());
264 }};
265}
266
267#[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#[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 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}