ailloli_ui_terminal_pty/
portable.rs

1//! `portable-pty` backend enabled by the crate's `portable` feature.
2//!
3//! Sessions own OS resources and spawn three detached threads: raw reader,
4//! output batcher, and child waiter. Both the raw channel and public event queue
5//! are unbounded; consumers must drain events often enough for their workload.
6
7use std::collections::VecDeque;
8use std::io::{Read, Write};
9use std::sync::atomic::{AtomicBool, Ordering};
10use std::sync::mpsc;
11use std::sync::{Arc, Mutex};
12use std::thread;
13
14use portable_pty::{ChildKiller, CommandBuilder, MasterPty};
15
16use crate::batch::{PtyBatchConfig, PtyOutputBatcher};
17use crate::handle::{PtyBackend, PtySession};
18use crate::{PtyError, PtyEvent, PtyExitStatus, PtyHandle, PtySize, PtySpawnConfig};
19
20/// Native [`portable_pty`] backend with configurable output batching.
21///
22/// Construction performs no OS I/O; [`PtyBackend::spawn`] allocates the PTY and
23/// child. This backend does not sandbox programs, validate paths/environment,
24/// redact output/errors, or impose queue memory bounds.
25///
26/// # Examples
27///
28/// ```
29/// use ailloli_ui_terminal_pty::PortablePtyBackend;
30/// let _backend = PortablePtyBackend::new();
31/// ```
32pub struct PortablePtyBackend {
33    /// Configuration copied into each spawned output-batcher thread.
34    batch_config: PtyBatchConfig,
35}
36
37impl PortablePtyBackend {
38    /// Creates a backend with [`PtyBatchConfig::default`].
39    ///
40    /// # Examples
41    ///
42    /// ```
43    /// use ailloli_ui_terminal_pty::PortablePtyBackend;
44    /// let _backend = PortablePtyBackend::new();
45    /// ```
46    pub fn new() -> Self {
47        Self {
48            batch_config: PtyBatchConfig::default(),
49        }
50    }
51
52    /// Creates a backend that copies `batch_config` into every spawned session.
53    ///
54    /// Values are stored without normalization. The batcher clamps a zero byte
55    /// threshold to one, but the portable receive loop uses the timeout exactly.
56    /// A zero timeout can therefore busy-spin whenever no raw chunk is ready.
57    /// The byte threshold remains a flush trigger, not a hard event-size bound.
58    ///
59    /// # Examples
60    ///
61    /// ```
62    /// use std::time::Duration;
63    /// use ailloli_ui_terminal_pty::{PortablePtyBackend, PtyBatchConfig};
64    /// let _backend = PortablePtyBackend::with_batch_config(PtyBatchConfig {
65    ///     max_bytes: 1024,
66    ///     flush_timeout: Duration::from_millis(4),
67    /// });
68    /// ```
69    pub fn with_batch_config(batch_config: PtyBatchConfig) -> Self {
70        Self { batch_config }
71    }
72}
73
74impl Default for PortablePtyBackend {
75    /// Creates the same backend as [`Self::new`].
76    ///
77    /// # Examples
78    ///
79    /// ```
80    /// use ailloli_ui_terminal_pty::PortablePtyBackend;
81    /// let _backend = PortablePtyBackend::default();
82    /// ```
83    fn default() -> Self {
84        Self::new()
85    }
86}
87
88impl PtyBackend for PortablePtyBackend {
89    /// Allocates a native PTY, spawns the configured child, and starts workers.
90    ///
91    /// Rows/columns clamp to at least one. `TERM` is set from `config.term`, then
92    /// ordered environment entries are applied; CWD is applied when present.
93    /// With no program the platform default is selected and `config.args` is
94    /// ignored. With a program, arguments are passed separately without shell
95    /// interpolation by this layer.
96    ///
97    /// Open/spawn/reader/writer setup errors map to [`PtyError::Spawn`] or
98    /// [`PtyError::Io`] with backend strings. A failure after child spawn but
99    /// before handle construction has no explicit kill path here and relies on
100    /// dropped backend objects. The successful threads are detached and unjoined.
101    ///
102    /// # Errors
103    ///
104    /// Returns [`PtyError::Spawn`] if the native PTY cannot be allocated or the
105    /// child cannot be started. Returns [`PtyError::Io`] if the spawned PTY's
106    /// reader or writer handle cannot be acquired.
107    fn spawn(&self, config: PtySpawnConfig) -> Result<PtyHandle, PtyError> {
108        let pty_system = portable_pty::native_pty_system();
109        let pair = pty_system
110            .openpty(to_portable_size(config.size))
111            .map_err(|err| PtyError::Spawn(err.to_string()))?;
112        let mut command = command_builder(&config);
113        command.env("TERM", &config.term);
114        for (key, value) in &config.env {
115            command.env(key, value);
116        }
117        if let Some(cwd) = &config.cwd {
118            command.cwd(cwd.as_os_str());
119        }
120
121        let child = pair
122            .slave
123            .spawn_command(command)
124            .map_err(|err| PtyError::Spawn(err.to_string()))?;
125        let killer = child.clone_killer();
126        let reader = pair
127            .master
128            .try_clone_reader()
129            .map_err(|err| PtyError::Io(err.to_string()))?;
130        let writer = pair
131            .master
132            .take_writer()
133            .map_err(|err| PtyError::Io(err.to_string()))?;
134        let master = Arc::new(Mutex::new(pair.master));
135        let events = Arc::new(Mutex::new(VecDeque::new()));
136        let shutdown = Arc::new(AtomicBool::new(false));
137        let (raw_tx, raw_rx) = mpsc::channel::<Vec<u8>>();
138
139        spawn_reader_thread(reader, raw_tx, events.clone(), shutdown.clone());
140        spawn_batcher_thread(raw_rx, events.clone(), self.batch_config);
141        spawn_wait_thread(child, events.clone(), shutdown.clone());
142
143        Ok(PtyHandle::new(Arc::new(PortablePtySession {
144            master,
145            writer: Mutex::new(writer),
146            killer: Mutex::new(killer),
147            events,
148            shutdown,
149        })))
150    }
151}
152
153/// Shared OS handles, event queue, child killer, and lifecycle flag for one PTY.
154struct PortablePtySession {
155    /// Locked master side used for resize operations.
156    master: Arc<Mutex<Box<dyn MasterPty + Send>>>,
157    /// Locked writer; each public write is followed by a flush.
158    writer: Mutex<Box<dyn Write + Send>>,
159    /// Locked child termination capability.
160    killer: Mutex<Box<dyn ChildKiller + Send + Sync>>,
161    /// Unbounded FIFO shared by reader/batcher/wait workers and consumers.
162    events: Arc<Mutex<VecDeque<PtyEvent>>>,
163    /// True after shutdown request or child-wait completion.
164    shutdown: Arc<AtomicBool>,
165}
166
167impl PtySession for PortablePtySession {
168    /// Serializes a complete write and flush, rejecting observed shutdown.
169    ///
170    /// Shutdown can race after the initial flag check, so a concurrent write may
171    /// still reach the OS or fail with a backend write error.
172    ///
173    /// # Errors
174    ///
175    /// Returns [`PtyError::Closed`] when shutdown was already observed, or
176    /// [`PtyError::Write`] if the writer mutex is poisoned or the native write
177    /// or flush fails.
178    fn write(&self, bytes: &[u8]) -> Result<(), PtyError> {
179        if self.shutdown.load(Ordering::SeqCst) {
180            return Err(PtyError::Closed);
181        }
182        let mut writer = self
183            .writer
184            .lock()
185            .map_err(|_| PtyError::Write("writer lock poisoned".into()))?;
186        writer
187            .write_all(bytes)
188            .map_err(|err| PtyError::Write(err.to_string()))?;
189        writer
190            .flush()
191            .map_err(|err| PtyError::Write(err.to_string()))
192    }
193
194    /// Serializes a clamped master resize, rejecting observed shutdown.
195    ///
196    /// Shutdown can race after the initial flag check.
197    ///
198    /// # Errors
199    ///
200    /// Returns [`PtyError::Closed`] when shutdown was already observed, or
201    /// [`PtyError::Resize`] if the master mutex is poisoned or the native resize
202    /// fails.
203    fn resize(&self, size: PtySize) -> Result<(), PtyError> {
204        if self.shutdown.load(Ordering::SeqCst) {
205            return Err(PtyError::Closed);
206        }
207        self.master
208            .lock()
209            .map_err(|_| PtyError::Resize("master lock poisoned".into()))?
210            .resize(to_portable_size(size))
211            .map_err(|err| PtyError::Resize(err.to_string()))
212    }
213
214    /// Drains the unbounded worker queue in mutex-enqueue order.
215    ///
216    /// Panics when the queue mutex is poisoned.
217    fn drain_events(&self) -> Vec<PtyEvent> {
218        self.events.lock().expect("pty events").drain(..).collect()
219    }
220
221    /// Atomically closes the session and asks the child killer to terminate.
222    ///
223    /// The flag changes before lock/kill. Thus a failure is not retryable through
224    /// this session: later calls observe the flag and return `Ok(())`. Workers are
225    /// not joined and the wait thread can still enqueue an exit event afterward.
226    ///
227    /// # Errors
228    ///
229    /// Returns [`PtyError::Shutdown`] when the child-killer mutex is poisoned or
230    /// the native kill request fails. Repeated calls after the first attempt are
231    /// successful even when that first attempt returned an error.
232    fn shutdown(&self) -> Result<(), PtyError> {
233        if self.shutdown.swap(true, Ordering::SeqCst) {
234            return Ok(());
235        }
236        self.killer
237            .lock()
238            .map_err(|_| PtyError::Shutdown("killer lock poisoned".into()))?
239            .kill()
240            .map_err(|err| PtyError::Shutdown(err.to_string()))
241    }
242
243    /// Observes request/wait completion using sequential consistency.
244    fn is_shutdown(&self) -> bool {
245        self.shutdown.load(Ordering::SeqCst)
246    }
247}
248
249/// Builds an explicit command with ordered args or the platform default program.
250fn command_builder(config: &PtySpawnConfig) -> CommandBuilder {
251    if let Some(program) = &config.program {
252        let mut command = CommandBuilder::new(program.as_os_str());
253        command.args(config.args.iter().map(String::as_str));
254        command
255    } else {
256        CommandBuilder::new_default_prog()
257    }
258}
259
260/// Clamps character dimensions and copies all fields into the backend type.
261fn to_portable_size(size: PtySize) -> portable_pty::PtySize {
262    let size = PtySize::new(size.rows, size.cols, size.pixel_width, size.pixel_height);
263    portable_pty::PtySize {
264        rows: size.rows,
265        cols: size.cols,
266        pixel_width: size.pixel_width,
267        pixel_height: size.pixel_height,
268    }
269}
270
271/// Spawns a detached 4,096-byte reader feeding the unbounded raw channel.
272///
273/// EOF ends silently. A read error enqueues a raw error unless shutdown is
274/// already observed. Channel disconnect ends silently. Poisoned event locking
275/// panics this worker.
276fn spawn_reader_thread(
277    mut reader: Box<dyn Read + Send>,
278    raw_tx: mpsc::Sender<Vec<u8>>,
279    events: Arc<Mutex<VecDeque<PtyEvent>>>,
280    shutdown: Arc<AtomicBool>,
281) {
282    thread::spawn(move || {
283        let mut buf = [0_u8; 4096];
284        loop {
285            match reader.read(&mut buf) {
286                Ok(0) => break,
287                Ok(n) => {
288                    if raw_tx.send(buf[..n].to_vec()).is_err() {
289                        break;
290                    }
291                }
292                Err(err) => {
293                    if !shutdown.load(Ordering::SeqCst) {
294                        events
295                            .lock()
296                            .expect("pty events")
297                            .push_back(PtyEvent::Error(err.to_string()));
298                    }
299                    break;
300                }
301            }
302        }
303    });
304}
305
306/// Spawns a detached receiver that batches raw chunks into the public queue.
307///
308/// Receive timeout drives inactivity ticks; channel disconnect flushes once and
309/// exits. Newline/threshold batches and final flushes preserve raw-channel order.
310/// The queue is unbounded, poison panics the worker, and a zero timeout can spin.
311fn spawn_batcher_thread(
312    raw_rx: mpsc::Receiver<Vec<u8>>,
313    events: Arc<Mutex<VecDeque<PtyEvent>>>,
314    config: PtyBatchConfig,
315) {
316    thread::spawn(move || {
317        let mut batcher = PtyOutputBatcher::with_config(config);
318        loop {
319            match raw_rx.recv_timeout(config.flush_timeout) {
320                Ok(bytes) => {
321                    for event in batcher.push(&bytes) {
322                        events.lock().expect("pty events").push_back(event);
323                    }
324                }
325                Err(mpsc::RecvTimeoutError::Timeout) => {
326                    if let Some(event) = batcher.tick() {
327                        events.lock().expect("pty events").push_back(event);
328                    }
329                }
330                Err(mpsc::RecvTimeoutError::Disconnected) => {
331                    if let Some(event) = batcher.flush() {
332                        events.lock().expect("pty events").push_back(event);
333                    }
334                    break;
335                }
336            }
337        }
338    });
339}
340
341/// Spawns a detached child waiter and then marks the session shut down.
342///
343/// Successful waits always enqueue an exit (code is always `Some` here), even
344/// after an explicit kill. Wait errors enqueue only before shutdown is observed.
345/// This worker races the batcher, so exit may be queued before final output.
346/// Poisoned event locking panics before the final shutdown store.
347fn spawn_wait_thread(
348    mut child: Box<dyn portable_pty::Child + Send + Sync>,
349    events: Arc<Mutex<VecDeque<PtyEvent>>>,
350    shutdown: Arc<AtomicBool>,
351) {
352    thread::spawn(move || {
353        match child.wait() {
354            Ok(status) => {
355                events
356                    .lock()
357                    .expect("pty events")
358                    .push_back(PtyEvent::Exit(PtyExitStatus {
359                        success: status.success(),
360                        exit_code: Some(status.exit_code()),
361                        signal: status.signal().map(str::to_string),
362                    }))
363            }
364            Err(err) => {
365                if !shutdown.load(Ordering::SeqCst) {
366                    events
367                        .lock()
368                        .expect("pty events")
369                        .push_back(PtyEvent::Error(err.to_string()));
370                }
371            }
372        }
373        shutdown.store(true, Ordering::SeqCst);
374    });
375}