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}