Skip to main content

running_process/pty/
mod.rs

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