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
12pub mod terminal_input;
14
15pub use running_process_platform_internal::platform::terminal::{
23 current_backend_kind, ConPtyBackendKind,
24};
25
26pub mod backend;
32pub use backend::{PtyChild, PtyMaster, PtySize};
34
35pub fn platform_shell_argv(command: &str) -> Vec<String> {
37 pty_platform::shell_argv(command)
38}
39
40pub fn wait_before_close_supported() -> bool {
42 pty_platform::wait_before_close_supported()
43}
44
45mod native_pty_process;
46pub use native_pty_process::{
48 InteractivePtyOptions, InteractivePtyPumpResult, InteractivePtySession, NativePtyProcess,
49};
50
51#[cfg(feature = "async-process")]
53pub mod async_pty;
54#[cfg(feature = "async-process")]
55pub use async_pty::{AsyncPtyProcess, IdleWaitOutcome};
56
57#[derive(Debug, Error)]
59pub enum PtyError {
60 #[error("pseudo-terminal process already started")]
62 AlreadyStarted,
63 #[error("pseudo-terminal process is not running")]
65 NotRunning,
66 #[error("pseudo-terminal timed out")]
68 Timeout,
69 #[error("pseudo-terminal I/O error: {0}")]
71 Io(
72 #[from]
74 std::io::Error,
75 ),
76 #[error("pseudo-terminal spawn failed: {0}")]
78 Spawn(
79 String,
81 ),
82 #[error("pseudo-terminal error: {0}")]
84 Other(
85 String,
87 ),
88}
89
90pub fn is_ignorable_process_control_error(err: &std::io::Error) -> bool {
92 pty_platform::is_ignorable_process_control_error(err)
93}
94
95pub struct PtyReadState {
97 pub chunks: VecDeque<Vec<u8>>,
99 pub closed: bool,
101}
102
103pub struct PtyReadShared {
105 pub state: Mutex<PtyReadState>,
107 pub condvar: Condvar,
109}
110
111pub type SharedPtyWriter = Arc<Mutex<Box<dyn Write + Send>>>;
116
117pub struct NativePtyHandles {
118 pub master: Box<dyn crate::pty::backend::PtyMaster>,
124 pub writer: SharedPtyWriter,
132 pub child: Box<dyn crate::pty::backend::PtyChild>,
134 pub process_guard: pty_platform::PtyProcessGuard,
136}
137
138pub struct IdleMonitorState {
140 pub last_reset_at: Instant,
142 pub returncode: Option<i32>,
144 pub interrupted: bool,
146}
147
148pub struct IdleDetectorCore {
151 pub timeout_seconds: f64,
153 pub stability_window_seconds: f64,
155 pub sample_interval_seconds: f64,
157 pub reset_on_input: bool,
159 pub reset_on_output: bool,
161 pub count_control_churn_as_output: bool,
163 pub enabled: Arc<AtomicBool>,
165 pub state: Mutex<IdleMonitorState>,
167 pub condvar: Condvar,
169}
170
171impl IdleDetectorCore {
172 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 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 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 pub fn enabled(&self) -> bool {
209 self.enabled.load(Ordering::Acquire)
210 }
211
212 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 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
277pub 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(¤t) {
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#[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 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
367pub fn input_contains_newline(data: &[u8]) -> bool {
369 data.iter().any(|byte| matches!(*byte, b'\r' | b'\n'))
370}
371
372pub(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
419pub 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
436pub fn store_pty_returncode(returncode: &Arc<Mutex<Option<i32>>>, code: i32) {
438 *returncode.lock().expect("pty returncode mutex poisoned") = Some(code);
439}
440
441pub 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 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
461pub fn write_pty_input(
463 handles: &Arc<Mutex<Option<NativePtyHandles>>>,
464 data: &[u8],
465) -> Result<(), std::io::Error> {
466 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
487pub fn windows_terminal_input_payload(data: &[u8]) -> Vec<u8> {
489 pty_platform::input_payload(data)
490}
491
492pub type WindowsJobHandle = pty_platform::PtyProcessGuard;
494
495pub use pty_platform::ChildProcessInfo;
497
498pub fn find_child_processes(parent_pid: u32) -> Vec<ChildProcessInfo> {
501 pty_platform::find_child_processes(parent_pid)
502}
503
504pub use pty_platform::OrphanConhostInfo;
507
508pub 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;