Skip to main content

running_process/pty/
native_pty_process.rs

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