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 portable_pty::CommandBuilder;
10use thiserror::Error;
11
12/// Re-exports for downstream crates that need portable-pty types.
13pub mod reexports {
14    /// Re-export of the `portable_pty` crate used by the PTY backend.
15    pub use portable_pty;
16}
17
18/// Unix PTY process-control helpers.
19#[cfg(unix)]
20pub(super) mod pty_posix;
21/// Windows PTY process-control helpers.
22#[cfg(windows)]
23pub(super) mod pty_windows;
24
25/// Native terminal input capture and translation helpers.
26pub mod terminal_input;
27
28// #150: ConPTY rewrite with PSEUDOCONSOLE_PASSTHROUGH_MODE so raw
29// child ANSI bytes reach the daemon ring buffer instead of ConPTY's
30// synthesized virtual-screen re-emission. Windows-only; Unix continues
31// to use portable-pty via the `pty_platform = pty_posix` alias above.
32/// Windows ConPTY backend that preserves raw child ANSI output.
33#[cfg(windows)]
34pub(super) mod conpty_passthrough;
35
36/// Reports whether the process-wide ConPTY API table resolved to the
37/// system `kernel32.dll` or to a sidecar `conpty.dll`. See #443.
38///
39/// Integration tests gate Win10-with-sidecar coverage on this — the
40/// byte-exact passthrough assertions can only hold when a sidecar is
41/// loaded on Win10, since the system `kernel32!CreatePseudoConsole`
42/// on Win10 < build 22000 silently ignores `PSEUDOCONSOLE_PASSTHROUGH_MODE`.
43#[cfg(windows)]
44pub use conpty_passthrough::conpty_api::{current_backend_kind, ConPtyBackendKind};
45
46// #150: backend abstraction so native_pty_process.rs calls a single
47// Backend::openpty() regardless of platform. Made `pub` in 4.0.1 so
48// downstream consumers (e.g. clud's SIGWINCH relay) can call
49// `PtyMaster::resize` / `get_size` through `NativePtyHandles.master`.
50/// Cross-platform PTY backend traits and platform-selected implementations.
51pub mod backend;
52/// Re-exported PTY backend handles and size type.
53pub use backend::{PtyChild, PtyMaster, PtySize};
54
55mod native_pty_process;
56/// Re-exported native PTY process and interactive session types.
57pub use native_pty_process::{
58    InteractivePtyOptions, InteractivePtyPumpResult, InteractivePtySession, NativePtyProcess,
59};
60
61#[cfg(unix)]
62use pty_posix as pty_platform;
63
64/// Errors returned by pseudo-terminal process operations.
65#[derive(Debug, Error)]
66pub enum PtyError {
67    /// The pseudo-terminal process has already been started.
68    #[error("pseudo-terminal process already started")]
69    AlreadyStarted,
70    /// The pseudo-terminal process is not currently running.
71    #[error("pseudo-terminal process is not running")]
72    NotRunning,
73    /// The pseudo-terminal operation exceeded its timeout.
74    #[error("pseudo-terminal timed out")]
75    Timeout,
76    /// An underlying I/O operation failed.
77    #[error("pseudo-terminal I/O error: {0}")]
78    Io(
79        /// The underlying I/O error.
80        #[from]
81        std::io::Error,
82    ),
83    /// Spawning the pseudo-terminal process failed.
84    #[error("pseudo-terminal spawn failed: {0}")]
85    Spawn(
86        /// Backend-provided spawn failure details.
87        String,
88    ),
89    /// A pseudo-terminal operation failed for another reason.
90    #[error("pseudo-terminal error: {0}")]
91    Other(
92        /// Human-readable error details.
93        String,
94    ),
95}
96
97/// Return whether a process-control error can be ignored during cleanup.
98pub fn is_ignorable_process_control_error(err: &std::io::Error) -> bool {
99    if matches!(
100        err.kind(),
101        std::io::ErrorKind::NotFound | std::io::ErrorKind::InvalidInput
102    ) {
103        return true;
104    }
105    #[cfg(unix)]
106    if err.raw_os_error() == Some(libc::ESRCH) {
107        return true;
108    }
109    false
110}
111
112/// Buffered output and close state for a PTY reader thread.
113pub struct PtyReadState {
114    /// Output chunks read from the PTY master.
115    pub chunks: VecDeque<Vec<u8>>,
116    /// Whether the PTY reader has reached EOF or stopped.
117    pub closed: bool,
118}
119
120/// Shared reader state paired with a condition variable for waiters.
121pub struct PtyReadShared {
122    /// Protected reader buffer and close state.
123    pub state: Mutex<PtyReadState>,
124    /// Notifies waiters when output arrives or the reader closes.
125    pub condvar: Condvar,
126}
127
128/// Platform-neutral handles for a running native PTY child.
129/// Independently-lockable PTY input writer (issue #590, cluster D). Kept
130/// separate from the `handles` mutex so a blocking write never holds that
131/// lock — see the field docs on [`NativePtyHandles::writer`].
132pub type SharedPtyWriter = Arc<Mutex<Box<dyn Write + Send>>>;
133
134pub struct NativePtyHandles {
135    // #150: master/child were `Box<dyn portable_pty::MasterPty>` etc.
136    // Refactored to use the cross-platform PtyMaster / PtyChild
137    // traits so the Windows path goes through `conpty_passthrough`
138    // (with PSEUDOCONSOLE_PASSTHROUGH_MODE) instead of portable-pty.
139    /// Master side of the PTY, used for resize and size queries.
140    pub master: Box<dyn crate::pty::backend::PtyMaster>,
141    /// Writer connected to the PTY master input stream.
142    ///
143    /// Held in its own `Arc<Mutex<…>>` (issue #590, cluster D) so a
144    /// blocking `write_all` on a full input pipe does NOT hold the outer
145    /// `handles` mutex. Otherwise `close()`/`kill()`/`poll()` — which all
146    /// lock `handles` — would deadlock behind an input write the child has
147    /// stopped consuming.
148    pub writer: SharedPtyWriter,
149    /// Spawned child process attached to the PTY slave.
150    pub child: Box<dyn crate::pty::backend::PtyChild>,
151    /// Windows Job Object that cleans up the PTY child tree on close.
152    #[cfg(windows)]
153    pub _job: WindowsJobHandle,
154}
155
156#[cfg(windows)]
157/// Owning wrapper around a Windows Job Object handle.
158pub struct WindowsJobHandle(
159    /// Raw Windows Job Object handle stored as an integer for Send safety.
160    pub usize,
161);
162
163#[cfg(windows)]
164impl WindowsJobHandle {
165    /// Assign an additional process (by PID) to this Job Object.
166    pub fn assign_pid(&self, pid: u32) -> Result<(), std::io::Error> {
167        use winapi::um::handleapi::CloseHandle;
168        use winapi::um::processthreadsapi::OpenProcess;
169        use winapi::um::winnt::PROCESS_SET_QUOTA;
170        use winapi::um::winnt::PROCESS_TERMINATE;
171
172        let handle = unsafe { OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, 0, pid) };
173        if handle.is_null() {
174            return Err(std::io::Error::last_os_error());
175        }
176        let result = unsafe {
177            winapi::um::jobapi2::AssignProcessToJobObject(
178                self.0 as winapi::shared::ntdef::HANDLE,
179                handle,
180            )
181        };
182        unsafe { CloseHandle(handle) };
183        if result == 0 {
184            return Err(std::io::Error::last_os_error());
185        }
186        Ok(())
187    }
188}
189
190#[cfg(windows)]
191impl Drop for WindowsJobHandle {
192    fn drop(&mut self) {
193        unsafe {
194            winapi::um::handleapi::CloseHandle(self.0 as winapi::shared::ntdef::HANDLE);
195        }
196    }
197}
198
199/// Shared mutable state for idle detection waits.
200pub struct IdleMonitorState {
201    /// Last time input or qualifying output reset the idle timer.
202    pub last_reset_at: Instant,
203    /// Observed child return code, when the process has exited.
204    pub returncode: Option<i32>,
205    /// Whether the recorded exit was caused by an interrupt request.
206    pub interrupted: bool,
207}
208
209/// Core idle detection logic, shareable across threads via Arc.
210/// The reader thread calls `record_output` directly.
211pub struct IdleDetectorCore {
212    /// Minimum idle duration before the detector reports an idle timeout.
213    pub timeout_seconds: f64,
214    /// Additional quiet window required before reporting idle.
215    pub stability_window_seconds: f64,
216    /// Poll interval used while waiting for idle or exit.
217    pub sample_interval_seconds: f64,
218    /// Whether PTY input resets the idle timer.
219    pub reset_on_input: bool,
220    /// Whether PTY output resets the idle timer.
221    pub reset_on_output: bool,
222    /// Whether ANSI/control churn without visible bytes counts as output.
223    pub count_control_churn_as_output: bool,
224    /// Runtime switch that enables or disables idle timeout detection.
225    pub enabled: Arc<AtomicBool>,
226    /// Protected idle timing and exit state.
227    pub state: Mutex<IdleMonitorState>,
228    /// Notifies idle waiters when activity, exit, or enablement changes.
229    pub condvar: Condvar,
230}
231
232impl IdleDetectorCore {
233    /// Record input activity and reset the idle timer when configured.
234    pub fn record_input(&self, byte_count: usize) {
235        if !self.reset_on_input || byte_count == 0 {
236            return;
237        }
238        let mut guard = self.state.lock().expect("idle monitor mutex poisoned");
239        guard.last_reset_at = Instant::now();
240        self.condvar.notify_all();
241    }
242
243    /// Record output activity and reset the idle timer when configured.
244    pub fn record_output(&self, data: &[u8]) {
245        if !self.reset_on_output || data.is_empty() {
246            return;
247        }
248        let control_bytes = control_churn_bytes(data);
249        let visible_output_bytes = data.len().saturating_sub(control_bytes);
250        let active_output =
251            visible_output_bytes > 0 || (self.count_control_churn_as_output && control_bytes > 0);
252        if !active_output {
253            return;
254        }
255        let mut guard = self.state.lock().expect("idle monitor mutex poisoned");
256        guard.last_reset_at = Instant::now();
257        self.condvar.notify_all();
258    }
259
260    /// Record child process exit information and wake idle waiters.
261    pub fn mark_exit(&self, returncode: i32, interrupted: bool) {
262        let mut guard = self.state.lock().expect("idle monitor mutex poisoned");
263        guard.returncode = Some(returncode);
264        guard.interrupted = interrupted;
265        self.condvar.notify_all();
266    }
267
268    /// Return whether idle timeout detection is currently enabled.
269    pub fn enabled(&self) -> bool {
270        self.enabled.load(Ordering::Acquire)
271    }
272
273    /// Enable or disable idle timeout detection.
274    pub fn set_enabled(&self, enabled: bool) {
275        let was_enabled = self.enabled.swap(enabled, Ordering::AcqRel);
276        if enabled && !was_enabled {
277            let mut guard = self.state.lock().expect("idle monitor mutex poisoned");
278            guard.last_reset_at = Instant::now();
279        }
280        self.condvar.notify_all();
281    }
282
283    /// Wait until the child exits, the idle threshold is reached, or the timeout expires.
284    pub fn wait(&self, timeout: Option<f64>) -> (bool, String, f64, Option<i32>) {
285        let started = Instant::now();
286        let overall_timeout = timeout.map(Duration::from_secs_f64);
287        let min_idle = self.timeout_seconds.max(self.stability_window_seconds);
288        let sample_interval = Duration::from_secs_f64(self.sample_interval_seconds.max(0.001));
289
290        let mut guard = self.state.lock().expect("idle monitor mutex poisoned");
291        loop {
292            let now = Instant::now();
293            let idle_for = now.duration_since(guard.last_reset_at).as_secs_f64();
294
295            if let Some(returncode) = guard.returncode {
296                let reason = if guard.interrupted {
297                    "interrupt"
298                } else {
299                    "process_exit"
300                };
301                return (false, reason.to_string(), idle_for, Some(returncode));
302            }
303
304            let enabled = self.enabled.load(Ordering::Acquire);
305            if enabled && idle_for >= min_idle {
306                return (true, "idle_timeout".to_string(), idle_for, None);
307            }
308
309            if let Some(limit) = overall_timeout {
310                if now.duration_since(started) >= limit {
311                    return (false, "timeout".to_string(), idle_for, None);
312                }
313            }
314
315            let idle_remaining = if enabled {
316                (min_idle - idle_for).max(0.0)
317            } else {
318                sample_interval.as_secs_f64()
319            };
320            let mut wait_for =
321                sample_interval.min(Duration::from_secs_f64(idle_remaining.max(0.001)));
322            if let Some(limit) = overall_timeout {
323                let elapsed = now.duration_since(started);
324                if elapsed < limit {
325                    let remaining = limit - elapsed;
326                    wait_for = wait_for.min(remaining);
327                }
328            }
329            let result = self
330                .condvar
331                .wait_timeout(guard, wait_for)
332                .expect("idle monitor mutex poisoned");
333            guard = result.0;
334        }
335    }
336}
337
338// ── Helper functions ──
339
340/// Count ANSI/control bytes that should not be treated as visible output.
341pub fn control_churn_bytes(data: &[u8]) -> usize {
342    let mut total = 0;
343    let mut index = 0;
344    while index < data.len() {
345        let byte = data[index];
346        if byte == 0x1B {
347            let start = index;
348            index += 1;
349            if index < data.len() && data[index] == b'[' {
350                index += 1;
351                while index < data.len() {
352                    let current = data[index];
353                    index += 1;
354                    if (0x40..=0x7E).contains(&current) {
355                        break;
356                    }
357                }
358            }
359            total += index - start;
360            continue;
361        }
362        if matches!(byte, 0x08 | 0x0D | 0x7F) {
363            total += 1;
364        }
365        index += 1;
366    }
367    total
368}
369
370/// Build a `portable_pty::CommandBuilder` from an argv vector.
371pub fn command_builder_from_argv(argv: &[String]) -> CommandBuilder {
372    let mut command = CommandBuilder::new(&argv[0]);
373    if argv.len() > 1 {
374        command.args(
375            argv[1..]
376                .iter()
377                .map(OsString::from)
378                .collect::<Vec<OsString>>(),
379        );
380    }
381    command
382}
383
384/// Spawn the background reader that drains PTY output into shared state.
385#[inline(never)]
386pub fn spawn_pty_reader(
387    mut reader: Box<dyn Read + Send>,
388    shared: Arc<PtyReadShared>,
389    echo: Arc<AtomicBool>,
390    idle_detector: Arc<Mutex<Option<Arc<IdleDetectorCore>>>>,
391    output_bytes_total: Arc<AtomicUsize>,
392    control_churn_bytes_total: Arc<AtomicUsize>,
393) {
394    crate::rp_rust_debug_scope!("running_process::spawn_pty_reader");
395    let idle_detector_snapshot = idle_detector
396        .lock()
397        .expect("idle detector mutex poisoned")
398        .clone();
399    let mut chunk = vec![0_u8; 65536];
400    loop {
401        match reader.read(&mut chunk) {
402            Ok(0) => break,
403            Ok(n) => {
404                let data = &chunk[..n];
405
406                let churn = control_churn_bytes(data);
407                let visible = data.len().saturating_sub(churn);
408                output_bytes_total.fetch_add(visible, Ordering::Relaxed);
409                control_churn_bytes_total.fetch_add(churn, Ordering::Relaxed);
410
411                if echo.load(Ordering::Relaxed) {
412                    let _ = std::io::stdout().write_all(data);
413                    let _ = std::io::stdout().flush();
414                }
415
416                if let Some(ref detector) = idle_detector_snapshot {
417                    detector.record_output(data);
418                }
419
420                let mut guard = shared.state.lock().expect("pty read mutex poisoned");
421                guard.chunks.push_back(data.to_vec());
422                shared.condvar.notify_all();
423            }
424            Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue,
425            Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
426                // #199: intentional — back-off on a non-blocking PTY
427                // master read that returned WouldBlock. There's no
428                // POSIX "wait for fd readable" that's portable
429                // across the OwnedFd / Windows OwnedHandle paths
430                // used here.
431                thread::sleep(Duration::from_millis(10));
432                continue;
433            }
434            Err(_) => break,
435        }
436    }
437    let mut guard = shared.state.lock().expect("pty read mutex poisoned");
438    guard.closed = true;
439    shared.condvar.notify_all();
440}
441
442/// Convert a `portable_pty` exit status into this crate's signed exit-code convention.
443pub fn portable_exit_code(status: portable_pty::ExitStatus) -> i32 {
444    if let Some(signal) = status.signal() {
445        let signal = signal.to_ascii_lowercase();
446        if signal.contains("interrupt") {
447            return -2;
448        }
449        if signal.contains("terminated") {
450            return -15;
451        }
452        if signal.contains("killed") {
453            return -9;
454        }
455    }
456    status.exit_code() as i32
457}
458
459/// Return whether input bytes contain a carriage return or newline.
460pub fn input_contains_newline(data: &[u8]) -> bool {
461    data.iter().any(|byte| matches!(*byte, b'\r' | b'\n'))
462}
463
464#[cfg(unix)]
465struct PosixTerminalModeGuard {
466    stdin_fd: i32,
467    original_mode: libc::termios,
468}
469
470#[cfg(unix)]
471impl Drop for PosixTerminalModeGuard {
472    fn drop(&mut self) {
473        unsafe {
474            libc::tcsetattr(self.stdin_fd, libc::TCSANOW, &self.original_mode);
475        }
476    }
477}
478
479#[cfg(unix)]
480fn acquire_posix_terminal_mode_guard() -> Result<PosixTerminalModeGuard, std::io::Error> {
481    let stdin_fd = libc::STDIN_FILENO;
482    let mut original_mode = unsafe { std::mem::zeroed::<libc::termios>() };
483    if unsafe { libc::tcgetattr(stdin_fd, &mut original_mode) } != 0 {
484        return Err(std::io::Error::last_os_error());
485    }
486    let mut raw_mode = original_mode;
487    unsafe {
488        libc::cfmakeraw(&mut raw_mode);
489    }
490    if unsafe { libc::tcsetattr(stdin_fd, libc::TCSANOW, &raw_mode) } != 0 {
491        return Err(std::io::Error::last_os_error());
492    }
493    Ok(PosixTerminalModeGuard {
494        stdin_fd,
495        original_mode,
496    })
497}
498
499#[cfg(unix)]
500/// Relay bytes from POSIX stdin into the active PTY until stopped or exited.
501#[inline(never)]
502pub(super) fn posix_terminal_input_relay_worker(
503    handles: Arc<Mutex<Option<NativePtyHandles>>>,
504    returncode: Arc<Mutex<Option<i32>>>,
505    input_bytes_total: Arc<AtomicUsize>,
506    newline_events_total: Arc<AtomicUsize>,
507    submit_events_total: Arc<AtomicUsize>,
508    stop: Arc<AtomicBool>,
509    active: Arc<AtomicBool>,
510) {
511    let _terminal_guard = match acquire_posix_terminal_mode_guard() {
512        Ok(guard) => guard,
513        Err(_) => {
514            active.store(false, Ordering::Release);
515            return;
516        }
517    };
518
519    let stdin_fd = libc::STDIN_FILENO;
520    let mut buffer = vec![0_u8; 65536];
521    loop {
522        if stop.load(Ordering::Acquire) {
523            break;
524        }
525        match poll_pty_process(&handles, &returncode) {
526            Ok(Some(_)) => break,
527            Ok(None) => {}
528            Err(_) => break,
529        }
530
531        let mut pollfd = libc::pollfd {
532            fd: stdin_fd,
533            events: libc::POLLIN,
534            revents: 0,
535        };
536        let poll_result = unsafe { libc::poll(&mut pollfd, 1, 50) };
537        if poll_result < 0 {
538            let err = std::io::Error::last_os_error();
539            if err.kind() == std::io::ErrorKind::Interrupted {
540                continue;
541            }
542            break;
543        }
544        if poll_result == 0 || pollfd.revents & libc::POLLIN == 0 {
545            continue;
546        }
547
548        let read_result = unsafe { libc::read(stdin_fd, buffer.as_mut_ptr().cast(), buffer.len()) };
549        if read_result < 0 {
550            let err = std::io::Error::last_os_error();
551            if err.kind() == std::io::ErrorKind::Interrupted {
552                continue;
553            }
554            break;
555        }
556        if read_result == 0 {
557            continue;
558        }
559
560        let mut data = buffer[..read_result as usize].to_vec();
561        loop {
562            let mut drain_pollfd = libc::pollfd {
563                fd: stdin_fd,
564                events: libc::POLLIN,
565                revents: 0,
566            };
567            let drain_ready = unsafe { libc::poll(&mut drain_pollfd, 1, 0) };
568            if drain_ready <= 0 || drain_pollfd.revents & libc::POLLIN == 0 {
569                break;
570            }
571            let drain_result =
572                unsafe { libc::read(stdin_fd, buffer.as_mut_ptr().cast(), buffer.len()) };
573            if drain_result <= 0 {
574                break;
575            }
576            data.extend_from_slice(&buffer[..drain_result as usize]);
577        }
578
579        record_pty_input_metrics(
580            &input_bytes_total,
581            &newline_events_total,
582            &submit_events_total,
583            &data,
584            input_contains_newline(&data),
585        );
586        if write_pty_input(&handles, &data).is_err() {
587            break;
588        }
589    }
590
591    active.store(false, Ordering::Release);
592}
593
594/// Record PTY input byte, newline, and submit counters for one input chunk.
595pub fn record_pty_input_metrics(
596    input_bytes_total: &Arc<AtomicUsize>,
597    newline_events_total: &Arc<AtomicUsize>,
598    submit_events_total: &Arc<AtomicUsize>,
599    data: &[u8],
600    submit: bool,
601) {
602    input_bytes_total.fetch_add(data.len(), Ordering::AcqRel);
603    if input_contains_newline(data) {
604        newline_events_total.fetch_add(1, Ordering::AcqRel);
605    }
606    if submit {
607        submit_events_total.fetch_add(1, Ordering::AcqRel);
608    }
609}
610
611/// Store the PTY child return code in shared process state.
612pub fn store_pty_returncode(returncode: &Arc<Mutex<Option<i32>>>, code: i32) {
613    *returncode.lock().expect("pty returncode mutex poisoned") = Some(code);
614}
615
616/// Poll the PTY child process and persist its return code after exit.
617pub fn poll_pty_process(
618    handles: &Arc<Mutex<Option<NativePtyHandles>>>,
619    returncode: &Arc<Mutex<Option<i32>>>,
620) -> Result<Option<i32>, std::io::Error> {
621    let mut guard = handles.lock().expect("pty handles mutex poisoned");
622    let Some(handles) = guard.as_mut() else {
623        return Ok(*returncode.lock().expect("pty returncode mutex poisoned"));
624    };
625    let status = handles.child.try_wait()?;
626    // #150: try_wait now returns Option<u32> (from PtyChild trait)
627    // instead of portable_pty's ExitStatus. Just cast for storage.
628    let code = status.map(|c| c as i32);
629    if let Some(code) = code {
630        store_pty_returncode(returncode, code);
631        return Ok(Some(code));
632    }
633    Ok(None)
634}
635
636/// Write input bytes to the running PTY after platform-specific translation.
637pub fn write_pty_input(
638    handles: &Arc<Mutex<Option<NativePtyHandles>>>,
639    data: &[u8],
640) -> Result<(), std::io::Error> {
641    // Clone the writer handle out from under the `handles` lock, then
642    // release `handles` BEFORE the blocking write (issue #590, cluster D).
643    // The PTY input pipe fills when the child stops reading stdin; a
644    // `write_all` that blocked while holding `handles` would deadlock every
645    // teardown/poll path that also locks `handles`.
646    let writer = {
647        let guard = handles.lock().expect("pty handles mutex poisoned");
648        let handles = guard.as_ref().ok_or_else(|| {
649            std::io::Error::new(
650                std::io::ErrorKind::NotConnected,
651                "Pseudo-terminal process is not running",
652            )
653        })?;
654        Arc::clone(&handles.writer)
655    };
656    #[cfg(windows)]
657    let payload = pty_windows::input_payload(data);
658    #[cfg(unix)]
659    let payload = pty_platform::input_payload(data);
660    let mut writer = writer.lock().expect("pty writer mutex poisoned");
661    writer.write_all(&payload)?;
662    writer.flush()
663}
664
665#[cfg(windows)]
666/// Translate newline bytes into the Windows PTY input payload format.
667pub fn windows_terminal_input_payload(data: &[u8]) -> Vec<u8> {
668    let mut translated = Vec::with_capacity(data.len());
669    let mut index = 0usize;
670    while index < data.len() {
671        let current = data[index];
672        if current == b'\r' {
673            translated.push(current);
674            if index + 1 < data.len() && data[index + 1] == b'\n' {
675                translated.push(b'\n');
676                index += 2;
677                continue;
678            }
679            index += 1;
680            continue;
681        }
682        if current == b'\n' {
683            translated.push(b'\r');
684            index += 1;
685            continue;
686        }
687        translated.push(current);
688        index += 1;
689    }
690    translated
691}
692
693#[cfg(windows)]
694/// Create a kill-on-close Windows Job Object and assign the child process to it.
695#[inline(never)]
696pub fn assign_child_to_windows_kill_on_close_job(
697    handle: Option<std::os::windows::io::RawHandle>,
698) -> Result<WindowsJobHandle, PtyError> {
699    crate::rp_rust_debug_scope!("running_process::pty::assign_child_to_windows_kill_on_close_job");
700    use std::mem::zeroed;
701
702    use winapi::shared::minwindef::FALSE;
703    use winapi::um::handleapi::INVALID_HANDLE_VALUE;
704    use winapi::um::jobapi2::{
705        AssignProcessToJobObject, CreateJobObjectW, SetInformationJobObject,
706    };
707    use winapi::um::winnt::{
708        JobObjectExtendedLimitInformation, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
709        JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
710    };
711
712    let Some(handle) = handle else {
713        return Err(PtyError::Other(
714            "Pseudo-terminal child does not expose a Windows process handle".into(),
715        ));
716    };
717
718    let job = unsafe { CreateJobObjectW(std::ptr::null_mut(), std::ptr::null()) };
719    if job.is_null() || job == INVALID_HANDLE_VALUE {
720        return Err(PtyError::Io(std::io::Error::last_os_error()));
721    }
722
723    let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { zeroed() };
724    info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
725    let result = unsafe {
726        SetInformationJobObject(
727            job,
728            JobObjectExtendedLimitInformation,
729            (&mut info as *mut JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast(),
730            std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
731        )
732    };
733    if result == FALSE {
734        let err = std::io::Error::last_os_error();
735        unsafe {
736            winapi::um::handleapi::CloseHandle(job);
737        }
738        return Err(PtyError::Io(err));
739    }
740
741    let result = unsafe { AssignProcessToJobObject(job, handle.cast()) };
742    if result == FALSE {
743        let err = std::io::Error::last_os_error();
744        unsafe {
745            winapi::um::handleapi::CloseHandle(job);
746        }
747        return Err(PtyError::Io(err));
748    }
749
750    Ok(WindowsJobHandle(job as usize))
751}
752
753/// Information about a child process found via Toolhelp snapshot.
754#[cfg(windows)]
755#[derive(Debug, Clone)]
756pub struct ChildProcessInfo {
757    /// Process identifier of the child process.
758    pub pid: u32,
759    /// Executable name reported by the Toolhelp process snapshot.
760    pub name: String,
761}
762
763/// Find all direct child processes of a given parent PID using the Windows Toolhelp API.
764/// Returns PID and process name for each child.
765#[cfg(windows)]
766pub fn find_child_processes(parent_pid: u32) -> Vec<ChildProcessInfo> {
767    use winapi::um::handleapi::CloseHandle;
768    use winapi::um::tlhelp32::{
769        CreateToolhelp32Snapshot, Process32First, Process32Next, PROCESSENTRY32, TH32CS_SNAPPROCESS,
770    };
771
772    let mut children = Vec::new();
773    let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
774    if snapshot == winapi::um::handleapi::INVALID_HANDLE_VALUE {
775        return children;
776    }
777
778    let mut entry: PROCESSENTRY32 = unsafe { std::mem::zeroed() };
779    entry.dwSize = std::mem::size_of::<PROCESSENTRY32>() as u32;
780
781    if unsafe { Process32First(snapshot, &mut entry) } != 0 {
782        loop {
783            if entry.th32ParentProcessID == parent_pid {
784                let name_bytes = &entry.szExeFile;
785                let name_len = name_bytes
786                    .iter()
787                    .position(|&b| b == 0)
788                    .unwrap_or(name_bytes.len());
789                let name = String::from_utf8_lossy(
790                    &name_bytes[..name_len]
791                        .iter()
792                        .map(|&c| c as u8)
793                        .collect::<Vec<u8>>(),
794                )
795                .into_owned();
796                children.push(ChildProcessInfo {
797                    pid: entry.th32ProcessID,
798                    name,
799                });
800            }
801            if unsafe { Process32Next(snapshot, &mut entry) } == 0 {
802                break;
803            }
804        }
805    }
806
807    unsafe { CloseHandle(snapshot) };
808    children
809}
810
811/// Return PIDs of all conhost.exe processes that are children of the current process.
812#[cfg(windows)]
813pub(super) fn conhost_children_of_current_process() -> Vec<u32> {
814    let our_pid = std::process::id();
815    find_child_processes(our_pid)
816        .into_iter()
817        .filter(|c| c.name.eq_ignore_ascii_case("conhost.exe"))
818        .map(|c| c.pid)
819        .collect()
820}
821
822/// After spawning a ConPTY child, find the new conhost.exe process that was created
823/// by the ConPTY infrastructure (child of our process, not present in the "before"
824/// snapshot) and assign it to the Job Object so it gets cleaned up on Job close.
825#[cfg(windows)]
826pub(super) fn assign_conpty_conhost_to_job(job: &WindowsJobHandle, before_pids: &[u32]) {
827    let after_pids = conhost_children_of_current_process();
828    for pid in after_pids {
829        if !before_pids.contains(&pid) {
830            // This is a newly created conhost.exe — assign it to the Job.
831            let _ = job.assign_pid(pid);
832        }
833    }
834}
835
836/// A conhost.exe process whose parent is no longer alive — likely an orphan
837/// from a dead ConPTY session.
838#[cfg(windows)]
839#[derive(Debug, Clone)]
840pub struct OrphanConhostInfo {
841    /// PID of the orphaned conhost.exe.
842    pub pid: u32,
843    /// PID that was the parent when the snapshot was taken.
844    pub parent_pid: u32,
845    /// Name of the parent process, if it can be resolved (empty if parent is dead).
846    pub parent_name: String,
847}
848
849/// Scan all conhost.exe processes on the system and return those whose parent
850/// process is no longer alive. These are likely orphans from dead ConPTY sessions.
851///
852/// Uses `CreateToolhelp32Snapshot` for a point-in-time snapshot — no sysinfo
853/// dependency, so it's lightweight and can be called frequently.
854#[cfg(windows)]
855pub fn find_orphan_conhosts() -> Vec<OrphanConhostInfo> {
856    use winapi::um::handleapi::CloseHandle;
857    use winapi::um::tlhelp32::{
858        CreateToolhelp32Snapshot, Process32First, Process32Next, PROCESSENTRY32, TH32CS_SNAPPROCESS,
859    };
860
861    let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
862    if snapshot == winapi::um::handleapi::INVALID_HANDLE_VALUE {
863        return Vec::new();
864    }
865
866    let mut entry: PROCESSENTRY32 = unsafe { std::mem::zeroed() };
867    entry.dwSize = std::mem::size_of::<PROCESSENTRY32>() as u32;
868
869    // First pass: collect all PIDs and identify conhost.exe processes.
870    let mut all_pids = std::collections::HashSet::new();
871    let mut conhosts: Vec<(u32, u32)> = Vec::new(); // (pid, parent_pid)
872    let mut parent_names: std::collections::HashMap<u32, String> = std::collections::HashMap::new();
873
874    if unsafe { Process32First(snapshot, &mut entry) } != 0 {
875        loop {
876            let name_bytes = &entry.szExeFile;
877            let name_len = name_bytes
878                .iter()
879                .position(|&b| b == 0)
880                .unwrap_or(name_bytes.len());
881            let name = String::from_utf8_lossy(
882                &name_bytes[..name_len]
883                    .iter()
884                    .map(|&c| c as u8)
885                    .collect::<Vec<u8>>(),
886            )
887            .into_owned();
888
889            all_pids.insert(entry.th32ProcessID);
890            parent_names.insert(entry.th32ProcessID, name.clone());
891
892            if name.eq_ignore_ascii_case("conhost.exe") {
893                conhosts.push((entry.th32ProcessID, entry.th32ParentProcessID));
894            }
895
896            if unsafe { Process32Next(snapshot, &mut entry) } == 0 {
897                break;
898            }
899        }
900    }
901
902    unsafe { CloseHandle(snapshot) };
903
904    // Second pass: filter to conhosts whose parent PID is not in the live set.
905    conhosts
906        .into_iter()
907        .filter(|&(_, parent_pid)| !all_pids.contains(&parent_pid))
908        .map(|(pid, parent_pid)| OrphanConhostInfo {
909            pid,
910            parent_pid,
911            parent_name: parent_names.get(&parent_pid).cloned().unwrap_or_default(),
912        })
913        .collect()
914}
915
916#[cfg(windows)]
917/// Apply a Unix-like niceness hint to a Windows PTY child priority class.
918#[inline(never)]
919pub fn apply_windows_pty_priority(
920    handle: Option<std::os::windows::io::RawHandle>,
921    nice: Option<i32>,
922) -> Result<(), PtyError> {
923    crate::rp_rust_debug_scope!("running_process::pty::apply_windows_pty_priority");
924    use winapi::um::processthreadsapi::SetPriorityClass;
925    use winapi::um::winbase::{
926        ABOVE_NORMAL_PRIORITY_CLASS, BELOW_NORMAL_PRIORITY_CLASS, HIGH_PRIORITY_CLASS,
927        IDLE_PRIORITY_CLASS,
928    };
929
930    let Some(handle) = handle else {
931        return Ok(());
932    };
933    let flags = match nice {
934        Some(value) if value >= 15 => IDLE_PRIORITY_CLASS,
935        Some(value) if value >= 1 => BELOW_NORMAL_PRIORITY_CLASS,
936        Some(value) if value <= -15 => HIGH_PRIORITY_CLASS,
937        Some(value) if value <= -1 => ABOVE_NORMAL_PRIORITY_CLASS,
938        _ => 0,
939    };
940    if flags == 0 {
941        return Ok(());
942    }
943    let result = unsafe { SetPriorityClass(handle.cast(), flags) };
944    if result == 0 {
945        return Err(PtyError::Io(std::io::Error::last_os_error()));
946    }
947    Ok(())
948}
949
950#[cfg(test)]
951mod tests {
952    use super::native_pty_process::resolved_spawn_cwd;
953
954    #[test]
955    fn resolved_spawn_cwd_preserves_explicit_value() {
956        assert_eq!(
957            resolved_spawn_cwd(Some("C:\\temp\\explicit")),
958            Some("C:\\temp\\explicit".to_string())
959        );
960    }
961
962    #[test]
963    fn resolved_spawn_cwd_defaults_to_current_dir_when_unset() {
964        let expected = std::env::current_dir()
965            .ok()
966            .map(|cwd| cwd.to_string_lossy().to_string());
967        assert_eq!(resolved_spawn_cwd(None), expected);
968    }
969}