Skip to main content

orcs_app/
printer_slot.rs

1//! Shared printer slot for routing output through rustyline's ExternalPrinter.
2//!
3//! This slot is shared between:
4//! - [`OrcsApp`](crate::OrcsApp) — sets/clears the printer during interactive mode
5//! - The tracing `MakeWriter` (in `orcs-cli`) — routes log output through the printer
6//!
7//! When the slot is empty, consumers fall back to stderr.
8//!
9//! # Non-blocking guarantee
10//!
11//! [`NonBlockingPrinter`] wraps the raw `ExternalPrinter` with a bounded
12//! intermediary channel and a dedicated drain thread. This ensures that
13//! callers (tracing subscriber, render_safe) **never block** on terminal
14//! output, preventing deadlocks when DEBUG-level logging saturates
15//! rustyline's internal bounded channel.
16
17use parking_lot::Mutex;
18use rustyline::ExternalPrinter;
19use std::sync::Arc;
20
21/// Result of attempting to print through the shared slot.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum PrintResult {
24    /// Message was enqueued for terminal display.
25    Sent,
26    /// No printer is installed (startup, shutdown, non-interactive).
27    /// Caller should fall back to direct output.
28    NoPrinter,
29    /// Printer is installed but the intermediary channel is full.
30    /// Message is dropped from terminal; log file retains it.
31    Dropped,
32}
33
34/// Non-blocking wrapper around rustyline's [`ExternalPrinter`].
35///
36/// A bounded intermediary channel (`SyncSender`) sits between the caller
37/// and the actual `ExternalPrinter`. A dedicated drain thread forwards
38/// messages from the channel to `ExternalPrinter::print()`.
39///
40/// - `try_print()` uses `try_send()` — returns immediately, never blocks.
41/// - The drain thread may block on `ExternalPrinter::print()` (rustyline's
42///   internal bounded channel), but this is a dedicated thread and does
43///   not affect the async runtime or readline thread.
44/// - On [`Drop`], the `SyncSender` is dropped, causing the drain thread's
45///   `recv()` to return `Err` and the thread to exit.
46struct NonBlockingPrinter {
47    tx: std::sync::mpsc::SyncSender<String>,
48}
49
50impl NonBlockingPrinter {
51    /// Capacity of the intermediary channel.
52    ///
53    /// 500 balances memory usage (~500 log lines buffered) vs. burst
54    /// tolerance during component initialization.
55    const CHANNEL_CAPACITY: usize = 500;
56
57    fn new(mut printer: Box<dyn ExternalPrinter + Send>) -> Self {
58        let (tx, rx) = std::sync::mpsc::sync_channel::<String>(Self::CHANNEL_CAPACITY);
59
60        // If thread spawn fails, the printer will not drain — but this is
61        // non-critical: logs still go to the log file, only terminal display
62        // is lost.  Logging the error to stderr is the safest fallback.
63        if let Err(e) = std::thread::Builder::new()
64            .name("orcs-printer-drain".into())
65            .spawn(move || {
66                while let Ok(msg) = rx.recv() {
67                    // ExternalPrinter::print() may block when rustyline's
68                    // internal channel is full — acceptable here because
69                    // this is a dedicated thread, not the async runtime.
70                    let _ = printer.print(msg);
71                }
72            })
73        {
74            eprintln!("warning: failed to spawn printer drain thread: {e}");
75        }
76
77        Self { tx }
78    }
79
80    /// Attempts to enqueue a message without blocking.
81    ///
82    /// Returns `true` if sent, `false` if channel is full (message dropped).
83    fn try_print(&self, msg: String) -> bool {
84        self.tx.try_send(msg).is_ok()
85    }
86}
87
88/// Shared slot holding an optional [`NonBlockingPrinter`].
89///
90/// Created before tracing subscriber init, shared with both the
91/// tracing `MakeWriter` and [`OrcsApp`](crate::OrcsApp).
92///
93/// - **Printer set** → tracing and IOOutput go through NonBlockingPrinter
94///   (preserves rustyline prompt, never blocks)
95/// - **Printer empty** → fallback to stderr (startup, shutdown, non-interactive)
96#[derive(Clone)]
97pub struct SharedPrinterSlot {
98    inner: Arc<Mutex<Option<NonBlockingPrinter>>>,
99}
100
101impl SharedPrinterSlot {
102    /// Creates an empty slot (no printer attached).
103    #[must_use]
104    pub fn new() -> Self {
105        Self {
106            inner: Arc::new(Mutex::new(None)),
107        }
108    }
109
110    /// Installs an ExternalPrinter into the slot, wrapped in a
111    /// [`NonBlockingPrinter`] for deadlock-free operation.
112    ///
113    /// All subsequent `try_print()` calls will route through this printer.
114    pub fn set(&self, printer: Box<dyn ExternalPrinter + Send>) {
115        *self.inner.lock() = Some(NonBlockingPrinter::new(printer));
116    }
117
118    /// Removes the printer from the slot.
119    ///
120    /// The [`NonBlockingPrinter`] is dropped, which drops the `SyncSender`,
121    /// causing the drain thread to exit cleanly.
122    pub fn clear(&self) {
123        *self.inner.lock() = None;
124    }
125
126    /// Attempts to print a message through the non-blocking printer.
127    ///
128    /// Returns [`PrintResult`] indicating the outcome:
129    /// - [`Sent`](PrintResult::Sent) — enqueued for terminal display
130    /// - [`NoPrinter`](PrintResult::NoPrinter) — no printer installed
131    /// - [`Dropped`](PrintResult::Dropped) — channel full, message dropped
132    pub fn try_print(&self, msg: String) -> PrintResult {
133        let guard = self.inner.lock();
134        if let Some(ref printer) = *guard {
135            if printer.try_print(msg) {
136                PrintResult::Sent
137            } else {
138                PrintResult::Dropped
139            }
140        } else {
141            PrintResult::NoPrinter
142        }
143    }
144}
145
146impl Default for SharedPrinterSlot {
147    fn default() -> Self {
148        Self::new()
149    }
150}