1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum Emitted {
40 Written,
42 Logged,
44 Dropped,
46}
47
48static FALLBACK_WORKDIR: Mutex<Option<WorkdirRoot>> = Mutex::new(None);
53
54pub 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
68pub 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
80static STDOUT_TO_STDERR: AtomicBool = AtomicBool::new(false);
84
85pub 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
99pub 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
113pub 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
122pub 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
132pub 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
138pub 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
150pub(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
166pub(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#[macro_export]
196macro_rules! runtime_out {
197 ($($arg:tt)*) => {{
198 $crate::diagnostic::to_stdout(&::std::format!($($arg)*));
199 }};
200}
201
202#[macro_export]
204macro_rules! runtime_err {
205 ($($arg:tt)*) => {{
206 $crate::diagnostic::to_stderr(&::std::format!($($arg)*));
207 }};
208}
209
210static STDOUT_CLOSED: AtomicBool = AtomicBool::new(false);
213
214static STDERR_CLOSED: AtomicBool = AtomicBool::new(false);
216
217static STDOUT_FAILURE: Mutex<Option<String>> = Mutex::new(None);
219
220pub 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
240pub 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
249pub fn stdout_closed() -> bool {
252 STDOUT_CLOSED.load(Ordering::Acquire)
253}
254
255pub fn stdout_failure() -> Option<String> {
258 STDOUT_FAILURE
259 .lock()
260 .unwrap_or_else(|poisoned| poisoned.into_inner())
261 .clone()
262}
263
264pub(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#[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#[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#[macro_export]
319macro_rules! report_print {
320 ($($arg:tt)*) => {{
321 $crate::diagnostic::report_to_stdout(::std::format!($($arg)*).as_bytes());
322 }};
323}
324
325#[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#[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 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}