Skip to main content

running_process_core/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
7use portable_pty::{native_pty_system, PtySize};
8
9use super::{
10    command_builder_from_argv, is_ignorable_process_control_error, poll_pty_process,
11    portable_exit_code, record_pty_input_metrics, spawn_pty_reader, store_pty_returncode,
12    write_pty_input, IdleDetectorCore, NativePtyHandles, PtyError, PtyReadShared, PtyReadState,
13};
14#[cfg(unix)]
15use super::posix_terminal_input_relay_worker;
16#[cfg(windows)]
17use super::{
18    apply_windows_pty_priority, assign_child_to_windows_kill_on_close_job,
19    assign_conpty_conhost_to_job, conhost_children_of_current_process,
20};
21
22#[cfg(unix)]
23use super::pty_posix as pty_platform;
24#[cfg(windows)]
25use super::pty_windows;
26
27pub struct NativePtyProcess {
28    pub argv: Vec<String>,
29    pub cwd: Option<String>,
30    pub env: Option<Vec<(String, String)>>,
31    pub rows: u16,
32    pub cols: u16,
33    #[cfg(windows)]
34    pub nice: Option<i32>,
35    pub handles: Arc<Mutex<Option<NativePtyHandles>>>,
36    pub reader: Arc<PtyReadShared>,
37    pub returncode: Arc<Mutex<Option<i32>>>,
38    pub input_bytes_total: Arc<AtomicUsize>,
39    pub newline_events_total: Arc<AtomicUsize>,
40    pub submit_events_total: Arc<AtomicUsize>,
41    /// When true, the reader thread writes PTY output to stdout.
42    pub echo: Arc<AtomicBool>,
43    /// When set, the reader thread feeds output directly to the idle detector.
44    pub idle_detector: Arc<Mutex<Option<Arc<IdleDetectorCore>>>>,
45    /// Visible (non-control) output bytes seen by the reader thread.
46    pub output_bytes_total: Arc<AtomicUsize>,
47    /// Control churn bytes (ANSI escapes, BS, CR, DEL) seen by the reader.
48    pub control_churn_bytes_total: Arc<AtomicUsize>,
49    pub reader_worker: Mutex<Option<thread::JoinHandle<()>>>,
50    pub terminal_input_relay_stop: Arc<AtomicBool>,
51    pub terminal_input_relay_active: Arc<AtomicBool>,
52    pub terminal_input_relay_worker: Mutex<Option<thread::JoinHandle<()>>>,
53}
54
55pub(super) fn resolved_spawn_cwd(cwd: Option<&str>) -> Option<String> {
56    cwd.map(str::to_owned).or_else(|| {
57        std::env::current_dir()
58            .ok()
59            .map(|cwd| cwd.to_string_lossy().to_string())
60    })
61}
62
63impl NativePtyProcess {
64    pub fn new(
65        argv: Vec<String>,
66        cwd: Option<String>,
67        env: Option<Vec<(String, String)>>,
68        rows: u16,
69        cols: u16,
70        nice: Option<i32>,
71    ) -> Result<Self, PtyError> {
72        if argv.is_empty() {
73            return Err(PtyError::Other("command cannot be empty".into()));
74        }
75        #[cfg(not(windows))]
76        let _ = nice;
77        Ok(Self {
78            argv,
79            cwd,
80            env,
81            rows,
82            cols,
83            #[cfg(windows)]
84            nice,
85            handles: Arc::new(Mutex::new(None)),
86            reader: Arc::new(PtyReadShared {
87                state: Mutex::new(PtyReadState {
88                    chunks: VecDeque::new(),
89                    closed: false,
90                }),
91                condvar: Condvar::new(),
92            }),
93            returncode: Arc::new(Mutex::new(None)),
94            input_bytes_total: Arc::new(AtomicUsize::new(0)),
95            newline_events_total: Arc::new(AtomicUsize::new(0)),
96            submit_events_total: Arc::new(AtomicUsize::new(0)),
97            echo: Arc::new(AtomicBool::new(false)),
98            idle_detector: Arc::new(Mutex::new(None)),
99            output_bytes_total: Arc::new(AtomicUsize::new(0)),
100            control_churn_bytes_total: Arc::new(AtomicUsize::new(0)),
101            reader_worker: Mutex::new(None),
102            terminal_input_relay_stop: Arc::new(AtomicBool::new(false)),
103            terminal_input_relay_active: Arc::new(AtomicBool::new(false)),
104            terminal_input_relay_worker: Mutex::new(None),
105        })
106    }
107
108    pub fn mark_reader_closed(&self) {
109        let mut guard = self.reader.state.lock().expect("pty read mutex poisoned");
110        guard.closed = true;
111        self.reader.condvar.notify_all();
112    }
113
114    pub fn store_returncode(&self, code: i32) {
115        store_pty_returncode(&self.returncode, code);
116    }
117
118    pub(super) fn join_reader_worker(&self) {
119        if let Some(worker) = self
120            .reader_worker
121            .lock()
122            .expect("pty reader worker mutex poisoned")
123            .take()
124        {
125            let _ = worker.join();
126        }
127    }
128
129    pub fn record_input_metrics(&self, data: &[u8], submit: bool) {
130        record_pty_input_metrics(
131            &self.input_bytes_total,
132            &self.newline_events_total,
133            &self.submit_events_total,
134            data,
135            submit,
136        );
137    }
138
139    pub fn write_impl(&self, data: &[u8], submit: bool) -> Result<(), PtyError> {
140        self.record_input_metrics(data, submit);
141        write_pty_input(&self.handles, data)?;
142        Ok(())
143    }
144
145    pub fn request_terminal_input_relay_stop(&self) {
146        self.terminal_input_relay_stop
147            .store(true, Ordering::Release);
148        self.terminal_input_relay_active
149            .store(false, Ordering::Release);
150    }
151
152    pub fn start_terminal_input_relay_impl(&self) -> Result<(), PtyError> {
153        let mut worker_guard = self
154            .terminal_input_relay_worker
155            .lock()
156            .expect("pty terminal input relay mutex poisoned");
157        if worker_guard.is_some() && self.terminal_input_relay_active() {
158            return Ok(());
159        }
160        if self
161            .handles
162            .lock()
163            .expect("pty handles mutex poisoned")
164            .is_none()
165        {
166            return Err(PtyError::NotRunning);
167        }
168
169        self.terminal_input_relay_stop
170            .store(false, Ordering::Release);
171        self.terminal_input_relay_active
172            .store(true, Ordering::Release);
173
174        let handles = Arc::clone(&self.handles);
175        let returncode = Arc::clone(&self.returncode);
176        let input_bytes_total = Arc::clone(&self.input_bytes_total);
177        let newline_events_total = Arc::clone(&self.newline_events_total);
178        let submit_events_total = Arc::clone(&self.submit_events_total);
179        let stop = Arc::clone(&self.terminal_input_relay_stop);
180        let active = Arc::clone(&self.terminal_input_relay_active);
181
182        #[cfg(windows)]
183        {
184            let capture = super::terminal_input::TerminalInputCore::new();
185            capture.start_impl().map_err(PtyError::Io)?;
186            *worker_guard = Some(thread::spawn(move || {
187                loop {
188                    if stop.load(Ordering::Acquire) {
189                        break;
190                    }
191                    match poll_pty_process(&handles, &returncode) {
192                        Ok(Some(_)) => break,
193                        Ok(None) => {}
194                        Err(_) => break,
195                    }
196                    match super::terminal_input::wait_for_terminal_input_event(
197                        &capture.state,
198                        &capture.condvar,
199                        Some(Duration::from_millis(50)),
200                    ) {
201                        super::terminal_input::TerminalInputWaitOutcome::Event(event) => {
202                            record_pty_input_metrics(
203                                &input_bytes_total,
204                                &newline_events_total,
205                                &submit_events_total,
206                                &event.data,
207                                event.submit,
208                            );
209                            if write_pty_input(&handles, &event.data).is_err() {
210                                break;
211                            }
212                        }
213                        super::terminal_input::TerminalInputWaitOutcome::Timeout => continue,
214                        super::terminal_input::TerminalInputWaitOutcome::Closed => break,
215                    }
216                }
217                active.store(false, Ordering::Release);
218                let _ = capture.stop_impl();
219            }));
220            Ok(())
221        }
222
223        #[cfg(unix)]
224        {
225            if unsafe { libc::isatty(libc::STDIN_FILENO) } != 1 {
226                self.terminal_input_relay_active
227                    .store(false, Ordering::Release);
228                return Ok(());
229            }
230
231            *worker_guard = Some(thread::spawn(move || {
232                posix_terminal_input_relay_worker(
233                    handles,
234                    returncode,
235                    input_bytes_total,
236                    newline_events_total,
237                    submit_events_total,
238                    stop,
239                    active,
240                );
241            }));
242            Ok(())
243        }
244    }
245
246    pub fn stop_terminal_input_relay_impl(&self) {
247        self.request_terminal_input_relay_stop();
248        if let Some(worker) = self
249            .terminal_input_relay_worker
250            .lock()
251            .expect("pty terminal input relay mutex poisoned")
252            .take()
253        {
254            let _ = worker.join();
255        }
256    }
257
258    pub fn terminal_input_relay_active(&self) -> bool {
259        self.terminal_input_relay_active.load(Ordering::Acquire)
260    }
261
262    /// Synchronously tear down the PTY and reap the child.
263    #[inline(never)]
264    pub fn close_impl(&self) -> Result<(), PtyError> {
265        crate::rp_rust_debug_scope!("running_process_core::NativePtyProcess::close_impl");
266        self.stop_terminal_input_relay_impl();
267        let mut guard = self.handles.lock().expect("pty handles mutex poisoned");
268        let Some(handles) = guard.take() else {
269            self.mark_reader_closed();
270            return Ok(());
271        };
272        drop(guard);
273
274        #[cfg(windows)]
275        let NativePtyHandles {
276            master,
277            writer,
278            mut child,
279            _job,
280        } = handles;
281        #[cfg(not(windows))]
282        let NativePtyHandles {
283            master,
284            writer,
285            mut child,
286        } = handles;
287
288        #[cfg(windows)]
289        {
290            {
291                crate::rp_rust_debug_scope!(
292                    "running_process_core::NativePtyProcess::close_impl.drop_job"
293                );
294                drop(_job);
295            }
296
297            {
298                crate::rp_rust_debug_scope!(
299                    "running_process_core::NativePtyProcess::close_impl.wait_job_exit"
300                );
301                let wait_deadline = Instant::now() + Duration::from_secs(2);
302                loop {
303                    match child.try_wait() {
304                        Ok(Some(status)) => {
305                            let code = portable_exit_code(status);
306                            self.store_returncode(code);
307                            break;
308                        }
309                        Ok(None) if Instant::now() < wait_deadline => {
310                            thread::sleep(Duration::from_millis(10));
311                        }
312                        Ok(None) => {
313                            if let Err(err) = child.kill() {
314                                if !is_ignorable_process_control_error(&err) {
315                                    return Err(PtyError::Io(err));
316                                }
317                            }
318                            let code = match child.wait() {
319                                Ok(status) => portable_exit_code(status),
320                                Err(_) => -9,
321                            };
322                            self.store_returncode(code);
323                            break;
324                        }
325                        Err(_) => {
326                            self.store_returncode(-9);
327                            break;
328                        }
329                    }
330                }
331            }
332            {
333                crate::rp_rust_debug_scope!(
334                    "running_process_core::NativePtyProcess::close_impl.drop_writer"
335                );
336                drop(writer);
337            }
338            {
339                crate::rp_rust_debug_scope!(
340                    "running_process_core::NativePtyProcess::close_impl.drop_master"
341                );
342                drop(master);
343            }
344            drop(child);
345            {
346                crate::rp_rust_debug_scope!(
347                    "running_process_core::NativePtyProcess::close_impl.join_reader"
348                );
349                self.join_reader_worker();
350            }
351            self.mark_reader_closed();
352            Ok(())
353        }
354
355        #[cfg(not(windows))]
356        {
357            drop(writer);
358            drop(master);
359
360            let code = {
361                crate::rp_rust_debug_scope!(
362                    "running_process_core::NativePtyProcess::close_impl.wait_child"
363                );
364                match child.wait() {
365                    Ok(status) => portable_exit_code(status),
366                    Err(_) => -9,
367                }
368            };
369            drop(child);
370
371            self.store_returncode(code);
372            {
373                crate::rp_rust_debug_scope!(
374                    "running_process_core::NativePtyProcess::close_impl.join_reader"
375                );
376                self.join_reader_worker();
377            }
378            self.mark_reader_closed();
379            Ok(())
380        }
381    }
382
383    /// Best-effort, non-blocking teardown for use from `Drop`.
384    #[inline(never)]
385    pub fn close_nonblocking(&self) {
386        crate::rp_rust_debug_scope!("running_process_core::NativePtyProcess::close_nonblocking");
387        #[cfg(windows)]
388        self.request_terminal_input_relay_stop();
389        let Ok(mut guard) = self.handles.lock() else {
390            return;
391        };
392        let Some(handles) = guard.take() else {
393            self.mark_reader_closed();
394            return;
395        };
396        drop(guard);
397
398        #[cfg(windows)]
399        let NativePtyHandles {
400            master,
401            writer,
402            mut child,
403            _job,
404        } = handles;
405        #[cfg(not(windows))]
406        let NativePtyHandles {
407            master,
408            writer,
409            mut child,
410        } = handles;
411
412        if let Err(err) = child.kill() {
413            if !is_ignorable_process_control_error(&err) {
414                return;
415            }
416        }
417        drop(writer);
418        drop(master);
419        drop(child);
420        #[cfg(windows)]
421        drop(_job);
422        self.mark_reader_closed();
423    }
424
425    pub fn start_impl(&self) -> Result<(), PtyError> {
426        crate::rp_rust_debug_scope!("running_process_core::NativePtyProcess::start");
427        let mut guard = self.handles.lock().expect("pty handles mutex poisoned");
428        if guard.is_some() {
429            return Err(PtyError::AlreadyStarted);
430        }
431
432        // Snapshot our conhost.exe children before openpty() so we can diff
433        // after spawn to find the new conhost.exe created by ConPTY.
434        #[cfg(windows)]
435        let conhost_pids_before = conhost_children_of_current_process();
436
437        let pty_system = native_pty_system();
438        let pair = pty_system
439            .openpty(PtySize {
440                rows: self.rows,
441                cols: self.cols,
442                pixel_width: 0,
443                pixel_height: 0,
444            })
445            .map_err(|e| PtyError::Spawn(e.to_string()))?;
446
447        let mut cmd = command_builder_from_argv(&self.argv);
448        let cwd = resolved_spawn_cwd(self.cwd.as_deref());
449        if let Some(cwd) = &cwd {
450            cmd.cwd(cwd);
451        }
452        if let Some(env) = &self.env {
453            cmd.env_clear();
454            for (key, value) in env {
455                cmd.env(key, value);
456            }
457        }
458
459        let reader = pair
460            .master
461            .try_clone_reader()
462            .map_err(|e| PtyError::Spawn(e.to_string()))?;
463        let writer = pair
464            .master
465            .take_writer()
466            .map_err(|e| PtyError::Spawn(e.to_string()))?;
467        let child = pair
468            .slave
469            .spawn_command(cmd)
470            .map_err(|e| PtyError::Spawn(e.to_string()))?;
471        #[cfg(windows)]
472        let job = assign_child_to_windows_kill_on_close_job(child.as_raw_handle())?;
473        #[cfg(windows)]
474        assign_conpty_conhost_to_job(&job, &conhost_pids_before);
475        #[cfg(windows)]
476        apply_windows_pty_priority(child.as_raw_handle(), self.nice)?;
477        let shared = Arc::clone(&self.reader);
478        let echo = Arc::clone(&self.echo);
479        let idle_detector = Arc::clone(&self.idle_detector);
480        let output_bytes = Arc::clone(&self.output_bytes_total);
481        let churn_bytes = Arc::clone(&self.control_churn_bytes_total);
482        let reader_worker = thread::spawn(move || {
483            spawn_pty_reader(
484                reader,
485                shared,
486                echo,
487                idle_detector,
488                output_bytes,
489                churn_bytes,
490            );
491        });
492        *self
493            .reader_worker
494            .lock()
495            .expect("pty reader worker mutex poisoned") = Some(reader_worker);
496
497        *guard = Some(NativePtyHandles {
498            master: pair.master,
499            writer,
500            child,
501            #[cfg(windows)]
502            _job: job,
503        });
504        Ok(())
505    }
506
507    pub fn respond_to_queries_impl(&self, data: &[u8]) -> Result<(), PtyError> {
508        #[cfg(windows)]
509        {
510            pty_windows::respond_to_queries(self, data)
511        }
512
513        #[cfg(unix)]
514        {
515            pty_platform::respond_to_queries(self, data)
516        }
517    }
518
519    pub fn resize_impl(&self, rows: u16, cols: u16) -> Result<(), PtyError> {
520        crate::rp_rust_debug_scope!("running_process_core::NativePtyProcess::resize");
521        let guard = self.handles.lock().expect("pty handles mutex poisoned");
522        if let Some(handles) = guard.as_ref() {
523            #[cfg(windows)]
524            {
525                let _ = (rows, cols, handles);
526                // ConPTY resize can leave ClosePseudoConsole blocked during
527                // teardown on Windows. Keep resize as a no-op until the
528                // backend can cancel the outstanding PTY read safely.
529                return Ok(());
530            }
531
532            #[cfg(not(windows))]
533            handles
534                .master
535                .resize(PtySize {
536                    rows,
537                    cols,
538                    pixel_width: 0,
539                    pixel_height: 0,
540                })
541                .map_err(|e| PtyError::Other(e.to_string()))?;
542        }
543        Ok(())
544    }
545
546    pub fn send_interrupt_impl(&self) -> Result<(), PtyError> {
547        crate::rp_rust_debug_scope!("running_process_core::NativePtyProcess::send_interrupt");
548        #[cfg(windows)]
549        {
550            pty_windows::send_interrupt(self)
551        }
552
553        #[cfg(unix)]
554        {
555            pty_platform::send_interrupt(self)
556        }
557    }
558
559    pub fn wait_impl(&self, timeout: Option<f64>) -> Result<i32, PtyError> {
560        crate::rp_rust_debug_scope!("running_process_core::NativePtyProcess::wait");
561        // Fast path: already exited.
562        if let Some(code) = *self
563            .returncode
564            .lock()
565            .expect("pty returncode mutex poisoned")
566        {
567            return Ok(code);
568        }
569        let start = Instant::now();
570        loop {
571            if let Some(code) = poll_pty_process(&self.handles, &self.returncode)? {
572                return Ok(code);
573            }
574            if timeout.is_some_and(|limit| start.elapsed() >= Duration::from_secs_f64(limit)) {
575                return Err(PtyError::Timeout);
576            }
577            thread::sleep(Duration::from_millis(10));
578        }
579    }
580
581    pub fn terminate_impl(&self) -> Result<(), PtyError> {
582        crate::rp_rust_debug_scope!("running_process_core::NativePtyProcess::terminate");
583        #[cfg(windows)]
584        {
585            if self
586                .handles
587                .lock()
588                .expect("pty handles mutex poisoned")
589                .is_none()
590            {
591                return Err(PtyError::NotRunning);
592            }
593            self.close_impl()
594        }
595
596        #[cfg(unix)]
597        {
598            pty_platform::terminate(self)
599        }
600    }
601
602    pub fn kill_impl(&self) -> Result<(), PtyError> {
603        crate::rp_rust_debug_scope!("running_process_core::NativePtyProcess::kill");
604        #[cfg(windows)]
605        {
606            if self
607                .handles
608                .lock()
609                .expect("pty handles mutex poisoned")
610                .is_none()
611            {
612                return Err(PtyError::NotRunning);
613            }
614            self.close_impl()
615        }
616
617        #[cfg(unix)]
618        {
619            pty_platform::kill(self)
620        }
621    }
622
623    pub fn terminate_tree_impl(&self) -> Result<(), PtyError> {
624        crate::rp_rust_debug_scope!("running_process_core::NativePtyProcess::terminate_tree");
625        #[cfg(windows)]
626        {
627            pty_windows::terminate_tree(self)
628        }
629
630        #[cfg(unix)]
631        {
632            pty_platform::terminate_tree(self)
633        }
634    }
635
636    pub fn kill_tree_impl(&self) -> Result<(), PtyError> {
637        crate::rp_rust_debug_scope!("running_process_core::NativePtyProcess::kill_tree");
638        #[cfg(windows)]
639        {
640            pty_windows::kill_tree(self)
641        }
642
643        #[cfg(unix)]
644        {
645            pty_platform::kill_tree(self)
646        }
647    }
648
649    /// Get the PID of the child process, if running.
650    pub fn pid(&self) -> Result<Option<u32>, PtyError> {
651        let guard = self.handles.lock().expect("pty handles mutex poisoned");
652        if let Some(handles) = guard.as_ref() {
653            #[cfg(unix)]
654            if let Some(pid) = handles.master.process_group_leader() {
655                if let Ok(pid) = u32::try_from(pid) {
656                    return Ok(Some(pid));
657                }
658            }
659            return Ok(handles.child.process_id());
660        }
661        Ok(None)
662    }
663
664    /// Wait for a chunk of output from the PTY reader.
665    /// Returns `Ok(Some(chunk))` on data, `Ok(None)` on timeout, `Err` on closed.
666    pub fn read_chunk_impl(&self, timeout: Option<f64>) -> Result<Option<Vec<u8>>, PtyError> {
667        let deadline = timeout.map(|secs| Instant::now() + Duration::from_secs_f64(secs));
668        let mut guard = self.reader.state.lock().expect("pty read mutex poisoned");
669        loop {
670            if let Some(chunk) = guard.chunks.pop_front() {
671                return Ok(Some(chunk));
672            }
673            if guard.closed {
674                return Err(PtyError::Other("Pseudo-terminal stream is closed".into()));
675            }
676            match deadline {
677                Some(deadline) => {
678                    let now = Instant::now();
679                    if now >= deadline {
680                        return Ok(None); // timeout
681                    }
682                    let wait = deadline.saturating_duration_since(now);
683                    let result = self
684                        .reader
685                        .condvar
686                        .wait_timeout(guard, wait)
687                        .expect("pty read mutex poisoned");
688                    guard = result.0;
689                }
690                None => {
691                    guard = self
692                        .reader
693                        .condvar
694                        .wait(guard)
695                        .expect("pty read mutex poisoned");
696                }
697            }
698        }
699    }
700
701    /// Wait for the reader thread to close.
702    pub fn wait_for_reader_closed_impl(&self, timeout: Option<f64>) -> bool {
703        let deadline = timeout.map(|secs| Instant::now() + Duration::from_secs_f64(secs));
704        let mut guard = self.reader.state.lock().expect("pty read mutex poisoned");
705        loop {
706            if guard.closed {
707                return true;
708            }
709            match deadline {
710                Some(deadline) => {
711                    let now = Instant::now();
712                    if now >= deadline {
713                        return false;
714                    }
715                    let wait = deadline.saturating_duration_since(now);
716                    let result = self
717                        .reader
718                        .condvar
719                        .wait_timeout(guard, wait)
720                        .expect("pty read mutex poisoned");
721                    guard = result.0;
722                }
723                None => {
724                    guard = self
725                        .reader
726                        .condvar
727                        .wait(guard)
728                        .expect("pty read mutex poisoned");
729                }
730            }
731        }
732    }
733
734    /// Wait for exit then drain remaining output.
735    pub fn wait_and_drain_impl(
736        &self,
737        timeout: Option<f64>,
738        drain_timeout: f64,
739    ) -> Result<i32, PtyError> {
740        let code = self.wait_impl(timeout)?;
741        let deadline = Instant::now() + Duration::from_secs_f64(drain_timeout.max(0.0));
742        let mut guard = self.reader.state.lock().expect("pty read mutex poisoned");
743        while !guard.closed {
744            let remaining = deadline.saturating_duration_since(Instant::now());
745            if remaining.is_zero() {
746                break;
747            }
748            let result = self
749                .reader
750                .condvar
751                .wait_timeout(guard, remaining)
752                .expect("pty read mutex poisoned");
753            guard = result.0;
754        }
755        Ok(code)
756    }
757
758    pub fn set_echo(&self, enabled: bool) {
759        self.echo.store(enabled, Ordering::Release);
760    }
761
762    pub fn echo_enabled(&self) -> bool {
763        self.echo.load(Ordering::Acquire)
764    }
765
766    pub fn attach_idle_detector(&self, detector: &Arc<IdleDetectorCore>) {
767        let mut guard = self
768            .idle_detector
769            .lock()
770            .expect("idle detector mutex poisoned");
771        *guard = Some(Arc::clone(detector));
772    }
773
774    pub fn detach_idle_detector(&self) {
775        let mut guard = self
776            .idle_detector
777            .lock()
778            .expect("idle detector mutex poisoned");
779        *guard = None;
780    }
781
782    pub fn pty_input_bytes_total(&self) -> usize {
783        self.input_bytes_total.load(Ordering::Acquire)
784    }
785
786    pub fn pty_newline_events_total(&self) -> usize {
787        self.newline_events_total.load(Ordering::Acquire)
788    }
789
790    pub fn pty_submit_events_total(&self) -> usize {
791        self.submit_events_total.load(Ordering::Acquire)
792    }
793
794    pub fn pty_output_bytes_total(&self) -> usize {
795        self.output_bytes_total.load(Ordering::Acquire)
796    }
797
798    pub fn pty_control_churn_bytes_total(&self) -> usize {
799        self.control_churn_bytes_total.load(Ordering::Acquire)
800    }
801}
802
803/// Safe defaults for a real interactive PTY session.
804///
805/// The helper turns on the parts that a terminal-style session usually needs:
806/// output echo, terminal input relay, and automatic PTY query replies.
807#[derive(Debug, Clone, Copy)]
808pub struct InteractivePtyOptions {
809    pub echo_output: bool,
810    pub relay_terminal_input: bool,
811    pub respond_to_queries: bool,
812}
813
814impl Default for InteractivePtyOptions {
815    fn default() -> Self {
816        Self {
817            echo_output: true,
818            relay_terminal_input: true,
819            respond_to_queries: true,
820        }
821    }
822}
823
824#[derive(Debug, Default)]
825pub struct InteractivePtyPumpResult {
826    pub chunks: Vec<Vec<u8>>,
827    pub stream_closed: bool,
828}
829
830/// Canonical interactive PTY recipe for downstream Rust consumers.
831///
832/// `NativePtyProcess` remains the low-level primitive. This wrapper owns the
833/// interactive setup that callers commonly forget to assemble correctly.
834pub struct InteractivePtySession {
835    process: NativePtyProcess,
836    options: InteractivePtyOptions,
837}
838
839impl InteractivePtySession {
840    pub fn new(process: NativePtyProcess) -> Self {
841        Self::with_options(process, InteractivePtyOptions::default())
842    }
843
844    pub fn with_options(process: NativePtyProcess, options: InteractivePtyOptions) -> Self {
845        Self { process, options }
846    }
847
848    pub fn process(&self) -> &NativePtyProcess {
849        &self.process
850    }
851
852    pub fn start(&self) -> Result<(), PtyError> {
853        self.process.set_echo(self.options.echo_output);
854        self.process.start_impl()?;
855        if self.options.relay_terminal_input {
856            self.process.start_terminal_input_relay_impl()?;
857        }
858        Ok(())
859    }
860
861    pub fn pump_output(
862        &self,
863        timeout: Option<f64>,
864        consume_all: bool,
865    ) -> Result<InteractivePtyPumpResult, PtyError> {
866        let mut pumped = InteractivePtyPumpResult::default();
867        let mut next_timeout = timeout;
868        loop {
869            match self.process.read_chunk_impl(next_timeout) {
870                Ok(Some(chunk)) => {
871                    if self.options.respond_to_queries {
872                        self.process.respond_to_queries_impl(&chunk)?;
873                    }
874                    pumped.chunks.push(chunk);
875                    if !consume_all {
876                        break;
877                    }
878                    next_timeout = Some(0.0);
879                }
880                Ok(None) => break,
881                Err(PtyError::Other(message)) if message == "Pseudo-terminal stream is closed" => {
882                    pumped.stream_closed = true;
883                    break;
884                }
885                Err(err) => return Err(err),
886            }
887        }
888        Ok(pumped)
889    }
890
891    pub fn resize(&self, rows: u16, cols: u16) -> Result<(), PtyError> {
892        self.process.resize_impl(rows, cols)
893    }
894
895    pub fn send_interrupt(&self) -> Result<(), PtyError> {
896        self.process.send_interrupt_impl()
897    }
898
899    pub fn wait(&self, timeout: Option<f64>) -> Result<i32, PtyError> {
900        self.process.wait_impl(timeout)
901    }
902
903    pub fn wait_and_drain(
904        &self,
905        timeout: Option<f64>,
906        drain_timeout: f64,
907    ) -> Result<i32, PtyError> {
908        self.process.wait_and_drain_impl(timeout, drain_timeout)
909    }
910
911    pub fn terminate(&self) -> Result<(), PtyError> {
912        self.process.terminate_impl()
913    }
914
915    pub fn kill(&self) -> Result<(), PtyError> {
916        self.process.kill_impl()
917    }
918
919    pub fn close(&self) -> Result<(), PtyError> {
920        self.process.close_impl()
921    }
922}
923
924impl Drop for NativePtyProcess {
925    fn drop(&mut self) {
926        self.close_nonblocking();
927    }
928}