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