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