Skip to main content

running_process/pty/
mod.rs

1use std::collections::VecDeque;
2use std::io::{Read, Write};
3use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
4use std::sync::{Arc, Condvar, Mutex};
5use std::thread;
6use std::time::{Duration, Instant};
7
8use thiserror::Error;
9
10use running_process_platform_internal::platform::terminal as pty_platform;
11
12/// Native terminal input capture and translation helpers.
13pub mod terminal_input;
14
15/// Reports whether the process-wide ConPTY API table resolved to the
16/// system `kernel32.dll` or to a sidecar `conpty.dll`. See #443.
17///
18/// Integration tests gate Win10-with-sidecar coverage on this — the
19/// byte-exact passthrough assertions can only hold when a sidecar is
20/// loaded on Win10, since the system `kernel32!CreatePseudoConsole`
21/// on Win10 < build 22000 silently ignores `PSEUDOCONSOLE_PASSTHROUGH_MODE`.
22pub use running_process_platform_internal::platform::terminal::{
23    current_backend_kind, ConPtyBackendKind,
24};
25
26// #150: backend abstraction so native_pty_process.rs calls a single
27// Backend::openpty() regardless of platform. Made `pub` in 4.0.1 so
28// downstream consumers (e.g. clud's SIGWINCH relay) can call
29// `PtyMaster::resize` / `get_size` through `NativePtyHandles.master`.
30/// Cross-platform PTY backend traits and platform-selected implementations.
31pub mod backend;
32/// Re-exported PTY backend handles and size type.
33pub use backend::{PtyChild, PtyMaster, PtySize};
34
35/// Build an argv for the selected host shell.
36pub fn platform_shell_argv(command: &str) -> Vec<String> {
37    pty_platform::shell_argv(command)
38}
39
40/// Whether this host can observe child exit before closing the PTY master.
41pub fn wait_before_close_supported() -> bool {
42    pty_platform::wait_before_close_supported()
43}
44
45mod native_pty_process;
46/// Re-exported native PTY process and interactive session types.
47pub use native_pty_process::{
48    InteractivePtyOptions, InteractivePtyPumpResult, InteractivePtySession, NativePtyProcess,
49};
50
51/// Async PTY facade using the bounded synchronous platform island.
52#[cfg(feature = "async-process")]
53pub mod async_pty;
54#[cfg(feature = "async-process")]
55pub use async_pty::{AsyncPtyProcess, IdleWaitOutcome};
56
57/// Errors returned by pseudo-terminal process operations.
58#[derive(Debug, Error)]
59pub enum PtyError {
60    /// The pseudo-terminal process has already been started.
61    #[error("pseudo-terminal process already started")]
62    AlreadyStarted,
63    /// The pseudo-terminal process is not currently running.
64    #[error("pseudo-terminal process is not running")]
65    NotRunning,
66    /// The pseudo-terminal operation exceeded its timeout.
67    #[error("pseudo-terminal timed out")]
68    Timeout,
69    /// An underlying I/O operation failed.
70    #[error("pseudo-terminal I/O error: {0}")]
71    Io(
72        /// The underlying I/O error.
73        #[from]
74        std::io::Error,
75    ),
76    /// Spawning the pseudo-terminal process failed.
77    #[error("pseudo-terminal spawn failed: {0}")]
78    Spawn(
79        /// Backend-provided spawn failure details.
80        String,
81    ),
82    /// A pseudo-terminal operation failed for another reason.
83    #[error("pseudo-terminal error: {0}")]
84    Other(
85        /// Human-readable error details.
86        String,
87    ),
88}
89
90/// Return whether a process-control error can be ignored during cleanup.
91pub fn is_ignorable_process_control_error(err: &std::io::Error) -> bool {
92    pty_platform::is_ignorable_process_control_error(err)
93}
94
95/// Buffered output and close state for a PTY reader thread.
96pub struct PtyReadState {
97    /// Output chunks read from the PTY master.
98    pub chunks: VecDeque<Vec<u8>>,
99    /// Whether the PTY reader has reached EOF or stopped.
100    pub closed: bool,
101}
102
103/// Shared reader state paired with a condition variable for waiters.
104pub struct PtyReadShared {
105    /// Protected reader buffer and close state.
106    pub state: Mutex<PtyReadState>,
107    /// Notifies waiters when output arrives or the reader closes.
108    pub condvar: Condvar,
109}
110
111/// Platform-neutral handles for a running native PTY child.
112/// Independently-lockable PTY input writer (issue #590, cluster D). Kept
113/// separate from the `handles` mutex so a blocking write never holds that
114/// lock — see the field docs on [`NativePtyHandles::writer`].
115pub type SharedPtyWriter = Arc<Mutex<Box<dyn Write + Send>>>;
116
117pub struct NativePtyHandles {
118    // #150: master/child previously stored concrete backend types.
119    // Refactored to use the cross-platform PtyMaster / PtyChild
120    // traits so the Windows path goes through `conpty_passthrough`
121    // (with PSEUDOCONSOLE_PASSTHROUGH_MODE) instead of portable-pty.
122    /// Master side of the PTY, used for resize and size queries.
123    pub master: Box<dyn crate::pty::backend::PtyMaster>,
124    /// Writer connected to the PTY master input stream.
125    ///
126    /// Held in its own `Arc<Mutex<…>>` (issue #590, cluster D) so a
127    /// blocking `write_all` on a full input pipe does NOT hold the outer
128    /// `handles` mutex. Otherwise `close()`/`kill()`/`poll()` — which all
129    /// lock `handles` — would deadlock behind an input write the child has
130    /// stopped consuming.
131    pub writer: SharedPtyWriter,
132    /// Spawned child process attached to the PTY slave.
133    pub child: Box<dyn crate::pty::backend::PtyChild>,
134    /// Host-owned process-tree containment guard.
135    pub process_guard: pty_platform::PtyProcessGuard,
136}
137
138/// Shared mutable state for idle detection waits.
139pub struct IdleMonitorState {
140    /// Last time input or qualifying output reset the idle timer.
141    pub last_reset_at: Instant,
142    /// Observed child return code, when the process has exited.
143    pub returncode: Option<i32>,
144    /// Whether the recorded exit was caused by an interrupt request.
145    pub interrupted: bool,
146}
147
148/// Core idle detection logic, shareable across threads via Arc.
149/// The reader thread calls `record_output` directly.
150pub struct IdleDetectorCore {
151    /// Minimum idle duration before the detector reports an idle timeout.
152    pub timeout_seconds: f64,
153    /// Additional quiet window required before reporting idle.
154    pub stability_window_seconds: f64,
155    /// Poll interval used while waiting for idle or exit.
156    pub sample_interval_seconds: f64,
157    /// Whether PTY input resets the idle timer.
158    pub reset_on_input: bool,
159    /// Whether PTY output resets the idle timer.
160    pub reset_on_output: bool,
161    /// Whether ANSI/control churn without visible bytes counts as output.
162    pub count_control_churn_as_output: bool,
163    /// Runtime switch that enables or disables idle timeout detection.
164    pub enabled: Arc<AtomicBool>,
165    /// Protected idle timing and exit state.
166    pub state: Mutex<IdleMonitorState>,
167    /// Notifies idle waiters when activity, exit, or enablement changes.
168    pub condvar: Condvar,
169}
170
171impl IdleDetectorCore {
172    /// Record input activity and reset the idle timer when configured.
173    pub fn record_input(&self, byte_count: usize) {
174        if !self.reset_on_input || byte_count == 0 {
175            return;
176        }
177        let mut guard = self.state.lock().expect("idle monitor mutex poisoned");
178        guard.last_reset_at = Instant::now();
179        self.condvar.notify_all();
180    }
181
182    /// Record output activity and reset the idle timer when configured.
183    pub fn record_output(&self, data: &[u8]) {
184        if !self.reset_on_output || data.is_empty() {
185            return;
186        }
187        let control_bytes = control_churn_bytes(data);
188        let visible_output_bytes = data.len().saturating_sub(control_bytes);
189        let active_output =
190            visible_output_bytes > 0 || (self.count_control_churn_as_output && control_bytes > 0);
191        if !active_output {
192            return;
193        }
194        let mut guard = self.state.lock().expect("idle monitor mutex poisoned");
195        guard.last_reset_at = Instant::now();
196        self.condvar.notify_all();
197    }
198
199    /// Record child process exit information and wake idle waiters.
200    pub fn mark_exit(&self, returncode: i32, interrupted: bool) {
201        let mut guard = self.state.lock().expect("idle monitor mutex poisoned");
202        guard.returncode = Some(returncode);
203        guard.interrupted = interrupted;
204        self.condvar.notify_all();
205    }
206
207    /// Return whether idle timeout detection is currently enabled.
208    pub fn enabled(&self) -> bool {
209        self.enabled.load(Ordering::Acquire)
210    }
211
212    /// Enable or disable idle timeout detection.
213    pub fn set_enabled(&self, enabled: bool) {
214        let was_enabled = self.enabled.swap(enabled, Ordering::AcqRel);
215        if enabled && !was_enabled {
216            let mut guard = self.state.lock().expect("idle monitor mutex poisoned");
217            guard.last_reset_at = Instant::now();
218        }
219        self.condvar.notify_all();
220    }
221
222    /// Wait until the child exits, the idle threshold is reached, or the timeout expires.
223    pub fn wait(&self, timeout: Option<f64>) -> (bool, String, f64, Option<i32>) {
224        let started = Instant::now();
225        let overall_timeout = timeout.map(Duration::from_secs_f64);
226        let min_idle = self.timeout_seconds.max(self.stability_window_seconds);
227        let sample_interval = Duration::from_secs_f64(self.sample_interval_seconds.max(0.001));
228
229        let mut guard = self.state.lock().expect("idle monitor mutex poisoned");
230        loop {
231            let now = Instant::now();
232            let idle_for = now.duration_since(guard.last_reset_at).as_secs_f64();
233
234            if let Some(returncode) = guard.returncode {
235                let reason = if guard.interrupted {
236                    "interrupt"
237                } else {
238                    "process_exit"
239                };
240                return (false, reason.to_string(), idle_for, Some(returncode));
241            }
242
243            let enabled = self.enabled.load(Ordering::Acquire);
244            if enabled && idle_for >= min_idle {
245                return (true, "idle_timeout".to_string(), idle_for, None);
246            }
247
248            if let Some(limit) = overall_timeout {
249                if now.duration_since(started) >= limit {
250                    return (false, "timeout".to_string(), idle_for, None);
251                }
252            }
253
254            let idle_remaining = if enabled {
255                (min_idle - idle_for).max(0.0)
256            } else {
257                sample_interval.as_secs_f64()
258            };
259            let mut wait_for =
260                sample_interval.min(Duration::from_secs_f64(idle_remaining.max(0.001)));
261            if let Some(limit) = overall_timeout {
262                let elapsed = now.duration_since(started);
263                if elapsed < limit {
264                    let remaining = limit - elapsed;
265                    wait_for = wait_for.min(remaining);
266                }
267            }
268            let result = self
269                .condvar
270                .wait_timeout(guard, wait_for)
271                .expect("idle monitor mutex poisoned");
272            guard = result.0;
273        }
274    }
275}
276
277// ── Helper functions ──
278
279/// Count ANSI/control bytes that should not be treated as visible output.
280pub fn control_churn_bytes(data: &[u8]) -> usize {
281    let mut total = 0;
282    let mut index = 0;
283    while index < data.len() {
284        let byte = data[index];
285        if byte == 0x1B {
286            let start = index;
287            index += 1;
288            if index < data.len() && data[index] == b'[' {
289                index += 1;
290                while index < data.len() {
291                    let current = data[index];
292                    index += 1;
293                    if (0x40..=0x7E).contains(&current) {
294                        break;
295                    }
296                }
297            }
298            total += index - start;
299            continue;
300        }
301        if matches!(byte, 0x08 | 0x0D | 0x7F) {
302            total += 1;
303        }
304        index += 1;
305    }
306    total
307}
308
309/// Spawn the background reader that drains PTY output into shared state.
310#[inline(never)]
311pub fn spawn_pty_reader(
312    mut reader: Box<dyn Read + Send>,
313    shared: Arc<PtyReadShared>,
314    echo: Arc<AtomicBool>,
315    idle_detector: Arc<Mutex<Option<Arc<IdleDetectorCore>>>>,
316    output_bytes_total: Arc<AtomicUsize>,
317    control_churn_bytes_total: Arc<AtomicUsize>,
318) {
319    crate::rp_rust_debug_scope!("running_process::spawn_pty_reader");
320    let idle_detector_snapshot = idle_detector
321        .lock()
322        .expect("idle detector mutex poisoned")
323        .clone();
324    let mut chunk = vec![0_u8; 65536];
325    loop {
326        match reader.read(&mut chunk) {
327            Ok(0) => break,
328            Ok(n) => {
329                let data = &chunk[..n];
330
331                let churn = control_churn_bytes(data);
332                let visible = data.len().saturating_sub(churn);
333                output_bytes_total.fetch_add(visible, Ordering::Relaxed);
334                control_churn_bytes_total.fetch_add(churn, Ordering::Relaxed);
335
336                if echo.load(Ordering::Relaxed) {
337                    let _ = std::io::stdout().write_all(data);
338                    let _ = std::io::stdout().flush();
339                }
340
341                if let Some(ref detector) = idle_detector_snapshot {
342                    detector.record_output(data);
343                }
344
345                let mut guard = shared.state.lock().expect("pty read mutex poisoned");
346                guard.chunks.push_back(data.to_vec());
347                shared.condvar.notify_all();
348            }
349            Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue,
350            Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
351                // #199: intentional — back-off on a non-blocking PTY
352                // master read that returned WouldBlock. There's no
353                // POSIX "wait for fd readable" that's portable
354                // across the OwnedFd / Windows OwnedHandle paths
355                // used here.
356                thread::sleep(Duration::from_millis(10));
357                continue;
358            }
359            Err(_) => break,
360        }
361    }
362    let mut guard = shared.state.lock().expect("pty read mutex poisoned");
363    guard.closed = true;
364    shared.condvar.notify_all();
365}
366
367/// Return whether input bytes contain a carriage return or newline.
368pub fn input_contains_newline(data: &[u8]) -> bool {
369    data.iter().any(|byte| matches!(*byte, b'\r' | b'\n'))
370}
371
372/// Relay bytes from the selected host terminal into the active PTY until stopped or exited.
373pub(super) struct TerminalInputRelayState {
374    pub handles: Arc<Mutex<Option<NativePtyHandles>>>,
375    pub returncode: Arc<Mutex<Option<i32>>>,
376    pub input_bytes_total: Arc<AtomicUsize>,
377    pub newline_events_total: Arc<AtomicUsize>,
378    pub submit_events_total: Arc<AtomicUsize>,
379    pub stop: Arc<AtomicBool>,
380    pub active: Arc<AtomicBool>,
381}
382
383#[inline(never)]
384pub(super) fn terminal_input_relay_worker(
385    input: pty_platform::TerminalInputSession,
386    state: TerminalInputRelayState,
387) {
388    loop {
389        if state.stop.load(Ordering::Acquire) {
390            break;
391        }
392        match poll_pty_process(&state.handles, &state.returncode) {
393            Ok(Some(_)) => break,
394            Ok(None) => {}
395            Err(_) => break,
396        }
397
398        let chunk = match input.read_chunk(Duration::from_millis(50)) {
399            Ok(Some(chunk)) => chunk,
400            Ok(None) => continue,
401            Err(_) => break,
402        };
403
404        record_pty_input_metrics(
405            &state.input_bytes_total,
406            &state.newline_events_total,
407            &state.submit_events_total,
408            &chunk.data,
409            chunk.submit,
410        );
411        if write_pty_input(&state.handles, &chunk.data).is_err() {
412            break;
413        }
414    }
415
416    state.active.store(false, Ordering::Release);
417}
418
419/// Record PTY input byte, newline, and submit counters for one input chunk.
420pub fn record_pty_input_metrics(
421    input_bytes_total: &Arc<AtomicUsize>,
422    newline_events_total: &Arc<AtomicUsize>,
423    submit_events_total: &Arc<AtomicUsize>,
424    data: &[u8],
425    submit: bool,
426) {
427    input_bytes_total.fetch_add(data.len(), Ordering::AcqRel);
428    if input_contains_newline(data) {
429        newline_events_total.fetch_add(1, Ordering::AcqRel);
430    }
431    if submit {
432        submit_events_total.fetch_add(1, Ordering::AcqRel);
433    }
434}
435
436/// Store the PTY child return code in shared process state.
437pub fn store_pty_returncode(returncode: &Arc<Mutex<Option<i32>>>, code: i32) {
438    *returncode.lock().expect("pty returncode mutex poisoned") = Some(code);
439}
440
441/// Poll the PTY child process and persist its return code after exit.
442pub fn poll_pty_process(
443    handles: &Arc<Mutex<Option<NativePtyHandles>>>,
444    returncode: &Arc<Mutex<Option<i32>>>,
445) -> Result<Option<i32>, std::io::Error> {
446    let mut guard = handles.lock().expect("pty handles mutex poisoned");
447    let Some(handles) = guard.as_mut() else {
448        return Ok(*returncode.lock().expect("pty returncode mutex poisoned"));
449    };
450    let status = handles.child.try_wait()?;
451    // #150: try_wait now returns Option<u32> (from PtyChild trait)
452    // The platform-owned child status is an unsigned exit code. Cast for storage.
453    let code = status.map(|c| c as i32);
454    if let Some(code) = code {
455        store_pty_returncode(returncode, code);
456        return Ok(Some(code));
457    }
458    Ok(None)
459}
460
461/// Write input bytes to the running PTY after platform-specific translation.
462pub fn write_pty_input(
463    handles: &Arc<Mutex<Option<NativePtyHandles>>>,
464    data: &[u8],
465) -> Result<(), std::io::Error> {
466    // Clone the writer handle out from under the `handles` lock, then
467    // release `handles` BEFORE the blocking write (issue #590, cluster D).
468    // The PTY input pipe fills when the child stops reading stdin; a
469    // `write_all` that blocked while holding `handles` would deadlock every
470    // teardown/poll path that also locks `handles`.
471    let writer = {
472        let guard = handles.lock().expect("pty handles mutex poisoned");
473        let handles = guard.as_ref().ok_or_else(|| {
474            std::io::Error::new(
475                std::io::ErrorKind::NotConnected,
476                "Pseudo-terminal process is not running",
477            )
478        })?;
479        Arc::clone(&handles.writer)
480    };
481    let payload = pty_platform::input_payload(data);
482    let mut writer = writer.lock().expect("pty writer mutex poisoned");
483    writer.write_all(&payload)?;
484    writer.flush()
485}
486
487/// Translate newline bytes into the Windows PTY input payload format.
488pub fn windows_terminal_input_payload(data: &[u8]) -> Vec<u8> {
489    pty_platform::input_payload(data)
490}
491
492/// Compatibility name for the host-owned PTY process-tree guard.
493pub type WindowsJobHandle = pty_platform::PtyProcessGuard;
494
495/// Information about a child process found via Toolhelp snapshot.
496pub use pty_platform::ChildProcessInfo;
497
498/// Find all direct child processes of a given parent PID using the Windows Toolhelp API.
499/// Returns PID and process name for each child.
500pub fn find_child_processes(parent_pid: u32) -> Vec<ChildProcessInfo> {
501    pty_platform::find_child_processes(parent_pid)
502}
503
504/// A conhost.exe process whose parent is no longer alive — likely an orphan
505/// from a dead ConPTY session.
506pub use pty_platform::OrphanConhostInfo;
507
508/// Scan all conhost.exe processes on the system and return those whose parent
509/// process is no longer alive. These are likely orphans from dead ConPTY sessions.
510///
511/// Uses `CreateToolhelp32Snapshot` for a point-in-time snapshot — no sysinfo
512/// dependency, so it's lightweight and can be called frequently.
513pub fn find_orphan_conhosts() -> Vec<OrphanConhostInfo> {
514    pty_platform::find_orphan_conhosts()
515}
516
517#[cfg(test)]
518mod tests {
519    use super::native_pty_process::resolved_spawn_cwd;
520
521    #[test]
522    fn resolved_spawn_cwd_preserves_explicit_value() {
523        assert_eq!(
524            resolved_spawn_cwd(Some("C:\\temp\\explicit")),
525            Some("C:\\temp\\explicit".to_string())
526        );
527    }
528
529    #[test]
530    fn resolved_spawn_cwd_defaults_to_current_dir_when_unset() {
531        let expected = std::env::current_dir()
532            .ok()
533            .map(|cwd| cwd.to_string_lossy().to_string());
534        assert_eq!(resolved_spawn_cwd(None), expected);
535    }
536}
537
538#[cfg(test)]
539#[path = "../tests/pty_core_coverage.rs"]
540mod coverage_tests;