Skip to main content

running_process/pty/
mod.rs

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