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.control_token()) {
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(
383            spawn_context,
384            PtyChild::control_token(&child),
385            self.nice,
386        )
387        .map_err(PtyError::Io)?;
388        let shared = Arc::clone(&self.reader);
389        let echo = Arc::clone(&self.echo);
390        let idle_detector = Arc::clone(&self.idle_detector);
391        let output_bytes = Arc::clone(&self.output_bytes_total);
392        let churn_bytes = Arc::clone(&self.control_churn_bytes_total);
393        let reader_worker = thread::spawn(move || {
394            spawn_pty_reader(
395                reader,
396                shared,
397                echo,
398                idle_detector,
399                output_bytes,
400                churn_bytes,
401            );
402        });
403        *self
404            .reader_worker
405            .lock()
406            .expect("pty reader worker mutex poisoned") = Some(reader_worker);
407
408        *guard = Some(NativePtyHandles {
409            master: Box::new(master) as Box<dyn PtyMaster>,
410            // #590 cluster D: writer lives behind its own mutex so a
411            // blocking input write never holds the `handles` lock.
412            writer: Arc::new(Mutex::new(writer)),
413            child: Box::new(child) as Box<dyn PtyChild>,
414            process_guard,
415        });
416        Ok(())
417    }
418
419    /// Respond to terminal query escape sequences found in a PTY output chunk.
420    pub fn respond_to_queries_impl(&self, data: &[u8]) -> Result<(), PtyError> {
421        let responses = pty_platform::query_responses(data);
422        if responses.is_empty() {
423            return Ok(());
424        }
425        let writer = {
426            let guard = self.handles.lock().expect("pty handles mutex poisoned");
427            let handles = guard.as_ref().ok_or(PtyError::NotRunning)?;
428            Arc::clone(&handles.writer)
429        };
430        let mut writer = writer.lock().expect("pty writer mutex poisoned");
431        for response in responses {
432            writer.write_all(&response).map_err(PtyError::Io)?;
433        }
434        writer.flush().map_err(PtyError::Io)
435    }
436
437    /// Resize the PTY to the given row and column dimensions.
438    pub fn resize_impl(&self, rows: u16, cols: u16) -> Result<(), PtyError> {
439        crate::rp_rust_debug_scope!("running_process::NativePtyProcess::resize");
440        let guard = self.handles.lock().expect("pty handles mutex poisoned");
441        if let Some(handles) = guard.as_ref() {
442            pty_platform::resize_pty(
443                handles.master.as_ref(),
444                PtySize {
445                    rows,
446                    cols,
447                    pixel_width: 0,
448                    pixel_height: 0,
449                },
450            )
451            .map_err(|error| PtyError::Other(error.to_string()))?;
452        }
453        Ok(())
454    }
455
456    /// Send an interrupt signal or control event to the PTY child.
457    pub fn send_interrupt_impl(&self) -> Result<(), PtyError> {
458        crate::rp_rust_debug_scope!("running_process::NativePtyProcess::send_interrupt");
459        let (target, writer) = {
460            let guard = self.handles.lock().expect("pty handles mutex poisoned");
461            let handles = guard.as_ref().ok_or(PtyError::NotRunning)?;
462            (handles.master.control_token(), Arc::clone(&handles.writer))
463        };
464        let wrote_input =
465            pty_platform::send_pty_interrupt(target, &writer).map_err(PtyError::Io)?;
466        if wrote_input {
467            self.record_input_metrics(&[0x03], false);
468        }
469        Ok(())
470    }
471
472    /// Wait for the PTY child to exit and return its exit code.
473    ///
474    /// Returns a timeout error when `timeout` elapses before exit.
475    pub fn wait_impl(&self, timeout: Option<f64>) -> Result<i32, PtyError> {
476        crate::rp_rust_debug_scope!("running_process::NativePtyProcess::wait");
477        // Fast path: already exited.
478        if let Some(code) = *self
479            .returncode
480            .lock()
481            .expect("pty returncode mutex poisoned")
482        {
483            return Ok(code);
484        }
485        let start = Instant::now();
486        loop {
487            if let Some(code) = poll_pty_process(&self.handles, &self.returncode)? {
488                return Ok(code);
489            }
490            if timeout.is_some_and(|limit| start.elapsed() >= Duration::from_secs_f64(limit)) {
491                return Err(PtyError::Timeout);
492            }
493            // #199: intentional — `wait_impl` poll. Same constraint
494            // as the close_impl variant above: no per-Child wait
495            // primitive on the trait surface.
496            thread::sleep(Duration::from_millis(10));
497        }
498    }
499
500    /// Request graceful termination of the PTY child.
501    pub fn terminate_impl(&self) -> Result<(), PtyError> {
502        crate::rp_rust_debug_scope!("running_process::NativePtyProcess::terminate");
503        let should_close = {
504            let mut guard = self.handles.lock().expect("pty handles mutex poisoned");
505            let handles = guard.as_mut().ok_or(PtyError::NotRunning)?;
506            let pid = handles.child.pid();
507            if pid == 0 {
508                return Err(PtyError::NotRunning);
509            }
510            pty_platform::terminate_pty_child(pid).map_err(PtyError::Io)?
511        };
512        if should_close {
513            self.close_impl()?;
514        }
515        Ok(())
516    }
517
518    /// Forcefully terminate the PTY child.
519    pub fn kill_impl(&self) -> Result<(), PtyError> {
520        crate::rp_rust_debug_scope!("running_process::NativePtyProcess::kill");
521        if self
522            .handles
523            .lock()
524            .expect("pty handles mutex poisoned")
525            .is_none()
526        {
527            return Err(PtyError::NotRunning);
528        }
529        self.close_impl()
530    }
531
532    /// Request graceful termination of the PTY child process tree.
533    pub fn terminate_tree_impl(&self) -> Result<(), PtyError> {
534        crate::rp_rust_debug_scope!("running_process::NativePtyProcess::terminate_tree");
535        let Some(pid) = self.pid()? else {
536            return if self
537                .returncode
538                .lock()
539                .expect("pty returncode mutex poisoned")
540                .is_some()
541            {
542                Ok(())
543            } else {
544                Err(PtyError::NotRunning)
545            };
546        };
547        if pty_platform::signal_pty_tree(pid, false).map_err(PtyError::Io)? {
548            self.close_impl()?;
549        }
550        Ok(())
551    }
552
553    /// Forcefully terminate the PTY child process tree.
554    pub fn kill_tree_impl(&self) -> Result<(), PtyError> {
555        crate::rp_rust_debug_scope!("running_process::NativePtyProcess::kill_tree");
556        let Some(pid) = self.pid()? else {
557            return if self
558                .returncode
559                .lock()
560                .expect("pty returncode mutex poisoned")
561                .is_some()
562            {
563                Ok(())
564            } else {
565                Err(PtyError::NotRunning)
566            };
567        };
568        if pty_platform::signal_pty_tree(pid, true).map_err(PtyError::Io)? {
569            self.close_impl()?;
570        }
571        Ok(())
572    }
573
574    /// Get the PID of the child process, if running.
575    pub fn pid(&self) -> Result<Option<u32>, PtyError> {
576        let guard = self.handles.lock().expect("pty handles mutex poisoned");
577        if let Some(handles) = guard.as_ref() {
578            return Ok(pty_platform::preferred_pty_pid(
579                handles.master.as_ref(),
580                handles.child.as_ref(),
581            ));
582        }
583        Ok(None)
584    }
585
586    /// Wait for a chunk of output from the PTY reader.
587    /// Returns `Ok(Some(chunk))` on data, `Ok(None)` on timeout, `Err` on closed.
588    pub fn read_chunk_impl(&self, timeout: Option<f64>) -> Result<Option<Vec<u8>>, PtyError> {
589        let deadline = timeout.map(|secs| Instant::now() + Duration::from_secs_f64(secs));
590        let mut guard = self.reader.state.lock().expect("pty read mutex poisoned");
591        loop {
592            if let Some(chunk) = guard.chunks.pop_front() {
593                return Ok(Some(chunk));
594            }
595            if guard.closed {
596                return Err(PtyError::Other("Pseudo-terminal stream is closed".into()));
597            }
598            match deadline {
599                Some(deadline) => {
600                    let now = Instant::now();
601                    if now >= deadline {
602                        return Ok(None); // timeout
603                    }
604                    let wait = deadline.saturating_duration_since(now);
605                    let result = self
606                        .reader
607                        .condvar
608                        .wait_timeout(guard, wait)
609                        .expect("pty read mutex poisoned");
610                    guard = result.0;
611                }
612                None => {
613                    guard = self
614                        .reader
615                        .condvar
616                        .wait(guard)
617                        .expect("pty read mutex poisoned");
618                }
619            }
620        }
621    }
622
623    /// Wait for the reader thread to close.
624    pub fn wait_for_reader_closed_impl(&self, timeout: Option<f64>) -> bool {
625        let deadline = timeout.map(|secs| Instant::now() + Duration::from_secs_f64(secs));
626        let mut guard = self.reader.state.lock().expect("pty read mutex poisoned");
627        loop {
628            if guard.closed {
629                return true;
630            }
631            match deadline {
632                Some(deadline) => {
633                    let now = Instant::now();
634                    if now >= deadline {
635                        return false;
636                    }
637                    let wait = deadline.saturating_duration_since(now);
638                    let result = self
639                        .reader
640                        .condvar
641                        .wait_timeout(guard, wait)
642                        .expect("pty read mutex poisoned");
643                    guard = result.0;
644                }
645                None => {
646                    guard = self
647                        .reader
648                        .condvar
649                        .wait(guard)
650                        .expect("pty read mutex poisoned");
651                }
652            }
653        }
654    }
655
656    /// Wait for exit then drain remaining output.
657    pub fn wait_and_drain_impl(
658        &self,
659        timeout: Option<f64>,
660        drain_timeout: f64,
661    ) -> Result<i32, PtyError> {
662        let code = self.wait_impl(timeout)?;
663        let deadline = Instant::now() + Duration::from_secs_f64(drain_timeout.max(0.0));
664        let mut guard = self.reader.state.lock().expect("pty read mutex poisoned");
665        while !guard.closed {
666            let remaining = deadline.saturating_duration_since(Instant::now());
667            if remaining.is_zero() {
668                break;
669            }
670            let result = self
671                .reader
672                .condvar
673                .wait_timeout(guard, remaining)
674                .expect("pty read mutex poisoned");
675            guard = result.0;
676        }
677        Ok(code)
678    }
679
680    /// Enable or disable echoing PTY output to stdout.
681    pub fn set_echo(&self, enabled: bool) {
682        self.echo.store(enabled, Ordering::Release);
683    }
684
685    /// Return whether PTY output echoing is enabled.
686    pub fn echo_enabled(&self) -> bool {
687        self.echo.load(Ordering::Acquire)
688    }
689
690    /// Attach an idle detector that observes reader-thread output.
691    pub fn attach_idle_detector(&self, detector: &Arc<IdleDetectorCore>) {
692        let mut guard = self
693            .idle_detector
694            .lock()
695            .expect("idle detector mutex poisoned");
696        *guard = Some(Arc::clone(detector));
697    }
698
699    /// Detach the current idle detector, if one is attached.
700    pub fn detach_idle_detector(&self) {
701        let mut guard = self
702            .idle_detector
703            .lock()
704            .expect("idle detector mutex poisoned");
705        *guard = None;
706    }
707
708    /// Return total bytes written to PTY input.
709    pub fn pty_input_bytes_total(&self) -> usize {
710        self.input_bytes_total.load(Ordering::Acquire)
711    }
712
713    /// Return the number of PTY input writes containing newlines.
714    pub fn pty_newline_events_total(&self) -> usize {
715        self.newline_events_total.load(Ordering::Acquire)
716    }
717
718    /// Return the number of recorded PTY input submit events.
719    pub fn pty_submit_events_total(&self) -> usize {
720        self.submit_events_total.load(Ordering::Acquire)
721    }
722
723    /// Return visible PTY output bytes observed by the reader thread.
724    pub fn pty_output_bytes_total(&self) -> usize {
725        self.output_bytes_total.load(Ordering::Acquire)
726    }
727
728    /// Return control-churn bytes observed by the reader thread.
729    pub fn pty_control_churn_bytes_total(&self) -> usize {
730        self.control_churn_bytes_total.load(Ordering::Acquire)
731    }
732}
733
734/// Safe defaults for a real interactive PTY session.
735///
736/// The helper turns on the parts that a terminal-style session usually needs:
737/// output echo, terminal input relay, and automatic PTY query replies.
738#[derive(Debug, Clone, Copy)]
739pub struct InteractivePtyOptions {
740    /// Echo PTY output to stdout while the session is running.
741    pub echo_output: bool,
742    /// Relay local terminal input into the PTY.
743    pub relay_terminal_input: bool,
744    /// Automatically answer terminal query escape sequences.
745    pub respond_to_queries: bool,
746}
747
748impl Default for InteractivePtyOptions {
749    fn default() -> Self {
750        Self {
751            echo_output: true,
752            relay_terminal_input: true,
753            respond_to_queries: true,
754        }
755    }
756}
757
758/// Output collected by one interactive PTY pump operation.
759#[derive(Debug, Default)]
760pub struct InteractivePtyPumpResult {
761    /// Output chunks read from the PTY.
762    pub chunks: Vec<Vec<u8>>,
763    /// Whether the PTY stream closed while pumping output.
764    pub stream_closed: bool,
765}
766
767/// Canonical interactive PTY recipe for downstream Rust consumers.
768///
769/// `NativePtyProcess` remains the low-level primitive. This wrapper owns the
770/// interactive setup that callers commonly forget to assemble correctly.
771pub struct InteractivePtySession {
772    process: NativePtyProcess,
773    options: InteractivePtyOptions,
774}
775
776impl InteractivePtySession {
777    /// Create an interactive PTY session with default options.
778    pub fn new(process: NativePtyProcess) -> Self {
779        Self::with_options(process, InteractivePtyOptions::default())
780    }
781
782    /// Create an interactive PTY session with explicit options.
783    pub fn with_options(process: NativePtyProcess, options: InteractivePtyOptions) -> Self {
784        Self { process, options }
785    }
786
787    /// Return the wrapped low-level PTY process.
788    pub fn process(&self) -> &NativePtyProcess {
789        &self.process
790    }
791
792    /// Start the wrapped PTY process and configured interactive helpers.
793    pub fn start(&self) -> Result<(), PtyError> {
794        self.process.set_echo(self.options.echo_output);
795        self.process.start_impl()?;
796        if self.options.relay_terminal_input {
797            self.process.start_terminal_input_relay_impl()?;
798        }
799        Ok(())
800    }
801
802    /// Read and optionally drain available PTY output.
803    ///
804    /// When query responses are enabled, terminal queries in each chunk are
805    /// answered before the chunk is returned.
806    pub fn pump_output(
807        &self,
808        timeout: Option<f64>,
809        consume_all: bool,
810    ) -> Result<InteractivePtyPumpResult, PtyError> {
811        let mut pumped = InteractivePtyPumpResult::default();
812        let mut next_timeout = timeout;
813        loop {
814            match self.process.read_chunk_impl(next_timeout) {
815                Ok(Some(chunk)) => {
816                    if self.options.respond_to_queries {
817                        self.process.respond_to_queries_impl(&chunk)?;
818                    }
819                    pumped.chunks.push(chunk);
820                    if !consume_all {
821                        break;
822                    }
823                    next_timeout = Some(0.0);
824                }
825                Ok(None) => break,
826                Err(PtyError::Other(message)) if message == "Pseudo-terminal stream is closed" => {
827                    pumped.stream_closed = true;
828                    break;
829                }
830                Err(err) => return Err(err),
831            }
832        }
833        Ok(pumped)
834    }
835
836    /// Resize the interactive PTY.
837    pub fn resize(&self, rows: u16, cols: u16) -> Result<(), PtyError> {
838        self.process.resize_impl(rows, cols)
839    }
840
841    /// Send an interrupt to the interactive PTY child.
842    pub fn send_interrupt(&self) -> Result<(), PtyError> {
843        self.process.send_interrupt_impl()
844    }
845
846    /// Wait for the interactive PTY child to exit.
847    pub fn wait(&self, timeout: Option<f64>) -> Result<i32, PtyError> {
848        self.process.wait_impl(timeout)
849    }
850
851    /// Wait for the child to exit, then drain remaining PTY output.
852    pub fn wait_and_drain(
853        &self,
854        timeout: Option<f64>,
855        drain_timeout: f64,
856    ) -> Result<i32, PtyError> {
857        self.process.wait_and_drain_impl(timeout, drain_timeout)
858    }
859
860    /// Request graceful termination of the interactive PTY child.
861    pub fn terminate(&self) -> Result<(), PtyError> {
862        self.process.terminate_impl()
863    }
864
865    /// Forcefully terminate the interactive PTY child.
866    pub fn kill(&self) -> Result<(), PtyError> {
867        self.process.kill_impl()
868    }
869
870    /// Close the interactive PTY session.
871    pub fn close(&self) -> Result<(), PtyError> {
872        self.process.close_impl()
873    }
874}
875
876impl Drop for NativePtyProcess {
877    fn drop(&mut self) {
878        self.close_nonblocking();
879    }
880}
881
882#[cfg(test)]
883#[path = "../tests/native_pty_process_coverage.rs"]
884mod coverage_tests;