Skip to main content

running_process/pty/
native_pty_process.rs

1use std::collections::VecDeque;
2use std::io::Write as _;
3use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
4use std::sync::{Arc, Condvar, Mutex};
5use std::thread;
6use std::time::{Duration, Instant};
7
8use super::backend::PtySlave;
9use super::backend::{Backend, PtyBackend, PtyChild, PtyMaster, PtySize};
10use super::{
11    is_ignorable_process_control_error, poll_pty_process, record_pty_input_metrics,
12    spawn_pty_reader, store_pty_returncode, terminal_input_relay_worker, write_pty_input,
13    IdleDetectorCore, NativePtyHandles, PtyError, PtyReadShared, PtyReadState,
14};
15use running_process_platform_internal::platform::terminal as pty_platform;
16
17/// Low-level native pseudo-terminal process wrapper.
18///
19/// The process is configured at construction time and is spawned by
20/// [`Self::start_impl`]. Output is collected by a reader thread and exposed
21/// through the chunk-reading methods.
22pub struct NativePtyProcess {
23    /// Command argv, including the executable as the first element.
24    pub argv: Vec<String>,
25    /// Working directory used when spawning the child, or the current directory.
26    pub cwd: Option<String>,
27    /// Environment overrides passed to the child process.
28    pub env: Option<Vec<(String, String)>>,
29    /// Initial PTY row count.
30    pub rows: u16,
31    /// Initial PTY column count.
32    pub cols: u16,
33    /// Optional host process priority hint for the PTY child.
34    pub nice: Option<i32>,
35    /// Native PTY handles for the running child, present after start.
36    pub handles: Arc<Mutex<Option<NativePtyHandles>>>,
37    /// Shared reader queue and condition variable for PTY output.
38    pub reader: Arc<PtyReadShared>,
39    /// Cached child exit code once the process has exited.
40    pub returncode: Arc<Mutex<Option<i32>>>,
41    /// Total bytes written to the PTY input stream.
42    pub input_bytes_total: Arc<AtomicUsize>,
43    /// Count of input writes containing a newline.
44    pub newline_events_total: Arc<AtomicUsize>,
45    /// Count of explicit submit events recorded for PTY input.
46    pub submit_events_total: Arc<AtomicUsize>,
47    /// When true, the reader thread writes PTY output to stdout.
48    pub echo: Arc<AtomicBool>,
49    /// When set, the reader thread feeds output directly to the idle detector.
50    pub idle_detector: Arc<Mutex<Option<Arc<IdleDetectorCore>>>>,
51    /// Visible (non-control) output bytes seen by the reader thread.
52    pub output_bytes_total: Arc<AtomicUsize>,
53    /// Control churn bytes (ANSI escapes, BS, CR, DEL) seen by the reader.
54    pub control_churn_bytes_total: Arc<AtomicUsize>,
55    /// Background worker that drains PTY output into the shared queue.
56    pub reader_worker: Mutex<Option<thread::JoinHandle<()>>>,
57    /// Stop flag observed by the terminal input relay worker.
58    pub terminal_input_relay_stop: Arc<AtomicBool>,
59    /// Whether the terminal input relay worker is currently active.
60    pub terminal_input_relay_active: Arc<AtomicBool>,
61    /// Background worker that forwards local terminal input into the PTY.
62    pub terminal_input_relay_worker: Mutex<Option<thread::JoinHandle<()>>>,
63}
64
65pub(super) fn resolved_spawn_cwd(cwd: Option<&str>) -> Option<String> {
66    cwd.map(str::to_owned).or_else(|| {
67        std::env::current_dir()
68            .ok()
69            .map(|cwd| cwd.to_string_lossy().to_string())
70    })
71}
72
73impl NativePtyProcess {
74    /// Create a pseudo-terminal process configuration.
75    ///
76    /// The child is not spawned until [`Self::start_impl`] is called.
77    pub fn new(
78        argv: Vec<String>,
79        cwd: Option<String>,
80        env: Option<Vec<(String, String)>>,
81        rows: u16,
82        cols: u16,
83        nice: Option<i32>,
84    ) -> Result<Self, PtyError> {
85        if argv.is_empty() {
86            return Err(PtyError::Other("command cannot be empty".into()));
87        }
88        Ok(Self {
89            argv,
90            cwd,
91            env,
92            rows,
93            cols,
94            nice,
95            handles: Arc::new(Mutex::new(None)),
96            reader: Arc::new(PtyReadShared {
97                state: Mutex::new(PtyReadState {
98                    chunks: VecDeque::new(),
99                    closed: false,
100                }),
101                condvar: Condvar::new(),
102            }),
103            returncode: Arc::new(Mutex::new(None)),
104            input_bytes_total: Arc::new(AtomicUsize::new(0)),
105            newline_events_total: Arc::new(AtomicUsize::new(0)),
106            submit_events_total: Arc::new(AtomicUsize::new(0)),
107            echo: Arc::new(AtomicBool::new(false)),
108            idle_detector: Arc::new(Mutex::new(None)),
109            output_bytes_total: Arc::new(AtomicUsize::new(0)),
110            control_churn_bytes_total: Arc::new(AtomicUsize::new(0)),
111            reader_worker: Mutex::new(None),
112            terminal_input_relay_stop: Arc::new(AtomicBool::new(false)),
113            terminal_input_relay_active: Arc::new(AtomicBool::new(false)),
114            terminal_input_relay_worker: Mutex::new(None),
115        })
116    }
117
118    /// Mark the reader stream closed and wake all waiting readers.
119    pub fn mark_reader_closed(&self) {
120        let mut guard = self.reader.state.lock().expect("pty read mutex poisoned");
121        guard.closed = true;
122        self.reader.condvar.notify_all();
123    }
124
125    /// Store the process return code if it has been observed.
126    pub fn store_returncode(&self, code: i32) {
127        store_pty_returncode(&self.returncode, code);
128    }
129
130    /// Record PTY input byte, newline, and submit counters.
131    pub fn record_input_metrics(&self, data: &[u8], submit: bool) {
132        record_pty_input_metrics(
133            &self.input_bytes_total,
134            &self.newline_events_total,
135            &self.submit_events_total,
136            data,
137            submit,
138        );
139    }
140
141    /// Write bytes to the PTY input stream and record input metrics.
142    pub fn write_impl(&self, data: &[u8], submit: bool) -> Result<(), PtyError> {
143        self.record_input_metrics(data, submit);
144        write_pty_input(&self.handles, data)?;
145        Ok(())
146    }
147
148    /// Signal the terminal input relay worker to stop.
149    pub fn request_terminal_input_relay_stop(&self) {
150        self.terminal_input_relay_stop
151            .store(true, Ordering::Release);
152        self.terminal_input_relay_active
153            .store(false, Ordering::Release);
154    }
155
156    /// Start forwarding local terminal input into the PTY.
157    pub fn start_terminal_input_relay_impl(&self) -> Result<(), PtyError> {
158        let mut worker_guard = self
159            .terminal_input_relay_worker
160            .lock()
161            .expect("pty terminal input relay mutex poisoned");
162        if worker_guard.is_some() && self.terminal_input_relay_active() {
163            return Ok(());
164        }
165        if self
166            .handles
167            .lock()
168            .expect("pty handles mutex poisoned")
169            .is_none()
170        {
171            return Err(PtyError::NotRunning);
172        }
173
174        let Some(input) = pty_platform::TerminalInputSession::new().map_err(PtyError::Io)? else {
175            self.terminal_input_relay_active
176                .store(false, Ordering::Release);
177            return Ok(());
178        };
179
180        self.terminal_input_relay_stop
181            .store(false, Ordering::Release);
182        self.terminal_input_relay_active
183            .store(true, Ordering::Release);
184
185        let relay_state = super::TerminalInputRelayState {
186            handles: Arc::clone(&self.handles),
187            returncode: Arc::clone(&self.returncode),
188            input_bytes_total: Arc::clone(&self.input_bytes_total),
189            newline_events_total: Arc::clone(&self.newline_events_total),
190            submit_events_total: Arc::clone(&self.submit_events_total),
191            stop: Arc::clone(&self.terminal_input_relay_stop),
192            active: Arc::clone(&self.terminal_input_relay_active),
193        };
194
195        *worker_guard = Some(thread::spawn(move || {
196            terminal_input_relay_worker(input, relay_state);
197        }));
198        Ok(())
199    }
200
201    /// Stop the terminal input relay worker and wait for it to exit.
202    pub fn stop_terminal_input_relay_impl(&self) {
203        self.request_terminal_input_relay_stop();
204        if let Some(worker) = self
205            .terminal_input_relay_worker
206            .lock()
207            .expect("pty terminal input relay mutex poisoned")
208            .take()
209        {
210            let _ = worker.join();
211        }
212    }
213
214    /// Return whether the terminal input relay worker is active.
215    pub fn terminal_input_relay_active(&self) -> bool {
216        self.terminal_input_relay_active.load(Ordering::Acquire)
217    }
218
219    /// Synchronously tear down the PTY and reap the child.
220    #[inline(never)]
221    pub fn close_impl(&self) -> Result<(), PtyError> {
222        crate::rp_rust_debug_scope!("running_process::NativePtyProcess::close_impl");
223        self.stop_terminal_input_relay_impl();
224        let mut guard = self.handles.lock().expect("pty handles mutex poisoned");
225        let Some(handles) = guard.take() else {
226            self.mark_reader_closed();
227            return Ok(());
228        };
229        drop(guard);
230
231        let NativePtyHandles {
232            master,
233            writer,
234            mut child,
235            process_guard,
236        } = handles;
237        let wait_before_close = pty_platform::wait_before_close_supported();
238        let mut control_error = None;
239        if wait_before_close {
240            if let Err(error) = pty_platform::kill_pty_process_group(master.as_ref()) {
241                if !is_ignorable_process_control_error(&error) {
242                    control_error = Some(error);
243                }
244            }
245            if let Err(error) = child.kill() {
246                if !is_ignorable_process_control_error(&error) && control_error.is_none() {
247                    control_error = Some(error);
248                }
249            }
250        }
251
252        // On Windows this closes the kill-on-close Job Object before the
253        // bounded reap. On Unix the guard is a no-op token.
254        drop(process_guard);
255        let reap_deadline = Instant::now() + Duration::from_secs(2);
256        let code = loop {
257            match child.try_wait() {
258                Ok(Some(status)) => break status as i32,
259                Ok(None) if Instant::now() < reap_deadline => {
260                    thread::sleep(Duration::from_millis(10));
261                }
262                Ok(None) if wait_before_close => break -9,
263                Ok(None) => {
264                    if let Err(error) = child.kill() {
265                        if !is_ignorable_process_control_error(&error) && control_error.is_none() {
266                            control_error = Some(error);
267                        }
268                    }
269                    let kill_deadline = Instant::now() + Duration::from_secs(2);
270                    break loop {
271                        match child.try_wait() {
272                            Ok(Some(status)) => break status as i32,
273                            Ok(None) if Instant::now() < kill_deadline => {
274                                thread::sleep(Duration::from_millis(10));
275                            }
276                            _ => break -9,
277                        }
278                    };
279                }
280                Err(error) => {
281                    if control_error.is_none() {
282                        control_error = Some(error);
283                    }
284                    break -9;
285                }
286            }
287        };
288        drop(writer);
289        let reader_worker = self
290            .reader_worker
291            .lock()
292            .expect("pty reader worker mutex poisoned")
293            .take();
294        let (teardown_tx, teardown_rx) = std::sync::mpsc::channel();
295        thread::spawn(move || {
296            drop(master);
297            drop(child);
298            if let Some(worker) = reader_worker {
299                let _ = worker.join();
300            }
301            let _ = teardown_tx.send(());
302        });
303        let _ = teardown_rx.recv_timeout(Duration::from_secs(2));
304        self.store_returncode(code);
305        self.mark_reader_closed();
306        control_error.map_or(Ok(()), |error| Err(PtyError::Io(error)))
307    }
308    /// Best-effort, non-blocking teardown for use from `Drop`.
309    #[inline(never)]
310    pub fn close_nonblocking(&self) {
311        crate::rp_rust_debug_scope!("running_process::NativePtyProcess::close_nonblocking");
312        self.request_terminal_input_relay_stop();
313        let Ok(mut guard) = self.handles.lock() else {
314            return;
315        };
316        let Some(handles) = guard.take() else {
317            self.mark_reader_closed();
318            return;
319        };
320        drop(guard);
321
322        let NativePtyHandles {
323            master,
324            writer,
325            mut child,
326            process_guard,
327        } = handles;
328        let _ = child.kill();
329        drop(writer);
330        if pty_platform::wait_before_close_supported() {
331            drop(master);
332            drop(child);
333            drop(process_guard);
334        } else {
335            thread::spawn(move || {
336                drop(master);
337                drop(child);
338                drop(process_guard);
339            });
340        }
341        self.mark_reader_closed();
342    }
343    /// Spawn the configured child process inside a native PTY.
344    pub fn start_impl(&self) -> Result<(), PtyError> {
345        crate::rp_rust_debug_scope!("running_process::NativePtyProcess::start");
346        let mut guard = self.handles.lock().expect("pty handles mutex poisoned");
347        if guard.is_some() {
348            return Err(PtyError::AlreadyStarted);
349        }
350
351        let spawn_context = pty_platform::before_pty_spawn();
352
353        let (mut master, slave) = Backend::openpty(PtySize {
354            rows: self.rows,
355            cols: self.cols,
356            pixel_width: 0,
357            pixel_height: 0,
358        })
359        .map_err(|e| PtyError::Spawn(e.to_string()))?;
360
361        // Build argv/cwd/env in the shape the backend wants.
362        let argv: Vec<std::ffi::OsString> =
363            self.argv.iter().map(std::ffi::OsString::from).collect();
364        let cwd = resolved_spawn_cwd(self.cwd.as_deref());
365        let env: Option<Vec<(std::ffi::OsString, std::ffi::OsString)>> =
366            self.env.as_ref().map(|e| {
367                e.iter()
368                    .map(|(k, v)| (std::ffi::OsString::from(k), std::ffi::OsString::from(v)))
369                    .collect()
370            });
371
372        let reader = master
373            .try_clone_reader()
374            .map_err(|e| PtyError::Spawn(e.to_string()))?;
375        let writer = master
376            .take_writer()
377            .map_err(|e| PtyError::Spawn(e.to_string()))?;
378        let cwd_path = cwd.as_deref().map(std::path::Path::new);
379        let child = slave
380            .spawn(&argv, cwd_path, env.as_deref())
381            .map_err(|e| PtyError::Spawn(e.to_string()))?;
382        let process_guard = pty_platform::prepare_pty_child(spawn_context, &child, self.nice)
383            .map_err(PtyError::Io)?;
384        let shared = Arc::clone(&self.reader);
385        let echo = Arc::clone(&self.echo);
386        let idle_detector = Arc::clone(&self.idle_detector);
387        let output_bytes = Arc::clone(&self.output_bytes_total);
388        let churn_bytes = Arc::clone(&self.control_churn_bytes_total);
389        let reader_worker = thread::spawn(move || {
390            spawn_pty_reader(
391                reader,
392                shared,
393                echo,
394                idle_detector,
395                output_bytes,
396                churn_bytes,
397            );
398        });
399        *self
400            .reader_worker
401            .lock()
402            .expect("pty reader worker mutex poisoned") = Some(reader_worker);
403
404        *guard = Some(NativePtyHandles {
405            master: Box::new(master) as Box<dyn PtyMaster>,
406            // #590 cluster D: writer lives behind its own mutex so a
407            // blocking input write never holds the `handles` lock.
408            writer: Arc::new(Mutex::new(writer)),
409            child: Box::new(child) as Box<dyn PtyChild>,
410            process_guard,
411        });
412        Ok(())
413    }
414
415    /// Respond to terminal query escape sequences found in a PTY output chunk.
416    pub fn respond_to_queries_impl(&self, data: &[u8]) -> Result<(), PtyError> {
417        let responses = pty_platform::query_responses(data);
418        if responses.is_empty() {
419            return Ok(());
420        }
421        let writer = {
422            let guard = self.handles.lock().expect("pty handles mutex poisoned");
423            let handles = guard.as_ref().ok_or(PtyError::NotRunning)?;
424            Arc::clone(&handles.writer)
425        };
426        let mut writer = writer.lock().expect("pty writer mutex poisoned");
427        for response in responses {
428            writer.write_all(&response).map_err(PtyError::Io)?;
429        }
430        writer.flush().map_err(PtyError::Io)
431    }
432
433    /// Resize the PTY to the given row and column dimensions.
434    pub fn resize_impl(&self, rows: u16, cols: u16) -> Result<(), PtyError> {
435        crate::rp_rust_debug_scope!("running_process::NativePtyProcess::resize");
436        let guard = self.handles.lock().expect("pty handles mutex poisoned");
437        if let Some(handles) = guard.as_ref() {
438            pty_platform::resize_pty(
439                handles.master.as_ref(),
440                PtySize {
441                    rows,
442                    cols,
443                    pixel_width: 0,
444                    pixel_height: 0,
445                },
446            )
447            .map_err(|error| PtyError::Other(error.to_string()))?;
448        }
449        Ok(())
450    }
451
452    /// Send an interrupt signal or control event to the PTY child.
453    pub fn send_interrupt_impl(&self) -> Result<(), PtyError> {
454        crate::rp_rust_debug_scope!("running_process::NativePtyProcess::send_interrupt");
455        let (target, writer) = {
456            let guard = self.handles.lock().expect("pty handles mutex poisoned");
457            let handles = guard.as_ref().ok_or(PtyError::NotRunning)?;
458            (
459                handles.master.interrupt_target().map_err(PtyError::Io)?,
460                Arc::clone(&handles.writer),
461            )
462        };
463        let wrote_input =
464            pty_platform::send_pty_interrupt(target, &writer).map_err(PtyError::Io)?;
465        if wrote_input {
466            self.record_input_metrics(&[0x03], false);
467        }
468        Ok(())
469    }
470
471    /// Wait for the PTY child to exit and return its exit code.
472    ///
473    /// Returns a timeout error when `timeout` elapses before exit.
474    pub fn wait_impl(&self, timeout: Option<f64>) -> Result<i32, PtyError> {
475        crate::rp_rust_debug_scope!("running_process::NativePtyProcess::wait");
476        // Fast path: already exited.
477        if let Some(code) = *self
478            .returncode
479            .lock()
480            .expect("pty returncode mutex poisoned")
481        {
482            return Ok(code);
483        }
484        let start = Instant::now();
485        loop {
486            if let Some(code) = poll_pty_process(&self.handles, &self.returncode)? {
487                return Ok(code);
488            }
489            if timeout.is_some_and(|limit| start.elapsed() >= Duration::from_secs_f64(limit)) {
490                return Err(PtyError::Timeout);
491            }
492            // #199: intentional — `wait_impl` poll. Same constraint
493            // as the close_impl variant above: no per-Child wait
494            // primitive on the trait surface.
495            thread::sleep(Duration::from_millis(10));
496        }
497    }
498
499    /// Request graceful termination of the PTY child.
500    pub fn terminate_impl(&self) -> Result<(), PtyError> {
501        crate::rp_rust_debug_scope!("running_process::NativePtyProcess::terminate");
502        let should_close = {
503            let mut guard = self.handles.lock().expect("pty handles mutex poisoned");
504            let handles = guard.as_mut().ok_or(PtyError::NotRunning)?;
505            let pid = handles.child.pid();
506            if pid == 0 {
507                return Err(PtyError::NotRunning);
508            }
509            pty_platform::terminate_pty_child(pid).map_err(PtyError::Io)?
510        };
511        if should_close {
512            self.close_impl()?;
513        }
514        Ok(())
515    }
516
517    /// Forcefully terminate the PTY child.
518    pub fn kill_impl(&self) -> Result<(), PtyError> {
519        crate::rp_rust_debug_scope!("running_process::NativePtyProcess::kill");
520        if self
521            .handles
522            .lock()
523            .expect("pty handles mutex poisoned")
524            .is_none()
525        {
526            return Err(PtyError::NotRunning);
527        }
528        self.close_impl()
529    }
530
531    /// Request graceful termination of the PTY child process tree.
532    pub fn terminate_tree_impl(&self) -> Result<(), PtyError> {
533        crate::rp_rust_debug_scope!("running_process::NativePtyProcess::terminate_tree");
534        let Some(pid) = self.pid()? else {
535            return if self
536                .returncode
537                .lock()
538                .expect("pty returncode mutex poisoned")
539                .is_some()
540            {
541                Ok(())
542            } else {
543                Err(PtyError::NotRunning)
544            };
545        };
546        if pty_platform::signal_pty_tree(pid, false).map_err(PtyError::Io)? {
547            self.close_impl()?;
548        }
549        Ok(())
550    }
551
552    /// Forcefully terminate the PTY child process tree.
553    pub fn kill_tree_impl(&self) -> Result<(), PtyError> {
554        crate::rp_rust_debug_scope!("running_process::NativePtyProcess::kill_tree");
555        let Some(pid) = self.pid()? else {
556            return if self
557                .returncode
558                .lock()
559                .expect("pty returncode mutex poisoned")
560                .is_some()
561            {
562                Ok(())
563            } else {
564                Err(PtyError::NotRunning)
565            };
566        };
567        if pty_platform::signal_pty_tree(pid, true).map_err(PtyError::Io)? {
568            self.close_impl()?;
569        }
570        Ok(())
571    }
572
573    /// Get the PID of the child process, if running.
574    pub fn pid(&self) -> Result<Option<u32>, PtyError> {
575        let guard = self.handles.lock().expect("pty handles mutex poisoned");
576        if let Some(handles) = guard.as_ref() {
577            return Ok(pty_platform::preferred_pty_pid(
578                handles.master.as_ref(),
579                handles.child.as_ref(),
580            ));
581        }
582        Ok(None)
583    }
584
585    /// Wait for a chunk of output from the PTY reader.
586    /// Returns `Ok(Some(chunk))` on data, `Ok(None)` on timeout, `Err` on closed.
587    pub fn read_chunk_impl(&self, timeout: Option<f64>) -> Result<Option<Vec<u8>>, PtyError> {
588        let deadline = timeout.map(|secs| Instant::now() + Duration::from_secs_f64(secs));
589        let mut guard = self.reader.state.lock().expect("pty read mutex poisoned");
590        loop {
591            if let Some(chunk) = guard.chunks.pop_front() {
592                return Ok(Some(chunk));
593            }
594            if guard.closed {
595                return Err(PtyError::Other("Pseudo-terminal stream is closed".into()));
596            }
597            match deadline {
598                Some(deadline) => {
599                    let now = Instant::now();
600                    if now >= deadline {
601                        return Ok(None); // timeout
602                    }
603                    let wait = deadline.saturating_duration_since(now);
604                    let result = self
605                        .reader
606                        .condvar
607                        .wait_timeout(guard, wait)
608                        .expect("pty read mutex poisoned");
609                    guard = result.0;
610                }
611                None => {
612                    guard = self
613                        .reader
614                        .condvar
615                        .wait(guard)
616                        .expect("pty read mutex poisoned");
617                }
618            }
619        }
620    }
621
622    /// Wait for the reader thread to close.
623    pub fn wait_for_reader_closed_impl(&self, timeout: Option<f64>) -> bool {
624        let deadline = timeout.map(|secs| Instant::now() + Duration::from_secs_f64(secs));
625        let mut guard = self.reader.state.lock().expect("pty read mutex poisoned");
626        loop {
627            if guard.closed {
628                return true;
629            }
630            match deadline {
631                Some(deadline) => {
632                    let now = Instant::now();
633                    if now >= deadline {
634                        return false;
635                    }
636                    let wait = deadline.saturating_duration_since(now);
637                    let result = self
638                        .reader
639                        .condvar
640                        .wait_timeout(guard, wait)
641                        .expect("pty read mutex poisoned");
642                    guard = result.0;
643                }
644                None => {
645                    guard = self
646                        .reader
647                        .condvar
648                        .wait(guard)
649                        .expect("pty read mutex poisoned");
650                }
651            }
652        }
653    }
654
655    /// Wait for exit then drain remaining output.
656    pub fn wait_and_drain_impl(
657        &self,
658        timeout: Option<f64>,
659        drain_timeout: f64,
660    ) -> Result<i32, PtyError> {
661        let code = self.wait_impl(timeout)?;
662        let deadline = Instant::now() + Duration::from_secs_f64(drain_timeout.max(0.0));
663        let mut guard = self.reader.state.lock().expect("pty read mutex poisoned");
664        while !guard.closed {
665            let remaining = deadline.saturating_duration_since(Instant::now());
666            if remaining.is_zero() {
667                break;
668            }
669            let result = self
670                .reader
671                .condvar
672                .wait_timeout(guard, remaining)
673                .expect("pty read mutex poisoned");
674            guard = result.0;
675        }
676        Ok(code)
677    }
678
679    /// Enable or disable echoing PTY output to stdout.
680    pub fn set_echo(&self, enabled: bool) {
681        self.echo.store(enabled, Ordering::Release);
682    }
683
684    /// Return whether PTY output echoing is enabled.
685    pub fn echo_enabled(&self) -> bool {
686        self.echo.load(Ordering::Acquire)
687    }
688
689    /// Attach an idle detector that observes reader-thread output.
690    pub fn attach_idle_detector(&self, detector: &Arc<IdleDetectorCore>) {
691        let mut guard = self
692            .idle_detector
693            .lock()
694            .expect("idle detector mutex poisoned");
695        *guard = Some(Arc::clone(detector));
696    }
697
698    /// Detach the current idle detector, if one is attached.
699    pub fn detach_idle_detector(&self) {
700        let mut guard = self
701            .idle_detector
702            .lock()
703            .expect("idle detector mutex poisoned");
704        *guard = None;
705    }
706
707    /// Return total bytes written to PTY input.
708    pub fn pty_input_bytes_total(&self) -> usize {
709        self.input_bytes_total.load(Ordering::Acquire)
710    }
711
712    /// Return the number of PTY input writes containing newlines.
713    pub fn pty_newline_events_total(&self) -> usize {
714        self.newline_events_total.load(Ordering::Acquire)
715    }
716
717    /// Return the number of recorded PTY input submit events.
718    pub fn pty_submit_events_total(&self) -> usize {
719        self.submit_events_total.load(Ordering::Acquire)
720    }
721
722    /// Return visible PTY output bytes observed by the reader thread.
723    pub fn pty_output_bytes_total(&self) -> usize {
724        self.output_bytes_total.load(Ordering::Acquire)
725    }
726
727    /// Return control-churn bytes observed by the reader thread.
728    pub fn pty_control_churn_bytes_total(&self) -> usize {
729        self.control_churn_bytes_total.load(Ordering::Acquire)
730    }
731}
732
733/// Safe defaults for a real interactive PTY session.
734///
735/// The helper turns on the parts that a terminal-style session usually needs:
736/// output echo, terminal input relay, and automatic PTY query replies.
737#[derive(Debug, Clone, Copy)]
738pub struct InteractivePtyOptions {
739    /// Echo PTY output to stdout while the session is running.
740    pub echo_output: bool,
741    /// Relay local terminal input into the PTY.
742    pub relay_terminal_input: bool,
743    /// Automatically answer terminal query escape sequences.
744    pub respond_to_queries: bool,
745}
746
747impl Default for InteractivePtyOptions {
748    fn default() -> Self {
749        Self {
750            echo_output: true,
751            relay_terminal_input: true,
752            respond_to_queries: true,
753        }
754    }
755}
756
757/// Output collected by one interactive PTY pump operation.
758#[derive(Debug, Default)]
759pub struct InteractivePtyPumpResult {
760    /// Output chunks read from the PTY.
761    pub chunks: Vec<Vec<u8>>,
762    /// Whether the PTY stream closed while pumping output.
763    pub stream_closed: bool,
764}
765
766/// Canonical interactive PTY recipe for downstream Rust consumers.
767///
768/// `NativePtyProcess` remains the low-level primitive. This wrapper owns the
769/// interactive setup that callers commonly forget to assemble correctly.
770pub struct InteractivePtySession {
771    process: NativePtyProcess,
772    options: InteractivePtyOptions,
773}
774
775impl InteractivePtySession {
776    /// Create an interactive PTY session with default options.
777    pub fn new(process: NativePtyProcess) -> Self {
778        Self::with_options(process, InteractivePtyOptions::default())
779    }
780
781    /// Create an interactive PTY session with explicit options.
782    pub fn with_options(process: NativePtyProcess, options: InteractivePtyOptions) -> Self {
783        Self { process, options }
784    }
785
786    /// Return the wrapped low-level PTY process.
787    pub fn process(&self) -> &NativePtyProcess {
788        &self.process
789    }
790
791    /// Start the wrapped PTY process and configured interactive helpers.
792    pub fn start(&self) -> Result<(), PtyError> {
793        self.process.set_echo(self.options.echo_output);
794        self.process.start_impl()?;
795        if self.options.relay_terminal_input {
796            self.process.start_terminal_input_relay_impl()?;
797        }
798        Ok(())
799    }
800
801    /// Read and optionally drain available PTY output.
802    ///
803    /// When query responses are enabled, terminal queries in each chunk are
804    /// answered before the chunk is returned.
805    pub fn pump_output(
806        &self,
807        timeout: Option<f64>,
808        consume_all: bool,
809    ) -> Result<InteractivePtyPumpResult, PtyError> {
810        let mut pumped = InteractivePtyPumpResult::default();
811        let mut next_timeout = timeout;
812        loop {
813            match self.process.read_chunk_impl(next_timeout) {
814                Ok(Some(chunk)) => {
815                    if self.options.respond_to_queries {
816                        self.process.respond_to_queries_impl(&chunk)?;
817                    }
818                    pumped.chunks.push(chunk);
819                    if !consume_all {
820                        break;
821                    }
822                    next_timeout = Some(0.0);
823                }
824                Ok(None) => break,
825                Err(PtyError::Other(message)) if message == "Pseudo-terminal stream is closed" => {
826                    pumped.stream_closed = true;
827                    break;
828                }
829                Err(err) => return Err(err),
830            }
831        }
832        Ok(pumped)
833    }
834
835    /// Resize the interactive PTY.
836    pub fn resize(&self, rows: u16, cols: u16) -> Result<(), PtyError> {
837        self.process.resize_impl(rows, cols)
838    }
839
840    /// Send an interrupt to the interactive PTY child.
841    pub fn send_interrupt(&self) -> Result<(), PtyError> {
842        self.process.send_interrupt_impl()
843    }
844
845    /// Wait for the interactive PTY child to exit.
846    pub fn wait(&self, timeout: Option<f64>) -> Result<i32, PtyError> {
847        self.process.wait_impl(timeout)
848    }
849
850    /// Wait for the child to exit, then drain remaining PTY output.
851    pub fn wait_and_drain(
852        &self,
853        timeout: Option<f64>,
854        drain_timeout: f64,
855    ) -> Result<i32, PtyError> {
856        self.process.wait_and_drain_impl(timeout, drain_timeout)
857    }
858
859    /// Request graceful termination of the interactive PTY child.
860    pub fn terminate(&self) -> Result<(), PtyError> {
861        self.process.terminate_impl()
862    }
863
864    /// Forcefully terminate the interactive PTY child.
865    pub fn kill(&self) -> Result<(), PtyError> {
866        self.process.kill_impl()
867    }
868
869    /// Close the interactive PTY session.
870    pub fn close(&self) -> Result<(), PtyError> {
871        self.process.close_impl()
872    }
873}
874
875impl Drop for NativePtyProcess {
876    fn drop(&mut self) {
877        self.close_nonblocking();
878    }
879}
880
881#[cfg(test)]
882#[path = "../tests/native_pty_process_coverage.rs"]
883mod coverage_tests;