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
80pub fn to_stdout(line: &str) -> Emitted {
82 let fallback = fallback_root();
83 emit_to(&mut std::io::stdout().lock(), line, fallback.as_ref())
84}
85
86pub fn credential_to_stdout(line: &str) -> Emitted {
90 emit_to(&mut std::io::stdout().lock(), line, None)
91}
92
93pub fn to_stderr(line: &str) -> Emitted {
95 let fallback = fallback_root();
96 emit_to(&mut std::io::stderr().lock(), line, fallback.as_ref())
97}
98
99pub fn raw_to_stdout(chunk: &str) -> Emitted {
104 let fallback = fallback_root();
105 emit_raw_to(&mut std::io::stdout().lock(), chunk, fallback.as_ref())
106}
107
108pub(crate) fn emit_to<W: Write>(
110 out: &mut W,
111 line: &str,
112 fallback: Option<&WorkdirRoot>,
113) -> Emitted {
114 let written = out
115 .write_all(line.as_bytes())
116 .and_then(|()| out.write_all(b"\n"))
117 .and_then(|()| out.flush());
118 match written {
119 Ok(()) => Emitted::Written,
120 Err(_) => fall_back(line, fallback),
121 }
122}
123
124pub(crate) fn emit_raw_to<W: Write>(
127 out: &mut W,
128 chunk: &str,
129 fallback: Option<&WorkdirRoot>,
130) -> Emitted {
131 let written = out.write_all(chunk.as_bytes()).and_then(|()| out.flush());
132 match written {
133 Ok(()) => Emitted::Written,
134 Err(_) => fall_back(chunk.trim_end_matches('\n'), fallback),
135 }
136}
137
138fn fall_back(line: &str, fallback: Option<&WorkdirRoot>) -> Emitted {
139 match fallback {
140 Some(workdir) => {
141 append_bootstrap_log(workdir, line);
142 Emitted::Logged
143 }
144 None => Emitted::Dropped,
145 }
146}
147
148#[macro_export]
154macro_rules! runtime_out {
155 ($($arg:tt)*) => {{
156 $crate::diagnostic::to_stdout(&::std::format!($($arg)*));
157 }};
158}
159
160#[macro_export]
162macro_rules! runtime_err {
163 ($($arg:tt)*) => {{
164 $crate::diagnostic::to_stderr(&::std::format!($($arg)*));
165 }};
166}
167
168static STDOUT_CLOSED: AtomicBool = AtomicBool::new(false);
171
172static STDERR_CLOSED: AtomicBool = AtomicBool::new(false);
174
175static STDOUT_FAILURE: Mutex<Option<String>> = Mutex::new(None);
177
178pub fn report_to_stdout(bytes: &[u8]) -> Emitted {
184 if STDOUT_CLOSED.load(Ordering::Acquire) {
185 return Emitted::Dropped;
186 }
187 report_to(
188 &mut std::io::stdout().lock(),
189 bytes,
190 &STDOUT_CLOSED,
191 Some(&STDOUT_FAILURE),
192 )
193}
194
195pub fn report_to_stderr(bytes: &[u8]) -> Emitted {
198 if STDERR_CLOSED.load(Ordering::Acquire) {
199 return Emitted::Dropped;
200 }
201 report_to(&mut std::io::stderr().lock(), bytes, &STDERR_CLOSED, None)
202}
203
204pub fn stdout_closed() -> bool {
207 STDOUT_CLOSED.load(Ordering::Acquire)
208}
209
210pub fn stdout_failure() -> Option<String> {
213 STDOUT_FAILURE
214 .lock()
215 .unwrap_or_else(|poisoned| poisoned.into_inner())
216 .clone()
217}
218
219pub(crate) fn report_to<W: Write>(
224 out: &mut W,
225 bytes: &[u8],
226 closed: &AtomicBool,
227 failure: Option<&Mutex<Option<String>>>,
228) -> Emitted {
229 if closed.load(Ordering::Acquire) {
230 return Emitted::Dropped;
231 }
232 match out.write_all(bytes).and_then(|()| out.flush()) {
233 Ok(()) => Emitted::Written,
234 Err(error) => {
235 closed.store(true, Ordering::Release);
236 if error.kind() != ErrorKind::BrokenPipe {
237 if let Some(slot) = failure {
238 slot.lock()
239 .unwrap_or_else(|poisoned| poisoned.into_inner())
240 .get_or_insert_with(|| error.to_string());
241 }
242 }
243 Emitted::Dropped
244 }
245 }
246}
247
248#[doc(hidden)]
251#[macro_export]
252macro_rules! __report_line {
253 () => {
254 ::std::string::String::from("\n")
255 };
256 ($($arg:tt)*) => {{
257 let mut line = ::std::format!($($arg)*);
258 line.push('\n');
259 line
260 }};
261}
262
263#[macro_export]
266macro_rules! report_println {
267 ($($arg:tt)*) => {{
268 $crate::diagnostic::report_to_stdout($crate::__report_line!($($arg)*).as_bytes());
269 }};
270}
271
272#[macro_export]
274macro_rules! report_print {
275 ($($arg:tt)*) => {{
276 $crate::diagnostic::report_to_stdout(::std::format!($($arg)*).as_bytes());
277 }};
278}
279
280#[macro_export]
282macro_rules! report_eprintln {
283 ($($arg:tt)*) => {{
284 $crate::diagnostic::report_to_stderr($crate::__report_line!($($arg)*).as_bytes());
285 }};
286}
287
288#[macro_export]
290macro_rules! report_eprint {
291 ($($arg:tt)*) => {{
292 $crate::diagnostic::report_to_stderr(::std::format!($($arg)*).as_bytes());
293 }};
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299 use std::io::{Error, ErrorKind};
300
301 struct Refusing {
304 kind: ErrorKind,
305 calls: usize,
306 }
307
308 impl Refusing {
309 fn new(kind: ErrorKind) -> Self {
310 Self { kind, calls: 0 }
311 }
312 }
313
314 impl Write for Refusing {
315 fn write(&mut self, _buf: &[u8]) -> std::io::Result<usize> {
316 self.calls += 1;
317 Err(Error::new(self.kind, "refused"))
318 }
319
320 fn flush(&mut self) -> std::io::Result<()> {
321 self.calls += 1;
322 Err(Error::new(self.kind, "refused"))
323 }
324 }
325
326 #[test]
327 fn refused_line_lands_in_the_bootstrap_log() {
328 let workdir = tempfile::tempdir().expect("temp workdir");
329 let outcome = emit_to(
330 &mut Refusing::new(ErrorKind::BrokenPipe),
331 "{\"url\":\"http://127.0.0.1:1/\"}",
332 Some(&WorkdirRoot::open(workdir.path()).unwrap()),
333 );
334 assert_eq!(outcome, Emitted::Logged);
335 let log = workdir.path().join("logs").join("bootstrap.log");
336 let contents = std::fs::read_to_string(&log).expect("bootstrap.log was written");
337 assert_eq!(contents, "{\"url\":\"http://127.0.0.1:1/\"}\n");
338 }
339
340 #[test]
341 fn refused_line_with_no_workdir_is_dropped() {
342 let outcome = emit_to(
343 &mut Refusing::new(ErrorKind::BrokenPipe),
344 "a warning raised during staging",
345 None,
346 );
347 assert_eq!(outcome, Emitted::Dropped);
348 }
349
350 #[test]
351 fn accepted_line_gets_exactly_one_newline() {
352 let mut sink: Vec<u8> = Vec::new();
353 let outcome = emit_to(&mut sink, "session: ses_01", None);
354 assert_eq!(outcome, Emitted::Written);
355 assert_eq!(String::from_utf8(sink).unwrap(), "session: ses_01\n");
356 }
357
358 #[test]
359 fn raw_chunk_is_written_verbatim() {
360 let mut sink: Vec<u8> = Vec::new();
361 let outcome = emit_raw_to(&mut sink, "child line\n", None);
362 assert_eq!(outcome, Emitted::Written);
363 assert_eq!(String::from_utf8(sink).unwrap(), "child line\n");
364 }
365
366 #[test]
367 fn refused_raw_chunk_is_logged_without_a_doubled_newline() {
368 let workdir = tempfile::tempdir().expect("temp workdir");
369 let outcome = emit_raw_to(
370 &mut Refusing::new(ErrorKind::BrokenPipe),
371 "child line\n",
372 Some(&WorkdirRoot::open(workdir.path()).unwrap()),
373 );
374 assert_eq!(outcome, Emitted::Logged);
375 let log = workdir.path().join("logs").join("bootstrap.log");
376 let contents = std::fs::read_to_string(&log).expect("bootstrap.log was written");
377 assert_eq!(contents, "child line\n");
378 }
379
380 #[test]
381 fn report_over_a_broken_pipe_latches_and_stops_writing() {
382 let closed = AtomicBool::new(false);
383 let failure = Mutex::new(None);
384 let mut out = Refusing::new(ErrorKind::BrokenPipe);
385 let first = report_to(&mut out, b"row one\n", &closed, Some(&failure));
386 assert_eq!(first, Emitted::Dropped);
387 assert!(closed.load(Ordering::Acquire));
388 assert_eq!(*failure.lock().unwrap(), None);
389 let calls_after_first = out.calls;
390 assert!(calls_after_first > 0);
391
392 let second = report_to(&mut out, b"row two\n", &closed, Some(&failure));
393 assert_eq!(second, Emitted::Dropped);
394 assert_eq!(
395 out.calls, calls_after_first,
396 "a latched stream is not written"
397 );
398 }
399
400 #[test]
401 fn report_over_a_full_disk_latches_and_records_the_error() {
402 let closed = AtomicBool::new(false);
403 let failure = Mutex::new(None);
404 let mut out = Refusing::new(ErrorKind::StorageFull);
405 let outcome = report_to(&mut out, b"row\n", &closed, Some(&failure));
406 assert_eq!(outcome, Emitted::Dropped);
407 assert!(closed.load(Ordering::Acquire));
408 assert_eq!(failure.lock().unwrap().as_deref(), Some("refused"));
409 }
410
411 #[test]
412 fn report_over_an_open_stream_writes_the_formatted_bytes() {
413 let cases: [(String, &str); 4] = [
414 (crate::__report_line!("{} rows", 3), "3 rows\n"),
415 (crate::__report_line!(), "\n"),
416 (::std::format!("value for {}: ", "KEY"), "value for KEY: "),
417 (crate::__report_line!("{:>4}|", 7), " 7|\n"),
418 ];
419 for (text, expected) in cases {
420 let closed = AtomicBool::new(false);
421 let mut sink: Vec<u8> = Vec::new();
422 let outcome = report_to(&mut sink, text.as_bytes(), &closed, None);
423 assert_eq!(outcome, Emitted::Written);
424 assert_eq!(String::from_utf8(sink).unwrap(), expected);
425 assert!(!closed.load(Ordering::Acquire));
426 }
427 }
428}