Skip to main content

pi/core/
output_guard.rs

1//! Process-global raw stdout coordinator for print/RPC modes.
2//!
3//! Port of `.references/pi/packages/coding-agent/src/core/output-guard.ts`.
4//!
5//! # Ownership model
6//!
7//! One dedicated Tokio writer task owns the raw stdout sink. Callers enqueue
8//! ordered FIFO write/flush/wait requests through a bounded channel. There is
9//! no process-wide `println!` monkeypatch in Rust: product-facing text must go
10//! through [`ProductOutput`], which routes to stderr while stdout is taken
11//! over so protocol frames on the real stdout sink stay clean.
12//!
13//! # Retry policy
14//!
15//! Transient write failures (`WouldBlock` / `EAGAIN` / `EWOULDBLOCK` /
16//! `ENOBUFS`, plus `Interrupted`) are retried every
17//! [`RAW_STDOUT_RETRY_DELAY`] until the full buffer is written. Unrecoverable
18//! I/O errors are stored and returned as [`OutputGuardError`]; this library
19//! never calls `process::exit`.
20//!
21//! # Result contract
22//!
23//! Every public fallible operation returns [`Result`]:
24//! - [`write_raw_stdout`] accepts into the bounded FIFO (awaits capacity) and
25//!   surfaces a prior fatal writer error if one already latched.
26//! - [`wait_for_raw_stdout_backpressure`] resolves only after every previously
27//!   accepted write has finished (or failed fatally).
28//! - [`flush_raw_stdout`] waits for drain, then flushes the underlying sink.
29//! - [`take_over_stdout`] / [`restore_stdout`] are idempotent state transitions;
30//!   they return `Err` only when the coordinator cannot be started.
31
32use std::fmt;
33use std::io;
34use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
35use std::sync::{Arc, LazyLock, Mutex};
36
37use thiserror::Error;
38use tokio::io::{AsyncWrite, AsyncWriteExt};
39use tokio::sync::{mpsc, oneshot};
40use tokio::time::{Duration, sleep};
41
42/// Delay between retries for transient raw-stdout write failures.
43pub const RAW_STDOUT_RETRY_DELAY: Duration = Duration::from_millis(10);
44
45/// Bounded FIFO capacity for raw-stdout requests.
46///
47/// Keeps memory bounded under a fast producer; [`write_raw_stdout`] awaits when
48/// the queue is full (backpressure).
49pub const RAW_STDOUT_QUEUE_CAPACITY: usize = 256;
50
51/// Errors produced by the raw-stdout coordinator.
52#[derive(Debug, Error, Clone)]
53pub enum OutputGuardError {
54    /// The dedicated writer task is no longer running.
55    #[error("raw stdout writer task is shut down")]
56    WriterShutdown,
57
58    /// The coordinator could not be started (no Tokio runtime).
59    #[error("raw stdout coordinator requires a Tokio runtime: {0}")]
60    RuntimeUnavailable(String),
61
62    /// Unrecoverable write or flush failure on the raw stdout sink.
63    #[error("raw stdout I/O failed: {0}")]
64    Io(String),
65
66    /// A previous unrecoverable writer failure is latched; further raw writes
67    /// are rejected until the coordinator is reinstalled (tests) or the
68    /// process exits.
69    #[error("raw stdout writer failed earlier: {0}")]
70    Latched(String),
71}
72
73impl OutputGuardError {
74    fn io(err: &io::Error) -> Self {
75        Self::Io(err.to_string())
76    }
77
78    fn latched(message: impl Into<String>) -> Self {
79        Self::Latched(message.into())
80    }
81}
82
83/// Result alias for raw-stdout coordinator operations.
84pub type Result<T, E = OutputGuardError> = std::result::Result<T, E>;
85
86enum Command {
87    Write {
88        bytes: Vec<u8>,
89        /// Sequence number assigned at enqueue time; used for drain waits.
90        seq: u64,
91    },
92    Wait {
93        /// Wait until `completed_seq >= target_seq`.
94        target_seq: u64,
95        reply: oneshot::Sender<Result<()>>,
96    },
97    Flush {
98        target_seq: u64,
99        reply: oneshot::Sender<Result<()>>,
100    },
101    Shutdown {
102        reply: oneshot::Sender<Result<()>>,
103    },
104    /// Test/support: replace the live sink after draining pending writes.
105    InstallSink {
106        sink: Box<dyn AsyncWrite + Unpin + Send>,
107        reply: oneshot::Sender<Result<()>>,
108    },
109}
110
111struct Shared {
112    taken_over: AtomicBool,
113    /// Monotonic enqueue counter; 0 means no writes have been accepted yet.
114    enqueued_seq: AtomicU64,
115    /// Last sequence fully processed by the writer (write completed or failed).
116    completed_seq: AtomicU64,
117    /// Latched fatal error message, if any.
118    fatal: Mutex<Option<String>>,
119    tx: mpsc::Sender<Command>,
120}
121
122impl Shared {
123    fn take_fatal(&self) -> Option<OutputGuardError> {
124        self.fatal
125            .lock()
126            .unwrap_or_else(std::sync::PoisonError::into_inner)
127            .clone()
128            .map(OutputGuardError::latched)
129    }
130
131    fn set_fatal(&self, err: &OutputGuardError) {
132        let message = err.to_string();
133        let mut guard = self
134            .fatal
135            .lock()
136            .unwrap_or_else(std::sync::PoisonError::into_inner);
137        if guard.is_none() {
138            *guard = Some(message);
139        }
140    }
141
142    fn clear_fatal(&self) {
143        *self
144            .fatal
145            .lock()
146            .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
147    }
148}
149
150struct Coordinator {
151    shared: Arc<Shared>,
152}
153
154static COORDINATOR: LazyLock<Mutex<Option<Coordinator>>> = LazyLock::new(|| Mutex::new(None));
155
156fn with_coordinator_mut<T>(f: impl FnOnce(&mut Option<Coordinator>) -> T) -> T {
157    let mut guard = COORDINATOR
158        .lock()
159        .unwrap_or_else(std::sync::PoisonError::into_inner);
160    f(&mut guard)
161}
162
163fn current_shared() -> Option<Arc<Shared>> {
164    with_coordinator_mut(|slot| slot.as_ref().map(|c| Arc::clone(&c.shared)))
165}
166
167/// Returns whether product stdout has been taken over for protocol use.
168#[must_use]
169pub fn is_stdout_taken_over() -> bool {
170    current_shared().is_some_and(|s| s.taken_over.load(Ordering::SeqCst))
171}
172
173/// Mark stdout as protocol-owned.
174///
175/// Idempotent: a second call is a no-op once takeover is already active.
176/// Starts the dedicated writer task on first use.
177///
178/// # Errors
179///
180/// Returns [`OutputGuardError::RuntimeUnavailable`] when no Tokio runtime is
181/// available to spawn the writer task.
182pub fn take_over_stdout() -> Result<()> {
183    let shared = ensure_coordinator()?;
184    shared.taken_over.store(true, Ordering::SeqCst);
185    Ok(())
186}
187
188/// Release protocol ownership of stdout.
189///
190/// Idempotent: safe when stdout is not currently taken over.
191pub fn restore_stdout() {
192    if let Some(shared) = current_shared() {
193        shared.taken_over.store(false, Ordering::SeqCst);
194    }
195}
196
197/// Queue `text` for ordered emission on the raw stdout sink.
198///
199/// Empty payloads are ignored. Non-empty payloads are accepted into the
200/// bounded FIFO in call order; this future resolves once the request has been
201/// accepted (or fails if the writer is shut down / already latched fatal).
202/// Completion of the underlying write is observed via
203/// [`wait_for_raw_stdout_backpressure`] or [`flush_raw_stdout`].
204///
205/// # Errors
206///
207/// - [`OutputGuardError::Latched`] when a prior unrecoverable writer error exists
208/// - [`OutputGuardError::WriterShutdown`] when the writer task has exited
209/// - [`OutputGuardError::RuntimeUnavailable`] when the coordinator cannot start
210pub async fn write_raw_stdout(text: impl AsRef<[u8]>) -> Result<()> {
211    let bytes = text.as_ref();
212    if bytes.is_empty() {
213        return Ok(());
214    }
215    let shared = ensure_coordinator()?;
216    if let Some(err) = shared.take_fatal() {
217        return Err(err);
218    }
219    let seq = shared.enqueued_seq.fetch_add(1, Ordering::SeqCst) + 1;
220    shared
221        .tx
222        .send(Command::Write {
223            bytes: bytes.to_vec(),
224            seq,
225        })
226        .await
227        .map_err(|_| OutputGuardError::WriterShutdown)?;
228    if let Some(err) = shared.take_fatal() {
229        return Err(err);
230    }
231    Ok(())
232}
233
234/// Wait until every previously accepted raw write has finished.
235///
236/// If a write fails unrecoverably while waiting, returns that error.
237///
238/// # Errors
239///
240/// - [`OutputGuardError::Latched`] / [`OutputGuardError::Io`] from the writer
241/// - [`OutputGuardError::WriterShutdown`] when the writer task has exited
242/// - [`OutputGuardError::RuntimeUnavailable`] when the coordinator cannot start
243pub async fn wait_for_raw_stdout_backpressure() -> Result<()> {
244    let shared = ensure_coordinator()?;
245    if let Some(err) = shared.take_fatal() {
246        return Err(err);
247    }
248    let target_seq = shared.enqueued_seq.load(Ordering::SeqCst);
249    if target_seq == 0 || shared.completed_seq.load(Ordering::SeqCst) >= target_seq {
250        return shared.take_fatal().map_or(Ok(()), Err);
251    }
252    let (reply_tx, reply_rx) = oneshot::channel();
253    shared
254        .tx
255        .send(Command::Wait {
256            target_seq,
257            reply: reply_tx,
258        })
259        .await
260        .map_err(|_| OutputGuardError::WriterShutdown)?;
261    reply_rx
262        .await
263        .map_err(|_| OutputGuardError::WriterShutdown)?
264}
265
266/// Wait for drain, then flush the underlying raw stdout sink.
267///
268/// # Errors
269///
270/// Same as [`wait_for_raw_stdout_backpressure`], plus flush I/O failures.
271pub async fn flush_raw_stdout() -> Result<()> {
272    let shared = ensure_coordinator()?;
273    if let Some(err) = shared.take_fatal() {
274        return Err(err);
275    }
276    let target_seq = shared.enqueued_seq.load(Ordering::SeqCst);
277    let (reply_tx, reply_rx) = oneshot::channel();
278    shared
279        .tx
280        .send(Command::Flush {
281            target_seq,
282            reply: reply_tx,
283        })
284        .await
285        .map_err(|_| OutputGuardError::WriterShutdown)?;
286    reply_rx
287        .await
288        .map_err(|_| OutputGuardError::WriterShutdown)?
289}
290
291/// Shut down the dedicated writer task after draining pending work.
292///
293/// Primarily for tests and orderly process teardown. After shutdown, the next
294/// public raw-stdout call starts a fresh coordinator.
295///
296/// # Errors
297///
298/// Returns writer I/O or shutdown communication failures.
299pub async fn shutdown_raw_stdout() -> Result<()> {
300    // Take the coordinator out of the global slot first so concurrent
301    // `ensure_coordinator` calls start a new writer instead of enqueueing onto
302    // a task that is about to exit.
303    let shared = with_coordinator_mut(|slot| slot.take().map(|c| c.shared));
304    let Some(shared) = shared else {
305        return Ok(());
306    };
307    let (reply_tx, reply_rx) = oneshot::channel();
308    if shared
309        .tx
310        .send(Command::Shutdown { reply: reply_tx })
311        .await
312        .is_err()
313    {
314        return Err(OutputGuardError::WriterShutdown);
315    }
316    reply_rx
317        .await
318        .map_err(|_| OutputGuardError::WriterShutdown)?
319}
320
321/// Install a custom [`AsyncWrite`] sink for the raw-stdout writer.
322///
323/// Intended for deterministic tests. Starts the coordinator if needed, drains
324/// pending writes, then swaps the live sink.
325///
326/// # Errors
327///
328/// Returns coordinator start or install communication failures.
329pub async fn install_raw_stdout_sink_for_test<W>(sink: W) -> Result<()>
330where
331    W: AsyncWrite + Unpin + Send + 'static,
332{
333    let shared = ensure_coordinator()?;
334    shared.clear_fatal();
335    let (reply_tx, reply_rx) = oneshot::channel();
336    shared
337        .tx
338        .send(Command::InstallSink {
339            sink: Box::new(sink),
340            reply: reply_tx,
341        })
342        .await
343        .map_err(|_| OutputGuardError::WriterShutdown)?;
344    reply_rx
345        .await
346        .map_err(|_| OutputGuardError::WriterShutdown)?
347}
348
349/// Product-facing output facade.
350///
351/// Rust cannot redirect every `println!` in the process. Callers that emit
352/// human-facing text (help, diagnostics, list-models tables, package status)
353/// must use this facade so that, while stdout is taken over for protocol
354/// frames, product text lands on stderr instead.
355pub struct ProductOutput;
356
357impl ProductOutput {
358    /// Write `text` without a trailing newline.
359    pub fn write(text: &str) {
360        use std::io::Write;
361        if is_stdout_taken_over() {
362            let _ = std::io::stderr().write_all(text.as_bytes());
363            let _ = std::io::stderr().flush();
364        } else {
365            let _ = std::io::stdout().write_all(text.as_bytes());
366            let _ = std::io::stdout().flush();
367        }
368    }
369
370    /// Write `text` followed by a newline.
371    pub fn writeln(text: &str) {
372        Self::write(text);
373        Self::write("\n");
374    }
375
376    /// Write formatted arguments (no trailing newline).
377    pub fn write_fmt(args: fmt::Arguments<'_>) {
378        Self::write(&format!("{args}"));
379    }
380
381    /// Write formatted arguments followed by a newline.
382    pub fn writeln_fmt(args: fmt::Arguments<'_>) {
383        Self::writeln(&format!("{args}"));
384    }
385}
386
387fn ensure_coordinator() -> Result<Arc<Shared>> {
388    if let Some(shared) = current_shared() {
389        return Ok(shared);
390    }
391    let handle = tokio::runtime::Handle::try_current()
392        .map_err(|err| OutputGuardError::RuntimeUnavailable(err.to_string()))?;
393    with_coordinator_mut(|slot| {
394        if let Some(existing) = slot.as_ref() {
395            return Ok(Arc::clone(&existing.shared));
396        }
397        let (tx, rx) = mpsc::channel(RAW_STDOUT_QUEUE_CAPACITY);
398        let shared = Arc::new(Shared {
399            taken_over: AtomicBool::new(false),
400            enqueued_seq: AtomicU64::new(0),
401            completed_seq: AtomicU64::new(0),
402            fatal: Mutex::new(None),
403            tx,
404        });
405        let worker_shared = Arc::clone(&shared);
406        handle.spawn(async move {
407            writer_loop(worker_shared, rx, Box::new(tokio::io::stdout())).await;
408        });
409        *slot = Some(Coordinator {
410            shared: Arc::clone(&shared),
411        });
412        Ok(shared)
413    })
414}
415
416async fn writer_loop(
417    shared: Arc<Shared>,
418    mut rx: mpsc::Receiver<Command>,
419    mut sink: Box<dyn AsyncWrite + Unpin + Send>,
420) {
421    while let Some(cmd) = rx.recv().await {
422        match cmd {
423            Command::Write { bytes, seq } => {
424                if shared.take_fatal().is_some() {
425                    shared.completed_seq.store(seq, Ordering::SeqCst);
426                    continue;
427                }
428                match write_all_with_retry(sink.as_mut(), &bytes).await {
429                    Ok(()) => {
430                        shared.completed_seq.store(seq, Ordering::SeqCst);
431                    }
432                    Err(err) => {
433                        shared.set_fatal(&err);
434                        shared.completed_seq.store(seq, Ordering::SeqCst);
435                    }
436                }
437            }
438            Command::Wait { target_seq, reply } => {
439                let result = wait_until_seq(&shared, target_seq).await;
440                let _ = reply.send(result);
441            }
442            Command::Flush { target_seq, reply } => {
443                let result = async {
444                    wait_until_seq(&shared, target_seq).await?;
445                    if let Some(err) = shared.take_fatal() {
446                        return Err(err);
447                    }
448                    flush_with_retry(sink.as_mut()).await
449                }
450                .await;
451                if let Err(err) = &result
452                    && !matches!(err, OutputGuardError::Latched(_))
453                {
454                    shared.set_fatal(err);
455                }
456                let _ = reply.send(result);
457            }
458            Command::Shutdown { reply } => {
459                let target = shared.enqueued_seq.load(Ordering::SeqCst);
460                let result = wait_until_seq(&shared, target).await;
461                let _ = reply.send(result);
462                break;
463            }
464            Command::InstallSink {
465                sink: new_sink,
466                reply,
467            } => {
468                let target = shared.enqueued_seq.load(Ordering::SeqCst);
469                let result = wait_until_seq(&shared, target).await;
470                if result.is_ok() {
471                    sink = new_sink;
472                    shared.clear_fatal();
473                }
474                let _ = reply.send(result);
475            }
476        }
477    }
478}
479
480async fn wait_until_seq(shared: &Shared, target_seq: u64) -> Result<()> {
481    if target_seq == 0 {
482        return shared.take_fatal().map_or(Ok(()), Err);
483    }
484    // The writer processes commands FIFO, so by the time a Wait/Flush/Shutdown
485    // command runs, every prior Write has already updated `completed_seq`.
486    // Poll only as a safety net for InstallSink after concurrent enqueues.
487    loop {
488        if shared.completed_seq.load(Ordering::SeqCst) >= target_seq {
489            return shared.take_fatal().map_or(Ok(()), Err);
490        }
491        if let Some(err) = shared.take_fatal() {
492            // Fatal may latch before completed_seq advances on the failing write.
493            if shared.completed_seq.load(Ordering::SeqCst) >= target_seq {
494                return Err(err);
495            }
496        }
497        tokio::task::yield_now().await;
498        sleep(Duration::from_millis(1)).await;
499    }
500}
501
502fn is_retryable_write_error(err: &io::Error) -> bool {
503    if matches!(
504        err.kind(),
505        io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted
506    ) {
507        return true;
508    }
509    #[cfg(unix)]
510    {
511        match err.raw_os_error() {
512            Some(code)
513                if code == nix::libc::EAGAIN
514                    || code == nix::libc::EWOULDBLOCK
515                    || code == nix::libc::ENOBUFS =>
516            {
517                return true;
518            }
519            _ => {}
520        }
521    }
522    false
523}
524
525async fn write_all_with_retry(
526    sink: &mut (dyn AsyncWrite + Unpin + Send),
527    mut bytes: &[u8],
528) -> Result<()> {
529    while !bytes.is_empty() {
530        match sink.write(bytes).await {
531            Ok(0) => {
532                return Err(OutputGuardError::io(&io::Error::new(
533                    io::ErrorKind::WriteZero,
534                    "raw stdout write returned 0 bytes",
535                )));
536            }
537            Ok(n) => {
538                bytes = &bytes[n..];
539            }
540            Err(err) if is_retryable_write_error(&err) => {
541                sleep(RAW_STDOUT_RETRY_DELAY).await;
542            }
543            Err(err) => return Err(OutputGuardError::io(&err)),
544        }
545    }
546    Ok(())
547}
548
549async fn flush_with_retry(sink: &mut (dyn AsyncWrite + Unpin + Send)) -> Result<()> {
550    loop {
551        match sink.flush().await {
552            Ok(()) => return Ok(()),
553            Err(err) if is_retryable_write_error(&err) => {
554                sleep(RAW_STDOUT_RETRY_DELAY).await;
555            }
556            Err(err) => return Err(OutputGuardError::io(&err)),
557        }
558    }
559}
560
561#[cfg(test)]
562mod tests {
563    use super::*;
564    use std::pin::Pin;
565    use std::sync::atomic::AtomicUsize;
566    use std::task::{Context, Poll};
567    use tokio::sync::Mutex as AsyncMutex;
568
569    type TestResult = std::result::Result<(), String>;
570
571    fn map_err(err: impl std::fmt::Display) -> String {
572        err.to_string()
573    }
574
575    /// Process-global coordinator is shared; serialize tests that mutate it.
576    static TEST_LOCK: LazyLock<AsyncMutex<()>> = LazyLock::new(|| AsyncMutex::new(()));
577
578    /// Recording sink that supports partial writes, would-block, and failure.
579    struct TestSink {
580        state: Arc<AsyncMutex<TestSinkState>>,
581    }
582
583    struct TestSinkState {
584        buf: Vec<u8>,
585        /// Force the next N write polls to return [`io::ErrorKind::WouldBlock`]
586        /// before succeeding.
587        would_block_remaining: usize,
588        /// Cap bytes accepted per successful write (`0` = unlimited).
589        max_chunk: usize,
590        /// If set, the next write returns this fatal error.
591        fatal_on_write: Option<io::ErrorKind>,
592        flush_count: usize,
593        write_calls: usize,
594    }
595
596    impl TestSink {
597        fn new(state: Arc<AsyncMutex<TestSinkState>>) -> Self {
598            Self { state }
599        }
600    }
601
602    impl AsyncWrite for TestSink {
603        fn poll_write(
604            self: Pin<&mut Self>,
605            cx: &mut Context<'_>,
606            buf: &[u8],
607        ) -> Poll<io::Result<usize>> {
608            let state = self.state.clone();
609            // Try lock without blocking the runtime; if contended, reschedule.
610            let Ok(mut guard) = state.try_lock() else {
611                cx.waker().wake_by_ref();
612                return Poll::Pending;
613            };
614            guard.write_calls += 1;
615            if let Some(kind) = guard.fatal_on_write.take() {
616                return Poll::Ready(Err(io::Error::from(kind)));
617            }
618            if guard.would_block_remaining > 0 {
619                guard.would_block_remaining -= 1;
620                // Schedule a wake so the retry sleep path is what advances us;
621                // also wake now so Pending writers without sleep still move.
622                cx.waker().wake_by_ref();
623                return Poll::Ready(Err(io::Error::from(io::ErrorKind::WouldBlock)));
624            }
625            if buf.is_empty() {
626                return Poll::Ready(Ok(0));
627            }
628            let n = if guard.max_chunk == 0 {
629                buf.len()
630            } else {
631                buf.len().min(guard.max_chunk)
632            };
633            guard.buf.extend_from_slice(&buf[..n]);
634            Poll::Ready(Ok(n))
635        }
636
637        fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
638            let state = self.state.clone();
639            let Ok(mut guard) = state.try_lock() else {
640                cx.waker().wake_by_ref();
641                return Poll::Pending;
642            };
643            guard.flush_count += 1;
644            Poll::Ready(Ok(()))
645        }
646
647        fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
648            self.poll_flush(cx)
649        }
650    }
651
652    async fn reset_coordinator() {
653        let _ = shutdown_raw_stdout().await;
654        with_coordinator_mut(|slot| {
655            *slot = None;
656        });
657    }
658
659    async fn install_sink(state: Arc<AsyncMutex<TestSinkState>>) -> TestResult {
660        reset_coordinator().await;
661        install_raw_stdout_sink_for_test(TestSink::new(state))
662            .await
663            .map_err(map_err)
664    }
665
666    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
667    async fn ordered_writes_preserve_fifo() -> TestResult {
668        let _guard = TEST_LOCK.lock().await;
669        let state = Arc::new(AsyncMutex::new(TestSinkState {
670            buf: Vec::new(),
671            would_block_remaining: 0,
672            max_chunk: 0,
673            fatal_on_write: None,
674            flush_count: 0,
675            write_calls: 0,
676        }));
677        install_sink(Arc::clone(&state)).await?;
678
679        write_raw_stdout("one\n").await.map_err(map_err)?;
680        write_raw_stdout("two\n").await.map_err(map_err)?;
681        write_raw_stdout("three\n").await.map_err(map_err)?;
682        wait_for_raw_stdout_backpressure().await.map_err(map_err)?;
683
684        let guard = state.lock().await;
685        let text = std::str::from_utf8(&guard.buf).map_err(map_err)?;
686        if text != "one\ntwo\nthree\n" {
687            return Err(format!("unexpected buffer: {text:?}"));
688        }
689        reset_coordinator().await;
690        Ok(())
691    }
692
693    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
694    async fn backpressure_waits_for_slow_partial_writes() -> TestResult {
695        let _guard = TEST_LOCK.lock().await;
696        let state = Arc::new(AsyncMutex::new(TestSinkState {
697            buf: Vec::new(),
698            would_block_remaining: 0,
699            max_chunk: 1,
700            fatal_on_write: None,
701            flush_count: 0,
702            write_calls: 0,
703        }));
704        install_sink(Arc::clone(&state)).await?;
705
706        write_raw_stdout("abcd").await.map_err(map_err)?;
707        wait_for_raw_stdout_backpressure().await.map_err(map_err)?;
708
709        let guard = state.lock().await;
710        let text = std::str::from_utf8(&guard.buf).map_err(map_err)?;
711        if text != "abcd" {
712            return Err(format!("unexpected buffer: {text:?}"));
713        }
714        if guard.write_calls < 4 {
715            return Err(format!(
716                "expected partial writes, got {}",
717                guard.write_calls
718            ));
719        }
720        reset_coordinator().await;
721        Ok(())
722    }
723
724    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
725    async fn would_block_is_retried() -> TestResult {
726        let _guard = TEST_LOCK.lock().await;
727        let state = Arc::new(AsyncMutex::new(TestSinkState {
728            buf: Vec::new(),
729            would_block_remaining: 3,
730            max_chunk: 0,
731            fatal_on_write: None,
732            flush_count: 0,
733            write_calls: 0,
734        }));
735        install_sink(Arc::clone(&state)).await?;
736
737        write_raw_stdout("ok").await.map_err(map_err)?;
738        wait_for_raw_stdout_backpressure().await.map_err(map_err)?;
739
740        let guard = state.lock().await;
741        let text = std::str::from_utf8(&guard.buf).map_err(map_err)?;
742        if text != "ok" {
743            return Err(format!("unexpected buffer: {text:?}"));
744        }
745        if guard.write_calls < 4 {
746            return Err(format!(
747                "expected would-block attempts plus success, got {}",
748                guard.write_calls
749            ));
750        }
751        reset_coordinator().await;
752        Ok(())
753    }
754
755    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
756    async fn flush_drains_and_flushes_sink() -> TestResult {
757        let _guard = TEST_LOCK.lock().await;
758        let state = Arc::new(AsyncMutex::new(TestSinkState {
759            buf: Vec::new(),
760            would_block_remaining: 0,
761            max_chunk: 0,
762            fatal_on_write: None,
763            flush_count: 0,
764            write_calls: 0,
765        }));
766        install_sink(Arc::clone(&state)).await?;
767
768        write_raw_stdout("flush-me").await.map_err(map_err)?;
769        flush_raw_stdout().await.map_err(map_err)?;
770
771        let guard = state.lock().await;
772        let text = std::str::from_utf8(&guard.buf).map_err(map_err)?;
773        if text != "flush-me" {
774            return Err(format!("unexpected buffer: {text:?}"));
775        }
776        if guard.flush_count < 1 {
777            return Err("expected at least one flush".to_owned());
778        }
779        reset_coordinator().await;
780        Ok(())
781    }
782
783    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
784    async fn takeover_is_idempotent_and_restorable() -> TestResult {
785        let _guard = TEST_LOCK.lock().await;
786        reset_coordinator().await;
787        if is_stdout_taken_over() {
788            return Err("expected not taken over".to_owned());
789        }
790
791        take_over_stdout().map_err(map_err)?;
792        if !is_stdout_taken_over() {
793            return Err("expected taken over".to_owned());
794        }
795        take_over_stdout().map_err(map_err)?;
796        if !is_stdout_taken_over() {
797            return Err("expected still taken over".to_owned());
798        }
799
800        restore_stdout();
801        if is_stdout_taken_over() {
802            return Err("expected restored".to_owned());
803        }
804        restore_stdout(); // idempotent
805        if is_stdout_taken_over() {
806            return Err("expected still restored".to_owned());
807        }
808        reset_coordinator().await;
809        Ok(())
810    }
811
812    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
813    async fn product_output_routes_to_stderr_when_taken_over() -> TestResult {
814        // Capture by temporarily taking over and ensuring ProductOutput does
815        // not panic / does not require raw stdout. Routing correctness for
816        // stderr vs stdout is observational via the flag branch; we assert
817        // the flag gate that ProductOutput consults.
818        let _guard = TEST_LOCK.lock().await;
819        reset_coordinator().await;
820        take_over_stdout().map_err(map_err)?;
821        if !is_stdout_taken_over() {
822            return Err("expected taken over".to_owned());
823        }
824        ProductOutput::writeln("protocol-clean product line");
825        restore_stdout();
826        if is_stdout_taken_over() {
827            return Err("expected restored".to_owned());
828        }
829        ProductOutput::writeln("normal product line");
830        reset_coordinator().await;
831        Ok(())
832    }
833
834    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
835    async fn writer_failure_propagates_and_latches() -> TestResult {
836        let _guard = TEST_LOCK.lock().await;
837        let state = Arc::new(AsyncMutex::new(TestSinkState {
838            buf: Vec::new(),
839            would_block_remaining: 0,
840            max_chunk: 0,
841            fatal_on_write: Some(io::ErrorKind::BrokenPipe),
842            flush_count: 0,
843            write_calls: 0,
844        }));
845        install_sink(Arc::clone(&state)).await?;
846
847        write_raw_stdout("will-fail").await.map_err(map_err)?;
848        let err = match wait_for_raw_stdout_backpressure().await {
849            Ok(()) => return Err("expected writer failure".to_owned()),
850            Err(err) => err,
851        };
852        if !matches!(err, OutputGuardError::Io(_) | OutputGuardError::Latched(_)) {
853            return Err(format!("unexpected error: {err}"));
854        }
855
856        let latched = match write_raw_stdout("after-fail").await {
857            Ok(()) => return Err("expected latched fatal".to_owned()),
858            Err(err) => err,
859        };
860        if !matches!(latched, OutputGuardError::Latched(_)) {
861            return Err(format!("expected latched error, got {latched}"));
862        }
863        reset_coordinator().await;
864        Ok(())
865    }
866
867    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
868    async fn shutdown_cancels_further_writes() -> TestResult {
869        let _guard = TEST_LOCK.lock().await;
870        let state = Arc::new(AsyncMutex::new(TestSinkState {
871            buf: Vec::new(),
872            would_block_remaining: 0,
873            max_chunk: 0,
874            fatal_on_write: None,
875            flush_count: 0,
876            write_calls: 0,
877        }));
878        install_sink(Arc::clone(&state)).await?;
879
880        write_raw_stdout("before-shutdown").await.map_err(map_err)?;
881        shutdown_raw_stdout().await.map_err(map_err)?;
882
883        // After shutdown the coordinator is cleared; a new write starts a
884        // fresh coordinator with process stdout — reinstall a sink first.
885        install_raw_stdout_sink_for_test(TestSink::new(Arc::clone(&state)))
886            .await
887            .map_err(map_err)?;
888        write_raw_stdout("after-restart").await.map_err(map_err)?;
889        wait_for_raw_stdout_backpressure().await.map_err(map_err)?;
890
891        let guard = state.lock().await;
892        let text = std::str::from_utf8(&guard.buf).map_err(map_err)?;
893        if !text.contains("before-shutdown") {
894            return Err(format!("missing before-shutdown in {text:?}"));
895        }
896        if !text.contains("after-restart") {
897            return Err(format!("missing after-restart in {text:?}"));
898        }
899        reset_coordinator().await;
900        Ok(())
901    }
902
903    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
904    async fn empty_write_is_noop() -> TestResult {
905        let _guard = TEST_LOCK.lock().await;
906        let state = Arc::new(AsyncMutex::new(TestSinkState {
907            buf: Vec::new(),
908            would_block_remaining: 0,
909            max_chunk: 0,
910            fatal_on_write: None,
911            flush_count: 0,
912            write_calls: 0,
913        }));
914        install_sink(Arc::clone(&state)).await?;
915
916        write_raw_stdout("").await.map_err(map_err)?;
917        wait_for_raw_stdout_backpressure().await.map_err(map_err)?;
918        let guard = state.lock().await;
919        if !guard.buf.is_empty() {
920            return Err(format!("expected empty buffer, got {:?}", guard.buf));
921        }
922        if guard.write_calls != 0 {
923            return Err(format!(
924                "expected zero write calls, got {}",
925                guard.write_calls
926            ));
927        }
928        reset_coordinator().await;
929        Ok(())
930    }
931
932    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
933    async fn concurrent_writers_stay_ordered_by_enqueue() -> TestResult {
934        let _guard = TEST_LOCK.lock().await;
935        let state = Arc::new(AsyncMutex::new(TestSinkState {
936            buf: Vec::new(),
937            would_block_remaining: 0,
938            max_chunk: 2,
939            fatal_on_write: None,
940            flush_count: 0,
941            write_calls: 0,
942        }));
943        install_sink(Arc::clone(&state)).await?;
944
945        let counter = Arc::new(AtomicUsize::new(0));
946        let mut handles = Vec::new();
947        for i in 0..8 {
948            let counter = Arc::clone(&counter);
949            handles.push(tokio::spawn(async move {
950                // Serialize enqueue order explicitly via a ticket so the test
951                // asserts FIFO of accepted requests, not race of spawn start.
952                while counter.load(Ordering::SeqCst) != i {
953                    tokio::task::yield_now().await;
954                }
955                let payload = format!("{i}");
956                write_raw_stdout(payload).await.map_err(map_err)?;
957                counter.fetch_add(1, Ordering::SeqCst);
958                Ok::<(), String>(())
959            }));
960        }
961        for handle in handles {
962            handle
963                .await
964                .map_err(|err| format!("join: {err}"))?
965                .map_err(map_err)?;
966        }
967        wait_for_raw_stdout_backpressure().await.map_err(map_err)?;
968
969        let guard = state.lock().await;
970        let text = std::str::from_utf8(&guard.buf).map_err(map_err)?;
971        if text != "01234567" {
972            return Err(format!("unexpected buffer: {text:?}"));
973        }
974        reset_coordinator().await;
975        Ok(())
976    }
977}