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