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