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}