Skip to main content

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