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