use std::collections::VecDeque;
use std::ffi::OsString;
use std::io::{Read, Write};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Condvar, Mutex};
use std::thread;
use std::time::{Duration, Instant};
use thiserror::Error;
use running_process_platform_internal::platform::terminal as pty_platform;
#[deprecated(
note = "use running_process::pty facade-owned types; portable-pty compatibility will be removed in 5.0"
)]
pub mod reexports {
pub use running_process_platform_internal::portable_pty_compat as portable_pty;
}
pub mod terminal_input;
pub use running_process_platform_internal::platform::terminal::{
current_backend_kind, ConPtyBackendKind,
};
pub mod backend;
pub use backend::{PtyChild, PtyMaster, PtySize};
pub fn platform_shell_argv(command: &str) -> Vec<String> {
pty_platform::shell_argv(command)
}
pub fn wait_before_close_supported() -> bool {
pty_platform::wait_before_close_supported()
}
#[deprecated(
note = "use NativePtyProcess or a facade-owned PTY backend; this helper will be removed in 5.0"
)]
pub fn command_builder_from_argv(
argv: &[String],
) -> running_process_platform_internal::portable_pty_compat::CommandBuilder {
use running_process_platform_internal::portable_pty_compat::CommandBuilder;
let mut command = CommandBuilder::new(&argv[0]);
if argv.len() > 1 {
command.args(
argv[1..]
.iter()
.map(OsString::from)
.collect::<Vec<OsString>>(),
);
}
command
}
#[deprecated(
note = "use PtyChild::wait; direct portable-pty status conversion will be removed in 5.0"
)]
pub fn portable_exit_code(
status: running_process_platform_internal::portable_pty_compat::ExitStatus,
) -> i32 {
if let Some(signal) = status.signal() {
let signal = signal.to_ascii_lowercase();
if signal.contains("interrupt") {
return -2;
}
if signal.contains("terminated") {
return -15;
}
if signal.contains("killed") {
return -9;
}
}
status.exit_code() as i32
}
mod native_pty_process;
pub use native_pty_process::{
InteractivePtyOptions, InteractivePtyPumpResult, InteractivePtySession, NativePtyProcess,
};
#[cfg(feature = "async-process")]
pub mod async_pty;
#[cfg(feature = "async-process")]
pub use async_pty::{AsyncPtyProcess, IdleWaitOutcome};
#[derive(Debug, Error)]
pub enum PtyError {
#[error("pseudo-terminal process already started")]
AlreadyStarted,
#[error("pseudo-terminal process is not running")]
NotRunning,
#[error("pseudo-terminal timed out")]
Timeout,
#[error("pseudo-terminal I/O error: {0}")]
Io(
#[from]
std::io::Error,
),
#[error("pseudo-terminal spawn failed: {0}")]
Spawn(
String,
),
#[error("pseudo-terminal error: {0}")]
Other(
String,
),
}
pub fn is_ignorable_process_control_error(err: &std::io::Error) -> bool {
pty_platform::is_ignorable_process_control_error(err)
}
pub struct PtyReadState {
pub chunks: VecDeque<Vec<u8>>,
pub closed: bool,
}
pub struct PtyReadShared {
pub state: Mutex<PtyReadState>,
pub condvar: Condvar,
}
pub type SharedPtyWriter = Arc<Mutex<Box<dyn Write + Send>>>;
pub struct NativePtyHandles {
pub master: Box<dyn crate::pty::backend::PtyMaster>,
pub writer: SharedPtyWriter,
pub child: Box<dyn crate::pty::backend::PtyChild>,
pub process_guard: pty_platform::PtyProcessGuard,
}
pub struct IdleMonitorState {
pub last_reset_at: Instant,
pub returncode: Option<i32>,
pub interrupted: bool,
}
pub struct IdleDetectorCore {
pub timeout_seconds: f64,
pub stability_window_seconds: f64,
pub sample_interval_seconds: f64,
pub reset_on_input: bool,
pub reset_on_output: bool,
pub count_control_churn_as_output: bool,
pub enabled: Arc<AtomicBool>,
pub state: Mutex<IdleMonitorState>,
pub condvar: Condvar,
}
impl IdleDetectorCore {
pub fn record_input(&self, byte_count: usize) {
if !self.reset_on_input || byte_count == 0 {
return;
}
let mut guard = self.state.lock().expect("idle monitor mutex poisoned");
guard.last_reset_at = Instant::now();
self.condvar.notify_all();
}
pub fn record_output(&self, data: &[u8]) {
if !self.reset_on_output || data.is_empty() {
return;
}
let control_bytes = control_churn_bytes(data);
let visible_output_bytes = data.len().saturating_sub(control_bytes);
let active_output =
visible_output_bytes > 0 || (self.count_control_churn_as_output && control_bytes > 0);
if !active_output {
return;
}
let mut guard = self.state.lock().expect("idle monitor mutex poisoned");
guard.last_reset_at = Instant::now();
self.condvar.notify_all();
}
pub fn mark_exit(&self, returncode: i32, interrupted: bool) {
let mut guard = self.state.lock().expect("idle monitor mutex poisoned");
guard.returncode = Some(returncode);
guard.interrupted = interrupted;
self.condvar.notify_all();
}
pub fn enabled(&self) -> bool {
self.enabled.load(Ordering::Acquire)
}
pub fn set_enabled(&self, enabled: bool) {
let was_enabled = self.enabled.swap(enabled, Ordering::AcqRel);
if enabled && !was_enabled {
let mut guard = self.state.lock().expect("idle monitor mutex poisoned");
guard.last_reset_at = Instant::now();
}
self.condvar.notify_all();
}
pub fn wait(&self, timeout: Option<f64>) -> (bool, String, f64, Option<i32>) {
let started = Instant::now();
let overall_timeout = timeout.map(Duration::from_secs_f64);
let min_idle = self.timeout_seconds.max(self.stability_window_seconds);
let sample_interval = Duration::from_secs_f64(self.sample_interval_seconds.max(0.001));
let mut guard = self.state.lock().expect("idle monitor mutex poisoned");
loop {
let now = Instant::now();
let idle_for = now.duration_since(guard.last_reset_at).as_secs_f64();
if let Some(returncode) = guard.returncode {
let reason = if guard.interrupted {
"interrupt"
} else {
"process_exit"
};
return (false, reason.to_string(), idle_for, Some(returncode));
}
let enabled = self.enabled.load(Ordering::Acquire);
if enabled && idle_for >= min_idle {
return (true, "idle_timeout".to_string(), idle_for, None);
}
if let Some(limit) = overall_timeout {
if now.duration_since(started) >= limit {
return (false, "timeout".to_string(), idle_for, None);
}
}
let idle_remaining = if enabled {
(min_idle - idle_for).max(0.0)
} else {
sample_interval.as_secs_f64()
};
let mut wait_for =
sample_interval.min(Duration::from_secs_f64(idle_remaining.max(0.001)));
if let Some(limit) = overall_timeout {
let elapsed = now.duration_since(started);
if elapsed < limit {
let remaining = limit - elapsed;
wait_for = wait_for.min(remaining);
}
}
let result = self
.condvar
.wait_timeout(guard, wait_for)
.expect("idle monitor mutex poisoned");
guard = result.0;
}
}
}
pub fn control_churn_bytes(data: &[u8]) -> usize {
let mut total = 0;
let mut index = 0;
while index < data.len() {
let byte = data[index];
if byte == 0x1B {
let start = index;
index += 1;
if index < data.len() && data[index] == b'[' {
index += 1;
while index < data.len() {
let current = data[index];
index += 1;
if (0x40..=0x7E).contains(¤t) {
break;
}
}
}
total += index - start;
continue;
}
if matches!(byte, 0x08 | 0x0D | 0x7F) {
total += 1;
}
index += 1;
}
total
}
#[inline(never)]
pub fn spawn_pty_reader(
mut reader: Box<dyn Read + Send>,
shared: Arc<PtyReadShared>,
echo: Arc<AtomicBool>,
idle_detector: Arc<Mutex<Option<Arc<IdleDetectorCore>>>>,
output_bytes_total: Arc<AtomicUsize>,
control_churn_bytes_total: Arc<AtomicUsize>,
) {
crate::rp_rust_debug_scope!("running_process::spawn_pty_reader");
let idle_detector_snapshot = idle_detector
.lock()
.expect("idle detector mutex poisoned")
.clone();
let mut chunk = vec![0_u8; 65536];
loop {
match reader.read(&mut chunk) {
Ok(0) => break,
Ok(n) => {
let data = &chunk[..n];
let churn = control_churn_bytes(data);
let visible = data.len().saturating_sub(churn);
output_bytes_total.fetch_add(visible, Ordering::Relaxed);
control_churn_bytes_total.fetch_add(churn, Ordering::Relaxed);
if echo.load(Ordering::Relaxed) {
let _ = std::io::stdout().write_all(data);
let _ = std::io::stdout().flush();
}
if let Some(ref detector) = idle_detector_snapshot {
detector.record_output(data);
}
let mut guard = shared.state.lock().expect("pty read mutex poisoned");
guard.chunks.push_back(data.to_vec());
shared.condvar.notify_all();
}
Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue,
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(10));
continue;
}
Err(_) => break,
}
}
let mut guard = shared.state.lock().expect("pty read mutex poisoned");
guard.closed = true;
shared.condvar.notify_all();
}
pub fn input_contains_newline(data: &[u8]) -> bool {
data.iter().any(|byte| matches!(*byte, b'\r' | b'\n'))
}
pub(super) struct TerminalInputRelayState {
pub handles: Arc<Mutex<Option<NativePtyHandles>>>,
pub returncode: Arc<Mutex<Option<i32>>>,
pub input_bytes_total: Arc<AtomicUsize>,
pub newline_events_total: Arc<AtomicUsize>,
pub submit_events_total: Arc<AtomicUsize>,
pub stop: Arc<AtomicBool>,
pub active: Arc<AtomicBool>,
}
#[inline(never)]
pub(super) fn terminal_input_relay_worker(
input: pty_platform::TerminalInputSession,
state: TerminalInputRelayState,
) {
loop {
if state.stop.load(Ordering::Acquire) {
break;
}
match poll_pty_process(&state.handles, &state.returncode) {
Ok(Some(_)) => break,
Ok(None) => {}
Err(_) => break,
}
let chunk = match input.read_chunk(Duration::from_millis(50)) {
Ok(Some(chunk)) => chunk,
Ok(None) => continue,
Err(_) => break,
};
record_pty_input_metrics(
&state.input_bytes_total,
&state.newline_events_total,
&state.submit_events_total,
&chunk.data,
chunk.submit,
);
if write_pty_input(&state.handles, &chunk.data).is_err() {
break;
}
}
state.active.store(false, Ordering::Release);
}
pub fn record_pty_input_metrics(
input_bytes_total: &Arc<AtomicUsize>,
newline_events_total: &Arc<AtomicUsize>,
submit_events_total: &Arc<AtomicUsize>,
data: &[u8],
submit: bool,
) {
input_bytes_total.fetch_add(data.len(), Ordering::AcqRel);
if input_contains_newline(data) {
newline_events_total.fetch_add(1, Ordering::AcqRel);
}
if submit {
submit_events_total.fetch_add(1, Ordering::AcqRel);
}
}
pub fn store_pty_returncode(returncode: &Arc<Mutex<Option<i32>>>, code: i32) {
*returncode.lock().expect("pty returncode mutex poisoned") = Some(code);
}
pub fn poll_pty_process(
handles: &Arc<Mutex<Option<NativePtyHandles>>>,
returncode: &Arc<Mutex<Option<i32>>>,
) -> Result<Option<i32>, std::io::Error> {
let mut guard = handles.lock().expect("pty handles mutex poisoned");
let Some(handles) = guard.as_mut() else {
return Ok(*returncode.lock().expect("pty returncode mutex poisoned"));
};
let status = handles.child.try_wait()?;
let code = status.map(|c| c as i32);
if let Some(code) = code {
store_pty_returncode(returncode, code);
return Ok(Some(code));
}
Ok(None)
}
pub fn write_pty_input(
handles: &Arc<Mutex<Option<NativePtyHandles>>>,
data: &[u8],
) -> Result<(), std::io::Error> {
let writer = {
let guard = handles.lock().expect("pty handles mutex poisoned");
let handles = guard.as_ref().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotConnected,
"Pseudo-terminal process is not running",
)
})?;
Arc::clone(&handles.writer)
};
let payload = pty_platform::input_payload(data);
let mut writer = writer.lock().expect("pty writer mutex poisoned");
writer.write_all(&payload)?;
writer.flush()
}
pub fn windows_terminal_input_payload(data: &[u8]) -> Vec<u8> {
pty_platform::input_payload(data)
}
pub type WindowsJobHandle = pty_platform::PtyProcessGuard;
pub use pty_platform::ChildProcessInfo;
pub fn find_child_processes(parent_pid: u32) -> Vec<ChildProcessInfo> {
pty_platform::find_child_processes(parent_pid)
}
pub use pty_platform::OrphanConhostInfo;
pub fn find_orphan_conhosts() -> Vec<OrphanConhostInfo> {
pty_platform::find_orphan_conhosts()
}
#[cfg(test)]
mod tests {
use super::native_pty_process::resolved_spawn_cwd;
#[test]
fn resolved_spawn_cwd_preserves_explicit_value() {
assert_eq!(
resolved_spawn_cwd(Some("C:\\temp\\explicit")),
Some("C:\\temp\\explicit".to_string())
);
}
#[test]
fn resolved_spawn_cwd_defaults_to_current_dir_when_unset() {
let expected = std::env::current_dir()
.ok()
.map(|cwd| cwd.to_string_lossy().to_string());
assert_eq!(resolved_spawn_cwd(None), expected);
}
}
#[cfg(test)]
#[path = "../tests/pty_core_coverage.rs"]
mod coverage_tests;