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#[deprecated(
19 note = "use running_process::pty facade-owned types; portable-pty compatibility will be removed in 5.0"
20)]
21pub mod reexports {
22 pub use running_process_platform_internal::portable_pty_compat as portable_pty;
24}
25
26pub mod terminal_input;
28
29pub use running_process_platform_internal::platform::terminal::{
37 current_backend_kind, ConPtyBackendKind,
38};
39
40pub mod backend;
46pub use backend::{PtyChild, PtyMaster, PtySize};
48
49pub fn platform_shell_argv(command: &str) -> Vec<String> {
51 pty_platform::shell_argv(command)
52}
53
54pub fn wait_before_close_supported() -> bool {
56 pty_platform::wait_before_close_supported()
57}
58
59#[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#[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;
103pub use native_pty_process::{
105 InteractivePtyOptions, InteractivePtyPumpResult, InteractivePtySession, NativePtyProcess,
106};
107
108#[cfg(feature = "async-process")]
110pub mod async_pty;
111#[cfg(feature = "async-process")]
112pub use async_pty::{AsyncPtyProcess, IdleWaitOutcome};
113
114#[derive(Debug, Error)]
116pub enum PtyError {
117 #[error("pseudo-terminal process already started")]
119 AlreadyStarted,
120 #[error("pseudo-terminal process is not running")]
122 NotRunning,
123 #[error("pseudo-terminal timed out")]
125 Timeout,
126 #[error("pseudo-terminal I/O error: {0}")]
128 Io(
129 #[from]
131 std::io::Error,
132 ),
133 #[error("pseudo-terminal spawn failed: {0}")]
135 Spawn(
136 String,
138 ),
139 #[error("pseudo-terminal error: {0}")]
141 Other(
142 String,
144 ),
145}
146
147pub fn is_ignorable_process_control_error(err: &std::io::Error) -> bool {
149 pty_platform::is_ignorable_process_control_error(err)
150}
151
152pub struct PtyReadState {
154 pub chunks: VecDeque<Vec<u8>>,
156 pub closed: bool,
158}
159
160pub struct PtyReadShared {
162 pub state: Mutex<PtyReadState>,
164 pub condvar: Condvar,
166}
167
168pub type SharedPtyWriter = Arc<Mutex<Box<dyn Write + Send>>>;
173
174pub struct NativePtyHandles {
175 pub master: Box<dyn crate::pty::backend::PtyMaster>,
181 pub writer: SharedPtyWriter,
189 pub child: Box<dyn crate::pty::backend::PtyChild>,
191 pub process_guard: pty_platform::PtyProcessGuard,
193}
194
195pub struct IdleMonitorState {
197 pub last_reset_at: Instant,
199 pub returncode: Option<i32>,
201 pub interrupted: bool,
203}
204
205pub struct IdleDetectorCore {
208 pub timeout_seconds: f64,
210 pub stability_window_seconds: f64,
212 pub sample_interval_seconds: f64,
214 pub reset_on_input: bool,
216 pub reset_on_output: bool,
218 pub count_control_churn_as_output: bool,
220 pub enabled: Arc<AtomicBool>,
222 pub state: Mutex<IdleMonitorState>,
224 pub condvar: Condvar,
226}
227
228impl IdleDetectorCore {
229 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 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 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 pub fn enabled(&self) -> bool {
266 self.enabled.load(Ordering::Acquire)
267 }
268
269 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 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
334pub 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(¤t) {
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#[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 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
424pub fn input_contains_newline(data: &[u8]) -> bool {
426 data.iter().any(|byte| matches!(*byte, b'\r' | b'\n'))
427}
428
429pub(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
476pub 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
493pub fn store_pty_returncode(returncode: &Arc<Mutex<Option<i32>>>, code: i32) {
495 *returncode.lock().expect("pty returncode mutex poisoned") = Some(code);
496}
497
498pub 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 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
518pub fn write_pty_input(
520 handles: &Arc<Mutex<Option<NativePtyHandles>>>,
521 data: &[u8],
522) -> Result<(), std::io::Error> {
523 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
544pub fn windows_terminal_input_payload(data: &[u8]) -> Vec<u8> {
546 pty_platform::input_payload(data)
547}
548
549pub type WindowsJobHandle = pty_platform::PtyProcessGuard;
551
552pub use pty_platform::ChildProcessInfo;
554
555pub fn find_child_processes(parent_pid: u32) -> Vec<ChildProcessInfo> {
558 pty_platform::find_child_processes(parent_pid)
559}
560
561pub use pty_platform::OrphanConhostInfo;
564
565pub 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;